Files
WatchedTogether/Jellyfin.Plugin.WatchedTogether/Services/LibraryAccessService.cs
T
dtourolle b4134dd744 Grant shared accounts the intersection of member library access
Previously the shared account's libraries were chosen independently of
its members, so a group could see a library that one of its members was
blocked from - joining a group became a way to gain access. That was
especially sharp with auto-created groups, where no admin is in the loop.

A shared account is now granted exactly the libraries every member can
already reach. If one member is blocked from a library, no group
containing them can see it. The account is therefore always a subset of
what each member could reach alone, which is what makes creating groups
at the login screen safe to leave on by default.

Details:

- "Enable all folders" is expanded to concrete library ids before
  intersecting, since it cannot otherwise be compared with an explicit
  list. Shared accounts are always given an explicit list, never the
  all-folders permission, so newly added libraries do not silently widen
  an existing group.
- Explicitly blocked folders are subtracted even for members who
  otherwise have access to everything.
- Fails closed: an unresolvable member contributes no access rather than
  being treated as unrestricted.
- Recomputed when membership changes, and re-applied to every group at
  startup so narrowing a member's own access narrows their groups.

Drops the now-meaningless EnableAllFolders/EnabledFolders provisioning
inputs and the DynamicGroupsEnableAllFolders setting. Adds 8 tests
covering the intersection rules.
2026-07-29 00:15:32 +02:00

177 lines
5.9 KiB
C#

using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using Jellyfin.Data;
using Jellyfin.Database.Implementations.Entities;
using Jellyfin.Database.Implementations.Enums;
using MediaBrowser.Controller.Library;
using Microsoft.Extensions.Logging;
namespace Jellyfin.Plugin.WatchedTogether.Services;
/// <summary>
/// Grants a shared account only the libraries that <em>every</em> member can already reach.
/// </summary>
/// <remarks>
/// Sharing an account must never be a way to gain access. If Alice is blocked from a library, a
/// group she belongs to cannot see it either, even when every other member can. The result is the
/// intersection of the members' access, so the shared account is always a subset of what each
/// member could reach alone.
/// </remarks>
public class LibraryAccessService : ILibraryAccessService
{
private readonly IUserManager _userManager;
private readonly ILibraryManager _libraryManager;
private readonly ILogger<LibraryAccessService> _logger;
/// <summary>
/// Initializes a new instance of the <see cref="LibraryAccessService"/> class.
/// </summary>
/// <param name="userManager">The user manager.</param>
/// <param name="libraryManager">The library manager.</param>
/// <param name="logger">The logger.</param>
public LibraryAccessService(
IUserManager userManager,
ILibraryManager libraryManager,
ILogger<LibraryAccessService> logger)
{
_userManager = userManager;
_libraryManager = libraryManager;
_logger = logger;
}
/// <inheritdoc />
public IReadOnlyList<Guid> ComputeIntersection(IReadOnlyList<Guid> memberIds)
{
ArgumentNullException.ThrowIfNull(memberIds);
if (memberIds.Count == 0)
{
return [];
}
HashSet<Guid>? intersection = null;
foreach (var memberId in memberIds)
{
var member = _userManager.GetUserById(memberId);
if (member is null)
{
// A member we cannot resolve contributes no access. Treating it as "everything"
// would silently widen the group.
_logger.LogWarning(
"Member {MemberId} could not be resolved; treating its library access as empty",
memberId);
return [];
}
var accessible = GetAccessibleLibraries(member);
if (intersection is null)
{
intersection = accessible;
}
else
{
intersection.IntersectWith(accessible);
}
// Nothing left in common; no later member can widen it again.
if (intersection.Count == 0)
{
break;
}
}
return intersection?.ToList() ?? (IReadOnlyList<Guid>)[];
}
/// <inheritdoc />
public async Task<IReadOnlyList<Guid>> ApplyIntersectionAsync(
Guid sharedUserId,
IReadOnlyList<Guid> memberIds)
{
var granted = ComputeIntersection(memberIds);
var sharedUser = _userManager.GetUserById(sharedUserId);
if (sharedUser is null)
{
return granted;
}
// Always an explicit list, never EnableAllFolders: "all" would keep growing as libraries
// are added, silently granting the group access nobody agreed to.
var policy = _userManager.GetUserDto(sharedUser).Policy;
if (policy is null)
{
_logger.LogWarning(
"Could not read the policy for shared account {SharedUserId}; library access unchanged",
sharedUserId);
return granted;
}
policy.EnableAllFolders = false;
policy.EnabledFolders = granted.ToArray();
await _userManager.UpdatePolicyAsync(sharedUserId, policy).ConfigureAwait(false);
if (granted.Count == 0)
{
_logger.LogWarning(
"Shared account {SharedUserId} has no libraries: its members have none in common",
sharedUserId);
}
else
{
_logger.LogInformation(
"Shared account {SharedUserId} granted {Count} libraries common to all members",
sharedUserId,
granted.Count);
}
return granted;
}
/// <summary>
/// Resolves the concrete set of libraries a single user can reach.
/// </summary>
/// <param name="user">The user to inspect.</param>
/// <returns>The library identifiers this user can see.</returns>
private HashSet<Guid> GetAccessibleLibraries(User user)
{
// "Enable all folders" has to be expanded into concrete ids before it can be intersected
// with another member's explicit list.
var accessible = user.HasPermission(PermissionKind.EnableAllFolders)
? GetAllLibraryIds()
: user.GetPreferenceValues<Guid>(PreferenceKind.EnabledFolders).ToHashSet();
// An explicitly blocked folder is removed even when the user otherwise has access to all.
accessible.ExceptWith(user.GetPreferenceValues<Guid>(PreferenceKind.BlockedMediaFolders));
return accessible;
}
/// <summary>
/// Gets the identifiers of every top-level library on the server.
/// </summary>
/// <returns>All library identifiers.</returns>
private HashSet<Guid> GetAllLibraryIds()
{
try
{
return _libraryManager.GetUserRootFolder()
.Children
.Select(c => c.Id)
.ToHashSet();
}
#pragma warning disable CA1031 // Failing closed is the safe direction for an access decision.
catch (Exception ex)
#pragma warning restore CA1031
{
_logger.LogError(ex, "Could not enumerate libraries; treating access as empty");
return [];
}
}
}