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>
This commit is contained in:
2026-08-20 22:56:10 +00:00
co-authored by Claude Opus 5
parent 1915a407a2
commit 10feb76880
7 changed files with 205 additions and 13 deletions
+65
View File
@@ -8,6 +8,7 @@ import {
permissionForWsProvider,
isRequestAllowedAtLevel,
isSelfServiceRoute,
isOwnerOnlyRoute,
} from './registry';
import { assertPermissionTotality, isExemptApiPath } from './totality';
@@ -298,3 +299,67 @@ test('notify: a read-level member can register and deregister their own device',
// Still scoped: read level does not become write level for the rest of the surface.
expect(isRequestAllowedAtLevel(notify!, 'read', 'POST', '/api/notify/_officer/notify')).toBe(false);
});
// ── ownerOnly: the administrative corner of a shareable surface ──
//
// The case: a plugin worth granting to a Member that also holds the credential everything else 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. Before this the choice was all of it or none.
//
// The owner never reaches any of this — `isApiRequestAllowed` returns for them before the permission is
// even resolved — so every case here is about somebody who legitimately holds the permission.
const withOwnerOnly: Permission = {
key: 'jellyfin',
label: 'Jellyfin',
description: 'films and shows',
kind: 'app',
api: ['/jellyfin'],
ownerOnly: ['/_config'],
personal: ['/_officer'],
selfService: ['POST /_config/ping'],
};
describe('isOwnerOnlyRoute', () => {
test('matches the declared sub-path', () => {
expect(isOwnerOnlyRoute(withOwnerOnly, '/api/jellyfin/_config')).toBe(true);
});
test('matches everything under it — a settings surface is a tree, not a list of leaves', () => {
expect(isOwnerOnlyRoute(withOwnerOnly, '/api/jellyfin/_config/3/users')).toBe(true);
expect(isOwnerOnlyRoute(withOwnerOnly, '/api/jellyfin/_config/3/activate')).toBe(true);
});
test('leaves the rest of the plugin alone', () => {
expect(isOwnerOnlyRoute(withOwnerOnly, '/api/jellyfin/_jf/Items')).toBe(false);
expect(isOwnerOnlyRoute(withOwnerOnly, '/api/jellyfin')).toBe(false);
});
test('does not match a path that merely starts with the same letters', () => {
// `/_configuration` is a different route. Prefix matching that ignored the boundary would silently
// lock a route nobody declared, which is the mirror of the bug this feature exists to prevent.
expect(isOwnerOnlyRoute(withOwnerOnly, '/api/jellyfin/_configuration')).toBe(false);
});
test('does not reach outside the permission that declared it', () => {
expect(isOwnerOnlyRoute(withOwnerOnly, '/api/music/_config')).toBe(false);
});
test('a permission declaring none is unaffected', () => {
expect(isOwnerOnlyRoute({ ...withOwnerOnly, ownerOnly: undefined }, '/api/jellyfin/_config')).toBe(false);
});
// The two rules that could contradict each other, and which way it has to fall. `authorize.ts` checks
// ownerOnly FIRST, so these assertions are about the matchers agreeing that the overlap exists — the
// precedence is what makes it safe.
test('overlapping selfService does not unlock it', () => {
expect(isSelfServiceRoute(withOwnerOnly, 'POST', '/api/jellyfin/_config/ping')).toBe(true);
expect(isOwnerOnlyRoute(withOwnerOnly, '/api/jellyfin/_config/ping')).toBe(true);
});
test('a write grant does not unlock it either', () => {
// `isRequestAllowedAtLevel` says yes, because level is not the question ownerOnly answers.
expect(isRequestAllowedAtLevel(withOwnerOnly, 'write', 'POST', '/api/jellyfin/_config')).toBe(true);
expect(isOwnerOnlyRoute(withOwnerOnly, '/api/jellyfin/_config')).toBe(true);
});
});