/** * Enrol a module's read-only principal on the remote API. * * Named for the MECHANISM, not for the capability it backs. A core service * named after one capability is core doing that provider's work, which is the * pattern `no-module-business-in-core` Scan C exists to stop — and it caught * this file under its first name. Enrolling an API principal is celilo's own * job: the row lands in celilo's `api_principals` table and the grants come * from celilo's command registry. `control_plane_api` is one caller of it. * * The consuming module generates an ed25519 keypair on its own system and * presents the public half here. The private half never crosses this boundary, * which is what makes "the console holds no credential celilo issued it" * checkable rather than merely intended. * * ## The grant set is derived, never passed in * * `readOnlyGrants(COMMANDS)` is the same classifier that decides whether a CLI * leaf is a read. Two consequences, both wanted: * * A new read verb is picked up WITHOUT anyone editing a list — but only at the * next enrolment. The grants are MATERIALISED into the principal row here, so a * celilo-mgr that is upgraded without the consumer being redeployed keeps * serving the grant set that existed when the consumer last installed. Measured: * 27 literal ops in the row, and a read verb celilo gains afterwards is denied. * * That is a limitation rather than a hole. It fails CLOSED, and it heals on the * next redeploy. Making it dynamic means a `ro:*` token resolved at check time, * which is a change to the authorization core and a real trade — a principal's * authority would then move whenever celilo reclassifies a verb, with nobody * re-granting anything. celilo#1153 carries that decision. * * A new WRITE verb is never granted, however it is named, because the * classifier reads the verb token rather than a list somebody maintains. A * hand-written list would drift in exactly one direction — wider — because * widening it is what unblocks whoever is stuck. * * There is deliberately no `grants` parameter. The widest thing this can issue * is the widest thing it should ever issue, and an argument would turn that * into a negotiation with the caller. */ import type { ControlPlaneApiCapability } from '@celilo/capabilities'; import { COMMANDS, readOnlyGrants } from '@celilo/core'; import { grantPrincipal, revokePrincipal } from './api-access'; export interface EnrolResult { name: string; grants: string[]; created: boolean; } /** * Every read-only op, sorted. * * Computed on each call rather than cached at module load. It is derived from a * static registry so the answer does not change within a process, but a cached * copy is one more thing that can be stale in a long-lived server and the * computation is a filter over a few hundred leaves. */ export function controlPlaneReadGrants(): string[] { return readOnlyGrants(COMMANDS); } /** * Enrol a consuming module's principal. Idempotent on `name`. * * Re-presenting the same name and key is a no-op reporting `created: false`, * which is what a redeploy does. Presenting a NEW key for an existing name * rotates it — the case a module that regenerated its keypair needs, and one a * module that did not will never hit. * * `grantPrincipal` validates the name and the key, so a malformed public key is * refused here rather than written into `authorized_keys` and failing at login * with a message about the wrong thing. */ export async function enrolControlPlanePrincipal(params: { name: string; publicKey: string; }): Promise { const grants = controlPlaneReadGrants(); const { principal, created } = await grantPrincipal({ name: params.name, publicKey: params.publicKey, grants, }); // `grantPrincipal` already reports false for a principal that existed, and a // re-grant that rotates a key is correctly not-a-creation. Re-deriving that // here with a second lookup would be two sources for one answer. return { name: principal.name, grants, created }; } /** * Remove a principal. Idempotent. * * Called from a consumer's `on_uninstall` so an uninstalled module does not * leave a working key behind. Revoking one that is already gone returns false * rather than throwing: an uninstall that fails on tidy-up is worse than one * that finds nothing to tidy. */ export async function revokeControlPlanePrincipal(name: string): Promise { return revokePrincipal(name); } /** * The `control_plane_api` method table handed to one consuming module. * * ## Why celilo builds this and no module provides it * * Every other function capability is implemented by a provider module's script, * which the loader imports and calls. That is structurally impossible here. * Enrolment writes celilo's own `api_principals` row and derives its grants from * celilo's own command registry, and a module script may import nothing but * `@celilo/capabilities` — its scripts are copied to a data directory at deploy, * so a relative import into the backend breaks. celilo-mgmt has exactly the same * limit as any other module here; being the management server's module does not * give its SCRIPTS a database handle. * * The only shape that would let celilo-mgmt "provide" it is core injecting * enrol/revoke into a factory context so the module's script forwards them * unchanged. Core would still do all the work, and the enrolment would newly * depend on the deployed copy of that script being current — a way to fail that * does not exist today. So this is framework-granted, like `cross_module_read`. * * ## Why the caller cannot name someone else's principal * * `name` is on the request because the contract puts it there, and the only * value accepted is the calling module's id. Without that check any module with * the capability could rotate another module's key by enrolling under its name, * or delete it outright by revoking it — the console's read access removed by a * module that has nothing to do with the console. * * The parameter should come off the contract entirely, the same argument that * kept `grants` off it: a caller that can negotiate WHOSE authority this is has * the same problem as one that can negotiate how much. That is a breaking change * to `@celilo/capabilities`' exported types, so it waits for the next major * rather than forcing one (celilo#1196). */ export function buildControlPlaneApi(consumerModuleId: string): ControlPlaneApiCapability { function requireOwnPrincipal(name: string, verb: string): void { if (name === consumerModuleId) return; throw new Error( `Module '${consumerModuleId}' tried to ${verb} the API principal '${name}'. A module may only ${verb} its own principal, named for its module id.`, ); } return { async enrol_principal(request) { requireOwnPrincipal(request.name, 'enrol'); return enrolControlPlanePrincipal(request); }, async revoke_principal(request) { requireOwnPrincipal(request.name, 'revoke'); return { revoked: await revokeControlPlanePrincipal(request.name) }; }, }; }