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>
218 lines
8.2 KiB
C#
218 lines
8.2 KiB
C#
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));
|
|
}
|
|
}
|