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:
@@ -2,6 +2,7 @@ import type { Hono } from 'hono';
|
||||
import type { HonoVariables } from '../create-router';
|
||||
import { createRouter } from '../create-router';
|
||||
import * as sidecar from '../sidecar-registry';
|
||||
import { getEffectivePermissions } from '../permissions/authorize';
|
||||
import { registerProxiedPrefix } from './proxied-prefixes';
|
||||
|
||||
// The shared auth-and-forward proxy every HTTP sidecar needs.
|
||||
@@ -52,6 +53,16 @@ export type SidecarProxyParams = {
|
||||
* only loses its ERROR MESSAGE — one Immich accepts is read to the end and never races at all.
|
||||
*/
|
||||
bufferRequestBody?: boolean;
|
||||
/**
|
||||
* The permission key gating this sidecar, when one does. Adds `X-Officer-Owner` and `X-Officer-Level`
|
||||
* to what is forwarded, so the sidecar can make the finer decisions the platform cannot make for it —
|
||||
* whose rows a read returns, which fields a level may see.
|
||||
*
|
||||
* The platform still decides IF the request arrives: the gate has already allowed it, and an owner-only
|
||||
* path declared in the manifest was refused before reaching here. These headers are for what the plugin
|
||||
* does with a request that is legitimately its own.
|
||||
*/
|
||||
permissionKey?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -75,6 +86,7 @@ export function createSidecarProxy({
|
||||
onRegister,
|
||||
timeoutSeconds,
|
||||
bufferRequestBody,
|
||||
permissionKey,
|
||||
}: SidecarProxyParams): SidecarProxy {
|
||||
let serverPort: number | null = null;
|
||||
|
||||
@@ -123,9 +135,31 @@ export function createSidecarProxy({
|
||||
const value = ctx.req.header(header);
|
||||
if (value) headers[header] = value;
|
||||
}
|
||||
// The authenticated owner, so the sidecar can scope its data. The sidecar binds loopback only, so it
|
||||
// trusts this header — which is exactly why nothing but this proxy may set it.
|
||||
headers['X-Officer-User'] = String(ctx.get('user').id);
|
||||
// ── Who is asking ──
|
||||
//
|
||||
// The sidecar binds loopback only and trusts these, which is exactly why nothing but this proxy may
|
||||
// set them. Note the loop above: it copies a THREE-ITEM ALLOWLIST off the inbound request, so a
|
||||
// client-supplied `X-Officer-Owner: 1` cannot reach a sidecar. If that is ever changed to a
|
||||
// copy-everything-except, it becomes privilege escalation against every plugin at once.
|
||||
//
|
||||
// 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 silently change
|
||||
// authorization in code the platform does not own and cannot see. `isOwner` is user id 1, pinned by a
|
||||
// CHECK constraint; it will mean the same thing in a year. A plugin wanting an "Admins too" tier
|
||||
// declares a second permission and lets the owner grant it, which keeps that answer on the
|
||||
// permissions page instead of compiled into an `if`.
|
||||
const userId = ctx.get('user').id;
|
||||
headers['X-Officer-User'] = String(userId);
|
||||
|
||||
if (permissionKey) {
|
||||
// Cached by role in `authorize.ts`, and the gate has already computed this for the same request, so
|
||||
// this is a map lookup rather than a query.
|
||||
const { isOwner, grants } = await getEffectivePermissions(userId);
|
||||
headers['X-Officer-Owner'] = isOwner ? '1' : '0';
|
||||
// The owner holds every permission implicitly and appears in no grants map — reporting their level
|
||||
// as the absent 'none' would invert the meaning for the one account that can do everything.
|
||||
headers['X-Officer-Level'] = isOwner ? 'write' : (grants.get(permissionKey) ?? 'none');
|
||||
}
|
||||
|
||||
const hasBody = method !== 'GET' && method !== 'HEAD';
|
||||
|
||||
|
||||
Reference in New Issue
Block a user