Files
WatchedTogether/docs/parental-restrictions-spec.md
T
dtourolleandClaude Opus 5 27cd2a3d37 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>
2026-09-19 12:30:50 +02:00

213 lines
11 KiB
Markdown

# 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 tag `watched-together:nothing`, which no item carries. `ContentRestrictions`
reads that sentinel back as "whitelist in force, allows nothing".
- `ApplyLibraryAccessAsync` became `ApplyDerivedPolicyAsync` on `ProvisioningService`, and also
clears `IsAdministrator`.
- 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:
1. `alice+kid` can 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*).
2. `kid` can log in as `alice+kid` **with 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`:
```csharp
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.CreateGroupAsync` and `UpdateGroupAsync`
- `UserLifecycleService.StartAsync`
- Switching a group from `Lifted` to `Inherit`
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`:
```csharp
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` / `UpdateGroupRequest` gain `RestrictionMode`.
- `CreateGroupRequest` does not: new groups are always `Inherit`, change it afterwards.
- Dashboard: the group list shows the mode. This is also the natural moment to add the per-group
edit form that `SyncUnwatched` and `SyncPlayCount` currently 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 (the `kid` typing `alice+kid` with 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
1. Should `Lifted` require 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.
2. 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.
3. 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.