A plugin worth granting to a Member can also hold the credential the rest of it runs on. Jellyfin's /_config stores a server's admin token and drives account provisioning; the rest of the plugin is exactly what a Member should have. The choice was all of it or none of it. `ownerOnly` on a plugin permission takes sub-paths relative to the mount prefix. The permission stays fully grantable — this is not the ownerOnly PERMISSION that manifest.ts refuses, and that refusal still stands: a permission nobody can delegate is a contradiction. This says a few paths administer the plugin rather than use it. Declared by the plugin, enforced by the platform. A plugin could check this itself and the first one would; the tenth would add a route and forget, with no boot failure and no warning — the shape assertPermissionTotality exists to prevent. In the gate it is enforced once, on the door they all pass through, and a route added later by someone who never read the manifest is covered by default. Checked before selfService and before kind. A path that is somehow both self-service and owner-only is a contradiction, and the safe reading has to win. A write grant does not unlock it either — level is not the question this answers. Also forwards X-Officer-Owner and X-Officer-Level to a sidecar whose proxy names its permission key, for the finer decisions the platform cannot make for it: whose rows a read returns, which fields a level may see. Facts, never the role name — a plugin branching on "Super Admin" would give every plugin repository an opinion about the platform's role vocabulary, and renaming a role would change authorization in code the platform does not own. These are safe to trust only because the proxy copies a three-item allowlist off the inbound request; that is now written where it would be changed. The owner's level is reported as write rather than the absent 'none': they hold everything implicitly and appear in no grants map, so the raw lookup inverts the meaning for the one account that can do anything. No plugin declares one yet. Jellyfin cannot until connections stop being per-user — locking /_config today leaves a Member with a plugin that only shows a connect form they may not submit. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
141 lines
8.1 KiB
Markdown
141 lines
8.1 KiB
Markdown
# Plugin route authorization
|
|
|
|
**Platform half built 2026-08-21.** The gate enforces `ownerOnly`, the manifest accepts it, and the proxy
|
|
forwards `X-Officer-Owner` / `X-Officer-Level`. **No plugin declares it yet** — jellyfin's `/_config` is
|
|
still reachable by anyone granted the plugin, and cannot be locked until the credential question at the
|
|
bottom of this file is answered.
|
|
|
|
The question: a plugin's **settings** routes should be reachable only by the owner, even when the plugin
|
|
itself is granted to other accounts. Jellyfin is the case that raised it — connecting a server, storing
|
|
its admin token and provisioning accounts is not something a Member should reach, but Jellyfin as a whole
|
|
is exactly the sort of plugin a Member should have.
|
|
|
|
## The shape we agreed
|
|
|
|
Authorization stays where the platform already puts it — the gate answers "may this account reach this
|
|
plugin, and at what level" — and the plugin decides what that means **inside** its own surface. That is
|
|
not a new principle. `src/servers/plugins/manifest.ts` already says it, in the comment refusing an
|
|
`ownerOnly` field on a permission:
|
|
|
|
> Every plugin permission is grantable, per role, at read or write … Finer visibility — whose rows a
|
|
> member sees, what a read means for this plugin's data — is the PLUGIN's business and lives in its own
|
|
> queries.
|
|
|
|
"Which of my routes are administrative" is the same kind of question as "whose rows does a read return".
|
|
It belongs to the plugin, because only the plugin knows that `/_config` is settings and `/_jf` is bytes.
|
|
|
|
So: **the platform tells the plugin who is asking; the plugin decides what they may do.**
|
|
|
|
### What gets passed, and what does not
|
|
|
|
Today the proxy sends exactly one fact (`src/servers/sidecar/create-proxy.ts`):
|
|
|
|
```
|
|
X-Officer-User: <id>
|
|
```
|
|
|
|
The extension is one or two more headers alongside it. Two points about how, both load-bearing:
|
|
|
|
**Pass derived facts, not the role name.** The instinct is to send `X-Officer-Role: Super Admin`. Don't.
|
|
Roles are the platform's vocabulary and they are not frozen — `Admin`, `Member`, `Developer` today, with
|
|
grants keyed on the role string in `role_permissions`. A plugin that branches on a role name gives every
|
|
plugin repository an opinion about that vocabulary, and renaming a role silently changes authorization
|
|
in code the platform does not own and cannot see.
|
|
|
|
Send instead what a plugin can act on without knowing anything:
|
|
|
|
```
|
|
X-Officer-User: <id>
|
|
X-Officer-Owner: 1 | 0 — is this the server owner
|
|
X-Officer-Level: none|read|write — this account's effective level for THIS plugin's permission
|
|
```
|
|
|
|
`isOwner` is the fact that is structurally stable: user id 1, pinned by a CHECK constraint, bypassing
|
|
every permission check. It will mean the same thing in a year. `Super Admin` might not.
|
|
|
|
A plugin that genuinely needs a third tier — "Admins may also do this" — should not get it by reading a
|
|
role. It should declare a second permission key and let the owner grant it. That keeps the answer to
|
|
"who may do this" on the permissions page, where the owner can see and change it, rather than compiled
|
|
into a plugin's `if`.
|
|
|
|
**Nothing may be trusted from the caller.** The proxy builds a fresh header object and copies a
|
|
three-item allowlist; it does not forward arbitrary inbound headers. That is why `X-Officer-User` is
|
|
safe to trust, and any new header inherits the same safety **only for as long as the allowlist stays an
|
|
allowlist**. If that loop is ever changed to a copy-everything-except, a client-supplied
|
|
`X-Officer-Owner: 1` becomes a privilege escalation against every plugin at once.
|
|
|
|
**Absent means denied.** A sidecar that does not see the headers — reached directly, an older platform,
|
|
a bug — must resolve to no privileges rather than to the owner. Same rule as `authorize.ts`, where every
|
|
catch returns deny.
|
|
|
|
## Where I disagree, and it is one point
|
|
|
|
Plugin-side enforcement has a failure mode the platform has already been bitten by, and it is worth
|
|
naming before it happens again rather than after.
|
|
|
|
`assertPermissionTotality` exists because a Member could 403 on `GET /api/tasks` and open
|
|
`/api/tasks/pipeline/ws` with a 101 in the same minute. The lesson written down at the time was
|
|
"**a patch does not survive the next door; refusing to boot does.**" Pure plugin-side authorization is
|
|
that same shape: jellyfin will check, because we are about to write the check. The next plugin's author
|
|
forgets one route, and nothing anywhere notices — no boot failure, no warning, no test.
|
|
|
|
So the recommendation is not "platform enforces instead". It is **declare in the manifest, enforce in the
|
|
proxy, and pass the facts to the sidecar as well**:
|
|
|
|
Built as a field on the permission rather than at the manifest root, following `readOnlyWrites` — both
|
|
are sub-paths written relative to the plugin's prefix with a rule attached, and one precedent is better
|
|
than two shapes:
|
|
|
|
```ts
|
|
// manifest.ts
|
|
permissions: [{ key: 'jellyfin', label: 'Jellyfin', description: '…', ownerOnly: ['/_config'] }],
|
|
```
|
|
|
|
- **Declared by the plugin**, because the plugin knows which of its paths are administrative.
|
|
- **Enforced by the platform**, in the proxy, before the request reaches the sidecar — so forgetting the
|
|
check inside the plugin is not a hole, and the rule holds for a route added later by someone who never
|
|
read this file.
|
|
- **Visible at install**, next to the permissions the owner already reviews, and greppable across every
|
|
plugin from one place.
|
|
- **Still passed to the sidecar**, because the plugin's own finer rules — whose rows, which fields — are
|
|
genuinely its business and cannot be expressed as a path prefix.
|
|
|
|
This does not contradict the `manifest.ts` comment above. That comment refuses to make a **permission**
|
|
owner-only, because a permission is a thing the owner delegates and one nobody can delegate is a
|
|
contradiction. A path prefix is not a grant; it is a statement that a route administers the plugin
|
|
rather than uses it.
|
|
|
|
## The frontend is a courtesy, not the lock
|
|
|
|
Hiding the Settings entry in the plugin's nav for a non-owner is worth doing, and it is not security.
|
|
Same rule as `usePermissions`, which fails open on purpose: hiding an entry is politeness, the 403 is the
|
|
lock. Write the guard on the route first and the nav second, never only the second.
|
|
|
|
## What must be settled before any of this lands
|
|
|
|
**A member with no server has no Jellyfin.** Connections are stored strictly per user — every query in
|
|
`plugins/jellyfin/db/queries.ts` filters on `eq(jellyfinServers.userId, userId)`. Lock `/_config` to the
|
|
owner today and a Member gets a plugin that can only ever show them the connect form they are forbidden
|
|
to submit.
|
|
|
|
The resolution that fits what already exists: a non-owner resolves to the **owner's active server URL**
|
|
authenticated with **their own token**, which provisioning already mints and stores in
|
|
`jellyfin_accounts`. That is arguably why those tokens are minted at all — per-user identity upstream, so
|
|
resume points, watched marks and next-up stay each person's own rather than everyone sharing the owner's.
|
|
|
|
That is a change to credential resolution, not to authorization, and it is the actual prerequisite. The
|
|
guard is a few lines; this is the work.
|
|
|
|
## Status
|
|
|
|
| | |
|
|
| ----------- | ------------------------------------------------------------------------------------- |
|
|
| Agreed | plugin decides what a level means inside its own surface |
|
|
| Agreed | pass `isOwner` and the effective level; never the role name |
|
|
| Agreed | fail closed when the headers are absent |
|
|
| Built | `ownerOnly` on a plugin permission, enforced by the gate before self-service and kind |
|
|
| Built | `X-Officer-Owner` and `X-Officer-Level`, sent when a proxy names its permission key |
|
|
| Built | manifest validation — a sub-path without a leading slash is a manifest error |
|
|
| Blocked on | credential resolution for non-owners, above |
|
|
| Not started | any plugin actually declaring one |
|