Files
T
dtourolle b4134dd744 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.
2026-07-29 00:15:32 +02:00

266 lines
10 KiB
C#

using System;
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
using System.Security.Cryptography;
using System.Threading.Tasks;
using Jellyfin.Data;
using Jellyfin.Database.Implementations.Enums;
using Jellyfin.Plugin.WatchedTogether.Configuration;
using MediaBrowser.Controller.Library;
using Microsoft.Extensions.Logging;
namespace Jellyfin.Plugin.WatchedTogether.Services;
/// <summary>
/// Creates and maintains shared accounts and the groups that describe them.
/// </summary>
public class ProvisioningService : IProvisioningService
{
/// <summary>
/// The database column limit on usernames. A generated name is shortened to fit.
/// </summary>
private const int MaxUsernameLength = 255;
/// <summary>
/// The provider key Jellyfin stores on a shared account to route its logins to us. Jellyfin
/// resolves providers by <c>GetType().FullName</c>, so this must match the provider type's
/// full name exactly.
/// </summary>
private static readonly string AuthProviderId =
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,
ILibraryAccessService libraryAccessService,
ILogger<ProvisioningService> logger)
{
_userManager = userManager;
_libraryAccessService = libraryAccessService;
_logger = logger;
}
/// <inheritdoc />
public async Task<SharedGroup> CreateGroupAsync(IReadOnlyList<Guid> memberIds, string? name)
{
ArgumentNullException.ThrowIfNull(memberIds);
var plugin = Plugin.Instance
?? throw new InvalidOperationException("The plugin is not initialised.");
var config = plugin.Configuration;
var distinctIds = memberIds.Distinct().ToList();
if (distinctIds.Count < 2)
{
throw new ArgumentException("A group needs at least two distinct members.", nameof(memberIds));
}
var members = new List<Jellyfin.Database.Implementations.Entities.User>(distinctIds.Count);
foreach (var id in distinctIds)
{
var member = _userManager.GetUserById(id)
?? throw new ArgumentException(
string.Format(CultureInfo.InvariantCulture, "No user exists with id {0}.", id),
nameof(memberIds));
// A shared account must not become a member of another group: its own login is already
// a union of other people's credentials, and nesting would compound that invisibly.
if (config.Groups.Any(g => g.SharedUserId == id))
{
throw new ArgumentException(
string.Format(
CultureInfo.InvariantCulture,
"'{0}' is itself a shared account and cannot be a member of a group.",
member.Username),
nameof(memberIds));
}
members.Add(member);
}
var accountName = string.IsNullOrWhiteSpace(name)
? BuildDefaultName(members.Select(m => m.Username), config.NameSeparator)
: name.Trim();
var sharedUser = await _userManager.CreateUserAsync(accountName).ConfigureAwait(false);
// Route this account's logins through our provider. Jellyfin matches providers by
// GetType().FullName, the same key the SSO plugin uses, and the assignment only sticks
// once the user is updated.
sharedUser.AuthenticationProviderId = AuthProviderId;
// The shared account never authenticates against its own password - our provider checks
// member hashes instead. Setting a random one avoids leaving a passwordless account behind
// if the provider is ever unassigned.
await _userManager.ChangePassword(sharedUser, GenerateUnusedPassword()).ConfigureAwait(false);
await _userManager.UpdateUserAsync(sharedUser).ConfigureAwait(false);
await ApplyLibraryAccessAsync(sharedUser.Id, distinctIds).ConfigureAwait(false);
var group = new SharedGroup
{
SharedUserId = sharedUser.Id,
MemberUserIds = distinctIds
};
config.Groups.Add(group);
plugin.UpdateConfiguration(config);
_logger.LogInformation(
"Created shared account {Username} ({SharedUserId}) with {MemberCount} members",
sharedUser.Username,
sharedUser.Id,
distinctIds.Count);
return group;
}
/// <inheritdoc />
public async Task<SharedGroup> UpdateGroupAsync(
Guid sharedUserId,
IReadOnlyList<Guid> memberIds,
bool syncUnwatched,
bool syncPlayCount,
bool isDisabled)
{
ArgumentNullException.ThrowIfNull(memberIds);
var plugin = Plugin.Instance
?? throw new InvalidOperationException("The plugin is not initialised.");
var config = plugin.Configuration;
var group = config.Groups.FirstOrDefault(g => g.SharedUserId == sharedUserId)
?? throw new ArgumentException("No group exists for that shared account.", nameof(sharedUserId));
var distinctIds = memberIds.Distinct().ToList();
if (distinctIds.Count < 2)
{
throw new ArgumentException("A group needs at least two distinct members.", nameof(memberIds));
}
foreach (var id in distinctIds)
{
if (_userManager.GetUserById(id) is null)
{
throw new ArgumentException(
string.Format(CultureInfo.InvariantCulture, "No user exists with id {0}.", id),
nameof(memberIds));
}
if (id == sharedUserId || config.Groups.Any(g => g.SharedUserId == id))
{
throw new ArgumentException(
"A shared account cannot be a member of a group.",
nameof(memberIds));
}
}
group.MemberUserIds = distinctIds;
group.SyncUnwatched = syncUnwatched;
group.SyncPlayCount = syncPlayCount;
group.IsDisabled = isDisabled;
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 group;
}
/// <inheritdoc />
public async Task DeleteGroupAsync(Guid sharedUserId, bool deleteSharedUser)
{
var plugin = Plugin.Instance
?? throw new InvalidOperationException("The plugin is not initialised.");
var config = plugin.Configuration;
var group = config.Groups.FirstOrDefault(g => g.SharedUserId == sharedUserId)
?? throw new ArgumentException("No group exists for that shared account.", nameof(sharedUserId));
// Drop the group first: if account deletion fails we are left with an orphaned account
// rather than a group pointing at a user that may be half-deleted.
config.Groups.Remove(group);
plugin.UpdateConfiguration(config);
if (deleteSharedUser && _userManager.GetUserById(sharedUserId) is not null)
{
await _userManager.DeleteUserAsync(sharedUserId).ConfigureAwait(false);
_logger.LogInformation("Deleted shared account {SharedUserId}", sharedUserId);
}
_logger.LogInformation("Removed group {SharedUserId}", sharedUserId);
}
/// <summary>
/// Joins member names into a display name, falling back to a generic name if the result would
/// exceed the username column limit. Membership is tracked by GUID, so the name is cosmetic.
/// </summary>
/// <param name="usernames">The member usernames.</param>
/// <param name="separator">The configured separator.</param>
/// <returns>A name that fits within the username length limit.</returns>
private static string BuildDefaultName(IEnumerable<string> usernames, string separator)
{
var sep = string.IsNullOrEmpty(separator) ? "+" : separator;
var joined = string.Join(sep, usernames);
if (joined.Length <= MaxUsernameLength)
{
return joined;
}
// Truncating mid-name would produce something misleading, so switch to a neutral label
// with a short unique suffix instead.
return string.Format(
CultureInfo.InvariantCulture,
"Shared-{0}",
Guid.NewGuid().ToString("N")[..8]);
}
/// <summary>
/// Generates a random password that is never used for authentication.
/// </summary>
/// <returns>A random password string.</returns>
private static string GenerateUnusedPassword()
=> Convert.ToBase64String(RandomNumberGenerator.GetBytes(48));
/// <summary>
/// Sets library access on the shared account.
/// </summary>
/// <param name="sharedUserId">The shared account.</param>
/// <param name="memberIds">The members whose access is intersected.</param>
/// <returns>A task representing the update.</returns>
private async Task ApplyLibraryAccessAsync(Guid sharedUserId, IReadOnlyList<Guid> memberIds)
{
var user = _userManager.GetUserById(sharedUserId);
if (user is null)
{
return;
}
// 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);
await _libraryAccessService.ApplyIntersectionAsync(sharedUserId, memberIds).ConfigureAwait(false);
}
}