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>
11 KiB
Spec: parental restrictions on shared accounts
Status: phase 1 implemented (rating, unrated, tags, unlock rule, dashboard). Phase 2 (access schedules, channels) not started.
Implemented design differs from the proposal below in one important way. The Inherit /
Lifted mode was replaced by a chosen rating cap: SharedGroup.InheritParentalRating (default
true) and SharedGroup.ParentalRatingCap. The admin picks the cap on a slider between the
strictest and the loosest member; unrated and tag rules stay strictest-wins. The unlock rule is
unchanged and is what makes the slider safe - members stricter than the chosen cap can no longer
unlock the account - and it also bounds the slider: past the loosest member nobody could unlock it,
so the cap is clamped back into range whenever it is applied (create, update, startup). This
removes the need to ever touch the user editor and cannot produce an account nobody can log into,
which Lifted could. The rest of the proposal (combination rules, recompute points, dynamic
groups always inheriting, never-administrator) stands.
Other implementation notes:
- Allowed tags. Jellyfin reads an empty allowed-tag list as "no whitelist" (unrestricted),
not "allows nothing" — see
BaseItem.IsVisibleViaTags. So members without a whitelist do not constrain, whitelists that exist are intersected, and an empty intersection is written as the single sentinel tagwatched-together:nothing, which no item carries.ContentRestrictionsreads that sentinel back as "whitelist in force, allows nothing". ApplyLibraryAccessAsyncbecameApplyDerivedPolicyAsynconProvisioningService, and also clearsIsAdministrator.- The unlock rule is applied on the dynamic-login path too (
DynamicGroupService, existing-group branch), since "kid+alice" typed against an "alice+kid" account with a raised cap reaches that code.
Problem
A shared account currently inherits its members' library access (the intersection, see
LibraryAccessService) but none of their content restrictions. A freshly provisioned shared
account has Jellyfin's defaults for parental rating, unrated items, tags and access schedules,
which means unrestricted.
Two consequences, given a parent alice and a child kid with a rating cap:
alice+kidcan play anything in the libraries both can see, regardless of the child's cap. This is sometimes what the parent wants (watching something above the cap together).kidcan log in asalice+kidwith their own password, alone, and get around their own cap. This is never what anyone wants, and it contradicts the README's promise that "joining a group can never grant anyone access they did not already have".
The feature must allow (1) as an explicit opt-in while making (2) impossible in every mode.
Design principle
A member may only unlock a shared account that is at least as restricted as they are.
This is checked at authentication time against the members' live policies, so it holds even if the shared account's stored policy has drifted. With inheritance on it is true by construction. With inheritance lifted, a restricted member's password simply stops unlocking the group; an unrestricted member's still does. That is exactly the "parent decides" model: the parent can start a film above the child's rating on the shared account, the child cannot start it on their own.
Per-group setting
Add to SharedGroup:
public RestrictionMode RestrictionMode { get; set; } = RestrictionMode.Inherit;
| Mode | Shared account's restrictions | Who can unlock |
|---|---|---|
Inherit (default) |
Recomputed from members, strictest wins. Overwrites whatever is set on the shared account in Jellyfin's user editor, same as library access does today. | Every eligible member (the unlock rule is always satisfied). |
Lifted |
Left alone. Whatever the admin sets on the shared account in Jellyfin's user editor stands, default unrestricted. | Only members whose own restrictions are no stricter than the shared account's. |
Groups created at login (DynamicGroupService) are always Inherit. Lifted is a dashboard-only
choice so nobody can widen access from the login screen.
Library access is unaffected by the mode: it stays an intersection in both.
What "restrictions" covers, and how each is combined
All combinations are strictest-wins. A member that cannot be resolved contributes "fully restricted" (mirrors the library code's "treat as empty" rule).
| Field | Where it lives on User |
Combination |
|---|---|---|
| Parental rating | MaxParentalRatingScore, MaxParentalRatingSubScore (int?, null = unrestricted) |
Minimum (score, subScore) tuple across members. Any non-null beats null. |
| Block unrated | PreferenceKind.BlockUnratedItems (list of UnratedItem) |
Union. |
| Blocked tags | PreferenceKind.BlockedTags |
Union. |
| Allowed tags | PreferenceKind.AllowedTags (whitelist that overrides blocking) |
Intersection. A member with an empty list allows nothing through this route, so the result is empty if any member's list is empty. |
| Access schedules | AccessSchedules (per-day hour ranges; empty = always) |
Phase 2, see below. |
| Channels | EnableAllChannels, EnabledChannels, BlockedChannels |
Same shape as libraries: intersection. Phase 2. |
Not in scope, but must hold as invariants regardless of mode: the shared account is never an administrator, never hidden-from-login-screen-exempt, and never gets remote access or content deletion that a member lacks. Add a test that asserts the first of these; the others can be follow-ups.
Access schedules (phase 2)
Schedules are the awkward one because they are intervals, not sets. Correct combination is per-day interval intersection:
- A member with no schedules is unrestricted for that day.
- For members with schedules, take the intersection of their windows on each day of the week.
- If the intersection is empty on every day, the shared account can never log in; log a warning the same way the library code does for "no libraries in common".
Ship phase 1 without touching schedules, and document that. It is better to say "schedules are not inherited yet" than to get interval maths wrong on the first pass.
The unlock rule
In SharedAccountAuthenticationProvider, after a submitted password matches a member m:
if (!restrictions.IsAtLeastAsStrict(sharedUser, m)) reject with a log line
IsAtLeastAsStrict(a, b) is true when every field of a is at least as restrictive as the
same field of b, using the same comparisons as the table above. It reads both users live, no
config involved.
This runs in both modes. In Inherit it is cheap insurance against drift between startup
reapplies (a member's cap is lowered in the user editor; the shared account is not recomputed
until restart; the unlock rule still refuses the member, correctly, until then). In Lifted it
is the whole feature.
Log at Information, not Warning: a child trying their password on the family account after the parent lifted restrictions is expected, not an incident.
When restrictions are recomputed
Same points as library access today, all in Inherit mode only:
ProvisioningService.CreateGroupAsyncandUpdateGroupAsyncUserLifecycleService.StartAsync- Switching a group from
LiftedtoInherit
Switching to Lifted writes nothing. The shared account keeps whatever it had (which, if it was
just in Inherit, is the strict computed policy). The admin then loosens it in Jellyfin's user
editor if they want to. Consider a dashboard hint saying so, otherwise "I set Lifted and nothing
changed" will be the first question.
Implementation shape
Mirror ILibraryAccessService:
public interface IRestrictionService
{
ContentRestrictions ComputeStrictest(IReadOnlyList<Guid> memberIds);
Task ApplyAsync(Guid sharedUserId, IReadOnlyList<Guid> memberIds);
bool IsAtLeastAsStrict(User candidate, User member);
}
ContentRestrictions is a plain record of the phase 1 fields. Writes go through
IUserManager.UpdatePolicyAsync with a UserPolicy built from the current one, exactly as
LibraryAccessService.ApplyIntersectionAsync does, so the same 10.11 / 12 compat path is used.
Wire it into ProvisioningService.ApplyLibraryAccessAsync (rename to something like
ApplyDerivedPolicyAsync) and UserLifecycleService.ReapplyLibraryAccessAsync.
API and dashboard
GroupDto/UpdateGroupRequestgainRestrictionMode.CreateGroupRequestdoes not: new groups are alwaysInherit, change it afterwards.- Dashboard: the group list shows the mode. This is also the natural moment to add the per-group
edit form that
SyncUnwatchedandSyncPlayCountcurrently lack (they are shown but not editable). One form, three controls. - Under the mode, in
Inherit, show the effective result ("Rating: PG-13 (from kid), 2 blocked tags") so the admin can see what was computed. Cheap to add and it will answer most support questions before they are asked.
Migration
Existing groups deserialise with RestrictionMode = Inherit, and the startup reapply will
tighten their shared accounts on the first boot after upgrade. That is the right default (it is
what the README already claims) but it is a behaviour change. Call it out in the release notes:
"Shared accounts now inherit the strictest member's parental rating, unrated-item block and tags.
If you relied on a shared account being unrestricted, set its group to Lifted in the dashboard."
Interaction with watched-state sync
None required. In Lifted mode a parent watching an R-rated film on alice+kid marks it watched
on kid too. kid cannot see the item anyway, so it is invisible; when they grow into the
rating it shows as already watched, which is accurate. Not worth special-casing.
Tests
RestrictionTests.cs, following LibraryAccessTests.cs:
- Rating: null + PG-13 = PG-13; PG + R = PG; sub-scores tie-break correctly.
- Unrated / blocked tags: union. Allowed tags: intersection, empty if any member has none.
- Unresolvable member yields fully restricted.
- Unlock rule: restricted member rejected on a lifted group; unrestricted member accepted; every member accepted on an inherited group; a member whose cap was lowered after the last reapply is rejected on an inherited group (the drift case).
- Dynamic creation always yields
Inherit, and the shared account is restricted before the first login completes (thekidtypingalice+kidwith their own password on a fresh server must not get an unrestricted session, even once). - Provisioning: shared account is never an administrator.
- End-to-end: parent + child, lifted, parent unlocks and plays above the cap, child is refused.
Open questions
- Should
Liftedrequire the admin to confirm ("this lets the shared account exceed a member's rating")? A one-line description on the control is probably enough; a modal is overkill. - Is there any value in a third mode where the admin sets restrictions on the shared account and they are enforced as a floor (never looser than members, may be stricter)? Probably not worth it until someone asks.
- Reapply on a timer rather than only at startup? Most Jellyfin servers run for weeks. The unlock rule covers the security side of drift; the only gap is a shared account staying too strict after a member's cap is raised, which fixes itself on restart and is harmless. Leave it.