capabilities: both doors now read the same declaration
replaces the account backstop. it was two hand-written lists — NON_OWNER_PATHS confining every non-owner to /api/auth + /api/music, and NON_OWNER_WS_PROVIDERS doing the same for sockets. they were not wrong, they were unscalable in one specific way: an allow-list answers "which paths" but never "why", so onboarding anyone who needed anything other than music meant editing an array in a middleware file and hoping the socket half got edited too. now both doors resolve against the registry, so they cannot disagree about what a role holds. terminal, chat, task-runner, pipeline and desktop are refused by being `execution` capabilities rather than by being absent from a list somebody maintains. fail-closed everywhere: an unknown capability key, a missing row, a database error or a deleted user all deny. the grant cache is keyed on role and has an explicit invalidation contract — unlike the one super-admin.ts refuses to have, this one has exactly one writer and it lives beside the reader. seeded Member → music at WRITE, which is precisely what the old path-based backstop allowed. granting `read` would have been a silent downgrade that broke playlists for the three live member accounts overnight. verified against the live database and real accounts: 27 http/socket cases, the read/write split (personal sub-paths writable at read, /music/scan not), cache invalidation after a revoke, and the borrowed test account's role restored. 20 new unit tests; full suite 362 pass 0 fail. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -3,6 +3,8 @@ import * as errors from '../custom-errors';
|
||||
import { IS_DEV_BUILD } from '../build-env';
|
||||
import { verify } from '../jwt';
|
||||
import { isSuperAdmin } from '../super-admin';
|
||||
import { isApiRequestAllowed } from '../capabilities/authorize';
|
||||
import { isExemptApiPath } from '../capabilities/totality';
|
||||
|
||||
const { PUBLIC_URL } = process.env;
|
||||
|
||||
@@ -53,32 +55,15 @@ const APP_ORIGIN_LIST: AppOrigin[] = Object.entries(process.env).flatMap(([key,
|
||||
|
||||
const APP_ORIGINS: string[] = APP_ORIGIN_LIST.map((a) => a.origin);
|
||||
|
||||
// The only path prefixes a non-owner account (and the music app) may reach. Still hand-written because
|
||||
// this is the ACCOUNT backstop below, not an origin rule — it holds whatever Origin a caller claims.
|
||||
const NON_OWNER_PATHS = ['/api/auth', '/api/music'];
|
||||
|
||||
// The SAME rule, for the other door into the platform.
|
||||
// The account backstop used to live here as two hand-written lists: NON_OWNER_PATHS, confining every
|
||||
// non-owner to '/api/auth' + '/api/music', and NON_OWNER_WS_PROVIDERS doing the same for sockets. Both
|
||||
// are gone, replaced by the capability registry (src/servers/capabilities/).
|
||||
//
|
||||
// WebSocket upgrades never reach this file's middleware. Bun's route table in server.tsx matches
|
||||
// '/api/terminal/ws' and friends before the '/api/*' catch-all that hands off to Hono, so the account
|
||||
// backstop below — and every other Hono middleware — is simply not on that code path. It was written
|
||||
// when routes were the only surface anyone was thinking about.
|
||||
//
|
||||
// The consequence was demonstrated on 2026-08-06: a Member token 403'd on `GET /api/tasks` opened
|
||||
// `/api/tasks/pipeline/ws` with a 101 in the same minute. Terminal, chat, task-runner, pipeline and
|
||||
// desktop were all reachable — a shell, the agent with --dangerously-skip-permissions, arbitrary script
|
||||
// execution, and the owner's physical screen.
|
||||
//
|
||||
// Kept here, beside NON_OWNER_PATHS, because these two are one rule expressed at two doors. Split them
|
||||
// across files and they drift; the drift is invisible until someone tries it. `cliamp`/`cliamp-audio`
|
||||
// are the socket half of `/api/music` — the music app's playback transport, which is exactly what a
|
||||
// music account is for.
|
||||
const NON_OWNER_WS_PROVIDERS = ['cliamp', 'cliamp-audio'];
|
||||
|
||||
/** Whether a non-owner account may open this websocket provider. Owners bypass this entirely. */
|
||||
export function isWsProviderAllowedForNonOwner(provider: string): boolean {
|
||||
return NON_OWNER_WS_PROVIDERS.includes(provider);
|
||||
}
|
||||
// They were not wrong, they were unscalable in one specific way: a hardcoded allow-list answers "which
|
||||
// paths" but never "why", so onboarding anyone who needed anything other than music meant editing an
|
||||
// array in a middleware file and hoping the socket half got edited too. The registry makes the two doors
|
||||
// read the same declaration, and the boot-time totality check makes a THIRD door impossible to add
|
||||
// without noticing. See capabilities/totality.ts for the incident that motivated it.
|
||||
|
||||
function pathAllowed(path: string, prefixes: string[]): boolean {
|
||||
return prefixes.some((prefix) => path === prefix || path.startsWith(`${prefix}/`));
|
||||
@@ -212,9 +197,16 @@ export const originScopeMiddleware: MiddlewareHandler = async function (ctx, nex
|
||||
}
|
||||
const isOwner = payload ? await isSuperAdmin(payload) : false;
|
||||
|
||||
// 1. Account backstop — non-owner accounts are confined to /api/auth + /api/music everywhere.
|
||||
if (payload && !isOwner && !pathAllowed(path, NON_OWNER_PATHS)) {
|
||||
throw errors.FORBIDDEN('This account is limited to the music app');
|
||||
// 1. Capability backstop — origin-INDEPENDENT, and the airtight half of this middleware. A valid
|
||||
// non-owner token may reach only what its ROLE has been granted, whatever Origin it claims and whether
|
||||
// or not it sends one. Exempt paths (signin, the public pages, the Bitwarden door) are skipped because
|
||||
// they are served above the account gate — the same list the boot check uses, deliberately.
|
||||
//
|
||||
// Non-/api paths are not ours: the DAV sync door authenticates with its own app password and carries no
|
||||
// platform account, so there is nothing here to resolve.
|
||||
if (payload && !isOwner && path.startsWith('/api') && !isExemptApiPath(path)) {
|
||||
const { allowed, reason } = await isApiRequestAllowed(payload.id, ctx.req.method, path);
|
||||
if (!allowed) throw errors.FORBIDDEN(reason ?? 'Not permitted for this account');
|
||||
}
|
||||
|
||||
// 2. Per-origin rules. Skipped entirely while origin checking is off (the account backstop above is
|
||||
|
||||
Reference in New Issue
Block a user