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.
This commit is contained in:
@@ -124,11 +124,11 @@ public class DynamicGroupService : IDynamicGroupService
|
||||
return null;
|
||||
}
|
||||
|
||||
// The account is limited to the libraries all named members share, so creating one at the
|
||||
// login screen cannot grant anybody access they did not already have.
|
||||
var group = await _provisioningService.CreateGroupAsync(
|
||||
members.Select(m => m.Id).ToList(),
|
||||
enteredUsername,
|
||||
enableAllFolders: config.DynamicGroupsEnableAllFolders,
|
||||
enabledFolders: null).ConfigureAwait(false);
|
||||
enteredUsername).ConfigureAwait(false);
|
||||
|
||||
var sharedUser = _userManager.GetUserById(group.SharedUserId);
|
||||
if (sharedUser is null)
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace Jellyfin.Plugin.WatchedTogether.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Computes and applies the library access a shared account should have.
|
||||
/// </summary>
|
||||
public interface ILibraryAccessService
|
||||
{
|
||||
/// <summary>
|
||||
/// Computes the set of libraries every one of the given members can reach.
|
||||
/// </summary>
|
||||
/// <param name="memberIds">The members to intersect.</param>
|
||||
/// <returns>
|
||||
/// The library identifiers common to all members. An empty set means the members have no
|
||||
/// library in common, and the shared account should see nothing.
|
||||
/// </returns>
|
||||
IReadOnlyList<Guid> ComputeIntersection(IReadOnlyList<Guid> memberIds);
|
||||
|
||||
/// <summary>
|
||||
/// Recomputes the intersection for a group and writes it to its shared account.
|
||||
/// </summary>
|
||||
/// <param name="sharedUserId">The shared account to update.</param>
|
||||
/// <param name="memberIds">The group's members.</param>
|
||||
/// <returns>The libraries granted to the shared account.</returns>
|
||||
Task<IReadOnlyList<Guid>> ApplyIntersectionAsync(Guid sharedUserId, IReadOnlyList<Guid> memberIds);
|
||||
}
|
||||
@@ -13,16 +13,14 @@ public interface IProvisioningService
|
||||
/// <summary>
|
||||
/// Creates a shared account for the given members and records the group.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The account is granted exactly the libraries every member can already reach, so it can never
|
||||
/// be used to see more than any one member could alone.
|
||||
/// </remarks>
|
||||
/// <param name="memberIds">The members whose passwords will unlock the account. At least two.</param>
|
||||
/// <param name="name">An explicit account name, or <c>null</c> to generate one from the member names.</param>
|
||||
/// <param name="enableAllFolders">Whether the shared account may access all libraries.</param>
|
||||
/// <param name="enabledFolders">Explicit library identifiers, used when <paramref name="enableAllFolders"/> is false.</param>
|
||||
/// <returns>The created group.</returns>
|
||||
Task<SharedGroup> CreateGroupAsync(
|
||||
IReadOnlyList<Guid> memberIds,
|
||||
string? name,
|
||||
bool enableAllFolders,
|
||||
IReadOnlyList<Guid>? enabledFolders);
|
||||
Task<SharedGroup> CreateGroupAsync(IReadOnlyList<Guid> memberIds, string? name);
|
||||
|
||||
/// <summary>
|
||||
/// Replaces the membership and options of an existing group.
|
||||
|
||||
@@ -0,0 +1,176 @@
|
||||
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 [];
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -31,25 +31,27 @@ public class ProvisioningService : IProvisioningService
|
||||
typeof(Auth.SharedAccountAuthenticationProvider).FullName!;
|
||||
|
||||
private readonly IUserManager _userManager;
|
||||
private readonly ILibraryAccessService _libraryAccessService;
|
||||
private readonly ILogger<ProvisioningService> _logger;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="ProvisioningService"/> class.
|
||||
/// </summary>
|
||||
/// <param name="userManager">The user manager.</param>
|
||||
/// <param name="libraryAccessService">The library access service.</param>
|
||||
/// <param name="logger">The logger.</param>
|
||||
public ProvisioningService(IUserManager userManager, ILogger<ProvisioningService> logger)
|
||||
public ProvisioningService(
|
||||
IUserManager userManager,
|
||||
ILibraryAccessService libraryAccessService,
|
||||
ILogger<ProvisioningService> logger)
|
||||
{
|
||||
_userManager = userManager;
|
||||
_libraryAccessService = libraryAccessService;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<SharedGroup> CreateGroupAsync(
|
||||
IReadOnlyList<Guid> memberIds,
|
||||
string? name,
|
||||
bool enableAllFolders,
|
||||
IReadOnlyList<Guid>? enabledFolders)
|
||||
public async Task<SharedGroup> CreateGroupAsync(IReadOnlyList<Guid> memberIds, string? name)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(memberIds);
|
||||
|
||||
@@ -103,7 +105,7 @@ public class ProvisioningService : IProvisioningService
|
||||
await _userManager.ChangePassword(sharedUser, GenerateUnusedPassword()).ConfigureAwait(false);
|
||||
await _userManager.UpdateUserAsync(sharedUser).ConfigureAwait(false);
|
||||
|
||||
await ApplyLibraryAccessAsync(sharedUser.Id, enableAllFolders, enabledFolders).ConfigureAwait(false);
|
||||
await ApplyLibraryAccessAsync(sharedUser.Id, distinctIds).ConfigureAwait(false);
|
||||
|
||||
var group = new SharedGroup
|
||||
{
|
||||
@@ -124,7 +126,7 @@ public class ProvisioningService : IProvisioningService
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<SharedGroup> UpdateGroupAsync(
|
||||
public async Task<SharedGroup> UpdateGroupAsync(
|
||||
Guid sharedUserId,
|
||||
IReadOnlyList<Guid> memberIds,
|
||||
bool syncUnwatched,
|
||||
@@ -170,13 +172,17 @@ public class ProvisioningService : IProvisioningService
|
||||
|
||||
plugin.UpdateConfiguration(config);
|
||||
|
||||
// Membership drives library access, so recompute it: adding a member can only narrow the
|
||||
// intersection, and removing one may widen it.
|
||||
await ApplyLibraryAccessAsync(sharedUserId, distinctIds).ConfigureAwait(false);
|
||||
|
||||
_logger.LogInformation(
|
||||
"Updated group {SharedUserId}: {MemberCount} members, disabled={IsDisabled}",
|
||||
sharedUserId,
|
||||
distinctIds.Count,
|
||||
isDisabled);
|
||||
|
||||
return Task.FromResult(group);
|
||||
return group;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
@@ -239,13 +245,9 @@ public class ProvisioningService : IProvisioningService
|
||||
/// Sets library access on the shared account.
|
||||
/// </summary>
|
||||
/// <param name="sharedUserId">The shared account.</param>
|
||||
/// <param name="enableAllFolders">Whether to grant access to every library.</param>
|
||||
/// <param name="enabledFolders">The explicit library list when not granting all.</param>
|
||||
/// <param name="memberIds">The members whose access is intersected.</param>
|
||||
/// <returns>A task representing the update.</returns>
|
||||
private async Task ApplyLibraryAccessAsync(
|
||||
Guid sharedUserId,
|
||||
bool enableAllFolders,
|
||||
IReadOnlyList<Guid>? enabledFolders)
|
||||
private async Task ApplyLibraryAccessAsync(Guid sharedUserId, IReadOnlyList<Guid> memberIds)
|
||||
{
|
||||
var user = _userManager.GetUserById(sharedUserId);
|
||||
if (user is null)
|
||||
@@ -253,24 +255,11 @@ public class ProvisioningService : IProvisioningService
|
||||
return;
|
||||
}
|
||||
|
||||
// Library access on the shared account is deliberate and independent of what each member
|
||||
// can reach individually - any member's password opens whatever this account can see.
|
||||
user.SetPermission(PermissionKind.EnableAllFolders, enableAllFolders);
|
||||
// Never "all folders": the shared account gets an explicit list of the libraries every
|
||||
// member can already reach, so joining a group can never grant access to anything.
|
||||
user.SetPermission(PermissionKind.EnableAllFolders, false);
|
||||
await _userManager.UpdateUserAsync(user).ConfigureAwait(false);
|
||||
|
||||
if (enableAllFolders)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var policy = _userManager.GetUserDto(user).Policy;
|
||||
if (policy is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
policy.EnableAllFolders = false;
|
||||
policy.EnabledFolders = enabledFolders?.ToArray() ?? [];
|
||||
await _userManager.UpdatePolicyAsync(sharedUserId, policy).ConfigureAwait(false);
|
||||
await _libraryAccessService.ApplyIntersectionAsync(sharedUserId, memberIds).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,6 +21,7 @@ public sealed class UserLifecycleService : IHostedService
|
||||
{
|
||||
private readonly IUserManager _userManager;
|
||||
private readonly IGroupService _groupService;
|
||||
private readonly ILibraryAccessService _libraryAccessService;
|
||||
private readonly ILogger<UserLifecycleService> _logger;
|
||||
|
||||
/// <summary>
|
||||
@@ -28,23 +29,27 @@ public sealed class UserLifecycleService : IHostedService
|
||||
/// </summary>
|
||||
/// <param name="userManager">The user manager.</param>
|
||||
/// <param name="groupService">The group service.</param>
|
||||
/// <param name="libraryAccessService">The library access service.</param>
|
||||
/// <param name="logger">The logger.</param>
|
||||
public UserLifecycleService(
|
||||
IUserManager userManager,
|
||||
IGroupService groupService,
|
||||
ILibraryAccessService libraryAccessService,
|
||||
ILogger<UserLifecycleService> logger)
|
||||
{
|
||||
_userManager = userManager;
|
||||
_groupService = groupService;
|
||||
_libraryAccessService = libraryAccessService;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task StartAsync(CancellationToken cancellationToken)
|
||||
public async Task StartAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
Reconcile();
|
||||
await ReapplyLibraryAccessAsync().ConfigureAwait(false);
|
||||
}
|
||||
#pragma warning disable CA1031 // Reconciliation must never prevent the server from starting.
|
||||
catch (Exception ex)
|
||||
@@ -52,8 +57,31 @@ public sealed class UserLifecycleService : IHostedService
|
||||
{
|
||||
_logger.LogError(ex, "Failed to reconcile Watched Together groups at startup");
|
||||
}
|
||||
}
|
||||
|
||||
return Task.CompletedTask;
|
||||
/// <summary>
|
||||
/// Recomputes every group's library access.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// A member's own library access can be narrowed at any time through the normal user editor,
|
||||
/// which would leave a group's stored intersection too wide. Recomputing at startup brings
|
||||
/// shared accounts back in line without needing to hook every policy change.
|
||||
/// </remarks>
|
||||
/// <returns>A task representing the update.</returns>
|
||||
private async Task ReapplyLibraryAccessAsync()
|
||||
{
|
||||
var config = Plugin.Instance?.Configuration;
|
||||
if (config is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
foreach (var group in config.Groups.ToList())
|
||||
{
|
||||
await _libraryAccessService
|
||||
.ApplyIntersectionAsync(group.SharedUserId, group.MemberUserIds)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
|
||||
Reference in New Issue
Block a user