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:
2026-07-29 00:15:32 +02:00
parent 7be07d16a2
commit b4134dd744
16 changed files with 472 additions and 121 deletions
@@ -72,10 +72,8 @@ public class DynamicGroupTests
provisioning.Setup(p => p.CreateGroupAsync( provisioning.Setup(p => p.CreateGroupAsync(
It.IsAny<IReadOnlyList<Guid>>(), It.IsAny<IReadOnlyList<Guid>>(),
It.IsAny<string?>(), It.IsAny<string?>()))
It.IsAny<bool>(), .ReturnsAsync((IReadOnlyList<Guid> ids, string? name) =>
It.IsAny<IReadOnlyList<Guid>?>()))
.ReturnsAsync((IReadOnlyList<Guid> ids, string? name, bool _, IReadOnlyList<Guid>? _) =>
new SharedGroup { SharedUserId = createdShared.Id, MemberUserIds = [.. ids] }); new SharedGroup { SharedUserId = createdShared.Id, MemberUserIds = [.. ids] });
userManager.Setup(m => m.GetUserById(createdShared.Id)).Returns(createdShared); userManager.Setup(m => m.GetUserById(createdShared.Id)).Returns(createdShared);
@@ -103,9 +101,7 @@ public class DynamicGroupTests
h.Provisioning.Verify( h.Provisioning.Verify(
p => p.CreateGroupAsync( p => p.CreateGroupAsync(
It.Is<IReadOnlyList<Guid>>(ids => ids.Count == 2), It.Is<IReadOnlyList<Guid>>(ids => ids.Count == 2),
"alice+bob", "alice+bob"),
It.IsAny<bool>(),
It.IsAny<IReadOnlyList<Guid>?>()),
Times.Once); Times.Once);
} }
@@ -229,9 +225,7 @@ public class DynamicGroupTests
h.Provisioning.Verify( h.Provisioning.Verify(
p => p.CreateGroupAsync( p => p.CreateGroupAsync(
It.Is<IReadOnlyList<Guid>>(ids => ids.Count == 3), It.Is<IReadOnlyList<Guid>>(ids => ids.Count == 3),
It.IsAny<string?>(), It.IsAny<string?>()),
It.IsAny<bool>(),
It.IsAny<IReadOnlyList<Guid>?>()),
Times.Once); Times.Once);
} }
} }
@@ -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;
/// <summary>
/// Covers the rule that a shared account sees only what every member can already see.
/// </summary>
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];
/// <summary>
/// Creates a user with either full access or an explicit library list.
/// </summary>
private static User MakeUser(
string name,
bool allFolders = false,
IEnumerable<Guid>? enabled = null,
IEnumerable<Guid>? 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<IUserManager>();
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<BaseItem>();
folder.Object.Id = id;
return folder.Object;
}).ToList();
var root = new Mock<Folder>();
root.Setup(r => r.Children).Returns(children);
var libraryManager = new Mock<ILibraryManager>();
libraryManager.Setup(l => l.GetUserRootFolder()).Returns(root.Object);
return new LibraryAccessService(
userManager.Object,
libraryManager.Object,
NullLogger<LibraryAccessService>.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([]));
}
}
@@ -34,14 +34,4 @@ public class PluginConfiguration : BasePluginConfiguration
/// this plugin once no local user matches the typed name. /// this plugin once no local user matches the typed name.
/// </remarks> /// </remarks>
public bool EnableDynamicGroups { get; set; } = true; public bool EnableDynamicGroups { get; set; } = true;
/// <summary>
/// Gets or sets a value indicating whether accounts created on demand may access all libraries.
/// </summary>
/// <remarks>
/// 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.
/// </remarks>
public bool DynamicGroupsEnableAllFolders { get; set; } = true;
} }
@@ -45,16 +45,10 @@
</div> </div>
</div> </div>
<div class="checkboxContainer checkboxContainer-withDescription"> <div class="fieldDescription" style="margin:1em 0">
<label class="emby-checkbox-label"> The shared account is granted only the libraries <em>every</em> member can
<input id="EnableAllFolders" type="checkbox" is="emby-checkbox" checked /> already reach. If one member is blocked from a library, the group cannot see
<span>Grant access to all libraries</span> it either, so sharing an account never grants anyone new access.
</label>
<div class="fieldDescription">
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.
</div>
</div> </div>
<div> <div>
@@ -81,17 +75,6 @@
</div> </div>
</div> </div>
<div class="checkboxContainer checkboxContainer-withDescription">
<label class="emby-checkbox-label">
<input id="DynamicGroupsEnableAllFolders" type="checkbox" is="emby-checkbox" />
<span>Auto-created accounts can access all libraries</span>
</label>
<div class="fieldDescription">
Turn this off to have auto-created accounts start with no library access
until you grant it.
</div>
</div>
<div class="inputContainer"> <div class="inputContainer">
<label class="inputLabel inputLabelUnfocused" for="NameSeparator">Name separator</label> <label class="inputLabel inputLabelUnfocused" for="NameSeparator">Name separator</label>
<input id="NameSeparator" name="NameSeparator" type="text" is="emby-input" maxlength="3" /> <input id="NameSeparator" name="NameSeparator" type="text" is="emby-input" maxlength="3" />
@@ -187,7 +170,6 @@
ApiClient.getPluginConfiguration(pluginUniqueId).then(function (config) { ApiClient.getPluginConfiguration(pluginUniqueId).then(function (config) {
page.querySelector('#NameSeparator').value = config.NameSeparator || '+'; page.querySelector('#NameSeparator').value = config.NameSeparator || '+';
page.querySelector('#EnableDynamicGroups').checked = config.EnableDynamicGroups; page.querySelector('#EnableDynamicGroups').checked = config.EnableDynamicGroups;
page.querySelector('#DynamicGroupsEnableAllFolders').checked = config.DynamicGroupsEnableAllFolders;
}) })
]).then(function () { ]).then(function () {
Dashboard.hideLoadingMsg(); Dashboard.hideLoadingMsg();
@@ -216,9 +198,7 @@
contentType: 'application/json', contentType: 'application/json',
data: JSON.stringify({ data: JSON.stringify({
MemberUserIds: selected, MemberUserIds: selected,
Name: page.querySelector('#NewGroupName').value || null, Name: page.querySelector('#NewGroupName').value || null
EnableAllFolders: page.querySelector('#EnableAllFolders').checked,
EnabledFolders: null
}) })
}).then(function () { }).then(function () {
Dashboard.hideLoadingMsg(); Dashboard.hideLoadingMsg();
@@ -247,7 +227,6 @@
ApiClient.getPluginConfiguration(pluginUniqueId).then(function (config) { ApiClient.getPluginConfiguration(pluginUniqueId).then(function (config) {
config.NameSeparator = page.querySelector('#NameSeparator').value || '+'; config.NameSeparator = page.querySelector('#NameSeparator').value || '+';
config.EnableDynamicGroups = page.querySelector('#EnableDynamicGroups').checked; config.EnableDynamicGroups = page.querySelector('#EnableDynamicGroups').checked;
config.DynamicGroupsEnableAllFolders = page.querySelector('#DynamicGroupsEnableAllFolders').checked;
ApiClient.updatePluginConfiguration(pluginUniqueId, config).then(function (result) { ApiClient.updatePluginConfiguration(pluginUniqueId, config).then(function (result) {
Dashboard.processPluginConfigurationUpdateResult(result); Dashboard.processPluginConfigurationUpdateResult(result);
}); });
@@ -111,9 +111,7 @@ public class WatchedTogetherController : ControllerBase
{ {
var group = await _provisioningService.CreateGroupAsync( var group = await _provisioningService.CreateGroupAsync(
request.MemberUserIds, request.MemberUserIds,
request.Name, request.Name).ConfigureAwait(false);
request.EnableAllFolders,
request.EnabledFolders).ConfigureAwait(false);
return Ok(new GroupDto return Ok(new GroupDto
{ {
@@ -16,15 +16,9 @@ public class CreateGroupRequest
/// <summary> /// <summary>
/// Gets or sets an explicit account name. When empty, one is generated from the member names. /// Gets or sets an explicit account name. When empty, one is generated from the member names.
/// </summary> /// </summary>
/// <remarks>
/// Library access is not specified here: the account is granted exactly the libraries every
/// member can already reach.
/// </remarks>
public string? Name { get; set; } public string? Name { get; set; }
/// <summary>
/// Gets or sets a value indicating whether the shared account may access all libraries.
/// </summary>
public bool EnableAllFolders { get; set; } = true;
/// <summary>
/// Gets or sets the explicit libraries the shared account may access.
/// </summary>
public IReadOnlyList<Guid>? EnabledFolders { get; set; }
} }
@@ -16,6 +16,7 @@ public class ServiceRegistrator : IPluginServiceRegistrator
public void RegisterServices(IServiceCollection serviceCollection, IServerApplicationHost applicationHost) public void RegisterServices(IServiceCollection serviceCollection, IServerApplicationHost applicationHost)
{ {
serviceCollection.AddSingleton<IGroupService, GroupService>(); serviceCollection.AddSingleton<IGroupService, GroupService>();
serviceCollection.AddSingleton<ILibraryAccessService, LibraryAccessService>();
serviceCollection.AddSingleton<IProvisioningService, ProvisioningService>(); serviceCollection.AddSingleton<IProvisioningService, ProvisioningService>();
serviceCollection.AddSingleton<IDynamicGroupService, DynamicGroupService>(); serviceCollection.AddSingleton<IDynamicGroupService, DynamicGroupService>();
@@ -124,11 +124,11 @@ public class DynamicGroupService : IDynamicGroupService
return null; 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( var group = await _provisioningService.CreateGroupAsync(
members.Select(m => m.Id).ToList(), members.Select(m => m.Id).ToList(),
enteredUsername, enteredUsername).ConfigureAwait(false);
enableAllFolders: config.DynamicGroupsEnableAllFolders,
enabledFolders: null).ConfigureAwait(false);
var sharedUser = _userManager.GetUserById(group.SharedUserId); var sharedUser = _userManager.GetUserById(group.SharedUserId);
if (sharedUser is null) 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> /// <summary>
/// Creates a shared account for the given members and records the group. /// Creates a shared account for the given members and records the group.
/// </summary> /// </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="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="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> /// <returns>The created group.</returns>
Task<SharedGroup> CreateGroupAsync( Task<SharedGroup> CreateGroupAsync(IReadOnlyList<Guid> memberIds, string? name);
IReadOnlyList<Guid> memberIds,
string? name,
bool enableAllFolders,
IReadOnlyList<Guid>? enabledFolders);
/// <summary> /// <summary>
/// Replaces the membership and options of an existing group. /// 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!; typeof(Auth.SharedAccountAuthenticationProvider).FullName!;
private readonly IUserManager _userManager; private readonly IUserManager _userManager;
private readonly ILibraryAccessService _libraryAccessService;
private readonly ILogger<ProvisioningService> _logger; private readonly ILogger<ProvisioningService> _logger;
/// <summary> /// <summary>
/// Initializes a new instance of the <see cref="ProvisioningService"/> class. /// Initializes a new instance of the <see cref="ProvisioningService"/> class.
/// </summary> /// </summary>
/// <param name="userManager">The user manager.</param> /// <param name="userManager">The user manager.</param>
/// <param name="libraryAccessService">The library access service.</param>
/// <param name="logger">The logger.</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; _userManager = userManager;
_libraryAccessService = libraryAccessService;
_logger = logger; _logger = logger;
} }
/// <inheritdoc /> /// <inheritdoc />
public async Task<SharedGroup> CreateGroupAsync( public async Task<SharedGroup> CreateGroupAsync(IReadOnlyList<Guid> memberIds, string? name)
IReadOnlyList<Guid> memberIds,
string? name,
bool enableAllFolders,
IReadOnlyList<Guid>? enabledFolders)
{ {
ArgumentNullException.ThrowIfNull(memberIds); ArgumentNullException.ThrowIfNull(memberIds);
@@ -103,7 +105,7 @@ public class ProvisioningService : IProvisioningService
await _userManager.ChangePassword(sharedUser, GenerateUnusedPassword()).ConfigureAwait(false); await _userManager.ChangePassword(sharedUser, GenerateUnusedPassword()).ConfigureAwait(false);
await _userManager.UpdateUserAsync(sharedUser).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 var group = new SharedGroup
{ {
@@ -124,7 +126,7 @@ public class ProvisioningService : IProvisioningService
} }
/// <inheritdoc /> /// <inheritdoc />
public Task<SharedGroup> UpdateGroupAsync( public async Task<SharedGroup> UpdateGroupAsync(
Guid sharedUserId, Guid sharedUserId,
IReadOnlyList<Guid> memberIds, IReadOnlyList<Guid> memberIds,
bool syncUnwatched, bool syncUnwatched,
@@ -170,13 +172,17 @@ public class ProvisioningService : IProvisioningService
plugin.UpdateConfiguration(config); 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( _logger.LogInformation(
"Updated group {SharedUserId}: {MemberCount} members, disabled={IsDisabled}", "Updated group {SharedUserId}: {MemberCount} members, disabled={IsDisabled}",
sharedUserId, sharedUserId,
distinctIds.Count, distinctIds.Count,
isDisabled); isDisabled);
return Task.FromResult(group); return group;
} }
/// <inheritdoc /> /// <inheritdoc />
@@ -239,13 +245,9 @@ public class ProvisioningService : IProvisioningService
/// Sets library access on the shared account. /// Sets library access on the shared account.
/// </summary> /// </summary>
/// <param name="sharedUserId">The shared account.</param> /// <param name="sharedUserId">The shared account.</param>
/// <param name="enableAllFolders">Whether to grant access to every library.</param> /// <param name="memberIds">The members whose access is intersected.</param>
/// <param name="enabledFolders">The explicit library list when not granting all.</param>
/// <returns>A task representing the update.</returns> /// <returns>A task representing the update.</returns>
private async Task ApplyLibraryAccessAsync( private async Task ApplyLibraryAccessAsync(Guid sharedUserId, IReadOnlyList<Guid> memberIds)
Guid sharedUserId,
bool enableAllFolders,
IReadOnlyList<Guid>? enabledFolders)
{ {
var user = _userManager.GetUserById(sharedUserId); var user = _userManager.GetUserById(sharedUserId);
if (user is null) if (user is null)
@@ -253,24 +255,11 @@ public class ProvisioningService : IProvisioningService
return; return;
} }
// Library access on the shared account is deliberate and independent of what each member // Never "all folders": the shared account gets an explicit list of the libraries every
// can reach individually - any member's password opens whatever this account can see. // member can already reach, so joining a group can never grant access to anything.
user.SetPermission(PermissionKind.EnableAllFolders, enableAllFolders); user.SetPermission(PermissionKind.EnableAllFolders, false);
await _userManager.UpdateUserAsync(user).ConfigureAwait(false); await _userManager.UpdateUserAsync(user).ConfigureAwait(false);
if (enableAllFolders) await _libraryAccessService.ApplyIntersectionAsync(sharedUserId, memberIds).ConfigureAwait(false);
{
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);
} }
} }
@@ -21,6 +21,7 @@ public sealed class UserLifecycleService : IHostedService
{ {
private readonly IUserManager _userManager; private readonly IUserManager _userManager;
private readonly IGroupService _groupService; private readonly IGroupService _groupService;
private readonly ILibraryAccessService _libraryAccessService;
private readonly ILogger<UserLifecycleService> _logger; private readonly ILogger<UserLifecycleService> _logger;
/// <summary> /// <summary>
@@ -28,23 +29,27 @@ public sealed class UserLifecycleService : IHostedService
/// </summary> /// </summary>
/// <param name="userManager">The user manager.</param> /// <param name="userManager">The user manager.</param>
/// <param name="groupService">The group service.</param> /// <param name="groupService">The group service.</param>
/// <param name="libraryAccessService">The library access service.</param>
/// <param name="logger">The logger.</param> /// <param name="logger">The logger.</param>
public UserLifecycleService( public UserLifecycleService(
IUserManager userManager, IUserManager userManager,
IGroupService groupService, IGroupService groupService,
ILibraryAccessService libraryAccessService,
ILogger<UserLifecycleService> logger) ILogger<UserLifecycleService> logger)
{ {
_userManager = userManager; _userManager = userManager;
_groupService = groupService; _groupService = groupService;
_libraryAccessService = libraryAccessService;
_logger = logger; _logger = logger;
} }
/// <inheritdoc /> /// <inheritdoc />
public Task StartAsync(CancellationToken cancellationToken) public async Task StartAsync(CancellationToken cancellationToken)
{ {
try try
{ {
Reconcile(); Reconcile();
await ReapplyLibraryAccessAsync().ConfigureAwait(false);
} }
#pragma warning disable CA1031 // Reconciliation must never prevent the server from starting. #pragma warning disable CA1031 // Reconciliation must never prevent the server from starting.
catch (Exception ex) catch (Exception ex)
@@ -52,8 +57,31 @@ public sealed class UserLifecycleService : IHostedService
{ {
_logger.LogError(ex, "Failed to reconcile Watched Together groups at startup"); _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 /> /// <inheritdoc />
+19 -15
View File
@@ -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 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. 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" login as "alice+bob+carol"
with any one member's password 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**. 1. Go to **Dashboard → Plugins → Watched Together**.
2. Under **Create a group**, select **two or more** members. 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 `+`. 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). 4. Click **Create group**.
5. Click **Create group**.
Either way, a new user appears in your user list and can be renamed like any other. 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 | | Setting | Default | Meaning |
| --- | --- | --- | | --- | --- | --- |
| Create groups automatically at login | on | Enables the `alice+bob` login flow described above. Turn off to require dashboard provisioning. | | 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. | | 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 ## 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 - **Library access is an intersection, never a union.** A shared account is granted only the
that account sees whatever libraries *you* granted *it*, independent of each member's own libraries that *every* member can already reach. If Alice is blocked from a library, no group
restrictions. If a member is normally blocked from a library but the shared account is not, that containing Alice can see it — even if everyone else can. Joining a group can therefore never grant
member's password now reaches it. Set the shared account's library access accordingly. anyone access they did not already have, which is what makes auto-creation safe to leave on.
- **Auto-creation grants library access without an admin in the loop.** With both Members with nothing in common produce an account that sees nothing.
*Create groups automatically at login* and *Auto-created accounts can access all libraries* on, - **Blocked folders stay blocked.** An explicitly blocked library is subtracted even from a member
any user who knows a colleague's username can pair it with their own and reach a full-library who otherwise has "access to all libraries".
account. That is a real privilege escalation if your libraries are not uniformly visible. It is - **The intersection is recomputed, not frozen.** It is recalculated whenever a group's membership
still gated on a valid member password — nobody gets in without one — but if per-user library changes, and re-applied to every group at server startup, so narrowing a member's own access
restrictions matter to you, turn off *Auto-created accounts can access all libraries* (or narrows the groups they belong to.
auto-creation entirely) and provision groups from the dashboard.
- **Disabled members are excluded.** A disabled Jellyfin user can no longer unlock the shared - **Disabled members are excluded.** A disabled Jellyfin user can no longer unlock the shared
account, and no longer receives watched state. account, and no longer receives watched state.
- **Shared accounts cannot be nested.** A shared account may not be a member of another group; this - **Shared accounts cannot be nested.** A shared account may not be a member of another group; this
+6 -2
View File
@@ -10,6 +10,9 @@ description: >
password unlocks the shared account, and anything marked watched or unwatched there password unlocks the shared account, and anything marked watched or unwatched there
propagates one-way to each member's individual account. 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 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 Jellyfin's built-in SyncPlay. Watched Together solves the "one TV, one login, but
everyone's Continue Watching should stay correct" problem instead. 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. # Point at the plugin project rather than the solution so the test project is not packaged.
project: "Jellyfin.Plugin.WatchedTogether/Jellyfin.Plugin.WatchedTogether.csproj" project: "Jellyfin.Plugin.WatchedTogether/Jellyfin.Plugin.WatchedTogether.csproj"
changelog: > changelog: >
Initial release: provisioned shared accounts, multi-password authentication, Initial release: shared accounts created on demand at login, multi-password
and one-way played-state sync to members. authentication, one-way played-state sync to members, and library access
computed as the intersection of the members'.
+1 -1
View File
@@ -2,7 +2,7 @@
{ {
"guid": "aa3288a0-e8c1-43e2-8045-8c3411142a5b", "guid": "aa3288a0-e8c1-43e2-8045-8c3411142a5b",
"name": "Watched Together", "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", "overview": "One shared login for several people; watched state flows back to each member's own account",
"owner": "dtourolle", "owner": "dtourolle",
"category": "General", "category": "General",