Files
platform/src/servers/api/users/capabilities-routes.ts
T
pastilhasandClaude Opus 5 4d513c0e13 files, for a member, in their own home
Introduces a fifth capability kind. `files` was `execution` — never grantable,
because it meant the OWNER'S filesystem. It is now `confined`: execution-shaped, but
the kernel enforces the boundary because the account has its own Linux user, its own
home, and no permission above it.

The rule that makes `confined` mean something lives in authorize.ts, once: a confined
grant is DROPPED for an account with no osUser. So "granted but unconfined" resolves
to no access rather than to the owner's home — which is what it would otherwise
resolve to, since getOwnerHomeDir ignores the email it is handed whenever HOME_DIR is
set. One rule covers the HTTP routes, the websocket doors and the dock, instead of
each router remembering.

resolveHomeDir(userId) is the new seam and it reads the row rather than the token, for
the same reason authorize.ts re-reads role: provisioning a Linux account for an
existing member has to take effect on the next request, not in thirty days.

The file browser resolves it in middleware and puts it on ctx user, because
getRootDir is called from fifteen places in that router. Making it async would have
meant editing fifteen call sites, and the cost of missing one is serving the owner's
home to a member. Now a handler cannot run without the answer.

Two things a real run caught:

- /ls seeds Downloads/Documents into the home as the service user, which is EPERM
  against a 700 home owned by the member — it took the whole listing down. Seeding is
  now best-effort there and happens at provision time instead, as the member.
- .unique() on os_user made db:push ask whether to TRUNCATE users, which is
  unanswerable non-interactively. uniqueIndex instead, per databases/CLAUDE.md.

Verified: a member without a Linux account is refused by name; with one, resolves to
their own home and NOT to HOME_DIR; the owner still resolves to HOME_DIR; and every
.. escape is refused while an absolute path is rebased under the root.

Terminal is still execution — that is the next stage.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 17:34:31 +00:00

134 lines
7.0 KiB
TypeScript

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';
import { capabilityAvailability } from '../../app-store/availability';
// 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);
// What EXISTS on this server, which is a different question from what this account may use. A
// capability the owner holds unconditionally still means nothing if its sidecar was never installed,
// and the owner is as subject to that as a member — see app-store/availability.ts.
const { unavailable, manifests } = await capabilityAvailability();
// 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 }));
const heldKeys = new Set(held.map((h) => h.key));
// Held AND present. Two subtractions rather than one because they mean different things to the UI: a
// capability withheld is "not yours", one whose sidecar is absent is "not here yet, install it".
const usable = held.filter(({ key }) => !unavailable.has(key));
return ctx.json({
isOwner,
capabilities: held,
/** Capabilities the account holds whose sidecar is not installed or is disabled. */
unavailable: [...unavailable].filter((key) => heldKeys.has(key)),
/**
* Dock tiles and routes belonging to installed sidecars the account may reach.
*
* Filtered by capability here rather than in the client: a member must not be handed the manifest
* of a feature they cannot use, even to hide it, because "hidden in the client" is the kind of
* privacy that lasts until someone opens the network tab.
*/
plugins: manifests.filter((m) => !m.capability || heldKeys.has(m.capability)),
// Flattened for the dock and the route guard, which care about paths rather than capability keys.
routes: usable.flatMap(({ key }) => CAPABILITY_BY_KEY.get(key)?.routes ?? []),
// The complement, and the frontend genuinely needs both. "Not in `routes`" cannot distinguish a route
// this account lacks from a route no capability claims at all — `/`, the settings shell, the sign-in
// screens — and a guard that cannot tell those apart either blanks the app or guards nothing.
// Routes of capabilities this account does not hold, PLUS those whose sidecar is not installed. The
// guard treats both the same — there is nothing to show — while `unavailable` above lets the UI
// explain the second case as something the owner can fix by installing it.
deniedRoutes: CAPABILITIES.filter((c) => !heldKeys.has(c.key) || unavailable.has(c.key)).flatMap(
(c) => c.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' && known.kind !== 'confined') {
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 });
});