# 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 memberIds); Task ApplyAsync(Guid sharedUserId, IReadOnlyList 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.