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;
|
||||
username: string | 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;
|
||||
/** 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,
|
||||
lastUsedAt: apiKeys.lastUsedAt,
|
||||
permissions: apiKeys.permissions,
|
||||
excludedKinds: apiKeys.excludedKinds,
|
||||
})
|
||||
.from(apiKeys)
|
||||
.innerJoin(users, eq(users.id, apiKeys.userId))
|
||||
@@ -54,8 +57,10 @@ type CreateApiKeyParams = {
|
||||
keyHash: string;
|
||||
prefix: string;
|
||||
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;
|
||||
/** Permission kinds this key may never reach, whatever the registry grows to hold. */
|
||||
excludedKinds?: string[] | null;
|
||||
};
|
||||
|
||||
export async function createApiKey(params: CreateApiKeyParams): Promise<ApiKeySelect> {
|
||||
@@ -68,6 +73,7 @@ export async function createApiKey(params: CreateApiKeyParams): Promise<ApiKeySe
|
||||
prefix: params.prefix,
|
||||
expiresAt: params.expiresAt ?? null,
|
||||
permissions: params.permissions ?? null,
|
||||
excludedKinds: params.excludedKinds ?? null,
|
||||
})
|
||||
.returning();
|
||||
return row!;
|
||||
@@ -82,6 +88,7 @@ export async function listApiKeys(userId: number): Promise<Omit<ApiKeySelect, 'k
|
||||
name: apiKeys.name,
|
||||
prefix: apiKeys.prefix,
|
||||
permissions: apiKeys.permissions,
|
||||
excludedKinds: apiKeys.excludedKinds,
|
||||
lastUsedAt: apiKeys.lastUsedAt,
|
||||
expiresAt: apiKeys.expiresAt,
|
||||
revokedAt: apiKeys.revokedAt,
|
||||
|
||||
@@ -21,12 +21,22 @@ import { users } from '../auth/schema';
|
||||
//
|
||||
// ── 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
|
||||
// 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
|
||||
// could. Intersection, never union — see `permissions/authorize.ts` → `decideApiRequest`.
|
||||
// There are two of them because there are two shapes of "less than everything", and only one of them
|
||||
// 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
|
||||
// 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. */
|
||||
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
|
||||
* reach nothing, which is a coherent thing to mint and a safe thing to default to.
|
||||
*/
|
||||
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
|
||||
* authenticated request would carry a write, which is a lot of churn to answer "is this still in use".
|
||||
|
||||
Reference in New Issue
Block a user