step 1/4: the permission engine is called permissions, not capabilities
The word meant four different things in this repo, not the three the offscale
doc records:
1. the permission registry → RENAMED here
2. $OFFICER_ROOT/capabilities/ items → kept; this is what capabilities are
3. sidecar routing keys → step 3, becoming `handles`
4. Lightning wallet features → kept; a domain term, and on the wire
to the mobile apps
The fourth was not in the doc and a global find-and-replace would have broken
the mobile wallet, which reads `{ kind, capabilities: Capability[] }` from the
wallet sidecar. So this renamed against an explicit file allowlist rather than
by sweeping the tree, and `CapabilityPage.tsx` — the UI for the item store, and
correctly named already — was left alone.
Moved: servers/capabilities/ → servers/permissions/, capability-gate.ts →
permission-gate.ts, users/capabilities-routes.ts → permissions-routes.ts,
hooks/useCapabilities.ts → usePermissions.ts. Identifiers follow.
Three breaks the typechecker could not see, all found by exercising it live.
The route paths moved with the prose sweep, so the server served
/user/permissions while the frontend still called /user/capabilities. A 404 on
every page load, and tsgo clean throughout.
The response FIELD moved too. `client.get<SelfPermissions>()` is an unchecked
cast, so `data.capabilities` became `undefined` at runtime with no compile
error — `can()` would have answered "no" to everything and the dock would have
emptied itself.
And the grants list was passed straight out of the database, so it arrived as
`{ role, capability, level }` while the screen read `grant.permission`. Every
role would have rendered as holding nothing. It is now mapped in the route:
the wire says `permission`, the column still says `capability`, and step 2
therefore changes nothing any client can see.
The stale react-query keys were the quiet one: two files still invalidated
['self-capabilities'] after the hook moved to ['self-permissions'], so
installing a plugin would have silently stopped refreshing the dock.
The database is untouched — `role_capabilities` and its `capability` column are
step 2, and the two call sites that cross that boundary say so in a comment.
Round-tripped the 9 live grants through the admin endpoint to prove the PUT
contract survived: 9 before, 9 after, Member's three intact.
Also reverted prettier churn on five landing-page files that a broad --write
picked up. Second time today; the lesson is not sticking.
tsgo clean. 797 tests, 787 pass, same 7 pre-existing failures — two of which
now read "path → permission" rather than "path → capability".
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
// Shared structured-progress contract for the Activity feature.
|
||||
//
|
||||
// Any capability that wants live progress bars / phase chips (instead of the UI regexing prose) appends
|
||||
// Any permission that wants live progress bars / phase chips (instead of the UI regexing prose) appends
|
||||
// NDJSON lines of this shape to its output/log file, alongside whatever human text it already writes:
|
||||
//
|
||||
// {"job":"dearly-devoted","cap":"split-audiobook","phase":"transcription","status":"running","pct":9,"detail":"0:50:00 / 9:28:24","ts":1730000000000}
|
||||
@@ -8,7 +8,7 @@
|
||||
//
|
||||
// The parser is deliberately tolerant: a line is treated as structured progress iff it JSON-parses to
|
||||
// an object carrying a `phase` or `status`; everything else is passed through as a raw log line. So a
|
||||
// capability can dual-write (human lines + NDJSON) and both render correctly.
|
||||
// permission can dual-write (human lines + NDJSON) and both render correctly.
|
||||
|
||||
export type ProgressLine = {
|
||||
job?: string;
|
||||
|
||||
@@ -7,7 +7,7 @@ import { logger } from '../chat/logger';
|
||||
* The door an AGENT knocks on — not a human, and not a browser.
|
||||
*
|
||||
* This is mounted above the account gate, so it does not carry a platform JWT and does not go through
|
||||
* `userMiddleware`. That exemption is declared and justified in `capabilities/totality.ts`; the claim
|
||||
* `userMiddleware`. That exemption is declared and justified in `permissions/totality.ts`; the claim
|
||||
* it makes is that this surface is authenticated by a per-panel bearer token instead, and that the
|
||||
* token's authority is tiny by construction:
|
||||
*
|
||||
|
||||
@@ -12,7 +12,7 @@ import { createRouter } from '../../create-router';
|
||||
// there leak the owner's project directory names — but it means the answer to "why is my agent not working"
|
||||
// has to live somewhere a member can reach.
|
||||
//
|
||||
// So this is its own router under the `chat` capability. Same grant, no owner gate, and nothing here reports
|
||||
// So this is its own router under the `chat` permission. Same grant, no owner gate, and nothing here reports
|
||||
// on anyone but the caller: two booleans about their own home. Denying it would not restrict an account, it
|
||||
// would just replace an explanation with a silence.
|
||||
//
|
||||
|
||||
@@ -174,7 +174,7 @@ export async function startAgentRun(params: StartAgentRunParams): Promise<StartA
|
||||
titleRun(
|
||||
// `osUser: null` and `isOwner: true` both track `homeDir` above: it is `getOwnerHomeDir`, which
|
||||
// discards the email it is given, so an agent run is always the owner's — its transcript is theirs
|
||||
// and readable directly. `agents` is an `execution` capability, so no other account reaches this.
|
||||
// and readable directly. `agents` is an `execution` permission, so no other account reaches this.
|
||||
// If agent runs ever reach members, this and line 134 have to move together.
|
||||
{ email: params.user.email, home: homeDir, osUser: null, isOwner: true },
|
||||
cwd,
|
||||
|
||||
@@ -14,7 +14,7 @@ import * as errors from '../../custom-errors';
|
||||
// asks the same questions of that user it would have asked of a browser session. That is not an
|
||||
// escalation — it can do exactly what the password could already do — but it does mean a leaked key is a
|
||||
// leaked account, which is why revocation is one call and last-used is recorded. Narrowing a key to a
|
||||
// subset of its holder's capabilities is the next step and wants a `scopes` column, not a change here.
|
||||
// subset of its holder's permissions is the next step and wants a `scopes` column, not a change here.
|
||||
|
||||
export const apiKeysRouter = createRouter();
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@ import { byId, type InstallMode } from '../../app-store/catalogue';
|
||||
//
|
||||
// Installing a sidecar starts a process on the machine, and provisioning one starts containers. That is
|
||||
// an administrative act however many members share the server, so this router gates on the owner in its
|
||||
// own right rather than relying on the capability layer alone. `server-admin` already covers it, and
|
||||
// own right rather than relying on the permission layer alone. `server-admin` already covers it, and
|
||||
// this is the belt to that braces — the same shape `/api/vault` uses, and for the same reason: a
|
||||
// mistake here is not a leak of data, it is arbitrary process control.
|
||||
//
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { Handler } from 'hono';
|
||||
import { getUserCount, createUser, replaceRoleGrants, USER_ROLES } from 'officerdb';
|
||||
import { DEFAULT_ROLE_CAPABILITIES } from '@@/capabilities/registry';
|
||||
import { DEFAULT_ROLE_PERMISSIONS } from '@@/permissions/registry';
|
||||
import argon2 from 'argon2';
|
||||
import * as errors from '@@/custom-errors';
|
||||
import { rememberUser } from '@@/_middlewares';
|
||||
@@ -55,11 +55,12 @@ export const bootstrapHandler: Handler = async function (ctx) {
|
||||
for (const role of USER_ROLES.filter((r) => r !== 'Super Admin')) {
|
||||
await replaceRoleGrants(
|
||||
role,
|
||||
DEFAULT_ROLE_CAPABILITIES.map((capability) => ({ capability, level: 'write' as const })),
|
||||
// `capability` is the DATABASE's column name, renamed in the step that renames the table.
|
||||
DEFAULT_ROLE_PERMISSIONS.map((permission) => ({ capability: permission, level: 'write' as const })),
|
||||
);
|
||||
}
|
||||
} catch (ex) {
|
||||
console.warn('[bootstrap] could not seed default role capabilities', ex);
|
||||
console.warn('[bootstrap] could not seed default role permissions', ex);
|
||||
}
|
||||
|
||||
// The launch-time snapshot was taken while the user table was still empty. Without this the owner's
|
||||
|
||||
@@ -37,7 +37,7 @@ export const signinHandler: Handler = async function (ctx) {
|
||||
|
||||
// There used to be a check here refusing any non-owner signing in through the web or mobile platform
|
||||
// origin. It is gone deliberately: onboarding members who use the platform in a browser is the point,
|
||||
// and what they can reach once inside is decided by their role's capabilities at every request. A rule
|
||||
// and what they can reach once inside is decided by their role's permissions at every request. A rule
|
||||
// that let a member hold a Gitea grant they could never sign in to use was not defence, just a wall.
|
||||
|
||||
const { id, name, username } = dbUser;
|
||||
|
||||
@@ -14,9 +14,9 @@ import { OFFICER_API_URL } from '../../officer-url.mjs';
|
||||
/**
|
||||
* The browser's half of the address book: name a panel, look up what a panel is, rename, remove.
|
||||
*
|
||||
* Mounted on the chat router rather than given its own prefix, because it is the same capability —
|
||||
* Mounted on the chat router rather than given its own prefix, because it is the same permission —
|
||||
* these routes create and name Claude sessions, which is what `chat` already grants. A new top-level
|
||||
* mount would have meant a new capability entry claiming the same authority under a second name.
|
||||
* mount would have meant a new permission entry claiming the same authority under a second name.
|
||||
*
|
||||
* The agent-facing door is separate and deliberately so: `servers/api/agent-handoff/router.ts`.
|
||||
*/
|
||||
|
||||
@@ -59,8 +59,8 @@ async function chatIdentity(user: { id: number; email: string }): Promise<ChatId
|
||||
// by id. `handleOpenCodeChat` carried a comment calling itself owner-only; nothing enforced it.
|
||||
//
|
||||
// So this is a stopgap, not a design: `who.isOwner` applied at every door below, until OpenCode carries an
|
||||
// identity the way `spawnClaudeAsMember` does. Restrict here rather than at the capability layer because
|
||||
// `chat` is one capability covering both harnesses, and splitting it would strand the grants already issued.
|
||||
// identity the way `spawnClaudeAsMember` does. Restrict here rather than at the permission layer because
|
||||
// `chat` is one permission covering both harnesses, and splitting it would strand the grants already issued.
|
||||
// The matching refusal on the execution path is in `websocket.ts` → `handleChat`.
|
||||
import { transcribeAudio } from '../stt/transcribe';
|
||||
import { registerAgentPanelRoutes } from './agent-panels-routes';
|
||||
|
||||
@@ -40,7 +40,7 @@ export function invalidateModelCache(): void {
|
||||
}
|
||||
|
||||
type OpenCodeModel = {
|
||||
capabilities?: { input?: { image?: boolean }; reasoning?: boolean };
|
||||
permissions?: { input?: { image?: boolean }; reasoning?: boolean };
|
||||
};
|
||||
|
||||
type ProvidersResponse = {
|
||||
@@ -69,13 +69,13 @@ async function listOpenCodeModels(): Promise<ModelInfo[]> {
|
||||
provider: providerId,
|
||||
contextWindow: 200000,
|
||||
maxTokens: 8192,
|
||||
reasoning: model?.capabilities?.reasoning ?? false,
|
||||
reasoning: model?.permissions?.reasoning ?? false,
|
||||
// Was hardcoded `false`, correctly, while nothing carried images — the composer gates on this
|
||||
// flag, so advertising `true` offered a capability that did not exist. Images are now plumbed
|
||||
// flag, so advertising `true` offered a permission that did not exist. Images are now plumbed
|
||||
// through `OpenCodeRunParams` to `opencode run --file`, so the honest answer is the model's
|
||||
// own: OpenCode publishes it per model and we had never read it. Defaults to false, so a model
|
||||
// that does not declare the capability keeps the affordance hidden rather than offering it.
|
||||
images: model?.capabilities?.input?.image ?? false,
|
||||
// that does not declare the permission keeps the affordance hidden rather than offering it.
|
||||
images: model?.permissions?.input?.image ?? false,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,7 +16,7 @@ import {
|
||||
// /api/plugins — what is on this machine, what is installed, and the four verbs that change it.
|
||||
//
|
||||
// Owner only, in its own right. Installing a plugin mounts routes and (later) starts a process, which is
|
||||
// an administrative act however many members share the server. The capability layer covers it too; this
|
||||
// an administrative act however many members share the server. The permission layer covers it too; this
|
||||
// is the belt to that braces, the same shape `/api/app-store` uses.
|
||||
//
|
||||
// ── This is not the app store ──
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { createRouter } from '../../create-router';
|
||||
import { getUserSettings, setUserSettings, getUserState, patchUserState } from 'officerdb';
|
||||
import { selfCapabilitiesRouter } from '../users/capabilities-routes';
|
||||
import { selfPermissionsRouter } from '../users/permissions-routes';
|
||||
|
||||
const DEFAULT_SETTINGS = {
|
||||
chat: {
|
||||
@@ -17,9 +17,9 @@ 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 the caller may reach. Under /api/user because it is a `core` permission — every account can ask
|
||||
// what it is allowed to do, including an account that is allowed almost nothing.
|
||||
settingsRouter.route('/', selfCapabilitiesRouter);
|
||||
settingsRouter.route('/', selfPermissionsRouter);
|
||||
|
||||
// GET /settings — return user settings from DB, default if empty
|
||||
settingsRouter.get('/settings', async (ctx) => {
|
||||
|
||||
+52
-41
@@ -4,36 +4,36 @@ 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';
|
||||
import { PERMISSIONS, GRANTABLE_PERMISSIONS, PERMISSION_BY_KEY } from '../../permissions/registry';
|
||||
import { getEffectivePermissions, invalidateRoleGrants } from '../../permissions/authorize';
|
||||
import { permissionAvailability } from '../../app-store/availability';
|
||||
import { pluginDockManifests } from '../../plugins/mount';
|
||||
|
||||
// Two audiences, deliberately split.
|
||||
//
|
||||
// `/user/capabilities` answers "what may I do" for the caller, and every account may ask. The dock, the
|
||||
// `/user/permissions` 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 from the capability gate is the lock.
|
||||
// 403 from the permission gate 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');
|
||||
if (!(await isSuperAdmin(ctx.get('user')))) throw errors.FORBIDDEN('Permission 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();
|
||||
/** What the caller may reach. Mounted under /api/user, which is a `core` permission, so nobody is 403'd. */
|
||||
export const selfPermissionsRouter = createRouter();
|
||||
|
||||
selfCapabilitiesRouter.get('/capabilities', async (ctx) => {
|
||||
selfPermissionsRouter.get('/permissions', async (ctx) => {
|
||||
const userId = ctx.get('user').id as number;
|
||||
const { isOwner, grants } = await getEffectiveCapabilities(userId);
|
||||
const { isOwner, grants } = await getEffectivePermissions(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,
|
||||
// permission 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();
|
||||
const { unavailable, manifests } = await permissionAvailability();
|
||||
// Plugin tiles, alongside the app store's. Two sources today because the app store still has its own
|
||||
// catalogue; when it is rebuilt on the plugin system this becomes one.
|
||||
const pluginManifests = await pluginDockManifests();
|
||||
@@ -41,58 +41,58 @@ selfCapabilitiesRouter.get('/capabilities', async (ctx) => {
|
||||
// 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 }))
|
||||
? PERMISSIONS.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".
|
||||
// permission 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. */
|
||||
permissions: held,
|
||||
/** Permissions 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
|
||||
* Filtered by permission 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, ...pluginManifests].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 ?? []),
|
||||
plugins: [...manifests, ...pluginManifests].filter((m) => !m.permission || heldKeys.has(m.permission)),
|
||||
// Flattened for the dock and the route guard, which care about paths rather than permission keys.
|
||||
routes: usable.flatMap(({ key }) => PERMISSION_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
|
||||
// this account lacks from a route no permission 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
|
||||
// Routes of permissions 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(
|
||||
deniedRoutes: PERMISSIONS.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();
|
||||
export const permissionAdminRouter = createRouter();
|
||||
|
||||
capabilityAdminRouter.get('/capabilities', ownerGate, async (ctx) => {
|
||||
permissionAdminRouter.get('/permissions', ownerGate, async (ctx) => {
|
||||
// Only what this server can actually do RIGHT NOW.
|
||||
//
|
||||
// The same subtraction the dock already makes, applied to the granting UI — which was showing all
|
||||
// fourteen app capabilities on a fresh install where none of their sidecars existed. Offering to grant
|
||||
// fourteen app permissions on a fresh install where none of their sidecars existed. Offering to grant
|
||||
// Photos on a machine with no Immich is not a permission decision, it is a menu of things that would
|
||||
// 403 for a different reason than the owner thinks.
|
||||
//
|
||||
// Fail open on a degraded read: `capabilityAvailability` returns an empty `unavailable` set when it
|
||||
// Fail open on a degraded read: `permissionAvailability` returns an empty `unavailable` set when it
|
||||
// cannot see install state, so the list falls back to everything rather than to nothing. An owner whose
|
||||
// Permissions screen emptied itself because one query failed would reasonably conclude the feature broke.
|
||||
const { unavailable } = await capabilityAvailability();
|
||||
const { unavailable } = await permissionAvailability();
|
||||
|
||||
const describe = (c: (typeof GRANTABLE_CAPABILITIES)[number]) => ({
|
||||
const describe = (c: (typeof GRANTABLE_PERMISSIONS)[number]) => ({
|
||||
key: c.key,
|
||||
label: c.label,
|
||||
description: c.description,
|
||||
@@ -104,34 +104,44 @@ capabilityAdminRouter.get('/capabilities', ownerGate, async (ctx) => {
|
||||
return ctx.json({
|
||||
// Only the grantable kinds are offered. `execution` and `admin` are deliberately absent: a UI that
|
||||
// shows a checkbox it will refuse to honour is worse than one that never offered it.
|
||||
capabilities: GRANTABLE_CAPABILITIES.filter((c) => !unavailable.has(c.key)).map(describe),
|
||||
permissions: GRANTABLE_PERMISSIONS.filter((c) => !unavailable.has(c.key)).map(describe),
|
||||
// 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(),
|
||||
// Mapped rather than passed through: `capability` is the DATABASE's column name, and the wire should
|
||||
// not leak it. Doing it here means renaming the column changes nothing any client can see — and the
|
||||
// round trip below already expects `permission`, so passing the row straight out left the screen
|
||||
// reading `grant.permission` on an object that only had `grant.capability`. Every role rendered as
|
||||
// holding nothing, with no error anywhere.
|
||||
grants: (await getAllRoleGrants()).map(({ role, capability, level }) => ({
|
||||
role,
|
||||
permission: capability,
|
||||
level,
|
||||
})),
|
||||
});
|
||||
});
|
||||
|
||||
capabilityAdminRouter.put('/capabilities/:role', ownerGate, async (ctx) => {
|
||||
permissionAdminRouter.put('/permissions/: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 }] }');
|
||||
if (!Array.isArray(raw)) throw errors.BAD_REQUEST('Expected { grants: [{ permission, level }] }');
|
||||
|
||||
// `capability` is the DATABASE's column name; it becomes `permission` when the table is renamed.
|
||||
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}'`);
|
||||
const { permission, level } = (entry ?? {}) as { permission?: unknown; level?: unknown };
|
||||
if (typeof permission !== 'string') throw errors.BAD_REQUEST('Each grant needs a permission key');
|
||||
if (level !== 'read' && level !== 'write') throw errors.BAD_REQUEST(`Bad level for '${permission}'`);
|
||||
|
||||
// The registry is the authority on what a capability key means, which is why the column has no CHECK.
|
||||
// The registry is the authority on what a permission 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}'`);
|
||||
const known = PERMISSION_BY_KEY.get(permission);
|
||||
if (!known) throw errors.BAD_REQUEST(`Unknown permission '${permission}'`);
|
||||
if (known.kind !== 'app' && known.kind !== 'confined') {
|
||||
throw errors.BAD_REQUEST(
|
||||
known.kind === 'execution'
|
||||
@@ -139,12 +149,13 @@ capabilityAdminRouter.put('/capabilities/:role', ownerGate, async (ctx) => {
|
||||
: `${known.label} is not grantable`,
|
||||
);
|
||||
}
|
||||
grants.push({ capability, level });
|
||||
// `capability` is the DATABASE's column name until the table rename.
|
||||
grants.push({ capability: permission, 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.
|
||||
// second call to this — see the note on grantCache in permissions/authorize.ts.
|
||||
invalidateRoleGrants(role);
|
||||
|
||||
return ctx.json({ role, grants });
|
||||
@@ -8,7 +8,7 @@ import { listUsersHandler, updateUserRoleHandler, deleteUserHandler } from './ma
|
||||
import { createUserHandler } from './create-user';
|
||||
import { provisionLinuxHandler } from './provision-linux-route';
|
||||
import { resetUserPasswordHandler } from './reset-user-password';
|
||||
import { capabilityAdminRouter } from './capabilities-routes';
|
||||
import { permissionAdminRouter } from './permissions-routes';
|
||||
|
||||
export const usersRouter = createRouter();
|
||||
usersRouter.use(originMiddleware);
|
||||
@@ -16,7 +16,7 @@ usersRouter.use(originMiddleware);
|
||||
// Self-update. Any signed-in account may change its own name, username and avatar.
|
||||
usersRouter.put('/', updateUserHandler);
|
||||
|
||||
// Everything below manages OTHER accounts and is the owner's alone. The global capability gate in
|
||||
// Everything below manages OTHER accounts and is the owner's alone. The global permission gate in
|
||||
// hono.ts already refuses a non-owner here — `user-admin` is `kind: 'admin'`, so it is
|
||||
// not grantable — but that router-level rule cannot see the one exception beside it: `PUT /` is
|
||||
// declared `selfService` so every account can edit its own profile. This gate is what keeps that
|
||||
@@ -37,5 +37,5 @@ usersRouter.post('/:id/provision-linux', ownerGate, provisionLinuxHandler);
|
||||
usersRouter.post('/:id/password', ownerGate, resetUserPasswordHandler);
|
||||
usersRouter.delete('/:id', ownerGate, deleteUserHandler);
|
||||
|
||||
// Which capabilities each role holds. Owner-gated inside its own router.
|
||||
usersRouter.route('/', capabilityAdminRouter);
|
||||
// Which permissions each role holds. Owner-gated inside its own router.
|
||||
usersRouter.route('/', permissionAdminRouter);
|
||||
|
||||
Reference in New Issue
Block a user