/** * `control_plane_api`, and the one property that has to hold forever. * * The console reads celilo's own state. What it must never acquire is the * ability to change it — and the way that would happen is not somebody adding * `module:deploy` on purpose. It is a new verb appearing in the registry and * quietly falling on the wrong side of a grant list nobody re-reads. * * So the grants are DERIVED, and these assert the derivation rather than a * snapshot of its output. A test that pinned today's list would pass forever * while the thing it protects rotted. */ import { afterEach, beforeEach, describe, expect, test } from 'bun:test'; import { mkdtempSync, rmSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { COMMANDS, readOnlyGrants } from '@celilo/core'; import { closeDb } from '../db/client'; import { getPrincipalByName, isAuthorized } from './api-access'; import { buildControlPlaneApi, controlPlaneReadGrants, enrolControlPlanePrincipal, revokeControlPlanePrincipal, } from './api-principal-enrolment'; /** A syntactically real ed25519 public key. Nothing here ever sees a private one. */ const PUBLIC_KEY = 'ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIB7cWlXqPn+aGaNKm2Rt1cwj6cVy0hL0hVXHhWJKtLZ9 celilo-web-console'; const OTHER_KEY = 'ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAILlqL4YQ1MDe0J0X2ZQ4pXFqNbXK9y7dQe2Vd1kZ8sWq rotated'; describe('the grant set', () => { test('is exactly the read-only ops the classifier derives', () => { // Not a snapshot. If this were a literal list it would pass forever while a // newer celilo added verbs it no longer covered. expect(controlPlaneReadGrants()).toEqual(readOnlyGrants(COMMANDS)); }); test('is not empty, so the derivation is doing something', () => { // The failure mode a derived list has that a literal one does not: a // classifier change makes it return nothing and every read starts failing // closed. That is safe and completely broken, so it is asserted. expect(controlPlaneReadGrants().length).toBeGreaterThan(10); }); test('contains no obviously mutating op', () => { // A coarse net on purpose. The classifier is the real gate; this catches the // case where the classifier itself is changed to admit writes, which no test // of the classifier's own output would notice. const mutating = /:(deploy|remove|delete|create|set|add|uninstall|restore|grant|revoke|pause)$/; expect(controlPlaneReadGrants().filter((op) => mutating.test(op))).toEqual([]); }); }); describe('enrolment', () => { let dir: string; beforeEach(async () => { // The service reaches for the global `getDb()`, so isolation is by // CELILO_DB_PATH rather than by passing a client around. dir = mkdtempSync(join(tmpdir(), 'celilo-cpa-')); process.env.CELILO_DB_PATH = join(dir, 'test.db'); closeDb(); }); afterEach(() => { closeDb(); rmSync(dir, { recursive: true, force: true }); }); test('creates a principal carrying the derived grants', async () => { const result = await enrolControlPlanePrincipal({ name: 'celilo-web-console', publicKey: PUBLIC_KEY, }); expect(result.created).toBe(true); expect(result.grants).toEqual(controlPlaneReadGrants()); const stored = await getPrincipalByName('celilo-web-console'); expect(stored?.publicKey).toBe(PUBLIC_KEY); }); test('is idempotent, because a redeploy presents the same name again', async () => { await enrolControlPlanePrincipal({ name: 'celilo-web-console', publicKey: PUBLIC_KEY }); const second = await enrolControlPlanePrincipal({ name: 'celilo-web-console', publicKey: PUBLIC_KEY, }); expect(second.created).toBe(false); }); test('a new key for an existing name ROTATES rather than duplicating', async () => { // What a module that regenerated its keypair needs. Refusing it would leave // a console that cannot authenticate and no way to fix it from the module. await enrolControlPlanePrincipal({ name: 'celilo-web-console', publicKey: PUBLIC_KEY }); await enrolControlPlanePrincipal({ name: 'celilo-web-console', publicKey: OTHER_KEY }); expect((await getPrincipalByName('celilo-web-console'))?.publicKey).toBe(OTHER_KEY); }); test('a malformed public key is refused here, not at login', async () => { // Otherwise it lands in authorized_keys and fails later with a message // about SSH rather than about the thing that was wrong. await expect( enrolControlPlanePrincipal({ name: 'celilo-web-console', publicKey: 'not-a-key' }), ).rejects.toThrow(); }); }); describe('what the enrolled principal may actually do', () => { let dir: string; beforeEach(async () => { // The service reaches for the global `getDb()`, so isolation is by // CELILO_DB_PATH rather than by passing a client around. dir = mkdtempSync(join(tmpdir(), 'celilo-cpa-')); process.env.CELILO_DB_PATH = join(dir, 'test.db'); closeDb(); await enrolControlPlanePrincipal({ name: 'celilo-web-console', publicKey: PUBLIC_KEY }); }); afterEach(() => { closeDb(); rmSync(dir, { recursive: true, force: true }); }); test('may run the console reads it exists for', async () => { expect(await isAuthorized('celilo-web-console', 'console', 'status')).toBe(true); expect(await isAuthorized('celilo-web-console', 'console', 'get')).toBe(true); }); test('may run the other reads the console polls', async () => { // `alerts:list`, not bare `alerts`. The API authorises command:subcommand, // so a caller that omits the subcommand is refused — which is what the // console server did until this test caught it. expect(await isAuthorized('celilo-web-console', 'alerts', 'list')).toBe(true); expect(await isAuthorized('celilo-web-console', 'alerts')).toBe(false); expect(await isAuthorized('celilo-web-console', 'backup', 'list')).toBe(true); }); test('may NOT deploy a module', async () => { // The whole point. Everything above is convenience; this is the boundary. expect(await isAuthorized('celilo-web-console', 'module', 'deploy')).toBe(false); }); test('may NOT uninstall, restore, or grant', async () => { expect(await isAuthorized('celilo-web-console', 'module', 'remove')).toBe(false); expect(await isAuthorized('celilo-web-console', 'backup', 'restore')).toBe(false); expect(await isAuthorized('celilo-web-console', 'api', 'grant')).toBe(false); }); }); describe('revocation', () => { let dir: string; beforeEach(async () => { // The service reaches for the global `getDb()`, so isolation is by // CELILO_DB_PATH rather than by passing a client around. dir = mkdtempSync(join(tmpdir(), 'celilo-cpa-')); process.env.CELILO_DB_PATH = join(dir, 'test.db'); closeDb(); }); afterEach(() => { closeDb(); rmSync(dir, { recursive: true, force: true }); }); test('removes the principal, so an uninstalled console leaves no working key', async () => { await enrolControlPlanePrincipal({ name: 'celilo-web-console', publicKey: PUBLIC_KEY }); expect(await revokeControlPlanePrincipal('celilo-web-console')).toBe(true); expect(await getPrincipalByName('celilo-web-console')).toBeNull(); }); test('revoking one that is already gone is false, not a throw', async () => { // An uninstall that fails on tidy-up is worse than one finding nothing to // tidy, and a hook that throws here leaves the module half-removed. expect(await revokeControlPlanePrincipal('never-existed')).toBe(false); }); }); describe('the capability handed to a consuming module', () => { let dir: string; beforeEach(async () => { dir = mkdtempSync(join(tmpdir(), 'celilo-cpa-')); process.env.CELILO_DB_PATH = join(dir, 'test.db'); closeDb(); }); afterEach(() => { closeDb(); rmSync(dir, { recursive: true, force: true }); }); test('enrols the caller and reports the grants it may use', async () => { const api = buildControlPlaneApi('celilo-web-console'); const result = await api.enrol_principal({ name: 'celilo-web-console', publicKey: PUBLIC_KEY, }); expect(result.created).toBe(true); expect(result.grants).toEqual(controlPlaneReadGrants()); expect(await getPrincipalByName('celilo-web-console')).not.toBeNull(); }); test('a module may NOT enrol under a neighbour’s name', async () => { // Otherwise any module holding this capability rotates the console's key by // presenting its own public half under the console's name — the console then // holds a principal whose authorized_keys entry it cannot log in with, and // the attacker holds one that works. const api = buildControlPlaneApi('hello-foo'); await expect( api.enrol_principal({ name: 'celilo-web-console', publicKey: PUBLIC_KEY }), ).rejects.toThrow(/only enrol its own principal/); expect(await getPrincipalByName('celilo-web-console')).toBeNull(); }); test('a module may NOT revoke a neighbour’s principal', async () => { // The denial-of-service half of the same hole: revocation is idempotent and // returns false for a name that never existed, so an unguarded revoke gives // no signal at all that a module just deleted someone else's access. await enrolControlPlanePrincipal({ name: 'celilo-web-console', publicKey: PUBLIC_KEY }); const api = buildControlPlaneApi('hello-foo'); await expect(api.revoke_principal({ name: 'celilo-web-console' })).rejects.toThrow( /only revoke its own principal/, ); expect(await getPrincipalByName('celilo-web-console')).not.toBeNull(); }); test('withdraws its own principal, and finding nothing is not an error', async () => { const api = buildControlPlaneApi('celilo-web-console'); await api.enrol_principal({ name: 'celilo-web-console', publicKey: PUBLIC_KEY }); expect(await api.revoke_principal({ name: 'celilo-web-console' })).toEqual({ revoked: true }); expect(await api.revoke_principal({ name: 'celilo-web-console' })).toEqual({ revoked: false }); }); test('what it enrols cannot write, whatever the caller asked for', async () => { // The grant set is not a parameter, so there is nothing to ask for. This // asserts the consequence rather than the absence: the principal the // capability produces is refused a deploy. const api = buildControlPlaneApi('celilo-web-console'); await api.enrol_principal({ name: 'celilo-web-console', publicKey: PUBLIC_KEY }); expect(await isAuthorized('celilo-web-console', 'module', 'list')).toBe(true); expect(await isAuthorized('celilo-web-console', 'module', 'deploy')).toBe(false); }); });