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:
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user