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",