Files
platform/docs/plugin-route-authorization.md
pastilhasandClaude Opus 5 10feb76880 permissions: a plugin can mark part of its surface owner-only
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>
2026-08-20 22:56:10 +00:00

8.1 KiB

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:

// 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