From b4134dd744418ac1dbfb2d45462dbfd1bb2ff332 Mon Sep 17 00:00:00 2001 From: Duncan Tourolle Date: Wed, 29 Jul 2026 00:15:32 +0200 Subject: [PATCH] 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. --- .../DynamicGroupTests.cs | 14 +- .../LibraryAccessTests.cs | 167 +++++++++++++++++ .../Configuration/PluginConfiguration.cs | 10 - .../Configuration/configPage.html | 31 +-- .../Controllers/WatchedTogetherController.cs | 4 +- .../Models/CreateGroupRequest.cs | 14 +- .../ServiceRegistrator.cs | 1 + .../Services/DynamicGroupService.cs | 6 +- .../Services/ILibraryAccessService.cs | 29 +++ .../Services/IProvisioningService.cs | 12 +- .../Services/LibraryAccessService.cs | 176 ++++++++++++++++++ .../Services/ProvisioningService.cs | 53 +++--- .../Services/UserLifecycleService.cs | 32 +++- README.md | 34 ++-- build.yaml | 8 +- manifest.json | 2 +- 16 files changed, 472 insertions(+), 121 deletions(-) create mode 100644 Jellyfin.Plugin.WatchedTogether.Tests/LibraryAccessTests.cs create mode 100644 Jellyfin.Plugin.WatchedTogether/Services/ILibraryAccessService.cs create mode 100644 Jellyfin.Plugin.WatchedTogether/Services/LibraryAccessService.cs diff --git a/Jellyfin.Plugin.WatchedTogether.Tests/DynamicGroupTests.cs b/Jellyfin.Plugin.WatchedTogether.Tests/DynamicGroupTests.cs index e41d83c..04a25c5 100644 --- a/Jellyfin.Plugin.WatchedTogether.Tests/DynamicGroupTests.cs +++ b/Jellyfin.Plugin.WatchedTogether.Tests/DynamicGroupTests.cs @@ -72,10 +72,8 @@ public class DynamicGroupTests provisioning.Setup(p => p.CreateGroupAsync( It.IsAny>(), - It.IsAny(), - It.IsAny(), - It.IsAny?>())) - .ReturnsAsync((IReadOnlyList ids, string? name, bool _, IReadOnlyList? _) => + It.IsAny())) + .ReturnsAsync((IReadOnlyList ids, string? name) => new SharedGroup { SharedUserId = createdShared.Id, MemberUserIds = [.. ids] }); userManager.Setup(m => m.GetUserById(createdShared.Id)).Returns(createdShared); @@ -103,9 +101,7 @@ public class DynamicGroupTests h.Provisioning.Verify( p => p.CreateGroupAsync( It.Is>(ids => ids.Count == 2), - "alice+bob", - It.IsAny(), - It.IsAny?>()), + "alice+bob"), Times.Once); } @@ -229,9 +225,7 @@ public class DynamicGroupTests h.Provisioning.Verify( p => p.CreateGroupAsync( It.Is>(ids => ids.Count == 3), - It.IsAny(), - It.IsAny(), - It.IsAny?>()), + It.IsAny()), Times.Once); } } diff --git a/Jellyfin.Plugin.WatchedTogether.Tests/LibraryAccessTests.cs b/Jellyfin.Plugin.WatchedTogether.Tests/LibraryAccessTests.cs new file mode 100644 index 0000000..6f60b0a --- /dev/null +++ b/Jellyfin.Plugin.WatchedTogether.Tests/LibraryAccessTests.cs @@ -0,0 +1,167 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using Jellyfin.Data; +using Jellyfin.Database.Implementations.Entities; +using Jellyfin.Database.Implementations.Enums; +using Jellyfin.Plugin.WatchedTogether.Services; +using MediaBrowser.Controller.Entities; +using MediaBrowser.Controller.Library; +using Microsoft.Extensions.Logging.Abstractions; +using Moq; +using Xunit; + +namespace Jellyfin.Plugin.WatchedTogether.Tests; + +/// +/// Covers the rule that a shared account sees only what every member can already see. +/// +public class LibraryAccessTests +{ + private static readonly Guid Movies = Guid.NewGuid(); + private static readonly Guid Shows = Guid.NewGuid(); + private static readonly Guid Kids = Guid.NewGuid(); + private static readonly Guid Adult = Guid.NewGuid(); + + private static readonly Guid[] AllLibraries = [Movies, Shows, Kids, Adult]; + + /// + /// Creates a user with either full access or an explicit library list. + /// + private static User MakeUser( + string name, + bool allFolders = false, + IEnumerable? enabled = null, + IEnumerable? blocked = null) + { + var user = new User(name, "Prov", "ResetProv"); + user.SetPermission(PermissionKind.EnableAllFolders, allFolders); + user.SetPreference(PreferenceKind.EnabledFolders, (enabled ?? []).ToArray()); + user.SetPreference(PreferenceKind.BlockedMediaFolders, (blocked ?? []).ToArray()); + return user; + } + + private static LibraryAccessService MakeService(params User[] users) + { + var userManager = new Mock(); + foreach (var u in users) + { + userManager.Setup(m => m.GetUserById(u.Id)).Returns(u); + } + + // The root folder's children are the server's top-level libraries. + var children = AllLibraries.Select(id => + { + var folder = new Mock(); + folder.Object.Id = id; + return folder.Object; + }).ToList(); + + var root = new Mock(); + root.Setup(r => r.Children).Returns(children); + + var libraryManager = new Mock(); + libraryManager.Setup(l => l.GetUserRootFolder()).Returns(root.Object); + + return new LibraryAccessService( + userManager.Object, + libraryManager.Object, + NullLogger.Instance); + } + + [Fact] + public void TwoExplicitLists_IntersectToTheCommonLibraries() + { + var alice = MakeUser("alice", enabled: [Movies, Shows, Kids]); + var bob = MakeUser("bob", enabled: [Shows, Kids, Adult]); + var service = MakeService(alice, bob); + + var result = service.ComputeIntersection([alice.Id, bob.Id]); + + Assert.Equal([Shows, Kids], result.OrderBy(g => g).ToList().OrderBy(g => g).ToList()); + Assert.DoesNotContain(Movies, result); + Assert.DoesNotContain(Adult, result); + } + + [Fact] + public void AMemberWithAllFolders_DoesNotWidenTheGroup() + { + // The restricted member is what bounds the group, not the permissive one. + var alice = MakeUser("alice", allFolders: true); + var bob = MakeUser("bob", enabled: [Kids]); + var service = MakeService(alice, bob); + + var result = service.ComputeIntersection([alice.Id, bob.Id]); + + Assert.Equal([Kids], result); + } + + [Fact] + public void AllMembersWithAllFolders_GetEveryLibrary() + { + var alice = MakeUser("alice", allFolders: true); + var bob = MakeUser("bob", allFolders: true); + var service = MakeService(alice, bob); + + var result = service.ComputeIntersection([alice.Id, bob.Id]); + + Assert.Equal(AllLibraries.OrderBy(g => g), result.OrderBy(g => g)); + } + + [Fact] + public void ABlockedFolder_IsRemovedEvenWithAllFolders() + { + // Blocking is an explicit denial and must survive the "sees everything" permission. + var alice = MakeUser("alice", allFolders: true, blocked: [Adult]); + var bob = MakeUser("bob", allFolders: true); + var service = MakeService(alice, bob); + + var result = service.ComputeIntersection([alice.Id, bob.Id]); + + Assert.DoesNotContain(Adult, result); + Assert.Contains(Movies, result); + } + + [Fact] + public void MembersWithNothingInCommon_GetNoLibraries() + { + var alice = MakeUser("alice", enabled: [Movies]); + var bob = MakeUser("bob", enabled: [Adult]); + var service = MakeService(alice, bob); + + Assert.Empty(service.ComputeIntersection([alice.Id, bob.Id])); + } + + [Fact] + public void AddingAThirdMember_CanOnlyNarrowAccess() + { + var alice = MakeUser("alice", enabled: [Movies, Shows, Kids]); + var bob = MakeUser("bob", enabled: [Shows, Kids]); + var carol = MakeUser("carol", enabled: [Kids]); + var service = MakeService(alice, bob, carol); + + var pair = service.ComputeIntersection([alice.Id, bob.Id]); + var trio = service.ComputeIntersection([alice.Id, bob.Id, carol.Id]); + + Assert.Equal(2, pair.Count); + Assert.Equal([Kids], trio); + } + + [Fact] + public void AnUnresolvableMember_YieldsNoAccess() + { + // Failing closed: a member we cannot read must not be treated as unrestricted. + var alice = MakeUser("alice", allFolders: true); + var service = MakeService(alice); + + Assert.Empty(service.ComputeIntersection([alice.Id, Guid.NewGuid()])); + } + + [Fact] + public void NoMembers_YieldsNoAccess() + { + var service = MakeService(); + + Assert.Empty(service.ComputeIntersection([])); + } +} diff --git a/Jellyfin.Plugin.WatchedTogether/Configuration/PluginConfiguration.cs b/Jellyfin.Plugin.WatchedTogether/Configuration/PluginConfiguration.cs index 39bf4f8..5016b2d 100644 --- a/Jellyfin.Plugin.WatchedTogether/Configuration/PluginConfiguration.cs +++ b/Jellyfin.Plugin.WatchedTogether/Configuration/PluginConfiguration.cs @@ -34,14 +34,4 @@ public class PluginConfiguration : BasePluginConfiguration /// this plugin once no local user matches the typed name. /// public bool EnableDynamicGroups { get; set; } = true; - - /// - /// Gets or sets a value indicating whether accounts created on demand may access all libraries. - /// - /// - /// Leaving this on means a dynamically created account sees every library, regardless of what - /// its members can each reach individually. Turn it off to have such accounts start with no - /// library access until an administrator grants it. - /// - public bool DynamicGroupsEnableAllFolders { get; set; } = true; } diff --git a/Jellyfin.Plugin.WatchedTogether/Configuration/configPage.html b/Jellyfin.Plugin.WatchedTogether/Configuration/configPage.html index 84e9c29..3334cc3 100644 --- a/Jellyfin.Plugin.WatchedTogether/Configuration/configPage.html +++ b/Jellyfin.Plugin.WatchedTogether/Configuration/configPage.html @@ -45,16 +45,10 @@ -
- -
- The shared account's library access is independent of each member's own - restrictions. If a member is normally blocked from a library but this - account is not, their password now reaches it. -
+
+ The shared account is granted only the libraries every member can + already reach. If one member is blocked from a library, the group cannot see + it either, so sharing an account never grants anyone new access.
@@ -81,17 +75,6 @@
-
- -
- Turn this off to have auto-created accounts start with no library access - until you grant it. -
-
-
@@ -187,7 +170,6 @@ ApiClient.getPluginConfiguration(pluginUniqueId).then(function (config) { page.querySelector('#NameSeparator').value = config.NameSeparator || '+'; page.querySelector('#EnableDynamicGroups').checked = config.EnableDynamicGroups; - page.querySelector('#DynamicGroupsEnableAllFolders').checked = config.DynamicGroupsEnableAllFolders; }) ]).then(function () { Dashboard.hideLoadingMsg(); @@ -216,9 +198,7 @@ contentType: 'application/json', data: JSON.stringify({ MemberUserIds: selected, - Name: page.querySelector('#NewGroupName').value || null, - EnableAllFolders: page.querySelector('#EnableAllFolders').checked, - EnabledFolders: null + Name: page.querySelector('#NewGroupName').value || null }) }).then(function () { Dashboard.hideLoadingMsg(); @@ -247,7 +227,6 @@ ApiClient.getPluginConfiguration(pluginUniqueId).then(function (config) { config.NameSeparator = page.querySelector('#NameSeparator').value || '+'; config.EnableDynamicGroups = page.querySelector('#EnableDynamicGroups').checked; - config.DynamicGroupsEnableAllFolders = page.querySelector('#DynamicGroupsEnableAllFolders').checked; ApiClient.updatePluginConfiguration(pluginUniqueId, config).then(function (result) { Dashboard.processPluginConfigurationUpdateResult(result); }); diff --git a/Jellyfin.Plugin.WatchedTogether/Controllers/WatchedTogetherController.cs b/Jellyfin.Plugin.WatchedTogether/Controllers/WatchedTogetherController.cs index 04f7d85..62fe988 100644 --- a/Jellyfin.Plugin.WatchedTogether/Controllers/WatchedTogetherController.cs +++ b/Jellyfin.Plugin.WatchedTogether/Controllers/WatchedTogetherController.cs @@ -111,9 +111,7 @@ public class WatchedTogetherController : ControllerBase { var group = await _provisioningService.CreateGroupAsync( request.MemberUserIds, - request.Name, - request.EnableAllFolders, - request.EnabledFolders).ConfigureAwait(false); + request.Name).ConfigureAwait(false); return Ok(new GroupDto { diff --git a/Jellyfin.Plugin.WatchedTogether/Models/CreateGroupRequest.cs b/Jellyfin.Plugin.WatchedTogether/Models/CreateGroupRequest.cs index 8eea3ae..22f317f 100644 --- a/Jellyfin.Plugin.WatchedTogether/Models/CreateGroupRequest.cs +++ b/Jellyfin.Plugin.WatchedTogether/Models/CreateGroupRequest.cs @@ -16,15 +16,9 @@ public class CreateGroupRequest /// /// Gets or sets an explicit account name. When empty, one is generated from the member names. /// + /// + /// Library access is not specified here: the account is granted exactly the libraries every + /// member can already reach. + /// public string? Name { get; set; } - - /// - /// Gets or sets a value indicating whether the shared account may access all libraries. - /// - public bool EnableAllFolders { get; set; } = true; - - /// - /// Gets or sets the explicit libraries the shared account may access. - /// - public IReadOnlyList? EnabledFolders { get; set; } } diff --git a/Jellyfin.Plugin.WatchedTogether/ServiceRegistrator.cs b/Jellyfin.Plugin.WatchedTogether/ServiceRegistrator.cs index a472140..156790b 100644 --- a/Jellyfin.Plugin.WatchedTogether/ServiceRegistrator.cs +++ b/Jellyfin.Plugin.WatchedTogether/ServiceRegistrator.cs @@ -16,6 +16,7 @@ public class ServiceRegistrator : IPluginServiceRegistrator public void RegisterServices(IServiceCollection serviceCollection, IServerApplicationHost applicationHost) { serviceCollection.AddSingleton(); + serviceCollection.AddSingleton(); serviceCollection.AddSingleton(); serviceCollection.AddSingleton(); diff --git a/Jellyfin.Plugin.WatchedTogether/Services/DynamicGroupService.cs b/Jellyfin.Plugin.WatchedTogether/Services/DynamicGroupService.cs index b5f8df8..e5f4031 100644 --- a/Jellyfin.Plugin.WatchedTogether/Services/DynamicGroupService.cs +++ b/Jellyfin.Plugin.WatchedTogether/Services/DynamicGroupService.cs @@ -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) diff --git a/Jellyfin.Plugin.WatchedTogether/Services/ILibraryAccessService.cs b/Jellyfin.Plugin.WatchedTogether/Services/ILibraryAccessService.cs new file mode 100644 index 0000000..8f9e5d7 --- /dev/null +++ b/Jellyfin.Plugin.WatchedTogether/Services/ILibraryAccessService.cs @@ -0,0 +1,29 @@ +using System; +using System.Collections.Generic; +using System.Threading.Tasks; + +namespace Jellyfin.Plugin.WatchedTogether.Services; + +/// +/// Computes and applies the library access a shared account should have. +/// +public interface ILibraryAccessService +{ + /// + /// Computes the set of libraries every one of the given members can reach. + /// + /// The members to intersect. + /// + /// 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. + /// + IReadOnlyList ComputeIntersection(IReadOnlyList memberIds); + + /// + /// Recomputes the intersection for a group and writes it to its shared account. + /// + /// The shared account to update. + /// The group's members. + /// The libraries granted to the shared account. + Task> ApplyIntersectionAsync(Guid sharedUserId, IReadOnlyList memberIds); +} diff --git a/Jellyfin.Plugin.WatchedTogether/Services/IProvisioningService.cs b/Jellyfin.Plugin.WatchedTogether/Services/IProvisioningService.cs index 76a4844..f5a3961 100644 --- a/Jellyfin.Plugin.WatchedTogether/Services/IProvisioningService.cs +++ b/Jellyfin.Plugin.WatchedTogether/Services/IProvisioningService.cs @@ -13,16 +13,14 @@ public interface IProvisioningService /// /// Creates a shared account for the given members and records the group. /// + /// + /// 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. + /// /// The members whose passwords will unlock the account. At least two. /// An explicit account name, or null to generate one from the member names. - /// Whether the shared account may access all libraries. - /// Explicit library identifiers, used when is false. /// The created group. - Task CreateGroupAsync( - IReadOnlyList memberIds, - string? name, - bool enableAllFolders, - IReadOnlyList? enabledFolders); + Task CreateGroupAsync(IReadOnlyList memberIds, string? name); /// /// Replaces the membership and options of an existing group. diff --git a/Jellyfin.Plugin.WatchedTogether/Services/LibraryAccessService.cs b/Jellyfin.Plugin.WatchedTogether/Services/LibraryAccessService.cs new file mode 100644 index 0000000..5d2223e --- /dev/null +++ b/Jellyfin.Plugin.WatchedTogether/Services/LibraryAccessService.cs @@ -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; + +/// +/// Grants a shared account only the libraries that every member can already reach. +/// +/// +/// 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. +/// +public class LibraryAccessService : ILibraryAccessService +{ + private readonly IUserManager _userManager; + private readonly ILibraryManager _libraryManager; + private readonly ILogger _logger; + + /// + /// Initializes a new instance of the class. + /// + /// The user manager. + /// The library manager. + /// The logger. + public LibraryAccessService( + IUserManager userManager, + ILibraryManager libraryManager, + ILogger logger) + { + _userManager = userManager; + _libraryManager = libraryManager; + _logger = logger; + } + + /// + public IReadOnlyList ComputeIntersection(IReadOnlyList memberIds) + { + ArgumentNullException.ThrowIfNull(memberIds); + + if (memberIds.Count == 0) + { + return []; + } + + HashSet? 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)[]; + } + + /// + public async Task> ApplyIntersectionAsync( + Guid sharedUserId, + IReadOnlyList 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; + } + + /// + /// Resolves the concrete set of libraries a single user can reach. + /// + /// The user to inspect. + /// The library identifiers this user can see. + private HashSet 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(PreferenceKind.EnabledFolders).ToHashSet(); + + // An explicitly blocked folder is removed even when the user otherwise has access to all. + accessible.ExceptWith(user.GetPreferenceValues(PreferenceKind.BlockedMediaFolders)); + + return accessible; + } + + /// + /// Gets the identifiers of every top-level library on the server. + /// + /// All library identifiers. + private HashSet 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 []; + } + } +} diff --git a/Jellyfin.Plugin.WatchedTogether/Services/ProvisioningService.cs b/Jellyfin.Plugin.WatchedTogether/Services/ProvisioningService.cs index 5911b23..296c808 100644 --- a/Jellyfin.Plugin.WatchedTogether/Services/ProvisioningService.cs +++ b/Jellyfin.Plugin.WatchedTogether/Services/ProvisioningService.cs @@ -31,25 +31,27 @@ public class ProvisioningService : IProvisioningService typeof(Auth.SharedAccountAuthenticationProvider).FullName!; private readonly IUserManager _userManager; + private readonly ILibraryAccessService _libraryAccessService; private readonly ILogger _logger; /// /// Initializes a new instance of the class. /// /// The user manager. + /// The library access service. /// The logger. - public ProvisioningService(IUserManager userManager, ILogger logger) + public ProvisioningService( + IUserManager userManager, + ILibraryAccessService libraryAccessService, + ILogger logger) { _userManager = userManager; + _libraryAccessService = libraryAccessService; _logger = logger; } /// - public async Task CreateGroupAsync( - IReadOnlyList memberIds, - string? name, - bool enableAllFolders, - IReadOnlyList? enabledFolders) + public async Task CreateGroupAsync(IReadOnlyList 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 } /// - public Task UpdateGroupAsync( + public async Task UpdateGroupAsync( Guid sharedUserId, IReadOnlyList 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; } /// @@ -239,13 +245,9 @@ public class ProvisioningService : IProvisioningService /// Sets library access on the shared account. /// /// The shared account. - /// Whether to grant access to every library. - /// The explicit library list when not granting all. + /// The members whose access is intersected. /// A task representing the update. - private async Task ApplyLibraryAccessAsync( - Guid sharedUserId, - bool enableAllFolders, - IReadOnlyList? enabledFolders) + private async Task ApplyLibraryAccessAsync(Guid sharedUserId, IReadOnlyList 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); } } diff --git a/Jellyfin.Plugin.WatchedTogether/Services/UserLifecycleService.cs b/Jellyfin.Plugin.WatchedTogether/Services/UserLifecycleService.cs index a290c76..7281c45 100644 --- a/Jellyfin.Plugin.WatchedTogether/Services/UserLifecycleService.cs +++ b/Jellyfin.Plugin.WatchedTogether/Services/UserLifecycleService.cs @@ -21,6 +21,7 @@ public sealed class UserLifecycleService : IHostedService { private readonly IUserManager _userManager; private readonly IGroupService _groupService; + private readonly ILibraryAccessService _libraryAccessService; private readonly ILogger _logger; /// @@ -28,23 +29,27 @@ public sealed class UserLifecycleService : IHostedService /// /// The user manager. /// The group service. + /// The library access service. /// The logger. public UserLifecycleService( IUserManager userManager, IGroupService groupService, + ILibraryAccessService libraryAccessService, ILogger logger) { _userManager = userManager; _groupService = groupService; + _libraryAccessService = libraryAccessService; _logger = logger; } /// - 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; + /// + /// Recomputes every group's library access. + /// + /// + /// 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. + /// + /// A task representing the update. + 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); + } } /// diff --git a/README.md b/README.md index cf75765..f003b67 100644 --- a/README.md +++ b/README.md @@ -43,6 +43,10 @@ watch alone on their phone, the series picks up where the group left off. The sync is **one-way**: shared account → members. What Alice watches privately is her business and never leaks into the shared account or onto Bob. +Library access is the **intersection** of the members', never the union: the group sees only what +everyone in it could already see. Sharing an account is therefore never a way to reach a library you +were not already allowed into. + ``` login as "alice+bob+carol" with any one member's password @@ -174,8 +178,7 @@ If you would rather provision groups explicitly — or you have turned auto-crea 1. Go to **Dashboard → Plugins → Watched Together**. 2. Under **Create a group**, select **two or more** members. 3. Optionally give the account a name. Left blank, the member names are joined with `+`. -4. Decide whether the account should see all libraries (see the security note below). -5. Click **Create group**. +4. Click **Create group**. Either way, a new user appears in your user list and can be renamed like any other. @@ -192,26 +195,27 @@ Either way, a new user appears in your user list and can be renamed like any oth | Setting | Default | Meaning | | --- | --- | --- | | Create groups automatically at login | on | Enables the `alice+bob` login flow described above. Turn off to require dashboard provisioning. | -| Auto-created accounts can access all libraries | on | Whether accounts made at the login screen start with full library access. Turn off to grant access deliberately. | | Name separator | `+` | The character joining member names, and the one split at login. Use `_` or `-` if you prefer. | +There is no library-access setting: a shared account always receives exactly the intersection of its +members' access. See the security notes below. + --- ## Security notes -Please read this before granting a shared account broad library access. +How this plugin bounds what a shared account can reach. -- **Access is a union, and it is deliberate.** Any member's password opens the shared account, and - that account sees whatever libraries *you* granted *it*, independent of each member's own - restrictions. If a member is normally blocked from a library but the shared account is not, that - member's password now reaches it. Set the shared account's library access accordingly. -- **Auto-creation grants library access without an admin in the loop.** With both - *Create groups automatically at login* and *Auto-created accounts can access all libraries* on, - any user who knows a colleague's username can pair it with their own and reach a full-library - account. That is a real privilege escalation if your libraries are not uniformly visible. It is - still gated on a valid member password — nobody gets in without one — but if per-user library - restrictions matter to you, turn off *Auto-created accounts can access all libraries* (or - auto-creation entirely) and provision groups from the dashboard. +- **Library access is an intersection, never a union.** A shared account is granted only the + libraries that *every* member can already reach. If Alice is blocked from a library, no group + containing Alice can see it — even if everyone else can. Joining a group can therefore never grant + anyone access they did not already have, which is what makes auto-creation safe to leave on. + Members with nothing in common produce an account that sees nothing. +- **Blocked folders stay blocked.** An explicitly blocked library is subtracted even from a member + who otherwise has "access to all libraries". +- **The intersection is recomputed, not frozen.** It is recalculated whenever a group's membership + changes, and re-applied to every group at server startup, so narrowing a member's own access + narrows the groups they belong to. - **Disabled members are excluded.** A disabled Jellyfin user can no longer unlock the shared account, and no longer receives watched state. - **Shared accounts cannot be nested.** A shared account may not be a member of another group; this diff --git a/build.yaml b/build.yaml index fe7ebdc..7c5a937 100644 --- a/build.yaml +++ b/build.yaml @@ -10,6 +10,9 @@ description: > password unlocks the shared account, and anything marked watched or unwatched there propagates one-way to each member's individual account. + A shared account is granted only the libraries every member can already reach, so + sharing an account never grants access nobody had. + This is not synchronized playback - for watching in lockstep across devices, use Jellyfin's built-in SyncPlay. Watched Together solves the "one TV, one login, but everyone's Continue Watching should stay correct" problem instead. @@ -23,5 +26,6 @@ dotnet_framework: "net9.0" # Point at the plugin project rather than the solution so the test project is not packaged. project: "Jellyfin.Plugin.WatchedTogether/Jellyfin.Plugin.WatchedTogether.csproj" changelog: > - Initial release: provisioned shared accounts, multi-password authentication, - and one-way played-state sync to members. + Initial release: shared accounts created on demand at login, multi-password + authentication, one-way played-state sync to members, and library access + computed as the intersection of the members'. diff --git a/manifest.json b/manifest.json index 96fb756..8850b60 100644 --- a/manifest.json +++ b/manifest.json @@ -2,7 +2,7 @@ { "guid": "aa3288a0-e8c1-43e2-8045-8c3411142a5b", "name": "Watched Together", - "description": "Lets several users share a single viewing account. Any member's password unlocks the shared account, and anything marked watched or unwatched there propagates one-way to each member's individual account. This is not synchronized playback - for that, use Jellyfin's built-in SyncPlay.", + "description": "Lets several users share a single viewing account. Log in as 'alice+bob' with your own password and the account is created on the spot. Any member's password unlocks it, and anything marked watched or unwatched there propagates one-way to each member's individual account. The account is granted only the libraries every member can already reach. This is not synchronized playback - for that, use Jellyfin's built-in SyncPlay.", "overview": "One shared login for several people; watched state flows back to each member's own account", "owner": "dtourolle", "category": "General",