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:
2026-09-19 12:30:50 +02:00
co-authored by Claude Opus 5
parent 0c5fc9487c
commit 27cd2a3d37
24 changed files with 1950 additions and 69 deletions
@@ -24,12 +24,20 @@ namespace Jellyfin.Plugin.WatchedTogether.Auth;
/// matched, eventually locking out members who did nothing wrong. Reading the live hash also means
/// member password changes take effect immediately, with no second copy of any credential stored.
/// </para>
/// <para>
/// A matching password is not the whole story: the member may only unlock an account that is at
/// least as restricted as they are. That is checked here against both users' <em>live</em>
/// policies, so it holds even when the shared account's stored policy has drifted from its
/// members', and it is what makes choosing a cap safe - the child's password stops opening an
/// account the parent raised above the child's rating.
/// </para>
/// </remarks>
public class SharedAccountAuthenticationProvider : IAuthenticationProvider, IRequiresResolvedUser
{
private readonly ICryptoProvider _cryptoProvider;
private readonly Lazy<Services.IGroupService> _groupService;
private readonly Lazy<Services.IDynamicGroupService> _dynamicGroupService;
private readonly Lazy<Services.IRestrictionService> _restrictionService;
private readonly ILogger<SharedAccountAuthenticationProvider> _logger;
/// <summary>
@@ -38,6 +46,7 @@ public class SharedAccountAuthenticationProvider : IAuthenticationProvider, IReq
/// <param name="cryptoProvider">The crypto provider used to verify stored password hashes.</param>
/// <param name="groupService">A deferred handle to the group service.</param>
/// <param name="dynamicGroupService">A deferred handle to the on-demand group creation service.</param>
/// <param name="restrictionService">A deferred handle to the content restriction service.</param>
/// <param name="logger">The logger.</param>
/// <remarks>
/// The group services are taken as <see cref="Lazy{T}"/> to break a container-level cycle.
@@ -50,11 +59,13 @@ public class SharedAccountAuthenticationProvider : IAuthenticationProvider, IReq
ICryptoProvider cryptoProvider,
Lazy<Services.IGroupService> groupService,
Lazy<Services.IDynamicGroupService> dynamicGroupService,
Lazy<Services.IRestrictionService> restrictionService,
ILogger<SharedAccountAuthenticationProvider> logger)
{
_cryptoProvider = cryptoProvider;
_groupService = groupService;
_dynamicGroupService = dynamicGroupService;
_restrictionService = restrictionService;
_logger = logger;
}
@@ -119,6 +130,17 @@ public class SharedAccountAuthenticationProvider : IAuthenticationProvider, IReq
continue;
}
// Information, not Warning: a child trying their password on the family account after
// the parent raised its cap is expected, not an incident.
if (!_restrictionService.Value.IsAtLeastAsStrict(resolvedUser, member))
{
_logger.LogInformation(
"Rejected login for {Username} by member {MemberUsername}: the shared account is less restricted than the member",
resolvedUser.Username,
member.Username);
throw new AuthenticationException("Invalid username or password.");
}
_logger.LogInformation(
"Shared account {SharedUsername} unlocked by member {MemberUsername}",
resolvedUser.Username,
@@ -41,6 +41,30 @@ public class SharedGroup
/// </summary>
public bool SyncPlayCount { get; set; }
/// <summary>
/// Gets or sets a value indicating whether the shared account's parental rating cap is the
/// strictest member's. When false, <see cref="ParentalRatingCap"/> is used instead.
/// </summary>
/// <remarks>
/// Groups created at the login screen always inherit; choosing a cap is a dashboard-only
/// action so nobody can widen access from the login screen. Unrated-item blocks and tag rules
/// are always the strictest member's - they have no meaningful "level" to choose.
/// </remarks>
public bool InheritParentalRating { get; set; } = true;
/// <summary>
/// Gets or sets the parental rating cap, as Jellyfin's numeric score, to use when
/// <see cref="InheritParentalRating"/> is false. <c>null</c> means no cap.
/// </summary>
/// <remarks>
/// Kept within the range spanned by the members' own caps whenever it is applied: no lower than
/// the strictest member (that would just be inheriting) and no looser than the loosest member
/// (nobody could unlock the account past that, since a member may only unlock an account at
/// least as restricted as they are). Wherever it sits in that range, members stricter than it
/// can no longer unlock the account - that is the whole point of choosing one.
/// </remarks>
public int? ParentalRatingCap { get; set; }
/// <summary>
/// Gets or sets a value indicating whether this group is suspended. A group drops out of both
/// authentication and sync while disabled - set automatically if it falls below two members.
@@ -6,7 +6,7 @@
</head>
<body>
<div id="WatchedTogetherConfigPage" data-role="page" class="page type-interior pluginConfigurationPage"
data-require="emby-input,emby-button,emby-select,emby-checkbox">
data-require="emby-input,emby-button,emby-select,emby-checkbox,emby-slider">
<div data-role="content">
<div class="content-primary">
@@ -47,8 +47,11 @@
<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.
already reach, and inherits the strictest member's parental rating, unrated
block and tag rules. If one member is blocked from something, the group cannot
see it either, so sharing an account never grants anyone new access. To let a
group watch above a member's rating, raise its cap with the slider afterwards:
members stricter than the chosen cap then can no longer unlock the account.
</div>
<div>
@@ -113,6 +116,141 @@
});
}
// Jellyfin's parental rating levels for this server, one entry per distinct score,
// ascending. Several names can share a score (e.g. "PG-13" and "TV-14").
var ratingLevels = [];
function loadRatingLevels() {
return ApiClient.getParentalRatings().then(function (ratings) {
var byValue = {};
ratings.forEach(function (r) {
if (r.Value === null || r.Value === undefined) { return; }
byValue[r.Value] = byValue[r.Value] || [];
byValue[r.Value].push(r.Name);
});
ratingLevels = Object.keys(byValue).map(function (v) {
return { value: Number(v), label: byValue[v].join(' / ') };
}).sort(function (a, b) { return a.value - b.value; });
}, function () {
// Names are cosmetic; scores still display without them.
});
}
function ratingLabel(score) {
if (score === null || score === undefined) { return 'No cap'; }
var level = ratingLevels.filter(function (l) { return l.value === score; })[0];
return level ? level.label : 'score ' + score;
}
function escapeHtml(text) {
return String(text).replace(/[&<>"']/g, function (c) {
return { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c];
});
}
// The positions the cap slider can take: every rating level from the strictest member
// up to the loosest, plus "No cap" when some member has none. Null when no member is
// capped, since there is then nothing to choose.
function capStops(g) {
if (g.StrictestMemberRating === null || g.StrictestMemberRating === undefined) {
return null;
}
var lo = g.StrictestMemberRating;
var hi = g.LoosestMemberRating;
var stops = ratingLevels.filter(function (l) {
return l.value >= lo && (hi === null || hi === undefined || l.value <= hi);
}).map(function (l) { return { value: l.value, label: l.label }; });
if (!stops.length || stops[0].value !== lo) {
stops.unshift({ value: lo, label: ratingLabel(lo) });
}
if (hi === null || hi === undefined) {
stops.push({ value: null, label: 'No cap' });
} else if (stops[stops.length - 1].value !== hi) {
stops.push({ value: hi, label: ratingLabel(hi) });
}
return stops;
}
function currentStop(g, stops) {
if (g.InheritParentalRating) { return 0; }
for (var i = 0; i < stops.length; i++) {
if (stops[i].value === g.ParentalRatingCap) { return i; }
}
return 0;
}
// Who the account still unlocks for at a given cap: members no stricter than it.
function describeCap(g, stop, index) {
var can = [], cannot = [];
g.Members.forEach(function (m) {
var own = m.MaxParentalRating;
var ok = own === null || own === undefined || (stop.value !== null && own >= stop.value);
(ok ? can : cannot).push(escapeHtml(m.Username));
});
var text = '<strong>' + escapeHtml(stop.label) + '</strong>';
if (index === 0) {
text += ' &mdash; inherited from the strictest member. Every member can unlock the account.';
} else {
text += ' &mdash; unlocks for ' + can.join(', ') + '.';
if (cannot.length) {
text += ' <span style="opacity:.8">' + cannot.join(', ') +
(cannot.length === 1 ? '\'s password no longer opens' : ' can no longer open') +
' this account.</span>';
}
}
return text;
}
function describeRestrictions(r) {
if (!r) { return 'Shared account not found.'; }
var parts = [];
if (r.BlockUnratedItems.length) {
parts.push('Unrated blocked: ' + escapeHtml(r.BlockUnratedItems.join(', ')));
}
if (r.BlockedTags.length) {
parts.push('Blocked tags: ' + escapeHtml(r.BlockedTags.join(', ')));
}
if (r.AllowsNothing) {
parts.push('<strong>Allowed tags have nothing in common: the account can see nothing</strong>');
} else if (r.AllowedTags.length) {
parts.push('Allowed tags: ' + escapeHtml(r.AllowedTags.join(', ')));
}
return parts.length ? parts.join(' &middot; ') : 'No unrated or tag rules from members.';
}
function updateGroup(group, changes, onDone) {
Dashboard.showLoadingMsg();
var body = {
MemberUserIds: group.Members.map(function (m) { return m.UserId; }),
SyncUnwatched: group.SyncUnwatched,
SyncPlayCount: group.SyncPlayCount,
IsDisabled: group.IsDisabled,
InheritParentalRating: group.InheritParentalRating,
ParentalRatingCap: group.ParentalRatingCap
};
Object.keys(changes).forEach(function (k) { body[k] = changes[k]; });
ApiClient.ajax({
type: 'POST',
url: apiUrl('Groups/' + group.SharedUserId),
contentType: 'application/json',
data: JSON.stringify(body)
}).then(function () {
Dashboard.hideLoadingMsg();
if (onDone) { onDone(); }
loadGroups();
}, function (response) {
Dashboard.hideLoadingMsg();
if (response && response.text) {
response.text().then(function (msg) {
Dashboard.alert({ title: 'Could not update group', message: msg });
});
} else {
Dashboard.alert('Could not update group.');
}
loadGroups();
});
}
function renderGroups(groups) {
var container = page.querySelector('#groupsList');
@@ -122,20 +260,108 @@
}
container.innerHTML = groups.map(function (g) {
var members = g.Members.map(function (m) { return m.Username; }).join(', ');
var members = g.Members.map(function (m) {
var own = m.MaxParentalRating;
var cap = (own === null || own === undefined) ? 'no cap' : ratingLabel(own);
return escapeHtml(m.Username) + ' <span style="opacity:.7">(' + escapeHtml(cap) + ')</span>';
}).join(', ');
var status = g.IsDisabled ? ' <span style="opacity:.7">(disabled)</span>' : '';
var stops = capStops(g);
var capHtml;
if (!stops) {
capHtml = '<div class="fieldDescription">Parental rating: no member has a cap, so there is nothing to choose.</div>';
} else {
var idx = currentStop(g, stops);
capHtml = '<div class="sliderContainer-settings" style="margin-top:.6em">' +
'<label class="sliderLabel">Parental rating cap</label>' +
'<div style="display:flex;align-items:center;gap:.8em">' +
'<span class="fieldDescription" style="white-space:nowrap">' + escapeHtml(stops[0].label) + '</span>' +
'<input type="range" is="emby-slider" class="ratingCapSlider" data-id="' + g.SharedUserId + '" ' +
'min="0" max="' + (stops.length - 1) + '" step="1" value="' + idx + '" style="flex:1" />' +
'<span class="fieldDescription" style="white-space:nowrap">' + escapeHtml(stops[stops.length - 1].label) + '</span>' +
'</div>' +
'<div class="fieldDescription ratingCapText" data-id="' + g.SharedUserId + '">' + describeCap(g, stops[idx], idx) + '</div>' +
'</div>';
}
return '<div class="listItem" style="padding:.6em 0;border-bottom:1px solid rgba(255,255,255,.1)">' +
'<h3 style="margin:0">' + g.SharedUsername + status + '</h3>' +
'<h3 style="margin:0">' + escapeHtml(g.SharedUsername) + status + '</h3>' +
'<div class="fieldDescription">Members: ' + members + '</div>' +
'<div class="fieldDescription">' +
'Sync unwatched: ' + (g.SyncUnwatched ? 'yes' : 'no') +
' &middot; Sync play count: ' + (g.SyncPlayCount ? 'yes' : 'no') + '</div>' +
capHtml +
'<div class="fieldDescription" style="opacity:.8">' + describeRestrictions(g.Restrictions) + '</div>' +
'<div style="margin-top:.4em">' +
'<button is="emby-button" type="button" class="raised btnEditGroup" data-id="' + g.SharedUserId + '">' +
'<span>Edit</span></button> ' +
'<button is="emby-button" type="button" class="raised btnDeleteGroup" ' +
'data-id="' + g.SharedUserId + '" data-name="' + g.SharedUsername + '">' +
'data-id="' + g.SharedUserId + '" data-name="' + escapeHtml(g.SharedUsername) + '">' +
'<span>Delete</span></button>' +
'</div>' +
'<form class="editGroupForm" data-id="' + g.SharedUserId + '" style="display:none;margin:.8em 0 .4em 1em">' +
'<div class="checkboxContainer"><label class="emby-checkbox-label">' +
'<input type="checkbox" is="emby-checkbox" name="SyncUnwatched"' + (g.SyncUnwatched ? ' checked' : '') + ' />' +
'<span>Sync unwatched</span></label></div>' +
'<div class="checkboxContainer"><label class="emby-checkbox-label">' +
'<input type="checkbox" is="emby-checkbox" name="SyncPlayCount"' + (g.SyncPlayCount ? ' checked' : '') + ' />' +
'<span>Sync play count</span></label></div>' +
'<div class="checkboxContainer"><label class="emby-checkbox-label">' +
'<input type="checkbox" is="emby-checkbox" name="IsDisabled"' + (g.IsDisabled ? ' checked' : '') + ' />' +
'<span>Disabled</span></label></div>' +
'<button is="emby-button" type="submit" class="raised button-submit emby-button"><span>Save</span></button> ' +
'<button is="emby-button" type="button" class="raised btnCancelEdit emby-button"><span>Cancel</span></button>' +
'</form>' +
'</div>';
}).join('');
function groupById(id) {
return groups.filter(function (g) { return g.SharedUserId === id; })[0];
}
container.querySelectorAll('.ratingCapSlider').forEach(function (slider) {
var group = groupById(slider.getAttribute('data-id'));
var stops = capStops(group);
var text = container.querySelector('.ratingCapText[data-id="' + group.SharedUserId + '"]');
slider.getBubbleText = function (value) { return stops[Number(value)].label; };
// Describe while dragging; only save on release.
slider.addEventListener('input', function () {
var i = Number(slider.value);
text.innerHTML = describeCap(group, stops[i], i);
});
slider.addEventListener('change', function () {
var i = Number(slider.value);
updateGroup(group, {
InheritParentalRating: i === 0,
ParentalRatingCap: stops[i].value
});
});
});
container.querySelectorAll('.btnEditGroup').forEach(function (btn) {
btn.addEventListener('click', function () {
var form = container.querySelector('.editGroupForm[data-id="' + btn.getAttribute('data-id') + '"]');
form.style.display = form.style.display === 'none' ? '' : 'none';
});
});
container.querySelectorAll('.btnCancelEdit').forEach(function (btn) {
btn.addEventListener('click', function () {
btn.closest('.editGroupForm').style.display = 'none';
});
});
container.querySelectorAll('.editGroupForm').forEach(function (form) {
form.addEventListener('submit', function (e) {
e.preventDefault();
updateGroup(groupById(form.getAttribute('data-id')), {
SyncUnwatched: form.querySelector('[name=SyncUnwatched]').checked,
SyncPlayCount: form.querySelector('[name=SyncPlayCount]').checked,
IsDisabled: form.querySelector('[name=IsDisabled]').checked
});
return false;
});
});
container.querySelectorAll('.btnDeleteGroup').forEach(function (btn) {
btn.addEventListener('click', function () {
var id = btn.getAttribute('data-id');
@@ -165,7 +391,7 @@
Dashboard.showLoadingMsg();
Promise.all([
loadGroups(),
loadRatingLevels().then(loadGroups),
loadEligibleUsers(),
ApiClient.getPluginConfiguration(pluginUniqueId).then(function (config) {
page.querySelector('#NameSeparator').value = config.NameSeparator || '+';
@@ -4,6 +4,7 @@ using System.Linq;
using System.Net.Mime;
using System.Threading.Tasks;
using Jellyfin.Plugin.WatchedTogether.Compat;
using Jellyfin.Plugin.WatchedTogether.Configuration;
using Jellyfin.Plugin.WatchedTogether.Models;
using Jellyfin.Plugin.WatchedTogether.Services;
using MediaBrowser.Common.Api;
@@ -25,6 +26,7 @@ namespace Jellyfin.Plugin.WatchedTogether.Controllers;
public class WatchedTogetherController : ControllerBase
{
private readonly IProvisioningService _provisioningService;
private readonly IRestrictionService _restrictionService;
private readonly IUserManager _userManager;
private readonly ILogger<WatchedTogetherController> _logger;
@@ -32,14 +34,17 @@ public class WatchedTogetherController : ControllerBase
/// Initializes a new instance of the <see cref="WatchedTogetherController"/> class.
/// </summary>
/// <param name="provisioningService">The provisioning service.</param>
/// <param name="restrictionService">The content restriction service.</param>
/// <param name="userManager">The user manager.</param>
/// <param name="logger">The logger.</param>
public WatchedTogetherController(
IProvisioningService provisioningService,
IRestrictionService restrictionService,
IUserManager userManager,
ILogger<WatchedTogetherController> logger)
{
_provisioningService = provisioningService;
_restrictionService = restrictionService;
_userManager = userManager;
_logger = logger;
}
@@ -58,21 +63,7 @@ public class WatchedTogetherController : ControllerBase
return Ok(Array.Empty<GroupDto>());
}
var groups = config.Groups.Select(g => new GroupDto
{
SharedUserId = g.SharedUserId,
SharedUsername = _userManager.GetUserById(g.SharedUserId)?.Username ?? "(deleted)",
SyncUnwatched = g.SyncUnwatched,
SyncPlayCount = g.SyncPlayCount,
IsDisabled = g.IsDisabled,
Members = g.MemberUserIds.Select(id => new MemberDto
{
UserId = id,
Username = _userManager.GetUserById(id)?.Username ?? "(deleted)"
}).ToList()
}).ToList();
return Ok(groups);
return Ok(config.Groups.Select(ToDto).ToList());
}
/// <summary>
@@ -114,19 +105,7 @@ public class WatchedTogetherController : ControllerBase
request.MemberUserIds,
request.Name).ConfigureAwait(false);
return Ok(new GroupDto
{
SharedUserId = group.SharedUserId,
SharedUsername = _userManager.GetUserById(group.SharedUserId)?.Username ?? string.Empty,
SyncUnwatched = group.SyncUnwatched,
SyncPlayCount = group.SyncPlayCount,
IsDisabled = group.IsDisabled,
Members = group.MemberUserIds.Select(id => new MemberDto
{
UserId = id,
Username = _userManager.GetUserById(id)?.Username ?? "(deleted)"
}).ToList()
});
return Ok(ToDto(group));
}
catch (ArgumentException ex)
{
@@ -157,7 +136,9 @@ public class WatchedTogetherController : ControllerBase
request.MemberUserIds,
request.SyncUnwatched,
request.SyncPlayCount,
request.IsDisabled).ConfigureAwait(false);
request.IsDisabled,
request.InheritParentalRating,
request.ParentalRatingCap).ConfigureAwait(false);
return NoContent();
}
@@ -192,4 +173,51 @@ public class WatchedTogetherController : ControllerBase
return BadRequest(ex.Message);
}
}
/// <summary>
/// Resolves a stored group against current user records, including the restrictions its
/// shared account actually carries right now.
/// </summary>
/// <param name="group">The stored group.</param>
/// <returns>The group as the dashboard shows it.</returns>
private GroupDto ToDto(SharedGroup group)
{
var sharedUser = _userManager.GetUserById(group.SharedUserId);
var range = _restrictionService.GetRatingRange(group.MemberUserIds);
return new GroupDto
{
SharedUserId = group.SharedUserId,
SharedUsername = sharedUser?.Username ?? "(deleted)",
SyncUnwatched = group.SyncUnwatched,
SyncPlayCount = group.SyncPlayCount,
IsDisabled = group.IsDisabled,
InheritParentalRating = group.InheritParentalRating,
ParentalRatingCap = group.ParentalRatingCap,
StrictestMemberRating = range.Strictest,
LoosestMemberRating = range.Loosest,
Restrictions = sharedUser is null ? null : ToDto(ContentRestrictions.FromUser(sharedUser)),
Members = group.MemberUserIds.Select(id =>
{
var member = _userManager.GetUserById(id);
return new MemberDto
{
UserId = id,
Username = member?.Username ?? "(deleted)",
MaxParentalRating = member is null ? 0 : member.MaxParentalRatingScore
};
}).ToList()
};
}
private static RestrictionsDto ToDto(ContentRestrictions restrictions)
=> new()
{
MaxParentalRating = restrictions.MaxParentalRatingScore,
MaxParentalSubRating = restrictions.MaxParentalRatingSubScore,
BlockUnratedItems = restrictions.BlockUnratedItems.Select(u => u.ToString()).OrderBy(u => u, StringComparer.Ordinal).ToList(),
BlockedTags = restrictions.BlockedTags.OrderBy(t => t, StringComparer.OrdinalIgnoreCase).ToList(),
AllowedTags = (restrictions.AllowedTags ?? Enumerable.Empty<string>()).OrderBy(t => t, StringComparer.OrdinalIgnoreCase).ToList(),
AllowsNothing = restrictions.HasAllowedTags && restrictions.AllowedTags!.Count == 0
};
}
@@ -37,4 +37,32 @@ public class GroupDto
/// Gets or sets a value indicating whether the group is suspended.
/// </summary>
public bool IsDisabled { get; set; }
/// <summary>
/// Gets or sets a value indicating whether the rating cap is the strictest member's.
/// </summary>
public bool InheritParentalRating { get; set; } = true;
/// <summary>
/// Gets or sets the chosen rating cap when not inheriting, as Jellyfin's numeric score
/// (<c>null</c> for none).
/// </summary>
public int? ParentalRatingCap { get; set; }
/// <summary>
/// Gets or sets the strictest member's rating cap, or <c>null</c> if no member has one - in
/// which case there is nothing to choose.
/// </summary>
public int? StrictestMemberRating { get; set; }
/// <summary>
/// Gets or sets the loosest member's rating cap, or <c>null</c> if some member has none. The
/// chosen cap can go no looser than this: past it nobody could unlock the account.
/// </summary>
public int? LoosestMemberRating { get; set; }
/// <summary>
/// Gets or sets the restrictions the shared account currently has.
/// </summary>
public RestrictionsDto? Restrictions { get; set; }
}
@@ -16,4 +16,10 @@ public class MemberDto
/// Gets or sets the member's username.
/// </summary>
public string Username { get; set; } = string.Empty;
/// <summary>
/// Gets or sets the member's own parental rating cap, as Jellyfin's numeric score, or
/// <c>null</c> for none. Shown so an admin can see which members a chosen cap shuts out.
/// </summary>
public int? MaxParentalRating { get; set; }
}
@@ -0,0 +1,41 @@
using System;
using System.Collections.Generic;
namespace Jellyfin.Plugin.WatchedTogether.Models;
/// <summary>
/// The content restrictions currently in effect on a shared account, for display.
/// </summary>
public class RestrictionsDto
{
/// <summary>
/// Gets or sets the parental rating cap as Jellyfin's numeric score, or <c>null</c> for none.
/// </summary>
public int? MaxParentalRating { get; set; }
/// <summary>
/// Gets or sets the sub-score cap that applies at the rating cap, or <c>null</c> for any.
/// </summary>
public int? MaxParentalSubRating { get; set; }
/// <summary>
/// Gets or sets the kinds of unrated item that are blocked.
/// </summary>
public IReadOnlyList<string> BlockUnratedItems { get; set; } = Array.Empty<string>();
/// <summary>
/// Gets or sets the tags that hide an item.
/// </summary>
public IReadOnlyList<string> BlockedTags { get; set; } = Array.Empty<string>();
/// <summary>
/// Gets or sets the tags an item must carry one of. Empty means no whitelist.
/// </summary>
public IReadOnlyList<string> AllowedTags { get; set; } = Array.Empty<string>();
/// <summary>
/// Gets or sets a value indicating whether the whitelist is in force but lets nothing through,
/// because the members' allowed-tag lists have nothing in common.
/// </summary>
public bool AllowsNothing { get; set; }
}
@@ -27,4 +27,15 @@ public class UpdateGroupRequest
/// Gets or sets a value indicating whether the group is suspended.
/// </summary>
public bool IsDisabled { get; set; }
/// <summary>
/// Gets or sets a value indicating whether the rating cap is the strictest member's.
/// </summary>
public bool InheritParentalRating { get; set; } = true;
/// <summary>
/// Gets or sets the rating cap to use when not inheriting, as Jellyfin's numeric score
/// (<c>null</c> for none). Kept within the members' range by the server.
/// </summary>
public int? ParentalRatingCap { get; set; }
}
@@ -18,6 +18,7 @@ public class ServiceRegistrator : IPluginServiceRegistrator
{
serviceCollection.AddSingleton<IGroupService, GroupService>();
serviceCollection.AddSingleton<ILibraryAccessService, LibraryAccessService>();
serviceCollection.AddSingleton<IRestrictionService, RestrictionService>();
serviceCollection.AddSingleton<IProvisioningService, ProvisioningService>();
serviceCollection.AddSingleton<IDynamicGroupService, DynamicGroupService>();
@@ -28,6 +29,8 @@ public class ServiceRegistrator : IPluginServiceRegistrator
provider => new Lazy<IGroupService>(provider.GetRequiredService<IGroupService>));
serviceCollection.AddSingleton(
provider => new Lazy<IDynamicGroupService>(provider.GetRequiredService<IDynamicGroupService>));
serviceCollection.AddSingleton(
provider => new Lazy<IRestrictionService>(provider.GetRequiredService<IRestrictionService>));
// Discovered by Jellyfin and matched to shared accounts via User.AuthenticationProviderId.
serviceCollection.AddSingleton<IAuthenticationProvider, SharedAccountAuthenticationProvider>();
@@ -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);
}
}