diff --git a/src/servers/api/settings/settings.ts b/src/servers/api/settings/settings.ts index 6b19dc3e..93b2182c 100644 --- a/src/servers/api/settings/settings.ts +++ b/src/servers/api/settings/settings.ts @@ -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; diff --git a/src/servers/api/users/capabilities-routes.ts b/src/servers/api/users/capabilities-routes.ts new file mode 100644 index 00000000..b850bb71 --- /dev/null +++ b/src/servers/api/users/capabilities-routes.ts @@ -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 }); +}); diff --git a/src/servers/api/users/users-router.ts b/src/servers/api/users/users-router.ts index eff997b2..a63165de 100644 --- a/src/servers/api/users/users-router.ts +++ b/src/servers/api/users/users-router.ts @@ -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); diff --git a/src/servers/capabilities/authorize.ts b/src/servers/capabilities/authorize.ts index aece6343..6644094b 100644 --- a/src/servers/capabilities/authorize.ts +++ b/src/servers/capabilities/authorize.ts @@ -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` }; } diff --git a/src/servers/capabilities/registry.test.ts b/src/servers/capabilities/registry.test.ts index c7158a9a..bdfbe324 100644 --- a/src/servers/capabilities/registry.test.ts +++ b/src/servers/capabilities/registry.test.ts @@ -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)); diff --git a/src/servers/capabilities/registry.ts b/src/servers/capabilities/registry.ts index d984370e..28908f07 100644 --- a/src/servers/capabilities/registry.ts +++ b/src/servers/capabilities/registry.ts @@ -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']); /**