capabilities: the api, and one honest exception
GET /api/user/capabilities is what the caller may reach, and every account may ask — it is mounted on a core capability so an account granted almost nothing can still find out what it has. the dock and route guards read it. it is a courtesy, never enforcement: hiding an icon is not access control and the 403 in origin-validation stays the lock. GET/PUT /api/users/capabilities edit the policy, owner-gated. the write path is where the registry's authority over capability keys is applied, which is why the column has no CHECK: unknown keys and non-app kinds are refused rather than stored for the resolver to drop on read. the exception is `selfService`. useAuth calls PUT /api/users to change your own name and avatar, and that route has always lived on the same router as the owner-only account administration around it — so declaring /users an admin capability locked every member out of their own profile. moving it to /api/user would be tidier and would break every shipped mobile client, so instead the registry says out loud that this one route is not what the capability around it is. exact method and exact path, so it cannot widen: verified that PUT /api/users passes while GET /api/users, PATCH /api/users/:id/role, DELETE /api/users/:id and PUT /api/users/:id are all still refused. 23 unit tests. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
import { createRouter } from '../../create-router';
|
||||
import { getUserSettings, setUserSettings, getUserState, patchUserState } from 'officerdb';
|
||||
import { selfCapabilitiesRouter } from '../users/capabilities-routes';
|
||||
|
||||
const DEFAULT_SETTINGS = {
|
||||
chat: {
|
||||
@@ -16,6 +17,10 @@ const DEFAULT_SETTINGS = {
|
||||
|
||||
export const settingsRouter = createRouter();
|
||||
|
||||
// What the caller may reach. Under /api/user because it is a `core` capability — every account can ask
|
||||
// what it is allowed to do, including an account that is allowed almost nothing.
|
||||
settingsRouter.route('/', selfCapabilitiesRouter);
|
||||
|
||||
// GET /settings — return user settings from DB, default if empty
|
||||
settingsRouter.get('/settings', async (ctx) => {
|
||||
const userId = ctx.get('user').id;
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
import type { MiddlewareHandler } from 'hono';
|
||||
import { createRouter } from '../../create-router';
|
||||
import * as errors from '@@/custom-errors';
|
||||
import { isSuperAdmin } from '../../super-admin';
|
||||
import { getAllRoleGrants, replaceRoleGrants, USER_ROLES } from 'officerdb';
|
||||
import type { UserRole } from 'officerdb';
|
||||
import { CAPABILITIES, GRANTABLE_CAPABILITIES, CAPABILITY_BY_KEY } from '../../capabilities/registry';
|
||||
import { getEffectiveCapabilities, invalidateRoleGrants } from '../../capabilities/authorize';
|
||||
|
||||
// Two audiences, deliberately split.
|
||||
//
|
||||
// `/user/capabilities` answers "what may I do" for the caller, and every account may ask. The dock, the
|
||||
// app registry and the route guards all read it, so it is the frontend's whole view of the permission
|
||||
// model — and it must never be the frontend's ENFORCEMENT of it. Hiding a dock icon is a courtesy; the
|
||||
// 403 in origin-validation is the lock.
|
||||
//
|
||||
// Everything else here is owner-only and edits the policy itself.
|
||||
|
||||
const ownerGate: MiddlewareHandler = async (ctx, next) => {
|
||||
if (!(await isSuperAdmin(ctx.get('user')))) throw errors.FORBIDDEN('Capability management is owner-only');
|
||||
return next();
|
||||
};
|
||||
|
||||
/** What the caller may reach. Mounted under /api/user, which is a `core` capability, so nobody is 403'd. */
|
||||
export const selfCapabilitiesRouter = createRouter();
|
||||
|
||||
selfCapabilitiesRouter.get('/capabilities', async (ctx) => {
|
||||
const userId = ctx.get('user').id as number;
|
||||
const { isOwner, grants } = await getEffectiveCapabilities(userId);
|
||||
|
||||
// The owner holds everything, and says so by listing it rather than by a flag the frontend has to
|
||||
// remember to special-case. One shape for both audiences means one code path in the UI.
|
||||
const held = isOwner
|
||||
? CAPABILITIES.map((c) => ({ key: c.key, level: 'write' as const }))
|
||||
: [...grants].map(([key, level]) => ({ key, level }));
|
||||
|
||||
return ctx.json({
|
||||
isOwner,
|
||||
capabilities: held,
|
||||
// Flattened for the dock and the route guard, which care about paths rather than capability keys.
|
||||
routes: held.flatMap(({ key }) => CAPABILITY_BY_KEY.get(key)?.routes ?? []),
|
||||
});
|
||||
});
|
||||
|
||||
/** Policy administration. Owner-only, mounted under /api/users. */
|
||||
export const capabilityAdminRouter = createRouter();
|
||||
|
||||
capabilityAdminRouter.get('/capabilities', ownerGate, async (ctx) => {
|
||||
return ctx.json({
|
||||
// Only the grantable kind is offered. `execution` and `admin` are deliberately not in this list:
|
||||
// a UI that shows a checkbox it will refuse to honour is worse than one that never offered it.
|
||||
capabilities: GRANTABLE_CAPABILITIES.map((c) => ({
|
||||
key: c.key,
|
||||
label: c.label,
|
||||
description: c.description,
|
||||
// What the owner is actually deciding about, shown so the grant is legible rather than a name.
|
||||
routes: c.routes ?? [],
|
||||
hasPersonalWrites: !!c.personal?.length,
|
||||
})),
|
||||
// Roles a grant may name. Super Admin is excluded: the owner bypasses this table entirely, and the
|
||||
// database refuses a row for that role.
|
||||
roles: USER_ROLES.filter((r) => r !== 'Super Admin'),
|
||||
grants: await getAllRoleGrants(),
|
||||
});
|
||||
});
|
||||
|
||||
capabilityAdminRouter.put('/capabilities/:role', ownerGate, async (ctx) => {
|
||||
const role = ctx.req.param('role') as UserRole;
|
||||
if (!USER_ROLES.includes(role)) throw errors.BAD_REQUEST(`Unknown role '${role}'`);
|
||||
if (role === 'Super Admin') throw errors.BAD_REQUEST('The owner is not governed by grants');
|
||||
|
||||
const body = ctx.get('body') as { grants?: unknown } | undefined;
|
||||
const raw = body?.grants;
|
||||
if (!Array.isArray(raw)) throw errors.BAD_REQUEST('Expected { grants: [{ capability, level }] }');
|
||||
|
||||
const grants: { capability: string; level: 'read' | 'write' }[] = [];
|
||||
for (const entry of raw) {
|
||||
const { capability, level } = (entry ?? {}) as { capability?: unknown; level?: unknown };
|
||||
if (typeof capability !== 'string') throw errors.BAD_REQUEST('Each grant needs a capability key');
|
||||
if (level !== 'read' && level !== 'write') throw errors.BAD_REQUEST(`Bad level for '${capability}'`);
|
||||
|
||||
// The registry is the authority on what a capability key means, which is why the column has no CHECK.
|
||||
// This is where that authority is applied — rejecting a name nothing defines, and refusing to store a
|
||||
// grant the resolver would drop on read anyway.
|
||||
const known = CAPABILITY_BY_KEY.get(capability);
|
||||
if (!known) throw errors.BAD_REQUEST(`Unknown capability '${capability}'`);
|
||||
if (known.kind !== 'app') {
|
||||
throw errors.BAD_REQUEST(
|
||||
known.kind === 'execution'
|
||||
? `${known.label} runs as the server owner and can never be granted`
|
||||
: `${known.label} is not grantable`,
|
||||
);
|
||||
}
|
||||
grants.push({ capability, level });
|
||||
}
|
||||
|
||||
await replaceRoleGrants(role, grants);
|
||||
// The cache's entire invalidation contract, discharged here. Adding a second writer means adding a
|
||||
// second call to this — see the note on grantCache in capabilities/authorize.ts.
|
||||
invalidateRoleGrants(role);
|
||||
|
||||
return ctx.json({ role, grants });
|
||||
});
|
||||
@@ -5,6 +5,7 @@ import { isSuperAdmin } from '@@/super-admin';
|
||||
import * as errors from '@@/custom-errors';
|
||||
import { updateUserHandler } from './update-user';
|
||||
import { listUsersHandler, updateUserRoleHandler, deleteUserHandler } from './manage-users';
|
||||
import { capabilityAdminRouter } from './capabilities-routes';
|
||||
|
||||
export const usersRouter = createRouter();
|
||||
usersRouter.use(originMiddleware);
|
||||
@@ -24,3 +25,6 @@ const ownerGate: MiddlewareHandler = async (ctx, next) => {
|
||||
usersRouter.get('/', ownerGate, listUsersHandler);
|
||||
usersRouter.patch('/:id/role', ownerGate, updateUserRoleHandler);
|
||||
usersRouter.delete('/:id', ownerGate, deleteUserHandler);
|
||||
|
||||
// Which capabilities each role holds. Owner-gated inside its own router.
|
||||
usersRouter.route('/', capabilityAdminRouter);
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
capabilityForApiPath,
|
||||
capabilityForWsProvider,
|
||||
isRequestAllowedAtLevel,
|
||||
isSelfServiceRoute,
|
||||
type CapabilityLevel,
|
||||
} from './registry';
|
||||
|
||||
@@ -111,6 +112,10 @@ export async function isApiRequestAllowed(
|
||||
// a 403 on a route that does not exist is not a leak, and a permissive default here would be.
|
||||
if (!capability) return { allowed: false, reason: 'no capability covers this path' };
|
||||
|
||||
// Checked before kind, because a self-service route acts on the caller and is therefore not the thing
|
||||
// the capability around it restricts. Exact method and path only — see the field's comment.
|
||||
if (isSelfServiceRoute(capability, method, path)) return { allowed: true };
|
||||
|
||||
if (capability.kind === 'execution') {
|
||||
return { allowed: false, reason: `${capability.label} runs as the server owner and cannot be shared` };
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
capabilityForApiPath,
|
||||
capabilityForWsProvider,
|
||||
isRequestAllowedAtLevel,
|
||||
isSelfServiceRoute,
|
||||
} from './registry';
|
||||
import { assertCapabilityTotality, isExemptApiPath } from './totality';
|
||||
|
||||
@@ -129,6 +130,27 @@ describe('levels', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('self-service routes', () => {
|
||||
const userAdmin = CAPABILITY_BY_KEY.get('user-admin')!;
|
||||
|
||||
test('PUT /api/users is self-profile update and stays reachable', () => {
|
||||
expect(isSelfServiceRoute(userAdmin, 'PUT', '/api/users')).toBe(true);
|
||||
expect(isSelfServiceRoute(userAdmin, 'put', '/api/users')).toBe(true);
|
||||
});
|
||||
|
||||
test('it does not open the rest of the router', () => {
|
||||
expect(isSelfServiceRoute(userAdmin, 'GET', '/api/users')).toBe(false);
|
||||
expect(isSelfServiceRoute(userAdmin, 'DELETE', '/api/users/5')).toBe(false);
|
||||
expect(isSelfServiceRoute(userAdmin, 'PATCH', '/api/users/5/role')).toBe(false);
|
||||
// The exact-match rule: a descendant of a self-service path is not self-service.
|
||||
expect(isSelfServiceRoute(userAdmin, 'PUT', '/api/users/5')).toBe(false);
|
||||
});
|
||||
|
||||
test('capabilities without the field are unaffected', () => {
|
||||
expect(isSelfServiceRoute(CAPABILITY_BY_KEY.get('wallet')!, 'PUT', '/api/wallet')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('kinds', () => {
|
||||
test('execution capabilities are never grantable', () => {
|
||||
const grantable = new Set(GRANTABLE_CAPABILITIES.map((c) => c.key));
|
||||
|
||||
@@ -67,6 +67,18 @@ export type Capability = {
|
||||
* Written relative to the capability's `api` prefix, like `personal`.
|
||||
*/
|
||||
readOnlyWrites?: string[];
|
||||
/**
|
||||
* Routes any authenticated account may call even holding NO grant on this capability, because they act
|
||||
* on the caller themselves. `METHOD /exact/path`, relative to the capability's prefix — exact, not a
|
||||
* prefix, so this cannot widen by accident.
|
||||
*
|
||||
* One entry exists and it should stay that way. `PUT /api/users` is self-profile update (useAuth.ts
|
||||
* calls it to change your own name and avatar) and has always lived on the same router as the owner-only
|
||||
* account administration beside it. Moving it to `/api/user` would be tidier and would break every
|
||||
* shipped mobile client, so the honest fix is to say out loud that this one route is not what the
|
||||
* capability around it is.
|
||||
*/
|
||||
selfService?: string[];
|
||||
};
|
||||
|
||||
export const CAPABILITIES: Capability[] = [
|
||||
@@ -297,6 +309,9 @@ export const CAPABILITIES: Capability[] = [
|
||||
kind: 'admin',
|
||||
api: ['/users'],
|
||||
routes: ['/settings/user-management'],
|
||||
// Changing your own name, username and avatar. Owner-only account administration is every other route
|
||||
// on this router and stays owner-only — see ownerGate in users-router.ts, which is the second lock.
|
||||
selfService: ['PUT /'],
|
||||
},
|
||||
{
|
||||
key: 'headscale',
|
||||
@@ -357,6 +372,21 @@ export function capabilityForWsProvider(provider: string): Capability | null {
|
||||
return CAPABILITIES.find((c) => c.ws?.includes(provider)) ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Is this an exact self-service route — one any authenticated account may call without holding the
|
||||
* capability at all? Matched exactly on method AND path, never as a prefix.
|
||||
*/
|
||||
export function isSelfServiceRoute(capability: Capability, method: string, path: string): boolean {
|
||||
if (!capability.selfService?.length) return false;
|
||||
const rest = path.startsWith('/api') ? path.slice('/api'.length) : path;
|
||||
const upper = method.toUpperCase();
|
||||
return capability.api.some((prefix) => {
|
||||
if (!isPrefixOf(prefix, rest)) return false;
|
||||
const sub = rest.slice(prefix.length) || '/';
|
||||
return capability.selfService!.includes(`${upper} ${sub}`);
|
||||
});
|
||||
}
|
||||
|
||||
const SAFE_METHODS = new Set(['GET', 'HEAD', 'OPTIONS']);
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user