a commander's bound is a rule, not a list
the allow list shipped an hour ago was a snapshot. plugin permissions
register at mount, so a plugin installed after a commander was created was
missing from its list and failed as a bare 403 with nothing saying why —
and every future thing a commander is meant to do is exactly that shape:
notify a device, send mail, spawn a sub-agent, hand a transcription to an
idle one, reach a plugin that does not exist yet.
so a credential now narrows two ways, and both only subtract:
permissions an ALLOW list. exact, a snapshot. right for a credential
with one job.
excludedKinds kinds it may never reach, resolved against the LIVE
registry on every request. right for a credential whose
job is open-ended.
a commander gets the second: every kind except admin. bounded away from
installing plugins, managing users and roles and changing server settings,
forever — and open to everything the platform learns to do, with nothing
to re-issue. still intersected with the account, so for a member it
narrows itself against their role.
verified against the running server with a commander-shaped key: tasks,
notify, file-browser and the transcribe PLUGIN all reach their routes;
/api/users, /api/plugins and /api/roles are 403. the plugin case is the
one an allow list could not have passed — it was mounted long after any
key was minted.
the commander's CLAUDE.md says what it cannot do in the rule's own terms,
and that a 403 is an answer rather than a problem to route around.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
This commit is contained in:
@@ -14,8 +14,10 @@ export type ApiKeyIdentity = {
|
|||||||
email: string;
|
email: string;
|
||||||
username: string | null;
|
username: string | null;
|
||||||
lastUsedAt: Date | null;
|
lastUsedAt: Date | null;
|
||||||
/** Permission keys this credential may reach, or null for the holder's full authority. */
|
/** Permission keys this credential may reach, or null for "anything not excluded". */
|
||||||
permissions: string[] | null;
|
permissions: string[] | null;
|
||||||
|
/** Permission kinds it may never reach, resolved against the live registry. */
|
||||||
|
excludedKinds: string[] | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -34,6 +36,7 @@ export async function findLiveApiKeyByHash(keyHash: string): Promise<ApiKeyIdent
|
|||||||
username: users.username,
|
username: users.username,
|
||||||
lastUsedAt: apiKeys.lastUsedAt,
|
lastUsedAt: apiKeys.lastUsedAt,
|
||||||
permissions: apiKeys.permissions,
|
permissions: apiKeys.permissions,
|
||||||
|
excludedKinds: apiKeys.excludedKinds,
|
||||||
})
|
})
|
||||||
.from(apiKeys)
|
.from(apiKeys)
|
||||||
.innerJoin(users, eq(users.id, apiKeys.userId))
|
.innerJoin(users, eq(users.id, apiKeys.userId))
|
||||||
@@ -54,8 +57,10 @@ type CreateApiKeyParams = {
|
|||||||
keyHash: string;
|
keyHash: string;
|
||||||
prefix: string;
|
prefix: string;
|
||||||
expiresAt?: Date | null;
|
expiresAt?: Date | null;
|
||||||
/** Omit or pass null for the holder's full authority; a list narrows the key to those permission keys. */
|
/** Omit or pass null for "anything not excluded"; a list narrows the key to those permission keys. */
|
||||||
permissions?: string[] | null;
|
permissions?: string[] | null;
|
||||||
|
/** Permission kinds this key may never reach, whatever the registry grows to hold. */
|
||||||
|
excludedKinds?: string[] | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
export async function createApiKey(params: CreateApiKeyParams): Promise<ApiKeySelect> {
|
export async function createApiKey(params: CreateApiKeyParams): Promise<ApiKeySelect> {
|
||||||
@@ -68,6 +73,7 @@ export async function createApiKey(params: CreateApiKeyParams): Promise<ApiKeySe
|
|||||||
prefix: params.prefix,
|
prefix: params.prefix,
|
||||||
expiresAt: params.expiresAt ?? null,
|
expiresAt: params.expiresAt ?? null,
|
||||||
permissions: params.permissions ?? null,
|
permissions: params.permissions ?? null,
|
||||||
|
excludedKinds: params.excludedKinds ?? null,
|
||||||
})
|
})
|
||||||
.returning();
|
.returning();
|
||||||
return row!;
|
return row!;
|
||||||
@@ -82,6 +88,7 @@ export async function listApiKeys(userId: number): Promise<Omit<ApiKeySelect, 'k
|
|||||||
name: apiKeys.name,
|
name: apiKeys.name,
|
||||||
prefix: apiKeys.prefix,
|
prefix: apiKeys.prefix,
|
||||||
permissions: apiKeys.permissions,
|
permissions: apiKeys.permissions,
|
||||||
|
excludedKinds: apiKeys.excludedKinds,
|
||||||
lastUsedAt: apiKeys.lastUsedAt,
|
lastUsedAt: apiKeys.lastUsedAt,
|
||||||
expiresAt: apiKeys.expiresAt,
|
expiresAt: apiKeys.expiresAt,
|
||||||
revokedAt: apiKeys.revokedAt,
|
revokedAt: apiKeys.revokedAt,
|
||||||
|
|||||||
@@ -21,12 +21,22 @@ import { users } from '../auth/schema';
|
|||||||
//
|
//
|
||||||
// ── Authority ──
|
// ── Authority ──
|
||||||
//
|
//
|
||||||
// A key carries its owner's authority, NARROWED by `permissions` when that column is set. Null means
|
// A key carries its owner's authority, NARROWED by two columns when they are set. Both null means
|
||||||
// unnarrowed — what every key minted by a human still is, and what the mobile apps hold: exactly what the
|
// unnarrowed — what every key minted by a human still is, and what the mobile apps hold: exactly what the
|
||||||
// password that made it could already do, so a key is never an escalation.
|
// password that made it could already do, so a key is never an escalation.
|
||||||
//
|
//
|
||||||
// A list means the key may reach only those permission keys, and still only where the account itself
|
// There are two of them because there are two shapes of "less than everything", and only one of them
|
||||||
// could. Intersection, never union — see `permissions/authorize.ts` → `decideApiRequest`.
|
// survives contact with a platform that keeps growing:
|
||||||
|
//
|
||||||
|
// `permissions` — an ALLOW list. Exact, and a snapshot. Right for a credential with one job.
|
||||||
|
// `excludedKinds` — kinds this key may never reach, whatever exists. Right for a credential whose job
|
||||||
|
// is open-ended, which is every commander: it should reach the notifier, the mailer,
|
||||||
|
// sub-agents and a plugin installed six months from now, and never the plugin
|
||||||
|
// INSTALLER. An allow list cannot say that — it names what exists today, so tomorrow's
|
||||||
|
// plugin is missing from it and fails as a bare 403 with nothing saying why.
|
||||||
|
//
|
||||||
|
// Both narrow and neither widens: the result is still intersected with what the account itself holds. See
|
||||||
|
// `permissions/authorize.ts` → `decideApiRequest`.
|
||||||
//
|
//
|
||||||
// **The owner is the reason this exists.** A Super Admin bypasses every role check, so a credential
|
// **The owner is the reason this exists.** A Super Admin bypasses every role check, so a credential
|
||||||
// carrying the owner's identity is unbounded by construction, and a commander is an agent holding one
|
// carrying the owner's identity is unbounded by construction, and a commander is an agent holding one
|
||||||
@@ -46,12 +56,20 @@ export const apiKeys = pgTable(
|
|||||||
/** First characters of the secret, in the clear, for display only. */
|
/** First characters of the secret, in the clear, for display only. */
|
||||||
prefix: text('prefix').notNull(),
|
prefix: text('prefix').notNull(),
|
||||||
/**
|
/**
|
||||||
* Permission keys this credential may reach, or null for the holder's full authority.
|
* Permission keys this credential may reach, or null for "anything not excluded below".
|
||||||
*
|
*
|
||||||
* An empty array is NOT the same as null and is not a mistake to guard against: it is a key that can
|
* An empty array is NOT the same as null and is not a mistake to guard against: it is a key that can
|
||||||
* reach nothing, which is a coherent thing to mint and a safe thing to default to.
|
* reach nothing, which is a coherent thing to mint and a safe thing to default to.
|
||||||
*/
|
*/
|
||||||
permissions: text('permissions').array(),
|
permissions: text('permissions').array(),
|
||||||
|
/**
|
||||||
|
* Permission KINDS this credential may never reach — `admin`, `execution`, `confined`, `app`, `core`.
|
||||||
|
*
|
||||||
|
* Resolved at request time against the live registry rather than expanded into `permissions` at
|
||||||
|
* creation, which is the whole difference: a commander minted today is bounded away from
|
||||||
|
* administration forever, and still reaches a plugin installed next year without being re-issued.
|
||||||
|
*/
|
||||||
|
excludedKinds: text('excluded_kinds').array(),
|
||||||
/**
|
/**
|
||||||
* Updated on use, but at most once a minute — see touchApiKey. Without the debounce every
|
* Updated on use, but at most once a minute — see touchApiKey. Without the debounce every
|
||||||
* authenticated request would carry a write, which is a lot of churn to answer "is this still in use".
|
* authenticated request would carry a write, which is a lot of churn to answer "is this still in use".
|
||||||
|
|||||||
+1
-1
@@ -391,7 +391,7 @@ async function upgradeWs(req: Request, server: any, provider: UpgradableProvider
|
|||||||
// whole point: the two doors cannot disagree about what a role holds, because there is only one
|
// whole point: the two doors cannot disagree about what a role holds, because there is only one
|
||||||
// declaration to read. `terminal`, `chat`, `task-runner`, `pipeline` and `desktop` are refused here
|
// declaration to read. `terminal`, `chat`, `task-runner`, `pipeline` and `desktop` are refused here
|
||||||
// by being `execution` permissions, not by being absent from an array someone has to maintain.
|
// by being `execution` permissions, not by being absent from an array someone has to maintain.
|
||||||
if (!(await isWsProviderAllowed(user.id, provider, user.permissions ?? null))) {
|
if (!(await isWsProviderAllowed(user.id, provider, user.scope ?? null))) {
|
||||||
return new Response('Forbidden', { status: 403 });
|
return new Response('Forbidden', { status: 403 });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -47,10 +47,10 @@ export const permissionGateMiddleware: MiddlewareHandler = async function (ctx,
|
|||||||
// A NARROWED credential is checked even for the owner — that narrowing is the only bound a Super Admin
|
// A NARROWED credential is checked even for the owner — that narrowing is the only bound a Super Admin
|
||||||
// has, so skipping the check for them would make it decorative on the one account it exists for. An
|
// has, so skipping the check for them would make it decorative on the one account it exists for. An
|
||||||
// unnarrowed owner still short-circuits, which keeps the common request free of a database read.
|
// unnarrowed owner still short-circuits, which keeps the common request free of a database read.
|
||||||
const credential = payload?.permissions ?? null;
|
const credential = payload?.scope ?? null;
|
||||||
// Stashed for the routes that pass authority on rather than just using it. Set unconditionally, so a
|
// Stashed for the routes that pass authority on rather than just using it. Set unconditionally, so a
|
||||||
// reader never has to distinguish "unnarrowed" from "the gate did not run".
|
// reader never has to distinguish "unnarrowed" from "the gate did not run".
|
||||||
ctx.set('credentialPermissions', credential);
|
ctx.set('credentialScope', credential);
|
||||||
const mustCheck = payload && (!isOwner || credential);
|
const mustCheck = payload && (!isOwner || credential);
|
||||||
|
|
||||||
if (mustCheck && path.startsWith('/api') && !isExemptApiPath(path) && !isPluginPublicPath(path)) {
|
if (mustCheck && path.startsWith('/api') && !isExemptApiPath(path) && !isPluginPublicPath(path)) {
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ import { mkdirSync, writeFileSync } from 'node:fs';
|
|||||||
import { join } from 'node:path';
|
import { join } from 'node:path';
|
||||||
import { createApiKey, revokeApiKeysByName } from 'officerdb';
|
import { createApiKey, revokeApiKeysByName } from 'officerdb';
|
||||||
import { mintApiKey } from '@@/auth-token';
|
import { mintApiKey } from '@@/auth-token';
|
||||||
import { PERMISSION_BY_KEY } from '@@/permissions/registry';
|
|
||||||
import { PORT, OFFICER_API_URL } from '../../officer-url.mjs';
|
import { PORT, OFFICER_API_URL } from '../../officer-url.mjs';
|
||||||
import { getCommanderDir } from '../../data-path';
|
import { getCommanderDir } from '../../data-path';
|
||||||
|
|
||||||
@@ -24,21 +23,28 @@ import { getCommanderDir } from '../../data-path';
|
|||||||
for months unattended. `api_keys.permissions` is the only thing that can bound a Super Admin — see
|
for months unattended. `api_keys.permissions` is the only thing that can bound a Super Admin — see
|
||||||
`permissions/authorize.ts` → `decideApiRequest`, which applies it BEFORE the owner short-circuit.
|
`permissions/authorize.ts` → `decideApiRequest`, which applies it BEFORE the owner short-circuit.
|
||||||
|
|
||||||
── Why the default is "everything except admin" ──
|
── Why it excludes a KIND rather than listing what it allows ──
|
||||||
|
|
||||||
A commander that can reach nothing is useless on the day it is created, and a default nobody can work
|
A commander's job is open-ended and grows. It should send a notification, send mail, spawn a sub-agent,
|
||||||
with gets widened in a hurry rather than thought about. So the default removes the class that is never
|
hand a transcription to an idle one — and reach a plugin installed six months from now without being
|
||||||
part of a watcher's job — installing plugins, managing users and roles, changing server settings — and
|
re-issued. What it should never do is administer the server it runs on.
|
||||||
leaves the rest.
|
|
||||||
|
|
||||||
It is a DEFAULT, not a policy: the list is stored on the key row and can be narrowed per commander.
|
An allow list cannot express that. A list is a snapshot of what existed on the day it was written, so a
|
||||||
And for a member it narrows itself, because the check is an intersection with what their role holds.
|
plugin added afterwards is simply missing from it, and the failure arrives as a bare 403 with nothing
|
||||||
|
saying why. Every future capability would land on that.
|
||||||
|
|
||||||
|
So the default is a rule: every kind except `admin`. It is resolved against the live registry on each
|
||||||
|
request, which means it keeps meaning the same thing as the platform grows — bounded away from
|
||||||
|
installing plugins, managing users and roles and changing server settings, forever, and open to
|
||||||
|
everything else the platform learns to do.
|
||||||
|
|
||||||
|
A DEFAULT, not a policy: it is stored on the key row and can be tightened per commander with an explicit
|
||||||
|
allow list alongside it. And for a member it narrows itself, because the check is an intersection with
|
||||||
|
what their role holds.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
/** Permission keys a commander gets unless told otherwise: everything the platform does that is not administration. */
|
/** What a commander gets unless told otherwise: everything the platform can do, except administering it. */
|
||||||
export function defaultCommanderPermissions(): string[] {
|
export const COMMANDER_EXCLUDED_KINDS = ['admin'];
|
||||||
return [...PERMISSION_BY_KEY.values()].filter((p) => p.kind !== 'admin').map((p) => p.key);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** The env file's path, and the name the key is stored under — both derived, so revocation cannot miss one. */
|
/** The env file's path, and the name the key is stored under — both derived, so revocation cannot miss one. */
|
||||||
export const commanderKeyName = (dirName: string) => `commander:${dirName}`;
|
export const commanderKeyName = (dirName: string) => `commander:${dirName}`;
|
||||||
@@ -60,9 +66,9 @@ export async function writeCommanderCredential(params: {
|
|||||||
userId: number;
|
userId: number;
|
||||||
email: string;
|
email: string;
|
||||||
dirName: string;
|
dirName: string;
|
||||||
permissions?: string[];
|
/** An explicit allow list, to tighten one commander further. Absent leaves it at the default rule. */
|
||||||
}): Promise<{ path: string; permissions: string[] }> {
|
permissions?: string[] | null;
|
||||||
const permissions = params.permissions ?? defaultCommanderPermissions();
|
}): Promise<{ path: string; excludedKinds: string[] }> {
|
||||||
const { secret, keyHash, prefix } = mintApiKey();
|
const { secret, keyHash, prefix } = mintApiKey();
|
||||||
|
|
||||||
// Revoke first. A commander recreated under a directory name that has been used before would otherwise
|
// Revoke first. A commander recreated under a directory name that has been used before would otherwise
|
||||||
@@ -73,7 +79,8 @@ export async function writeCommanderCredential(params: {
|
|||||||
name: commanderKeyName(params.dirName),
|
name: commanderKeyName(params.dirName),
|
||||||
keyHash,
|
keyHash,
|
||||||
prefix,
|
prefix,
|
||||||
permissions,
|
permissions: params.permissions ?? null,
|
||||||
|
excludedKinds: COMMANDER_EXCLUDED_KINDS,
|
||||||
});
|
});
|
||||||
|
|
||||||
const path = commanderEnvPath(params.email, params.dirName);
|
const path = commanderEnvPath(params.email, params.dirName);
|
||||||
@@ -94,5 +101,5 @@ export async function writeCommanderCredential(params: {
|
|||||||
{ mode: 0o600 },
|
{ mode: 0o600 },
|
||||||
);
|
);
|
||||||
|
|
||||||
return { path, permissions };
|
return { path, excludedKinds: COMMANDER_EXCLUDED_KINDS };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -88,9 +88,13 @@ export function commanderClaudeMd(params: CommanderClaudeMdParams): string {
|
|||||||
'`POST /api/tasks/<dirName>/run` runs one and returns what it did — one reviewed definition on',
|
'`POST /api/tasks/<dirName>/run` runs one and returns what it did — one reviewed definition on',
|
||||||
'disk instead of a re-implementation living in a loop nobody reads.',
|
'disk instead of a re-implementation living in a loop nobody reads.',
|
||||||
'',
|
'',
|
||||||
'The credential is narrowed and revoked when this commander is deleted. A 403 is an answer, not a',
|
'It reaches everything the platform does EXCEPT administering it — installing plugins, managing',
|
||||||
'problem to route around: it means this commander was not given that, and the owner is the one who',
|
'users or roles, changing server settings. That holds for features added later too: the rule is',
|
||||||
'decides otherwise.',
|
'resolved per request rather than frozen into a list, so a plugin installed next year is already',
|
||||||
|
'covered without this commander being re-issued.',
|
||||||
|
'',
|
||||||
|
'A 403 is an answer, not a problem to route around. It means this commander was not given that,',
|
||||||
|
'and the owner is the one who decides otherwise. Say so rather than finding another way in.',
|
||||||
'',
|
'',
|
||||||
]
|
]
|
||||||
: [
|
: [
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { tmpdir } from 'node:os';
|
|||||||
import { getTaskByDirName, type TaskRecord } from './task-files';
|
import { getTaskByDirName, type TaskRecord } from './task-files';
|
||||||
import { getOwnerHomeDir } from '../../data-path';
|
import { getOwnerHomeDir } from '../../data-path';
|
||||||
import { buildTaskApiEnv } from './task-api-env';
|
import { buildTaskApiEnv } from './task-api-env';
|
||||||
|
import type { CredentialScope } from '../../auth-token';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Everything a script-mode task run needs before something spawns it.
|
* Everything a script-mode task run needs before something spawns it.
|
||||||
@@ -58,7 +59,7 @@ export type ScriptRunUser = {
|
|||||||
email: string;
|
email: string;
|
||||||
username?: string | null;
|
username?: string | null;
|
||||||
/** The caller's credential narrowing, propagated to the script's own token. See `task-api-env.ts`. */
|
/** The caller's credential narrowing, propagated to the script's own token. See `task-api-env.ts`. */
|
||||||
permissions?: string[] | null;
|
scope?: CredentialScope | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type PreparedScriptRun = {
|
export type PreparedScriptRun = {
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { sign } from '@@/jwt';
|
import { sign } from '@@/jwt';
|
||||||
|
import type { CredentialScope } from '@@/auth-token';
|
||||||
import { PORT, OFFICER_API_URL } from '../../officer-url.mjs';
|
import { PORT, OFFICER_API_URL } from '../../officer-url.mjs';
|
||||||
|
|
||||||
const { PUBLIC_URL } = process.env;
|
const { PUBLIC_URL } = process.env;
|
||||||
@@ -24,7 +25,7 @@ type TaskApiUser = {
|
|||||||
* back through the API with the owner's full authority and the limit would have bought nothing. The
|
* back through the API with the owner's full authority and the limit would have bought nothing. The
|
||||||
* script gets exactly what its caller had — never more.
|
* script gets exactly what its caller had — never more.
|
||||||
*/
|
*/
|
||||||
permissions?: string[] | null;
|
scope?: CredentialScope | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
// Environment that lets a script task call Officer's own API instead of reimplementing server-side
|
// Environment that lets a script task call Officer's own API instead of reimplementing server-side
|
||||||
@@ -45,7 +46,7 @@ export async function buildTaskApiEnv(user: TaskApiUser): Promise<Record<string,
|
|||||||
username: user.username ?? '',
|
username: user.username ?? '',
|
||||||
// Only when narrowed. An absent claim is what every ordinary run already signs, and `resolveAuthToken`
|
// Only when narrowed. An absent claim is what every ordinary run already signs, and `resolveAuthToken`
|
||||||
// spreads the payload onto the identity, so this reaches both doors with no further wiring.
|
// spreads the payload onto the identity, so this reaches both doors with no further wiring.
|
||||||
...(user.permissions ? { permissions: user.permissions } : {}),
|
...(user.scope ? { scope: user.scope } : {}),
|
||||||
},
|
},
|
||||||
'12h',
|
'12h',
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -94,7 +94,7 @@ tasksRouter.post('/:name/run', async (ctx) => {
|
|||||||
id: user.id,
|
id: user.id,
|
||||||
email: user.email,
|
email: user.email,
|
||||||
username: user.username ?? '',
|
username: user.username ?? '',
|
||||||
permissions: ctx.get('credentialPermissions'),
|
scope: ctx.get('credentialScope'),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
// A non-zero exit is reported, not thrown: the script ran, and its output is the useful part of the
|
// A non-zero exit is reported, not thrown: the script ran, and its output is the useful part of the
|
||||||
|
|||||||
@@ -69,15 +69,32 @@ export type AuthIdentity = {
|
|||||||
/** JWT only, seconds since the epoch. */
|
/** JWT only, seconds since the epoch. */
|
||||||
exp?: number;
|
exp?: number;
|
||||||
/**
|
/**
|
||||||
* API key only: the permission keys this credential may reach, or null for the holder's full authority.
|
* How this credential is narrowed, or null/absent for one carrying its holder's full authority — which
|
||||||
|
* is every session JWT and every key a human has minted.
|
||||||
*
|
*
|
||||||
* Carried on the identity rather than looked up again by the gate, because the key row was already read
|
* Carried on the identity rather than looked up again by the gate, because the key row was already read
|
||||||
* to answer "whose is this" — and because a second lookup is a second chance for the two doors to
|
* to answer "whose is this" — and because a second lookup is a second chance for the two doors to
|
||||||
* disagree about the same credential, which is the failure this whole file exists to prevent.
|
* disagree about the same credential, which is the failure this whole file exists to prevent.
|
||||||
*/
|
*/
|
||||||
permissions?: string[] | null;
|
scope?: CredentialScope | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A narrowing, in the two shapes that are actually useful.
|
||||||
|
*
|
||||||
|
* `allow` is exact and a snapshot; `excludeKinds` is a rule resolved against the live registry. A
|
||||||
|
* commander wants the second: bounded away from administration forever, and still able to reach a plugin
|
||||||
|
* installed long after it was created. See `api-keys/schema.ts` for the full argument.
|
||||||
|
*/
|
||||||
|
export type CredentialScope = {
|
||||||
|
allow?: string[] | null;
|
||||||
|
excludeKinds?: string[] | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Does this narrowing actually narrow anything? Null and two empty rules are the same thing: no. */
|
||||||
|
export const isNarrowed = (scope: CredentialScope | null | undefined): scope is CredentialScope =>
|
||||||
|
!!scope && (Array.isArray(scope.allow) || !!scope.excludeKinds?.length);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Turn an API key into a caller, or null.
|
* Turn an API key into a caller, or null.
|
||||||
*
|
*
|
||||||
@@ -94,7 +111,14 @@ export async function resolveApiKey(secret: string): Promise<AuthIdentity | null
|
|||||||
// is debounced to at most once a minute per key inside touchApiKey.
|
// is debounced to at most once a minute per key inside touchApiKey.
|
||||||
void touchApiKey(row.keyId, row.lastUsedAt).catch(() => {});
|
void touchApiKey(row.keyId, row.lastUsedAt).catch(() => {});
|
||||||
|
|
||||||
return { id: row.userId, email: row.email, username: row.username, via: 'api-key', permissions: row.permissions };
|
const scope: CredentialScope = { allow: row.permissions, excludeKinds: row.excludedKinds };
|
||||||
|
return {
|
||||||
|
id: row.userId,
|
||||||
|
email: row.email,
|
||||||
|
username: row.username,
|
||||||
|
via: 'api-key',
|
||||||
|
scope: isNarrowed(scope) ? scope : null,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import type { User } from 'types';
|
import type { User } from 'types';
|
||||||
import { Hono } from 'hono';
|
import { Hono } from 'hono';
|
||||||
|
import type { CredentialScope } from './auth-token';
|
||||||
|
|
||||||
export type HonoVariables = {
|
export type HonoVariables = {
|
||||||
body: Record<string, unknown>;
|
body: Record<string, unknown>;
|
||||||
@@ -15,14 +16,14 @@ export type HonoVariables = {
|
|||||||
*/
|
*/
|
||||||
user: User & { homeDir?: string };
|
user: User & { homeDir?: string };
|
||||||
/**
|
/**
|
||||||
* The narrowing carried by the credential that made this request — permission keys, or null when it
|
* The narrowing carried by the credential that made this request, or null when it carries its holder's
|
||||||
* carries its holder's full authority (every session JWT, every key a human minted).
|
* full authority (every session JWT, every key a human minted).
|
||||||
*
|
*
|
||||||
* Set by the permission gate, which has already resolved the token and is the only place that should.
|
* Set by the permission gate, which has already resolved the token and is the only place that should.
|
||||||
* A route reads it when it hands authority ONWARD — `POST /tasks/:name/run` mints a token for the
|
* A route reads it when it hands authority ONWARD — `POST /tasks/:name/run` mints a token for the
|
||||||
* script it spawns, and that token must not be wider than the credential that asked for the run.
|
* script it spawns, and that token must not be wider than the credential that asked for the run.
|
||||||
*/
|
*/
|
||||||
credentialPermissions: string[] | null;
|
credentialScope: CredentialScope | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const createRouter = () => new Hono<{ Variables: HonoVariables }>();
|
export const createRouter = () => new Hono<{ Variables: HonoVariables }>();
|
||||||
|
|||||||
@@ -226,11 +226,12 @@ describe('deciding a websocket', () => {
|
|||||||
*/
|
*/
|
||||||
describe('a narrowed credential', () => {
|
describe('a narrowed credential', () => {
|
||||||
const owner: EffectivePermissions = { isOwner: true, grants: new Map() };
|
const owner: EffectivePermissions = { isOwner: true, grants: new Map() };
|
||||||
|
const allow = (...keys: string[]) => ({ allow: keys });
|
||||||
|
|
||||||
test('bounds the owner, who nothing else bounds', () => {
|
test('bounds the owner, who nothing else bounds', () => {
|
||||||
expect(decideApiRequest(owner, 'POST', '/api/tasks', ['tasks']).allowed).toBe(true);
|
expect(decideApiRequest(owner, 'POST', '/api/tasks', allow('tasks')).allowed).toBe(true);
|
||||||
expect(decideApiRequest(owner, 'GET', '/api/users', ['tasks']).allowed).toBe(false);
|
expect(decideApiRequest(owner, 'GET', '/api/users', allow('tasks')).allowed).toBe(false);
|
||||||
expect(decideWsProvider(owner, 'terminal', ['tasks'])).toBe(false);
|
expect(decideWsProvider(owner, 'terminal', allow('tasks'))).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('null and undefined leave the holder unnarrowed', () => {
|
test('null and undefined leave the holder unnarrowed', () => {
|
||||||
@@ -240,25 +241,70 @@ describe('a narrowed credential', () => {
|
|||||||
|
|
||||||
// An empty list is a coherent thing to mint, and the safe default for a credential nobody has decided
|
// An empty list is a coherent thing to mint, and the safe default for a credential nobody has decided
|
||||||
// about yet. It must not read as "no narrowing".
|
// about yet. It must not read as "no narrowing".
|
||||||
test('an empty list reaches nothing', () => {
|
test('an empty allow list reaches nothing', () => {
|
||||||
expect(decideApiRequest(owner, 'GET', '/api/tasks', []).allowed).toBe(false);
|
expect(decideApiRequest(owner, 'GET', '/api/tasks', allow()).allowed).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Intersection, never union: naming a permission does not grant it.
|
// Intersection, never union: naming a permission does not grant it.
|
||||||
test('it cannot widen an account that was never granted it', () => {
|
test('it cannot widen an account that was never granted it', () => {
|
||||||
const d = decideApiRequest(resolve(member()), 'GET', '/api/file-browser', ['files']);
|
const d = decideApiRequest(resolve(member()), 'GET', '/api/file-browser', allow('files'));
|
||||||
expect(d.allowed).toBe(false);
|
expect(d.allowed).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('it narrows an account that was granted it', () => {
|
test('it narrows an account that was granted it', () => {
|
||||||
const held = resolve(member(), grants(['files', 'write']));
|
const held = resolve(member(), grants(['files', 'write']));
|
||||||
expect(decideApiRequest(held, 'GET', '/api/file-browser', ['files']).allowed).toBe(true);
|
expect(decideApiRequest(held, 'GET', '/api/file-browser', allow('files')).allowed).toBe(true);
|
||||||
expect(decideApiRequest(held, 'GET', '/api/file-browser', ['tasks']).allowed).toBe(false);
|
expect(decideApiRequest(held, 'GET', '/api/file-browser', allow('tasks')).allowed).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Same rule the unnarrowed path already has, one step earlier: a path nothing serves must not be
|
// Same rule the unnarrowed path already has, one step earlier: a path nothing serves must not be
|
||||||
// reachable by failing to resolve.
|
// reachable by failing to resolve.
|
||||||
test('a path no permission covers is refused', () => {
|
test('a path no permission covers is refused', () => {
|
||||||
expect(decideApiRequest(owner, 'GET', '/api/nothing-serves-this', ['tasks']).allowed).toBe(false);
|
expect(decideApiRequest(owner, 'GET', '/api/nothing-serves-this', allow('tasks')).allowed).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
/*
|
||||||
|
── Excluding a KIND, rather than listing what is allowed ──
|
||||||
|
|
||||||
|
The shape a commander needs, and the reason there are two. A commander's job is open-ended: it should
|
||||||
|
reach the notifier, the mailer, sub-agents, and a plugin installed six months from now — and never the
|
||||||
|
plugin INSTALLER. An allow list cannot say that. It names what existed the day it was written, so a
|
||||||
|
plugin added later is simply missing from it and fails as a bare 403 with nothing explaining why.
|
||||||
|
|
||||||
|
Excluding kinds is resolved against the live registry on every request, so it keeps meaning the same
|
||||||
|
thing as the platform grows.
|
||||||
|
*/
|
||||||
|
describe('a credential that excludes a kind', () => {
|
||||||
|
const owner: EffectivePermissions = { isOwner: true, grants: new Map() };
|
||||||
|
const commander = { excludeKinds: ['admin'] };
|
||||||
|
|
||||||
|
test('the owner keeps everything except that kind', () => {
|
||||||
|
expect(decideApiRequest(owner, 'POST', '/api/tasks', commander).allowed).toBe(true);
|
||||||
|
expect(decideApiRequest(owner, 'GET', '/api/notify', commander).allowed).toBe(true);
|
||||||
|
expect(decideApiRequest(owner, 'GET', '/api/file-browser', commander).allowed).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('and cannot administer the server', () => {
|
||||||
|
expect(decideApiRequest(owner, 'GET', '/api/users', commander).allowed).toBe(false);
|
||||||
|
expect(decideApiRequest(owner, 'GET', '/api/plugins', commander).allowed).toBe(false);
|
||||||
|
expect(decideApiRequest(owner, 'GET', '/api/roles', commander).allowed).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
// The property an allow list cannot have. `permissionForApiPath` resolves against the registry as it is
|
||||||
|
// at request time, so anything mounted after this credential was minted is covered by the same rule.
|
||||||
|
test('a permission that did not exist when it was minted is covered by the rule', () => {
|
||||||
|
// A plugin's permission is `app` kind and is registered at mount, long after any commander was made.
|
||||||
|
// The decision reads the kind off the REGISTRY, never off the credential, so nothing about this
|
||||||
|
// credential has to change when the registry grows — which is the property an allow list cannot have.
|
||||||
|
const laterKind: string = 'app';
|
||||||
|
expect(commander.excludeKinds.includes(laterKind)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('both rules apply together, and both only subtract', () => {
|
||||||
|
const both = { allow: ['tasks', 'user-admin'], excludeKinds: ['admin'] };
|
||||||
|
expect(decideApiRequest(owner, 'POST', '/api/tasks', both).allowed).toBe(true);
|
||||||
|
// Named by the allow list AND excluded by kind — excluded wins, because both are subtractive.
|
||||||
|
expect(decideApiRequest(owner, 'GET', '/api/users', both).allowed).toBe(false);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { getUserById, getRoleGrants } from 'officerdb';
|
import { getUserById, getRoleGrants } from 'officerdb';
|
||||||
|
import type { CredentialScope } from '../auth-token';
|
||||||
import type { UserRole } from 'officerdb';
|
import type { UserRole } from 'officerdb';
|
||||||
import {
|
import {
|
||||||
PERMISSION_BY_KEY,
|
PERMISSION_BY_KEY,
|
||||||
@@ -141,18 +142,42 @@ export async function isApiRequestAllowed(
|
|||||||
userId: number | undefined,
|
userId: number | undefined,
|
||||||
method: string,
|
method: string,
|
||||||
path: string,
|
path: string,
|
||||||
credential?: string[] | null,
|
scope?: CredentialScope | null,
|
||||||
): Promise<{ allowed: boolean; reason?: string }> {
|
): Promise<{ allowed: boolean; reason?: string }> {
|
||||||
return decideApiRequest(await getEffectivePermissions(userId), method, path, credential);
|
return decideApiRequest(await getEffectivePermissions(userId), method, path, scope);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Does this credential's own narrowing admit this permission?
|
||||||
|
*
|
||||||
|
* Two rules, both subtractive, and an absent one admits everything:
|
||||||
|
*
|
||||||
|
* - `excludeKinds` is resolved against the LIVE registry, so it keeps meaning what it meant as the
|
||||||
|
* platform grows. That is what makes it right for a commander: excluded from administration forever,
|
||||||
|
* and able to reach a plugin installed long after it was minted. An allow list cannot express that —
|
||||||
|
* it names what existed on the day it was written, so next year's plugin is simply missing from it.
|
||||||
|
* - `allow` is an exact list, for a credential with one job.
|
||||||
|
*
|
||||||
|
* An unresolvable permission is refused rather than admitted: a path nothing serves must not become
|
||||||
|
* reachable by failing to resolve, which is the same rule the unnarrowed path already applies below.
|
||||||
|
*/
|
||||||
|
function credentialAdmits(
|
||||||
|
scope: CredentialScope,
|
||||||
|
permission: { key: string; kind: string } | null | undefined,
|
||||||
|
): boolean {
|
||||||
|
if (!permission) return false;
|
||||||
|
if (scope.excludeKinds?.includes(permission.kind)) return false;
|
||||||
|
if (Array.isArray(scope.allow) && !scope.allow.includes(permission.key)) return false;
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The decision, given an already-resolved account. Pure, and where every rule below actually lives.
|
* The decision, given an already-resolved account. Pure, and where every rule below actually lives.
|
||||||
*
|
*
|
||||||
* `credential` is the narrowing carried by an API key — the permission keys THAT key may reach, or
|
* `scope` is the narrowing carried by an API key, or null/undefined for one that carries its holder's
|
||||||
* null/undefined for one that carries its holder's full authority (which is every key a human has minted
|
* full authority (which is every key a human has minted and every session JWT). It is an intersection,
|
||||||
* and every session JWT). It is an intersection, never a union: a key cannot reach what its account
|
* never a union: a key cannot reach what its account could not, and its account cannot reach, through
|
||||||
* could not, and its account cannot reach, through this key, what the key does not name.
|
* this key, what the key excludes.
|
||||||
*
|
*
|
||||||
* **It is checked before the owner bypass, and that is the whole point.** A Super Admin consults no
|
* **It is checked before the owner bypass, and that is the whole point.** A Super Admin consults no
|
||||||
* grants table, so a credential carrying the owner's identity is unbounded by construction — which is
|
* grants table, so a credential carrying the owner's identity is unbounded by construction — which is
|
||||||
@@ -163,17 +188,14 @@ export function decideApiRequest(
|
|||||||
effective: EffectivePermissions,
|
effective: EffectivePermissions,
|
||||||
method: string,
|
method: string,
|
||||||
path: string,
|
path: string,
|
||||||
credential?: string[] | null,
|
scope?: CredentialScope | null,
|
||||||
): { allowed: boolean; reason?: string } {
|
): { allowed: boolean; reason?: string } {
|
||||||
const { isOwner, grants } = effective;
|
const { isOwner, grants } = effective;
|
||||||
|
|
||||||
if (credential) {
|
// Same deny-the-unmapped rule as below, one step earlier: a path this cannot resolve must not become
|
||||||
const scoped = permissionForApiPath(path);
|
// reachable by failing to resolve.
|
||||||
// Same deny-the-unmapped rule as below, one step earlier: a narrowed credential naming nothing that
|
if (scope && !credentialAdmits(scope, permissionForApiPath(path))) {
|
||||||
// covers this path cannot be allowed onto it by failing to resolve the path.
|
return { allowed: false, reason: 'this credential is not permitted to use that' };
|
||||||
if (!scoped || !credential.includes(scoped.key)) {
|
|
||||||
return { allowed: false, reason: 'this credential is not permitted to use that' };
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isOwner) return { allowed: true };
|
if (isOwner) return { allowed: true };
|
||||||
@@ -224,25 +246,22 @@ export function decideApiRequest(
|
|||||||
export async function isWsProviderAllowed(
|
export async function isWsProviderAllowed(
|
||||||
userId: number | undefined,
|
userId: number | undefined,
|
||||||
provider: string,
|
provider: string,
|
||||||
credential?: string[] | null,
|
scope?: CredentialScope | null,
|
||||||
): Promise<boolean> {
|
): Promise<boolean> {
|
||||||
return decideWsProvider(await getEffectivePermissions(userId), provider, credential);
|
return decideWsProvider(await getEffectivePermissions(userId), provider, scope);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The decision, given an already-resolved account. See `isWsProviderAllowed` for why level is not read. */
|
/** The decision, given an already-resolved account. See `isWsProviderAllowed` for why level is not read. */
|
||||||
export function decideWsProvider(
|
export function decideWsProvider(
|
||||||
effective: EffectivePermissions,
|
effective: EffectivePermissions,
|
||||||
provider: string,
|
provider: string,
|
||||||
credential?: string[] | null,
|
scope?: CredentialScope | null,
|
||||||
): boolean {
|
): boolean {
|
||||||
const { isOwner, grants } = effective;
|
const { isOwner, grants } = effective;
|
||||||
|
|
||||||
// Before the owner bypass, for the reason given in `decideApiRequest`. A socket is the one door a
|
// Before the owner bypass, for the reason given in `decideApiRequest`. A socket is the one door a
|
||||||
// narrowed credential must not walk through just because it authenticated.
|
// narrowed credential must not walk through just because it authenticated.
|
||||||
if (credential) {
|
if (scope && !credentialAdmits(scope, permissionForWsProvider(provider))) return false;
|
||||||
const scoped = permissionForWsProvider(provider);
|
|
||||||
if (!scoped || !credential.includes(scoped.key)) return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isOwner) return true;
|
if (isOwner) return true;
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user