/** * Remote API access control (see openspec/changes/replace-ssh-cli-api/proposal.md, Slice 2a). * * Stores API principals (name + SSH public key + grants), answers authz * queries (deny-by-default), and renders the API account's `authorized_keys` * with one forced-command line per principal. No SSH plumbing lives here — * `renderAuthorizedKeys()` returns the file content; installing it is a * separate operational step (Slice 2b). */ import { randomUUID } from 'node:crypto'; import { eq } from 'drizzle-orm'; import { getDb } from '../db/client'; import { type ApiPrincipal, apiPrincipals } from '../db/schema'; const NAME_PATTERN = /^[a-z0-9]+(-[a-z0-9]+)*$/; const GRANT_PATTERN = /^(\*|[a-z0-9]+(-[a-z0-9]+)*(:(\*|[a-z0-9]+(-[a-z0-9]+)*))?)$/; const PUBKEY_TYPES = [ 'ssh-ed25519', 'ssh-rsa', 'ssh-dss', 'ecdsa-sha2-nistp256', 'ecdsa-sha2-nistp384', 'ecdsa-sha2-nistp521', 'sk-ssh-ed25519@openssh.com', 'sk-ecdsa-sha2-nistp256@openssh.com', ]; const FORCED_COMMAND_OPTS = 'no-pty,no-port-forwarding,no-agent-forwarding,no-X11-forwarding'; export function validatePrincipalName(name: string): void { if (!NAME_PATTERN.test(name)) { throw new Error( `Invalid principal name "${name}": use kebab-case (e.g. "alice", "ci-deployer").`, ); } } /** Basic SSH public-key shape check: ` [comment]`. */ export function validatePublicKey(key: string): void { const parts = key.trim().split(/\s+/); if ( parts.length < 2 || !PUBKEY_TYPES.includes(parts[0]) || !/^[A-Za-z0-9+/]+={0,3}$/.test(parts[1]) ) { throw new Error( 'Invalid SSH public key. Expected " [comment]" (e.g. contents of ~/.ssh/id_ed25519.pub).', ); } } export function validateGrant(grant: string): void { if (!GRANT_PATTERN.test(grant)) { throw new Error(`Invalid grant "${grant}": use "command:subcommand", "command:*", or "*".`); } } /** * Does this grant set permit `command`/`subcommand`? Pure — deny-by-default. * Matches an exact `command:subcommand`, a `command:*` wildcard, `*`, or a * bare `command` grant for a subcommand-less command. */ export function grantsAllow(grants: string[], command: string, subcommand?: string): boolean { const op = subcommand ? `${command}:${subcommand}` : command; for (const grant of grants) { if (grant === '*') return true; if (grant === op) return true; if (grant === `${command}:*`) return true; if (!subcommand && grant === command) return true; } return false; } export async function listPrincipals(): Promise { return getDb().select().from(apiPrincipals).all(); } export async function getPrincipalByName(name: string): Promise { const rows = getDb().select().from(apiPrincipals).where(eq(apiPrincipals.name, name)).all(); return rows[0] ?? null; } /** * Create or replace a principal's key + grants (upsert by name). Validates all * inputs; throws on the first invalid one. */ export async function grantPrincipal(params: { name: string; publicKey: string; grants: string[]; }): Promise<{ principal: ApiPrincipal; created: boolean }> { const { name, publicKey, grants } = params; validatePrincipalName(name); validatePublicKey(publicKey); for (const grant of grants) validateGrant(grant); const db = getDb(); const existing = await getPrincipalByName(name); if (existing) { db.update(apiPrincipals).set({ publicKey, grants }).where(eq(apiPrincipals.name, name)).run(); const updated = await getPrincipalByName(name); if (!updated) throw new Error(`Principal "${name}" vanished after update`); return { principal: updated, created: false }; } const row: ApiPrincipal = { id: randomUUID(), name, publicKey, grants, createdAt: new Date(), updatedAt: new Date(), }; db.insert(apiPrincipals).values(row).run(); return { principal: row, created: true }; } /** Remove a principal. Returns false if it didn't exist. */ export async function revokePrincipal(name: string): Promise { const existing = await getPrincipalByName(name); if (!existing) return false; getDb().delete(apiPrincipals).where(eq(apiPrincipals.name, name)).run(); return true; } /** Authz entry point: is `principalName` allowed to run `command`/`subcommand`? */ export async function isAuthorized( principalName: string, command: string, subcommand?: string, ): Promise { const principal = await getPrincipalByName(principalName); if (!principal) return false; return grantsAllow(principal.grants, command, subcommand); } /** * Render the API account's `authorized_keys` — one forced-command line per * principal. The principal name is baked into the command so sshd hands the * identity to `api-serve` (see the design doc). Names are kebab-case-validated, * so no shell-escaping is required inside the `command=` string. */ export async function renderAuthorizedKeys(): Promise { const principals = await listPrincipals(); if (principals.length === 0) return ''; return `${principals .map( (p) => `command="celilo api-serve --principal=${p.name}",${FORCED_COMMAND_OPTS} ${p.publicKey}`, ) .join('\n')}\n`; }