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,212 @@
|
||||
# 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.
|
||||
Reference in New Issue
Block a user