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:
@@ -72,10 +72,8 @@ public class DynamicGroupTests
|
||||
|
||||
provisioning.Setup(p => p.CreateGroupAsync(
|
||||
It.IsAny<IReadOnlyList<Guid>>(),
|
||||
It.IsAny<string?>(),
|
||||
It.IsAny<bool>(),
|
||||
It.IsAny<IReadOnlyList<Guid>?>()))
|
||||
.ReturnsAsync((IReadOnlyList<Guid> ids, string? name, bool _, IReadOnlyList<Guid>? _) =>
|
||||
It.IsAny<string?>()))
|
||||
.ReturnsAsync((IReadOnlyList<Guid> 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<IReadOnlyList<Guid>>(ids => ids.Count == 2),
|
||||
"alice+bob",
|
||||
It.IsAny<bool>(),
|
||||
It.IsAny<IReadOnlyList<Guid>?>()),
|
||||
"alice+bob"),
|
||||
Times.Once);
|
||||
}
|
||||
|
||||
@@ -229,9 +225,7 @@ public class DynamicGroupTests
|
||||
h.Provisioning.Verify(
|
||||
p => p.CreateGroupAsync(
|
||||
It.Is<IReadOnlyList<Guid>>(ids => ids.Count == 3),
|
||||
It.IsAny<string?>(),
|
||||
It.IsAny<bool>(),
|
||||
It.IsAny<IReadOnlyList<Guid>?>()),
|
||||
It.IsAny<string?>()),
|
||||
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.
|
||||
/// </remarks>
|
||||
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 class="checkboxContainer checkboxContainer-withDescription">
|
||||
<label class="emby-checkbox-label">
|
||||
<input id="EnableAllFolders" type="checkbox" is="emby-checkbox" checked />
|
||||
<span>Grant access to all libraries</span>
|
||||
</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 class="fieldDescription" style="margin:1em 0">
|
||||
The shared account is granted only the libraries <em>every</em> 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.
|
||||
</div>
|
||||
|
||||
<div>
|
||||
@@ -81,17 +75,6 @@
|
||||
</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">
|
||||
<label class="inputLabel inputLabelUnfocused" for="NameSeparator">Name separator</label>
|
||||
<input id="NameSeparator" name="NameSeparator" type="text" is="emby-input" maxlength="3" />
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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
|
||||
{
|
||||
|
||||
@@ -16,15 +16,9 @@ public class CreateGroupRequest
|
||||
/// <summary>
|
||||
/// Gets or sets an explicit account name. When empty, one is generated from the member names.
|
||||
/// </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; }
|
||||
|
||||
/// <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)
|
||||
{
|
||||
serviceCollection.AddSingleton<IGroupService, GroupService>();
|
||||
serviceCollection.AddSingleton<ILibraryAccessService, LibraryAccessService>();
|
||||
serviceCollection.AddSingleton<IProvisioningService, ProvisioningService>();
|
||||
serviceCollection.AddSingleton<IDynamicGroupService, DynamicGroupService>();
|
||||
|
||||
|
||||
@@ -124,11 +124,11 @@ public class DynamicGroupService : IDynamicGroupService
|
||||
return null;
|
||||
}
|
||||
|
||||
// The account is limited to the libraries all named members share, so creating one at the
|
||||
// login screen cannot grant anybody access they did not already have.
|
||||
var group = await _provisioningService.CreateGroupAsync(
|
||||
members.Select(m => m.Id).ToList(),
|
||||
enteredUsername,
|
||||
enableAllFolders: config.DynamicGroupsEnableAllFolders,
|
||||
enabledFolders: null).ConfigureAwait(false);
|
||||
enteredUsername).ConfigureAwait(false);
|
||||
|
||||
var sharedUser = _userManager.GetUserById(group.SharedUserId);
|
||||
if (sharedUser is null)
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace Jellyfin.Plugin.WatchedTogether.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Computes and applies the library access a shared account should have.
|
||||
/// </summary>
|
||||
public interface ILibraryAccessService
|
||||
{
|
||||
/// <summary>
|
||||
/// Computes the set of libraries every one of the given members can reach.
|
||||
/// </summary>
|
||||
/// <param name="memberIds">The members to intersect.</param>
|
||||
/// <returns>
|
||||
/// The library identifiers common to all members. An empty set means the members have no
|
||||
/// library in common, and the shared account should see nothing.
|
||||
/// </returns>
|
||||
IReadOnlyList<Guid> ComputeIntersection(IReadOnlyList<Guid> memberIds);
|
||||
|
||||
/// <summary>
|
||||
/// Recomputes the intersection for a group and writes it to its shared account.
|
||||
/// </summary>
|
||||
/// <param name="sharedUserId">The shared account to update.</param>
|
||||
/// <param name="memberIds">The group's members.</param>
|
||||
/// <returns>The libraries granted to the shared account.</returns>
|
||||
Task<IReadOnlyList<Guid>> ApplyIntersectionAsync(Guid sharedUserId, IReadOnlyList<Guid> memberIds);
|
||||
}
|
||||
@@ -13,16 +13,14 @@ public interface IProvisioningService
|
||||
/// <summary>
|
||||
/// Creates a shared account for the given members and records the group.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The account is granted exactly the libraries every member can already reach, so it can never
|
||||
/// be used to see more than any one member could alone.
|
||||
/// </remarks>
|
||||
/// <param name="memberIds">The members whose passwords will unlock the account. At least two.</param>
|
||||
/// <param name="name">An explicit account name, or <c>null</c> to generate one from the member names.</param>
|
||||
/// <param name="enableAllFolders">Whether the shared account may access all libraries.</param>
|
||||
/// <param name="enabledFolders">Explicit library identifiers, used when <paramref name="enableAllFolders"/> is false.</param>
|
||||
/// <returns>The created group.</returns>
|
||||
Task<SharedGroup> CreateGroupAsync(
|
||||
IReadOnlyList<Guid> memberIds,
|
||||
string? name,
|
||||
bool enableAllFolders,
|
||||
IReadOnlyList<Guid>? enabledFolders);
|
||||
Task<SharedGroup> CreateGroupAsync(IReadOnlyList<Guid> memberIds, string? name);
|
||||
|
||||
/// <summary>
|
||||
/// Replaces the membership and options of an existing group.
|
||||
|
||||
@@ -0,0 +1,176 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using Jellyfin.Data;
|
||||
using Jellyfin.Database.Implementations.Entities;
|
||||
using Jellyfin.Database.Implementations.Enums;
|
||||
using MediaBrowser.Controller.Library;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Jellyfin.Plugin.WatchedTogether.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Grants a shared account only the libraries that <em>every</em> member can already reach.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Sharing an account must never be a way to gain access. If Alice is blocked from a library, a
|
||||
/// group she belongs to cannot see it either, even when every other member can. The result is the
|
||||
/// intersection of the members' access, so the shared account is always a subset of what each
|
||||
/// member could reach alone.
|
||||
/// </remarks>
|
||||
public class LibraryAccessService : ILibraryAccessService
|
||||
{
|
||||
private readonly IUserManager _userManager;
|
||||
private readonly ILibraryManager _libraryManager;
|
||||
private readonly ILogger<LibraryAccessService> _logger;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="LibraryAccessService"/> class.
|
||||
/// </summary>
|
||||
/// <param name="userManager">The user manager.</param>
|
||||
/// <param name="libraryManager">The library manager.</param>
|
||||
/// <param name="logger">The logger.</param>
|
||||
public LibraryAccessService(
|
||||
IUserManager userManager,
|
||||
ILibraryManager libraryManager,
|
||||
ILogger<LibraryAccessService> logger)
|
||||
{
|
||||
_userManager = userManager;
|
||||
_libraryManager = libraryManager;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public IReadOnlyList<Guid> ComputeIntersection(IReadOnlyList<Guid> memberIds)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(memberIds);
|
||||
|
||||
if (memberIds.Count == 0)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
HashSet<Guid>? intersection = null;
|
||||
|
||||
foreach (var memberId in memberIds)
|
||||
{
|
||||
var member = _userManager.GetUserById(memberId);
|
||||
if (member is null)
|
||||
{
|
||||
// A member we cannot resolve contributes no access. Treating it as "everything"
|
||||
// would silently widen the group.
|
||||
_logger.LogWarning(
|
||||
"Member {MemberId} could not be resolved; treating its library access as empty",
|
||||
memberId);
|
||||
return [];
|
||||
}
|
||||
|
||||
var accessible = GetAccessibleLibraries(member);
|
||||
|
||||
if (intersection is null)
|
||||
{
|
||||
intersection = accessible;
|
||||
}
|
||||
else
|
||||
{
|
||||
intersection.IntersectWith(accessible);
|
||||
}
|
||||
|
||||
// Nothing left in common; no later member can widen it again.
|
||||
if (intersection.Count == 0)
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return intersection?.ToList() ?? (IReadOnlyList<Guid>)[];
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<Guid>> ApplyIntersectionAsync(
|
||||
Guid sharedUserId,
|
||||
IReadOnlyList<Guid> memberIds)
|
||||
{
|
||||
var granted = ComputeIntersection(memberIds);
|
||||
|
||||
var sharedUser = _userManager.GetUserById(sharedUserId);
|
||||
if (sharedUser is null)
|
||||
{
|
||||
return granted;
|
||||
}
|
||||
|
||||
// Always an explicit list, never EnableAllFolders: "all" would keep growing as libraries
|
||||
// are added, silently granting the group access nobody agreed to.
|
||||
var policy = _userManager.GetUserDto(sharedUser).Policy;
|
||||
if (policy is null)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Could not read the policy for shared account {SharedUserId}; library access unchanged",
|
||||
sharedUserId);
|
||||
return granted;
|
||||
}
|
||||
|
||||
policy.EnableAllFolders = false;
|
||||
policy.EnabledFolders = granted.ToArray();
|
||||
|
||||
await _userManager.UpdatePolicyAsync(sharedUserId, policy).ConfigureAwait(false);
|
||||
|
||||
if (granted.Count == 0)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Shared account {SharedUserId} has no libraries: its members have none in common",
|
||||
sharedUserId);
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogInformation(
|
||||
"Shared account {SharedUserId} granted {Count} libraries common to all members",
|
||||
sharedUserId,
|
||||
granted.Count);
|
||||
}
|
||||
|
||||
return granted;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resolves the concrete set of libraries a single user can reach.
|
||||
/// </summary>
|
||||
/// <param name="user">The user to inspect.</param>
|
||||
/// <returns>The library identifiers this user can see.</returns>
|
||||
private HashSet<Guid> GetAccessibleLibraries(User user)
|
||||
{
|
||||
// "Enable all folders" has to be expanded into concrete ids before it can be intersected
|
||||
// with another member's explicit list.
|
||||
var accessible = user.HasPermission(PermissionKind.EnableAllFolders)
|
||||
? GetAllLibraryIds()
|
||||
: user.GetPreferenceValues<Guid>(PreferenceKind.EnabledFolders).ToHashSet();
|
||||
|
||||
// An explicitly blocked folder is removed even when the user otherwise has access to all.
|
||||
accessible.ExceptWith(user.GetPreferenceValues<Guid>(PreferenceKind.BlockedMediaFolders));
|
||||
|
||||
return accessible;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the identifiers of every top-level library on the server.
|
||||
/// </summary>
|
||||
/// <returns>All library identifiers.</returns>
|
||||
private HashSet<Guid> GetAllLibraryIds()
|
||||
{
|
||||
try
|
||||
{
|
||||
return _libraryManager.GetUserRootFolder()
|
||||
.Children
|
||||
.Select(c => c.Id)
|
||||
.ToHashSet();
|
||||
}
|
||||
#pragma warning disable CA1031 // Failing closed is the safe direction for an access decision.
|
||||
catch (Exception ex)
|
||||
#pragma warning restore CA1031
|
||||
{
|
||||
_logger.LogError(ex, "Could not enumerate libraries; treating access as empty");
|
||||
return [];
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -31,25 +31,27 @@ public class ProvisioningService : IProvisioningService
|
||||
typeof(Auth.SharedAccountAuthenticationProvider).FullName!;
|
||||
|
||||
private readonly IUserManager _userManager;
|
||||
private readonly ILibraryAccessService _libraryAccessService;
|
||||
private readonly ILogger<ProvisioningService> _logger;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="ProvisioningService"/> class.
|
||||
/// </summary>
|
||||
/// <param name="userManager">The user manager.</param>
|
||||
/// <param name="libraryAccessService">The library access service.</param>
|
||||
/// <param name="logger">The logger.</param>
|
||||
public ProvisioningService(IUserManager userManager, ILogger<ProvisioningService> logger)
|
||||
public ProvisioningService(
|
||||
IUserManager userManager,
|
||||
ILibraryAccessService libraryAccessService,
|
||||
ILogger<ProvisioningService> logger)
|
||||
{
|
||||
_userManager = userManager;
|
||||
_libraryAccessService = libraryAccessService;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<SharedGroup> CreateGroupAsync(
|
||||
IReadOnlyList<Guid> memberIds,
|
||||
string? name,
|
||||
bool enableAllFolders,
|
||||
IReadOnlyList<Guid>? enabledFolders)
|
||||
public async Task<SharedGroup> CreateGroupAsync(IReadOnlyList<Guid> memberIds, string? name)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(memberIds);
|
||||
|
||||
@@ -103,7 +105,7 @@ public class ProvisioningService : IProvisioningService
|
||||
await _userManager.ChangePassword(sharedUser, GenerateUnusedPassword()).ConfigureAwait(false);
|
||||
await _userManager.UpdateUserAsync(sharedUser).ConfigureAwait(false);
|
||||
|
||||
await ApplyLibraryAccessAsync(sharedUser.Id, enableAllFolders, enabledFolders).ConfigureAwait(false);
|
||||
await ApplyLibraryAccessAsync(sharedUser.Id, distinctIds).ConfigureAwait(false);
|
||||
|
||||
var group = new SharedGroup
|
||||
{
|
||||
@@ -124,7 +126,7 @@ public class ProvisioningService : IProvisioningService
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<SharedGroup> UpdateGroupAsync(
|
||||
public async Task<SharedGroup> UpdateGroupAsync(
|
||||
Guid sharedUserId,
|
||||
IReadOnlyList<Guid> memberIds,
|
||||
bool syncUnwatched,
|
||||
@@ -170,13 +172,17 @@ public class ProvisioningService : IProvisioningService
|
||||
|
||||
plugin.UpdateConfiguration(config);
|
||||
|
||||
// Membership drives library access, so recompute it: adding a member can only narrow the
|
||||
// intersection, and removing one may widen it.
|
||||
await ApplyLibraryAccessAsync(sharedUserId, distinctIds).ConfigureAwait(false);
|
||||
|
||||
_logger.LogInformation(
|
||||
"Updated group {SharedUserId}: {MemberCount} members, disabled={IsDisabled}",
|
||||
sharedUserId,
|
||||
distinctIds.Count,
|
||||
isDisabled);
|
||||
|
||||
return Task.FromResult(group);
|
||||
return group;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
@@ -239,13 +245,9 @@ public class ProvisioningService : IProvisioningService
|
||||
/// Sets library access on the shared account.
|
||||
/// </summary>
|
||||
/// <param name="sharedUserId">The shared account.</param>
|
||||
/// <param name="enableAllFolders">Whether to grant access to every library.</param>
|
||||
/// <param name="enabledFolders">The explicit library list when not granting all.</param>
|
||||
/// <param name="memberIds">The members whose access is intersected.</param>
|
||||
/// <returns>A task representing the update.</returns>
|
||||
private async Task ApplyLibraryAccessAsync(
|
||||
Guid sharedUserId,
|
||||
bool enableAllFolders,
|
||||
IReadOnlyList<Guid>? enabledFolders)
|
||||
private async Task ApplyLibraryAccessAsync(Guid sharedUserId, IReadOnlyList<Guid> memberIds)
|
||||
{
|
||||
var user = _userManager.GetUserById(sharedUserId);
|
||||
if (user is null)
|
||||
@@ -253,24 +255,11 @@ public class ProvisioningService : IProvisioningService
|
||||
return;
|
||||
}
|
||||
|
||||
// Library access on the shared account is deliberate and independent of what each member
|
||||
// can reach individually - any member's password opens whatever this account can see.
|
||||
user.SetPermission(PermissionKind.EnableAllFolders, enableAllFolders);
|
||||
// Never "all folders": the shared account gets an explicit list of the libraries every
|
||||
// member can already reach, so joining a group can never grant access to anything.
|
||||
user.SetPermission(PermissionKind.EnableAllFolders, false);
|
||||
await _userManager.UpdateUserAsync(user).ConfigureAwait(false);
|
||||
|
||||
if (enableAllFolders)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var policy = _userManager.GetUserDto(user).Policy;
|
||||
if (policy is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
policy.EnableAllFolders = false;
|
||||
policy.EnabledFolders = enabledFolders?.ToArray() ?? [];
|
||||
await _userManager.UpdatePolicyAsync(sharedUserId, policy).ConfigureAwait(false);
|
||||
await _libraryAccessService.ApplyIntersectionAsync(sharedUserId, memberIds).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,6 +21,7 @@ public sealed class UserLifecycleService : IHostedService
|
||||
{
|
||||
private readonly IUserManager _userManager;
|
||||
private readonly IGroupService _groupService;
|
||||
private readonly ILibraryAccessService _libraryAccessService;
|
||||
private readonly ILogger<UserLifecycleService> _logger;
|
||||
|
||||
/// <summary>
|
||||
@@ -28,23 +29,27 @@ public sealed class UserLifecycleService : IHostedService
|
||||
/// </summary>
|
||||
/// <param name="userManager">The user manager.</param>
|
||||
/// <param name="groupService">The group service.</param>
|
||||
/// <param name="libraryAccessService">The library access service.</param>
|
||||
/// <param name="logger">The logger.</param>
|
||||
public UserLifecycleService(
|
||||
IUserManager userManager,
|
||||
IGroupService groupService,
|
||||
ILibraryAccessService libraryAccessService,
|
||||
ILogger<UserLifecycleService> logger)
|
||||
{
|
||||
_userManager = userManager;
|
||||
_groupService = groupService;
|
||||
_libraryAccessService = libraryAccessService;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task StartAsync(CancellationToken cancellationToken)
|
||||
public async Task StartAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
Reconcile();
|
||||
await ReapplyLibraryAccessAsync().ConfigureAwait(false);
|
||||
}
|
||||
#pragma warning disable CA1031 // Reconciliation must never prevent the server from starting.
|
||||
catch (Exception ex)
|
||||
@@ -52,8 +57,31 @@ public sealed class UserLifecycleService : IHostedService
|
||||
{
|
||||
_logger.LogError(ex, "Failed to reconcile Watched Together groups at startup");
|
||||
}
|
||||
}
|
||||
|
||||
return Task.CompletedTask;
|
||||
/// <summary>
|
||||
/// Recomputes every group's library access.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// A member's own library access can be narrowed at any time through the normal user editor,
|
||||
/// which would leave a group's stored intersection too wide. Recomputing at startup brings
|
||||
/// shared accounts back in line without needing to hook every policy change.
|
||||
/// </remarks>
|
||||
/// <returns>A task representing the update.</returns>
|
||||
private async Task ReapplyLibraryAccessAsync()
|
||||
{
|
||||
var config = Plugin.Instance?.Configuration;
|
||||
if (config is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
foreach (var group in config.Groups.ToList())
|
||||
{
|
||||
await _libraryAccessService
|
||||
.ApplyIntersectionAsync(group.SharedUserId, group.MemberUserIds)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
|
||||
@@ -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
|
||||
|
||||
+6
-2
@@ -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'.
|
||||
|
||||
+1
-1
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user