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:
2026-08-24 13:47:46 +00:00
co-authored by Claude Opus 5
parent e29a4ee380
commit 12a9605b01
13 changed files with 197 additions and 69 deletions
+55 -9
View File
@@ -226,11 +226,12 @@ describe('deciding a websocket', () => {
*/
describe('a narrowed credential', () => {
const owner: EffectivePermissions = { isOwner: true, grants: new Map() };
const allow = (...keys: string[]) => ({ allow: keys });
test('bounds the owner, who nothing else bounds', () => {
expect(decideApiRequest(owner, 'POST', '/api/tasks', ['tasks']).allowed).toBe(true);
expect(decideApiRequest(owner, 'GET', '/api/users', ['tasks']).allowed).toBe(false);
expect(decideWsProvider(owner, 'terminal', ['tasks'])).toBe(false);
expect(decideApiRequest(owner, 'POST', '/api/tasks', allow('tasks')).allowed).toBe(true);
expect(decideApiRequest(owner, 'GET', '/api/users', allow('tasks')).allowed).toBe(false);
expect(decideWsProvider(owner, 'terminal', allow('tasks'))).toBe(false);
});
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
// about yet. It must not read as "no narrowing".
test('an empty list reaches nothing', () => {
expect(decideApiRequest(owner, 'GET', '/api/tasks', []).allowed).toBe(false);
test('an empty allow list reaches nothing', () => {
expect(decideApiRequest(owner, 'GET', '/api/tasks', allow()).allowed).toBe(false);
});
// Intersection, never union: naming a permission does not grant 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);
});
test('it narrows an account that was granted it', () => {
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', ['tasks']).allowed).toBe(false);
expect(decideApiRequest(held, 'GET', '/api/file-browser', allow('files')).allowed).toBe(true);
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
// reachable by failing to resolve.
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);
});
});
+40 -21
View File
@@ -1,4 +1,5 @@
import { getUserById, getRoleGrants } from 'officerdb';
import type { CredentialScope } from '../auth-token';
import type { UserRole } from 'officerdb';
import {
PERMISSION_BY_KEY,
@@ -141,18 +142,42 @@ export async function isApiRequestAllowed(
userId: number | undefined,
method: string,
path: string,
credential?: string[] | null,
scope?: CredentialScope | null,
): 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.
*
* `credential` is the narrowing carried by an API key — the permission keys THAT key may reach, or
* null/undefined for one that carries its holder's full authority (which is every key a human has minted
* and every session JWT). It is an intersection, never a union: a key cannot reach what its account
* could not, and its account cannot reach, through this key, what the key does not name.
* `scope` is the narrowing carried by an API key, or null/undefined for one that carries its holder's
* full authority (which is every key a human has minted and every session JWT). It is an intersection,
* never a union: a key cannot reach what its account could not, and its account cannot reach, through
* this key, what the key excludes.
*
* **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
@@ -163,17 +188,14 @@ export function decideApiRequest(
effective: EffectivePermissions,
method: string,
path: string,
credential?: string[] | null,
scope?: CredentialScope | null,
): { allowed: boolean; reason?: string } {
const { isOwner, grants } = effective;
if (credential) {
const scoped = permissionForApiPath(path);
// Same deny-the-unmapped rule as below, one step earlier: a narrowed credential naming nothing that
// covers this path cannot be allowed onto it by failing to resolve the path.
if (!scoped || !credential.includes(scoped.key)) {
return { allowed: false, reason: 'this credential is not permitted to use that' };
}
// Same deny-the-unmapped rule as below, one step earlier: a path this cannot resolve must not become
// reachable by failing to resolve.
if (scope && !credentialAdmits(scope, permissionForApiPath(path))) {
return { allowed: false, reason: 'this credential is not permitted to use that' };
}
if (isOwner) return { allowed: true };
@@ -224,25 +246,22 @@ export function decideApiRequest(
export async function isWsProviderAllowed(
userId: number | undefined,
provider: string,
credential?: string[] | null,
scope?: CredentialScope | null,
): 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. */
export function decideWsProvider(
effective: EffectivePermissions,
provider: string,
credential?: string[] | null,
scope?: CredentialScope | null,
): boolean {
const { isOwner, grants } = effective;
// 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.
if (credential) {
const scoped = permissionForWsProvider(provider);
if (!scoped || !credential.includes(scoped.key)) return false;
}
if (scope && !credentialAdmits(scope, permissionForWsProvider(provider))) return false;
if (isOwner) return true;