Inherit parental restrictions on shared accounts, with a chosen rating cap
A shared account previously inherited its members' library access but none of their content restrictions, so a child could log into "alice+kid" with their own password and get around their own rating cap. The shared account now gets the strictest member's parental rating, unrated-item block, blocked tags and allowed tags, recomputed at creation, on membership change and at startup. An admin can raise the rating cap on a slider between the strictest and the loosest member; unrated and tag rules stay strictest-wins. What makes raising the cap safe is the unlock rule: after a member's password matches, both users' live policies are compared and the login is refused if the account is looser than the member on any field. So raising the cap above the child's rating means the child's password no longer opens the account, while the parent's still does. The same rule bounds the slider - past the loosest member nobody could unlock the account - so a chosen cap is clamped back into range whenever applied. Allowed tags need care: Jellyfin reads an empty list as "no whitelist", so an empty intersection of members' whitelists is written as a sentinel tag no item carries. Access schedules and channels are not inherited yet. The shared account is never an administrator. Groups created at the login screen always inherit and are restricted before the first session exists. The dashboard shows each member's cap, who a chosen cap shuts out, and the restrictions in effect, and gains a per-group edit form for the sync options. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,236 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using Jellyfin.Data;
|
||||
using Jellyfin.Data.Enums;
|
||||
using Jellyfin.Database.Implementations.Entities;
|
||||
using Jellyfin.Database.Implementations.Enums;
|
||||
|
||||
namespace Jellyfin.Plugin.WatchedTogether.Services;
|
||||
|
||||
/// <summary>
|
||||
/// The content restrictions on one user, in a form that can be compared and combined.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Every field is read and written the way Jellyfin's <c>BaseItem.IsParentalAllowed</c> reads it,
|
||||
/// so "stricter" here means "hides at least everything the other hides" there:
|
||||
/// </para>
|
||||
/// <list type="bullet">
|
||||
/// <item>The rating cap allows an item whose score is below the cap, or equal to it with a
|
||||
/// sub-score no higher than the sub-cap (a null sub-cap allows any). A null cap allows everything.</item>
|
||||
/// <item>Each blocked unrated kind hides the unrated items of that kind.</item>
|
||||
/// <item>A blocked tag hides any item carrying it.</item>
|
||||
/// <item>A non-empty allowed-tag list hides any item carrying none of them. An <em>empty</em> list
|
||||
/// is not a whitelist at all: it allows everything through this route.</item>
|
||||
/// </list>
|
||||
/// </remarks>
|
||||
public sealed record ContentRestrictions
|
||||
{
|
||||
/// <summary>
|
||||
/// The tag written as the whole allowed-tag list when the members' whitelists have nothing in
|
||||
/// common. No item carries it, so it hides everything - which an empty list would not, since
|
||||
/// Jellyfin reads an empty list as "no whitelist".
|
||||
/// </summary>
|
||||
public const string NothingAllowedTag = "watched-together:nothing";
|
||||
|
||||
/// <summary>
|
||||
/// Gets restrictions that hide everything. Used for a member that cannot be resolved, on the
|
||||
/// same principle as library access: an unknown member must not widen the group.
|
||||
/// </summary>
|
||||
public static ContentRestrictions FullyRestricted { get; } = new()
|
||||
{
|
||||
MaxParentalRatingScore = 0,
|
||||
MaxParentalRatingSubScore = 0,
|
||||
BlockUnratedItems = Enum.GetValues<UnratedItem>().ToHashSet(),
|
||||
BlockedTags = new HashSet<string>(StringComparer.OrdinalIgnoreCase),
|
||||
AllowedTags = new HashSet<string>(StringComparer.OrdinalIgnoreCase),
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Gets the parental rating cap, or <c>null</c> for no cap.
|
||||
/// </summary>
|
||||
public int? MaxParentalRatingScore { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the sub-score cap that applies at exactly <see cref="MaxParentalRatingScore"/>, or
|
||||
/// <c>null</c> for any sub-score.
|
||||
/// </summary>
|
||||
public int? MaxParentalRatingSubScore { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the kinds of unrated item that are hidden.
|
||||
/// </summary>
|
||||
public IReadOnlySet<UnratedItem> BlockUnratedItems { get; init; } = new HashSet<UnratedItem>();
|
||||
|
||||
/// <summary>
|
||||
/// Gets the tags that hide an item.
|
||||
/// </summary>
|
||||
public IReadOnlySet<string> BlockedTags { get; init; } = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
/// <summary>
|
||||
/// Gets the whitelist an item must match, <c>null</c> when there is no whitelist, or an empty
|
||||
/// set when the whitelist is in force but lets nothing through.
|
||||
/// </summary>
|
||||
public IReadOnlySet<string>? AllowedTags { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets a value indicating whether a whitelist is in force.
|
||||
/// </summary>
|
||||
public bool HasAllowedTags => AllowedTags is not null;
|
||||
|
||||
/// <summary>
|
||||
/// Reads a user's live restrictions.
|
||||
/// </summary>
|
||||
/// <param name="user">The user to read.</param>
|
||||
/// <returns>The user's restrictions.</returns>
|
||||
public static ContentRestrictions FromUser(User user)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(user);
|
||||
|
||||
var allowed = CleanTags(user.GetPreference(PreferenceKind.AllowedTags));
|
||||
|
||||
return new ContentRestrictions
|
||||
{
|
||||
MaxParentalRatingScore = user.MaxParentalRatingScore,
|
||||
MaxParentalRatingSubScore = user.MaxParentalRatingSubScore,
|
||||
BlockUnratedItems = user.GetPreferenceValues<UnratedItem>(PreferenceKind.BlockUnratedItems).ToHashSet(),
|
||||
BlockedTags = CleanTags(user.GetPreference(PreferenceKind.BlockedTags)),
|
||||
AllowedTags = allowed.Count switch
|
||||
{
|
||||
0 => null,
|
||||
// A whitelist consisting only of the sentinel is the stored form of "nothing".
|
||||
1 when allowed.Contains(NothingAllowedTag) => new HashSet<string>(StringComparer.OrdinalIgnoreCase),
|
||||
_ => allowed,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Combines two sets of restrictions, keeping the stricter value of each field.
|
||||
/// </summary>
|
||||
/// <param name="other">The restrictions to combine with.</param>
|
||||
/// <returns>Restrictions at least as strict as both.</returns>
|
||||
public ContentRestrictions CombineStrictest(ContentRestrictions other)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(other);
|
||||
|
||||
var (score, subScore) = RatingCapIsAtMost(this, other)
|
||||
? (MaxParentalRatingScore, MaxParentalRatingSubScore)
|
||||
: (other.MaxParentalRatingScore, other.MaxParentalRatingSubScore);
|
||||
|
||||
var blockUnrated = new HashSet<UnratedItem>(BlockUnratedItems);
|
||||
blockUnrated.UnionWith(other.BlockUnratedItems);
|
||||
|
||||
var blocked = new HashSet<string>(BlockedTags, StringComparer.OrdinalIgnoreCase);
|
||||
blocked.UnionWith(other.BlockedTags);
|
||||
|
||||
// Only members with a whitelist constrain; a member without one accepts the other's.
|
||||
IReadOnlySet<string>? allowed;
|
||||
if (AllowedTags is null)
|
||||
{
|
||||
allowed = other.AllowedTags;
|
||||
}
|
||||
else if (other.AllowedTags is null)
|
||||
{
|
||||
allowed = AllowedTags;
|
||||
}
|
||||
else
|
||||
{
|
||||
var both = new HashSet<string>(AllowedTags, StringComparer.OrdinalIgnoreCase);
|
||||
both.IntersectWith(other.AllowedTags);
|
||||
allowed = both;
|
||||
}
|
||||
|
||||
return new ContentRestrictions
|
||||
{
|
||||
MaxParentalRatingScore = score,
|
||||
MaxParentalRatingSubScore = subScore,
|
||||
BlockUnratedItems = blockUnrated,
|
||||
BlockedTags = blocked,
|
||||
AllowedTags = allowed,
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Determines whether these restrictions hide at least everything <paramref name="other"/>
|
||||
/// hides.
|
||||
/// </summary>
|
||||
/// <param name="other">The restrictions to compare against.</param>
|
||||
/// <returns><c>true</c> if every field here is at least as restrictive.</returns>
|
||||
public bool IsAtLeastAsStrictAs(ContentRestrictions other)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(other);
|
||||
|
||||
if (!RatingCapIsAtMost(this, other))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!BlockUnratedItems.IsSupersetOf(other.BlockUnratedItems))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!BlockedTags.IsSupersetOf(other.BlockedTags))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// No whitelist on the other side constrains nothing. Otherwise ours must exist and let
|
||||
// through no more than theirs does.
|
||||
return other.AllowedTags is null
|
||||
|| (AllowedTags is not null && AllowedTags.IsSubsetOf(other.AllowedTags));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the allowed-tag list in the form Jellyfin stores it: empty for no whitelist, the
|
||||
/// sentinel for a whitelist that allows nothing.
|
||||
/// </summary>
|
||||
/// <returns>The tags to write to the user's policy.</returns>
|
||||
public string[] AllowedTagsForPolicy()
|
||||
{
|
||||
if (AllowedTags is null)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
return AllowedTags.Count == 0 ? [NothingAllowedTag] : AllowedTags.ToArray();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Compares two rating caps: true when <paramref name="a"/>'s cap allows no more than
|
||||
/// <paramref name="b"/>'s. A null cap allows everything; at an equal score, a null sub-cap
|
||||
/// allows every sub-score.
|
||||
/// </summary>
|
||||
private static bool RatingCapIsAtMost(ContentRestrictions a, ContentRestrictions b)
|
||||
{
|
||||
if (b.MaxParentalRatingScore is null)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if (a.MaxParentalRatingScore is null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (a.MaxParentalRatingScore.Value != b.MaxParentalRatingScore.Value)
|
||||
{
|
||||
return a.MaxParentalRatingScore.Value < b.MaxParentalRatingScore.Value;
|
||||
}
|
||||
|
||||
if (b.MaxParentalRatingSubScore is null)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
return a.MaxParentalRatingSubScore is not null
|
||||
&& a.MaxParentalRatingSubScore.Value <= b.MaxParentalRatingSubScore.Value;
|
||||
}
|
||||
|
||||
private static HashSet<string> CleanTags(IEnumerable<string> tags)
|
||||
=> tags.Where(t => !string.IsNullOrWhiteSpace(t))
|
||||
.Select(t => t.Trim())
|
||||
.ToHashSet(StringComparer.OrdinalIgnoreCase);
|
||||
}
|
||||
@@ -31,6 +31,7 @@ public class DynamicGroupService : IDynamicGroupService
|
||||
{
|
||||
private readonly IUserManager _userManager;
|
||||
private readonly IProvisioningService _provisioningService;
|
||||
private readonly IRestrictionService _restrictionService;
|
||||
private readonly ICryptoProvider _cryptoProvider;
|
||||
private readonly ILogger<DynamicGroupService> _logger;
|
||||
|
||||
@@ -39,16 +40,19 @@ public class DynamicGroupService : IDynamicGroupService
|
||||
/// </summary>
|
||||
/// <param name="userManager">The user manager.</param>
|
||||
/// <param name="provisioningService">The provisioning service.</param>
|
||||
/// <param name="restrictionService">The content restriction service.</param>
|
||||
/// <param name="cryptoProvider">The crypto provider.</param>
|
||||
/// <param name="logger">The logger.</param>
|
||||
public DynamicGroupService(
|
||||
IUserManager userManager,
|
||||
IProvisioningService provisioningService,
|
||||
IRestrictionService restrictionService,
|
||||
ICryptoProvider cryptoProvider,
|
||||
ILogger<DynamicGroupService> logger)
|
||||
{
|
||||
_userManager = userManager;
|
||||
_provisioningService = provisioningService;
|
||||
_restrictionService = restrictionService;
|
||||
_cryptoProvider = cryptoProvider;
|
||||
_logger = logger;
|
||||
}
|
||||
@@ -155,6 +159,17 @@ public class DynamicGroupService : IDynamicGroupService
|
||||
return null;
|
||||
}
|
||||
|
||||
// The same unlock rule the authentication provider applies to a resolved account: an
|
||||
// existing group may have had its rating cap raised in the dashboard since it was created.
|
||||
if (!_restrictionService.IsAtLeastAsStrict(existingUser, matched))
|
||||
{
|
||||
_logger.LogInformation(
|
||||
"Rejected login for {Username} by member {MemberUsername}: the shared account is less restricted than the member",
|
||||
existingUser.Username,
|
||||
matched.Username);
|
||||
return null;
|
||||
}
|
||||
|
||||
_logger.LogInformation(
|
||||
"Login as {Entered} resolved to the existing shared account {Username}",
|
||||
enteredUsername,
|
||||
@@ -165,8 +180,9 @@ public class DynamicGroupService : IDynamicGroupService
|
||||
|
||||
// Passing no name lets provisioning generate the canonical alphabetically-sorted one, so
|
||||
// the account is named the same whichever order the members were typed in.
|
||||
// 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.
|
||||
// The account is limited to the libraries all named members share and inherits their
|
||||
// strictest restrictions before this returns, so creating one at the login screen cannot
|
||||
// grant anybody access they did not already have - not even for the session it creates.
|
||||
var group = await _provisioningService.CreateGroupAsync(memberIds, null).ConfigureAwait(false);
|
||||
|
||||
var sharedUser = _userManager.GetUserById(group.SharedUserId);
|
||||
|
||||
@@ -30,13 +30,20 @@ public interface IProvisioningService
|
||||
/// <param name="syncUnwatched">Whether unwatched state propagates too.</param>
|
||||
/// <param name="syncPlayCount">Whether play counts are raised on watch.</param>
|
||||
/// <param name="isDisabled">Whether the group is suspended.</param>
|
||||
/// <returns>The updated group.</returns>
|
||||
/// <param name="inheritParentalRating">Whether the rating cap is the strictest member's.</param>
|
||||
/// <param name="parentalRatingCap">
|
||||
/// The rating cap to use instead, as Jellyfin's numeric score (<c>null</c> for none). Ignored
|
||||
/// when inheriting. Kept within the members' range: see <see cref="SharedGroup.ParentalRatingCap"/>.
|
||||
/// </param>
|
||||
/// <returns>The updated group, with the cap as actually stored.</returns>
|
||||
Task<SharedGroup> UpdateGroupAsync(
|
||||
Guid sharedUserId,
|
||||
IReadOnlyList<Guid> memberIds,
|
||||
bool syncUnwatched,
|
||||
bool syncPlayCount,
|
||||
bool isDisabled);
|
||||
bool isDisabled,
|
||||
bool inheritParentalRating,
|
||||
int? parentalRatingCap);
|
||||
|
||||
/// <summary>
|
||||
/// Removes a group, optionally deleting its shared account.
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading.Tasks;
|
||||
using Jellyfin.Database.Implementations.Entities;
|
||||
using Jellyfin.Plugin.WatchedTogether.Configuration;
|
||||
|
||||
namespace Jellyfin.Plugin.WatchedTogether.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Computes, applies and checks the content restrictions a shared account should have.
|
||||
/// </summary>
|
||||
public interface IRestrictionService
|
||||
{
|
||||
/// <summary>
|
||||
/// Computes the strictest combination of the given members' restrictions.
|
||||
/// </summary>
|
||||
/// <param name="memberIds">The members to combine.</param>
|
||||
/// <returns>Restrictions at least as strict as every member's.</returns>
|
||||
ContentRestrictions ComputeStrictest(IReadOnlyList<Guid> memberIds);
|
||||
|
||||
/// <summary>
|
||||
/// Finds the range the members' parental rating caps span.
|
||||
/// </summary>
|
||||
/// <param name="memberIds">The members to inspect.</param>
|
||||
/// <returns>The strictest and loosest caps among them.</returns>
|
||||
RatingRange GetRatingRange(IReadOnlyList<Guid> memberIds);
|
||||
|
||||
/// <summary>
|
||||
/// Writes the restrictions a group's shared account should have: the strictest member's,
|
||||
/// except for the rating cap when the group has chosen its own.
|
||||
/// </summary>
|
||||
/// <param name="group">The group whose shared account to update.</param>
|
||||
/// <returns>
|
||||
/// The restrictions written, and whether the group's chosen cap had to be adjusted to stay
|
||||
/// within its members' range - in which case <paramref name="group"/> has been updated in
|
||||
/// place and the caller should persist it.
|
||||
/// </returns>
|
||||
Task<RestrictionApplyResult> ApplyAsync(SharedGroup group);
|
||||
|
||||
/// <summary>
|
||||
/// Determines whether <paramref name="candidate"/> hides at least everything
|
||||
/// <paramref name="member"/> cannot see, reading both users live.
|
||||
/// </summary>
|
||||
/// <param name="candidate">The shared account being unlocked.</param>
|
||||
/// <param name="member">The member whose password matched.</param>
|
||||
/// <returns><c>true</c> if the member may unlock the account.</returns>
|
||||
bool IsAtLeastAsStrict(User candidate, User member);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The range spanned by a set of members' parental rating caps.
|
||||
/// </summary>
|
||||
/// <param name="Strictest">The lowest cap, or <c>null</c> if no member has one.</param>
|
||||
/// <param name="Loosest">The highest cap, or <c>null</c> if any member has none.</param>
|
||||
public readonly record struct RatingRange(int? Strictest, int? Loosest)
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets a value indicating whether there is anything to choose: at least one member is capped.
|
||||
/// </summary>
|
||||
public bool HasChoice => Strictest is not null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The outcome of applying a group's restrictions to its shared account.
|
||||
/// </summary>
|
||||
/// <param name="Restrictions">What was written.</param>
|
||||
/// <param name="CapAdjusted">Whether the group's chosen cap was changed to fit its members' range.</param>
|
||||
public sealed record RestrictionApplyResult(ContentRestrictions Restrictions, bool CapAdjusted);
|
||||
@@ -33,6 +33,7 @@ public class ProvisioningService : IProvisioningService
|
||||
|
||||
private readonly IUserManager _userManager;
|
||||
private readonly ILibraryAccessService _libraryAccessService;
|
||||
private readonly IRestrictionService _restrictionService;
|
||||
private readonly ILogger<ProvisioningService> _logger;
|
||||
|
||||
/// <summary>
|
||||
@@ -40,14 +41,17 @@ public class ProvisioningService : IProvisioningService
|
||||
/// </summary>
|
||||
/// <param name="userManager">The user manager.</param>
|
||||
/// <param name="libraryAccessService">The library access service.</param>
|
||||
/// <param name="restrictionService">The content restriction service.</param>
|
||||
/// <param name="logger">The logger.</param>
|
||||
public ProvisioningService(
|
||||
IUserManager userManager,
|
||||
ILibraryAccessService libraryAccessService,
|
||||
IRestrictionService restrictionService,
|
||||
ILogger<ProvisioningService> logger)
|
||||
{
|
||||
_userManager = userManager;
|
||||
_libraryAccessService = libraryAccessService;
|
||||
_restrictionService = restrictionService;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
@@ -119,14 +123,17 @@ public class ProvisioningService : IProvisioningService
|
||||
sharedUser.AuthenticationProviderId = AuthProviderId;
|
||||
await _userManager.UpdateUserAsync(sharedUser).ConfigureAwait(false);
|
||||
|
||||
await ApplyLibraryAccessAsync(sharedUser.Id, distinctIds).ConfigureAwait(false);
|
||||
|
||||
var group = new SharedGroup
|
||||
{
|
||||
SharedUserId = sharedUser.Id,
|
||||
MemberUserIds = distinctIds
|
||||
};
|
||||
|
||||
// Restrict before the group is recorded, so an account created from the login screen is
|
||||
// never usable, even once, with fewer restrictions than its members have. A new group
|
||||
// always inherits; a cap is chosen afterwards in the dashboard.
|
||||
await ApplyDerivedPolicyAsync(group).ConfigureAwait(false);
|
||||
|
||||
config.Groups.Add(group);
|
||||
plugin.UpdateConfiguration(config);
|
||||
|
||||
@@ -145,7 +152,9 @@ public class ProvisioningService : IProvisioningService
|
||||
IReadOnlyList<Guid> memberIds,
|
||||
bool syncUnwatched,
|
||||
bool syncPlayCount,
|
||||
bool isDisabled)
|
||||
bool isDisabled,
|
||||
bool inheritParentalRating,
|
||||
int? parentalRatingCap)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(memberIds);
|
||||
|
||||
@@ -188,18 +197,25 @@ public class ProvisioningService : IProvisioningService
|
||||
group.SyncUnwatched = syncUnwatched;
|
||||
group.SyncPlayCount = syncPlayCount;
|
||||
group.IsDisabled = isDisabled;
|
||||
group.InheritParentalRating = inheritParentalRating;
|
||||
group.ParentalRatingCap = inheritParentalRating ? null : parentalRatingCap;
|
||||
|
||||
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);
|
||||
// Membership drives the derived policy, so recompute it: adding a member can only narrow
|
||||
// library access and tighten restrictions, and removing one may widen either. The chosen
|
||||
// cap may be pulled back into the new members' range, in which case it is saved again.
|
||||
if (await ApplyDerivedPolicyAsync(group).ConfigureAwait(false))
|
||||
{
|
||||
plugin.UpdateConfiguration(config);
|
||||
}
|
||||
|
||||
_logger.LogInformation(
|
||||
"Updated group {SharedUserId}: {MemberCount} members, disabled={IsDisabled}",
|
||||
"Updated group {SharedUserId}: {MemberCount} members, disabled={IsDisabled}, rating cap={RatingCap}",
|
||||
sharedUserId,
|
||||
distinctIds.Count,
|
||||
isDisabled);
|
||||
isDisabled,
|
||||
group.InheritParentalRating ? "inherited" : group.ParentalRatingCap?.ToString(CultureInfo.InvariantCulture) ?? "none");
|
||||
|
||||
return group;
|
||||
}
|
||||
@@ -266,24 +282,30 @@ public class ProvisioningService : IProvisioningService
|
||||
=> Convert.ToBase64String(RandomNumberGenerator.GetBytes(48));
|
||||
|
||||
/// <summary>
|
||||
/// Sets library access on the shared account.
|
||||
/// Writes everything about the shared account's policy that follows from its membership:
|
||||
/// library access and content restrictions.
|
||||
/// </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)
|
||||
/// <param name="group">The group whose shared account to update.</param>
|
||||
/// <returns><c>true</c> if the group's chosen rating cap was adjusted and needs saving.</returns>
|
||||
private async Task<bool> ApplyDerivedPolicyAsync(SharedGroup group)
|
||||
{
|
||||
var user = _userManager.GetUserById(sharedUserId);
|
||||
var user = _userManager.GetUserById(group.SharedUserId);
|
||||
if (user is null)
|
||||
{
|
||||
return;
|
||||
return false;
|
||||
}
|
||||
|
||||
// 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.
|
||||
// member can already reach, so joining a group can never grant access to anything. And
|
||||
// never an administrator: the account is a union of other people's credentials and must
|
||||
// not carry a privilege none of them individually hold.
|
||||
user.SetPermission(PermissionKind.EnableAllFolders, false);
|
||||
user.SetPermission(PermissionKind.IsAdministrator, false);
|
||||
await _userManager.UpdateUserAsync(user).ConfigureAwait(false);
|
||||
|
||||
await _libraryAccessService.ApplyIntersectionAsync(sharedUserId, memberIds).ConfigureAwait(false);
|
||||
await _libraryAccessService.ApplyIntersectionAsync(group.SharedUserId, group.MemberUserIds).ConfigureAwait(false);
|
||||
|
||||
var applied = await _restrictionService.ApplyAsync(group).ConfigureAwait(false);
|
||||
return applied.CapAdjusted;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,217 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using Jellyfin.Database.Implementations.Entities;
|
||||
using Jellyfin.Plugin.WatchedTogether.Configuration;
|
||||
using MediaBrowser.Controller.Library;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Jellyfin.Plugin.WatchedTogether.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Gives a shared account its members' content restrictions - strictest wins, except for a rating
|
||||
/// cap the group has chosen - and refuses to let a member unlock an account looser than they are.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The second half is what makes choosing a cap safe. A parent may raise the shared account's cap
|
||||
/// above the child's to watch something together; the unlock rule means the child's own password
|
||||
/// no longer opens that account, so they cannot use it to get around their cap alone. It also
|
||||
/// bounds the choice: past the loosest member's cap nobody could unlock the account at all.
|
||||
/// </remarks>
|
||||
public class RestrictionService : IRestrictionService
|
||||
{
|
||||
private readonly IUserManager _userManager;
|
||||
private readonly ILogger<RestrictionService> _logger;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="RestrictionService"/> class.
|
||||
/// </summary>
|
||||
/// <param name="userManager">The user manager.</param>
|
||||
/// <param name="logger">The logger.</param>
|
||||
public RestrictionService(IUserManager userManager, ILogger<RestrictionService> logger)
|
||||
{
|
||||
_userManager = userManager;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public ContentRestrictions ComputeStrictest(IReadOnlyList<Guid> memberIds)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(memberIds);
|
||||
|
||||
if (memberIds.Count == 0)
|
||||
{
|
||||
return ContentRestrictions.FullyRestricted;
|
||||
}
|
||||
|
||||
ContentRestrictions? result = null;
|
||||
|
||||
foreach (var memberId in memberIds)
|
||||
{
|
||||
var member = _userManager.GetUserById(memberId);
|
||||
if (member is null)
|
||||
{
|
||||
// Same rule as library access: an unknown member must not widen the group.
|
||||
_logger.LogWarning(
|
||||
"Member {MemberId} could not be resolved; treating its restrictions as total",
|
||||
memberId);
|
||||
return ContentRestrictions.FullyRestricted;
|
||||
}
|
||||
|
||||
var own = ContentRestrictions.FromUser(member);
|
||||
result = result is null ? own : result.CombineStrictest(own);
|
||||
}
|
||||
|
||||
return result!;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public RatingRange GetRatingRange(IReadOnlyList<Guid> memberIds)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(memberIds);
|
||||
|
||||
int? strictest = null;
|
||||
int? loosest = null;
|
||||
var anyUncapped = false;
|
||||
|
||||
foreach (var memberId in memberIds)
|
||||
{
|
||||
var member = _userManager.GetUserById(memberId);
|
||||
|
||||
// An unknown member counts as fully capped, consistent with ComputeStrictest.
|
||||
var cap = member is null ? 0 : member.MaxParentalRatingScore;
|
||||
if (cap is null)
|
||||
{
|
||||
anyUncapped = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
strictest = strictest is null ? cap : Math.Min(strictest.Value, cap.Value);
|
||||
loosest = loosest is null ? cap : Math.Max(loosest.Value, cap.Value);
|
||||
}
|
||||
|
||||
return new RatingRange(strictest, anyUncapped ? null : loosest);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<RestrictionApplyResult> ApplyAsync(SharedGroup group)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(group);
|
||||
|
||||
var restrictions = ComputeStrictest(group.MemberUserIds);
|
||||
var adjusted = ClampChosenCap(group);
|
||||
|
||||
if (!group.InheritParentalRating)
|
||||
{
|
||||
// The chosen cap replaces only the rating; everything else stays strictest-wins. The
|
||||
// sub-score cap is dropped: the choice is a level, not a level-and-a-half.
|
||||
restrictions = restrictions with
|
||||
{
|
||||
MaxParentalRatingScore = group.ParentalRatingCap,
|
||||
MaxParentalRatingSubScore = null,
|
||||
};
|
||||
}
|
||||
|
||||
var sharedUser = _userManager.GetUserById(group.SharedUserId);
|
||||
if (sharedUser is null)
|
||||
{
|
||||
return new RestrictionApplyResult(restrictions, adjusted);
|
||||
}
|
||||
|
||||
var policy = _userManager.GetUserDto(sharedUser).Policy;
|
||||
if (policy is null)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Could not read the policy for shared account {SharedUserId}; restrictions unchanged",
|
||||
group.SharedUserId);
|
||||
return new RestrictionApplyResult(restrictions, adjusted);
|
||||
}
|
||||
|
||||
policy.MaxParentalRating = restrictions.MaxParentalRatingScore;
|
||||
policy.MaxParentalSubRating = restrictions.MaxParentalRatingSubScore;
|
||||
policy.BlockUnratedItems = restrictions.BlockUnratedItems.ToArray();
|
||||
policy.BlockedTags = restrictions.BlockedTags.ToArray();
|
||||
policy.AllowedTags = restrictions.AllowedTagsForPolicy();
|
||||
|
||||
// A shared account is a union of other people's credentials; it must never carry a
|
||||
// privilege none of them individually hold.
|
||||
policy.IsAdministrator = false;
|
||||
|
||||
await _userManager.UpdatePolicyAsync(group.SharedUserId, policy).ConfigureAwait(false);
|
||||
|
||||
if (restrictions.HasAllowedTags && restrictions.AllowedTags!.Count == 0)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Shared account {SharedUserId} can see nothing: its members' allowed-tag lists have nothing in common",
|
||||
group.SharedUserId);
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogInformation(
|
||||
"Shared account {SharedUserId} restricted to rating cap {Score}/{SubScore} ({Source}), {UnratedCount} unrated kinds blocked, {BlockedCount} blocked tags, {AllowedCount} allowed tags",
|
||||
group.SharedUserId,
|
||||
restrictions.MaxParentalRatingScore,
|
||||
restrictions.MaxParentalRatingSubScore,
|
||||
group.InheritParentalRating ? "strictest member" : "chosen",
|
||||
restrictions.BlockUnratedItems.Count,
|
||||
restrictions.BlockedTags.Count,
|
||||
restrictions.AllowedTags?.Count);
|
||||
}
|
||||
|
||||
return new RestrictionApplyResult(restrictions, adjusted);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Keeps a group's chosen cap within its members' range, turning a choice that has become
|
||||
/// meaningless back into inheritance.
|
||||
/// </summary>
|
||||
/// <param name="group">The group to adjust in place.</param>
|
||||
/// <returns><c>true</c> if anything changed.</returns>
|
||||
private bool ClampChosenCap(SharedGroup group)
|
||||
{
|
||||
if (group.InheritParentalRating)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var range = GetRatingRange(group.MemberUserIds);
|
||||
|
||||
// Nobody capped, or a choice at or below the strictest member: that is just inheriting.
|
||||
if (!range.HasChoice || (group.ParentalRatingCap is not null && group.ParentalRatingCap <= range.Strictest))
|
||||
{
|
||||
_logger.LogInformation(
|
||||
"Group {SharedUserId}: chosen rating cap {Cap} is no looser than its strictest member; inheriting instead",
|
||||
group.SharedUserId,
|
||||
group.ParentalRatingCap);
|
||||
group.InheritParentalRating = true;
|
||||
group.ParentalRatingCap = null;
|
||||
return true;
|
||||
}
|
||||
|
||||
// Looser than the loosest member: nobody could unlock the account. Pull it back to the
|
||||
// loosest member rather than leave a group nobody can log into.
|
||||
if (range.Loosest is not null && (group.ParentalRatingCap is null || group.ParentalRatingCap > range.Loosest))
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Group {SharedUserId}: chosen rating cap {Cap} is looser than every member; lowered to {Loosest}",
|
||||
group.SharedUserId,
|
||||
group.ParentalRatingCap,
|
||||
range.Loosest);
|
||||
group.ParentalRatingCap = range.Loosest;
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public bool IsAtLeastAsStrict(User candidate, User member)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(candidate);
|
||||
ArgumentNullException.ThrowIfNull(member);
|
||||
|
||||
return ContentRestrictions.FromUser(candidate)
|
||||
.IsAtLeastAsStrictAs(ContentRestrictions.FromUser(member));
|
||||
}
|
||||
}
|
||||
@@ -23,6 +23,7 @@ public sealed class UserLifecycleService : IHostedService
|
||||
private readonly IUserManager _userManager;
|
||||
private readonly IGroupService _groupService;
|
||||
private readonly ILibraryAccessService _libraryAccessService;
|
||||
private readonly IRestrictionService _restrictionService;
|
||||
private readonly ILogger<UserLifecycleService> _logger;
|
||||
|
||||
/// <summary>
|
||||
@@ -31,16 +32,19 @@ public sealed class UserLifecycleService : IHostedService
|
||||
/// <param name="userManager">The user manager.</param>
|
||||
/// <param name="groupService">The group service.</param>
|
||||
/// <param name="libraryAccessService">The library access service.</param>
|
||||
/// <param name="restrictionService">The content restriction service.</param>
|
||||
/// <param name="logger">The logger.</param>
|
||||
public UserLifecycleService(
|
||||
IUserManager userManager,
|
||||
IGroupService groupService,
|
||||
ILibraryAccessService libraryAccessService,
|
||||
IRestrictionService restrictionService,
|
||||
ILogger<UserLifecycleService> logger)
|
||||
{
|
||||
_userManager = userManager;
|
||||
_groupService = groupService;
|
||||
_libraryAccessService = libraryAccessService;
|
||||
_restrictionService = restrictionService;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
@@ -50,7 +54,7 @@ public sealed class UserLifecycleService : IHostedService
|
||||
try
|
||||
{
|
||||
Reconcile();
|
||||
await ReapplyLibraryAccessAsync().ConfigureAwait(false);
|
||||
await ReapplyDerivedPolicyAsync().ConfigureAwait(false);
|
||||
}
|
||||
#pragma warning disable CA1031 // Reconciliation must never prevent the server from starting.
|
||||
catch (Exception ex)
|
||||
@@ -61,27 +65,40 @@ public sealed class UserLifecycleService : IHostedService
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Recomputes every group's library access.
|
||||
/// Recomputes every group's library access and content restrictions.
|
||||
/// </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.
|
||||
/// A member's own access can be narrowed at any time through the normal user editor, which
|
||||
/// would leave a group's stored policy too wide. Recomputing at startup brings shared accounts
|
||||
/// back in line without needing to hook every policy change. Between restarts the unlock rule
|
||||
/// in the authentication provider covers the security side of that drift.
|
||||
/// </remarks>
|
||||
/// <returns>A task representing the update.</returns>
|
||||
private async Task ReapplyLibraryAccessAsync()
|
||||
private async Task ReapplyDerivedPolicyAsync()
|
||||
{
|
||||
var config = Plugin.Instance?.Configuration;
|
||||
if (config is null)
|
||||
var plugin = Plugin.Instance;
|
||||
if (plugin is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var config = plugin.Configuration;
|
||||
var changed = false;
|
||||
|
||||
foreach (var group in config.Groups.ToList())
|
||||
{
|
||||
await _libraryAccessService
|
||||
.ApplyIntersectionAsync(group.SharedUserId, group.MemberUserIds)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
// A member's cap may have moved since the group's own cap was chosen.
|
||||
var applied = await _restrictionService.ApplyAsync(group).ConfigureAwait(false);
|
||||
changed |= applied.CapAdjusted;
|
||||
}
|
||||
|
||||
if (changed)
|
||||
{
|
||||
plugin.UpdateConfiguration(config);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user