import type { Database } from 'bun:sqlite'; import { describe, expect, test } from 'bun:test'; import type { ModuleManifest } from '../manifest/schema'; import { checkAllowlist, getProviderManifest, validateCapabilityAccess } from './validation'; /** * A consumer's reference to `dns_external`'s restricted `tsig` secret. * * The access gate fires only for a secret the consumer actually names * (celilo#854), so every fixture that expects a REFUSAL has to name one. */ const TSIG_REFERENCE = { name: 'tsig', type: 'string' as const, required: false, source: 'capability' as const, derive_from: '$capability:dns_external.tsig', }; describe('Capability Access Validation', () => { describe('checkAllowlist', () => { test('should return true when consumer provides capability in allowlist', () => { const consumerCapabilities = ['public_web']; const allowlist = ['public_web', 'api_gateway']; const result = checkAllowlist(consumerCapabilities, allowlist); expect(result).toBe(true); }); test('should return true when consumer provides multiple capabilities and one matches', () => { const consumerCapabilities = ['dns_internal', 'monitoring', 'public_web']; const allowlist = ['public_web', 'api_gateway']; const result = checkAllowlist(consumerCapabilities, allowlist); expect(result).toBe(true); }); test('should return false when consumer provides no matching capability', () => { const consumerCapabilities = ['monitoring', 'logging']; const allowlist = ['public_web', 'api_gateway']; const result = checkAllowlist(consumerCapabilities, allowlist); expect(result).toBe(false); }); test('should return false when consumer provides no capabilities', () => { const consumerCapabilities: string[] = []; const allowlist = ['public_web', 'api_gateway']; const result = checkAllowlist(consumerCapabilities, allowlist); expect(result).toBe(false); }); test('should return false when allowlist is empty', () => { const consumerCapabilities = ['public_web']; const allowlist: string[] = []; const result = checkAllowlist(consumerCapabilities, allowlist); expect(result).toBe(false); }); test('should return false when both are empty', () => { const consumerCapabilities: string[] = []; const allowlist: string[] = []; const result = checkAllowlist(consumerCapabilities, allowlist); expect(result).toBe(false); }); test('should match exact capability names', () => { const consumerCapabilities = ['public_web_v2']; const allowlist = ['public_web']; const result = checkAllowlist(consumerCapabilities, allowlist); expect(result).toBe(false); }); test('should be case-sensitive', () => { const consumerCapabilities = ['Public_Web']; const allowlist = ['public_web']; const result = checkAllowlist(consumerCapabilities, allowlist); expect(result).toBe(false); }); }); describe('getProviderManifest', () => { test('should return manifest when capability exists', () => { const mockManifest: ModuleManifest = { celilo_contract: '1.0', id: 'dns-external', name: 'DNS External', version: '1.0.0', description: 'External DNS', requires: { capabilities: [] }, provides: { capabilities: [ { name: 'dns_external', version: '1.0.0', data: {}, secrets: [ { name: 'tsig', type: 'string', description: 'TSIG secret', readable_by: ['public_web'], }, ], }, ], }, variables: { owns: [], imports: [] }, }; const mockDb = { prepare: (_query: string) => ({ get: (capabilityName: string) => { if (capabilityName === 'dns_external') { return { manifest_data: JSON.stringify(mockManifest), }; } return undefined; }, }), } as unknown as Database; const result = getProviderManifest('dns_external', mockDb); expect(result).toEqual(mockManifest); }); test('should return null when capability does not exist', () => { const mockDb = { prepare: () => ({ get: () => undefined, }), } as unknown as Database; const result = getProviderManifest('unknown_capability', mockDb); expect(result).toBeNull(); }); test('should parse JSON manifest correctly', () => { const mockManifest: ModuleManifest = { celilo_contract: '1.0', id: 'test-module', name: 'Test Module', version: '1.0.0', description: 'Test', requires: { capabilities: [] }, provides: { capabilities: [ { name: 'test_capability', version: '1.0.0', data: { nested: { value: 'test' } }, }, ], }, variables: { owns: [], imports: [] }, }; const mockDb = { prepare: () => ({ get: () => ({ manifest_data: JSON.stringify(mockManifest), }), }), } as unknown as Database; const result = getProviderManifest('test_capability', mockDb); expect(result?.provides?.capabilities?.[0]?.data).toEqual({ nested: { value: 'test' }, }); }); }); describe('validateCapabilityAccess', () => { test('should return success when module does not require capabilities', async () => { const manifest: ModuleManifest = { celilo_contract: '1.0', id: 'test-module', name: 'Test Module', version: '1.0.0', description: 'Test', requires: { capabilities: [] }, provides: { capabilities: [] }, variables: { owns: [], imports: [] }, }; const mockDb = {} as Database; const result = await validateCapabilityAccess(manifest, mockDb); expect(result.success).toBe(true); }); test('should return success when module requires capabilities array is empty', async () => { const manifest: ModuleManifest = { celilo_contract: '1.0', id: 'test-module', name: 'Test Module', version: '1.0.0', description: 'Test', requires: { capabilities: [], }, provides: { capabilities: [] }, variables: { owns: [], imports: [] }, }; const mockDb = {} as Database; const result = await validateCapabilityAccess(manifest, mockDb); expect(result.success).toBe(true); }); test('a framework-granted capability needs no providing module', async () => { // This is the breakage, not a hypothetical. `control_plane_api` has no // provider module and never will — celilo grants it (web-ui-console D7b) — // so the console's manifest, already merged, hit the refusal below and // could not be imported at all. Note the db here is the SAME one that // fails the test after this one: the difference is entirely which // capability is asked for. const manifest: ModuleManifest = { celilo_contract: '1.0', id: 'celilo-web-console', name: 'Celilo Web Console', version: '1.0.0', description: 'Console', requires: { capabilities: [{ name: 'control_plane_api', version: '1.0.0' }], }, provides: { capabilities: [] }, variables: { owns: [], imports: [] }, }; const noProviderDb = { prepare: () => ({ get: () => undefined, }), } as unknown as Database; const result = await validateCapabilityAccess(manifest, noProviderDb); expect(result.success).toBe(true); }); test('should return error when required capability not found', async () => { const manifest: ModuleManifest = { celilo_contract: '1.0', id: 'caddy', name: 'Caddy', version: '1.0.0', description: 'Web server', requires: { capabilities: [{ name: 'dns_external', version: '1.0.0' }], }, provides: { capabilities: [] }, variables: { owns: [], imports: [] }, }; const mockDb = { prepare: () => ({ get: () => undefined, }), } as unknown as Database; const result = await validateCapabilityAccess(manifest, mockDb); expect(result.success).toBe(false); expect(result.error).toContain("Required capability 'dns_external' not found"); expect(result.error).toContain('No module provides this capability'); }); test('should skip framework-granted privileges (no provider required)', async () => { // cross_module_read is a privilege (allow-listed to celilo-mgmt), // not a provider-backed capability. validateCapabilityAccess must // not demand a providing module for it — otherwise celilo-mgmt // can't be imported (regression: openspec/specs/progressive-zone-disclosure/spec.md). const manifest: ModuleManifest = { celilo_contract: '1.0', id: 'celilo-mgmt', name: 'Celilo Management Server', version: '1.0.0', description: 'Management server', requires: { capabilities: [{ name: 'cross_module_read', version: '^1.0' }], }, provides: { capabilities: [] }, variables: { owns: [], imports: [] }, }; // DB must NOT be consulted for a privilege — fail loudly if it is. const mockDb = { prepare: () => { throw new Error('getProviderManifest should not be called for a privilege'); }, } as unknown as Database; const result = await validateCapabilityAccess(manifest, mockDb); expect(result.success).toBe(true); }); test('should return success when capability has no secrets', async () => { const manifest: ModuleManifest = { celilo_contract: '1.0', id: 'consumer', name: 'Consumer', version: '1.0.0', description: 'Test', requires: { capabilities: [{ name: 'monitoring', version: '1.0.0' }], }, provides: { capabilities: [] }, variables: { owns: [], imports: [] }, }; const providerManifest: ModuleManifest = { celilo_contract: '1.0', id: 'provider', name: 'Provider', version: '1.0.0', description: 'Test', requires: { capabilities: [] }, provides: { capabilities: [ { name: 'monitoring', version: '1.0.0', data: {}, }, ], }, variables: { owns: [], imports: [] }, }; const mockDb = { prepare: () => ({ get: () => ({ manifest_data: JSON.stringify(providerManifest), }), }), } as unknown as Database; const result = await validateCapabilityAccess(manifest, mockDb); expect(result.success).toBe(true); }); test('should return success when secret has no readable_by restriction', async () => { const manifest: ModuleManifest = { celilo_contract: '1.0', id: 'consumer', name: 'Consumer', version: '1.0.0', description: 'Test', requires: { capabilities: [{ name: 'dns_external', version: '1.0.0' }], }, provides: { capabilities: [] }, variables: { owns: [], imports: [] }, }; const providerManifest: ModuleManifest = { celilo_contract: '1.0', id: 'dns-external', name: 'DNS External', version: '1.0.0', description: 'Test', requires: { capabilities: [] }, provides: { capabilities: [ { name: 'dns_external', version: '1.0.0', data: {}, secrets: [ { name: 'api_key', type: 'string', description: 'API key', // No readable_by - accessible to all }, ], }, ], }, variables: { owns: [], imports: [] }, }; const mockDb = { prepare: () => ({ get: () => ({ manifest_data: JSON.stringify(providerManifest), }), }), } as unknown as Database; const result = await validateCapabilityAccess(manifest, mockDb); expect(result.success).toBe(true); }); test('should return success when consumer matches allowlist', async () => { const manifest: ModuleManifest = { celilo_contract: '1.0', id: 'caddy', name: 'Caddy', version: '1.0.0', description: 'Web server', requires: { capabilities: [{ name: 'dns_external', version: '1.0.0' }], }, provides: { capabilities: [ { name: 'public_web', version: '1.0.0', data: {}, }, ], }, variables: { owns: [], imports: [] }, }; const providerManifest: ModuleManifest = { celilo_contract: '1.0', id: 'dns-external', name: 'DNS External', version: '1.0.0', description: 'Test', requires: { capabilities: [] }, provides: { capabilities: [ { name: 'dns_external', version: '1.0.0', data: {}, secrets: [ { name: 'tsig', type: 'string', description: 'TSIG secret', readable_by: ['public_web'], }, ], }, ], }, variables: { owns: [], imports: [] }, }; const mockDb = { prepare: () => ({ get: () => ({ manifest_data: JSON.stringify(providerManifest), }), }), } as unknown as Database; const result = await validateCapabilityAccess(manifest, mockDb); expect(result.success).toBe(true); }); test('should return error when consumer does not match allowlist', async () => { const manifest: ModuleManifest = { celilo_contract: '1.0', id: 'some-app', name: 'Some App', version: '1.0.0', description: 'Test', requires: { capabilities: [{ name: 'dns_external', version: '1.0.0' }], }, provides: { capabilities: [ { name: 'monitoring', version: '1.0.0', data: {}, }, ], }, variables: { owns: [TSIG_REFERENCE], imports: [] }, }; const providerManifest: ModuleManifest = { celilo_contract: '1.0', id: 'dns-external', name: 'DNS External', version: '1.0.0', description: 'Test', requires: { capabilities: [] }, provides: { capabilities: [ { name: 'dns_external', version: '1.0.0', data: {}, secrets: [ { name: 'tsig', type: 'string', description: 'TSIG secret', readable_by: ['public_web'], }, ], }, ], }, variables: { owns: [], imports: [] }, }; const mockDb = { prepare: () => ({ get: () => ({ manifest_data: JSON.stringify(providerManifest), }), }), } as unknown as Database; const result = await validateCapabilityAccess(manifest, mockDb); expect(result.success).toBe(false); expect(result.error).toContain("Module 'some-app' cannot access secret 'tsig'"); expect(result.error).toContain("capability 'dns_external'"); expect(result.error).toContain('only allows access to modules that provide: public_web'); expect(result.error).toContain('This module provides: monitoring'); }); test('should return error when consumer provides no capabilities', async () => { const manifest: ModuleManifest = { celilo_contract: '1.0', id: 'consumer', name: 'Consumer', version: '1.0.0', description: 'Test', requires: { capabilities: [{ name: 'dns_external', version: '1.0.0' }], }, provides: { capabilities: [] }, // No provides section - empty variables: { owns: [TSIG_REFERENCE], imports: [] }, }; const providerManifest: ModuleManifest = { celilo_contract: '1.0', id: 'dns-external', name: 'DNS External', version: '1.0.0', description: 'Test', requires: { capabilities: [] }, provides: { capabilities: [ { name: 'dns_external', version: '1.0.0', data: {}, secrets: [ { name: 'tsig', type: 'string', description: 'TSIG secret', readable_by: ['public_web'], }, ], }, ], }, variables: { owns: [], imports: [] }, }; const mockDb = { prepare: () => ({ get: () => ({ manifest_data: JSON.stringify(providerManifest), }), }), } as unknown as Database; const result = await validateCapabilityAccess(manifest, mockDb); expect(result.success).toBe(false); expect(result.error).toContain('This module provides: (none)'); }); test('should validate all required capabilities', async () => { const manifest: ModuleManifest = { celilo_contract: '1.0', id: 'complex-app', name: 'Complex App', version: '1.0.0', description: 'Test', requires: { capabilities: [ { name: 'dns_external', version: '1.0.0' }, { name: 'database', version: '1.0.0' }, ], }, provides: { capabilities: [ { name: 'public_web', version: '1.0.0', data: {}, }, ], }, variables: { owns: [], imports: [] }, }; let callCount = 0; const mockDb = { prepare: () => ({ get: (capabilityName: string) => { callCount++; if (capabilityName === 'dns_external') { return { manifest_data: JSON.stringify({ id: 'dns-external', name: 'DNS', version: '1.0.0', description: 'Test', provides: { capabilities: [ { name: 'dns_external', version: '1.0.0', data: {}, secrets: [ { name: 'tsig', type: 'string', readable_by: ['public_web'], }, ], }, ], }, }), }; } if (capabilityName === 'database') { return { manifest_data: JSON.stringify({ id: 'postgres', name: 'PostgreSQL', version: '1.0.0', description: 'Test', provides: { capabilities: [ { name: 'database', version: '1.0.0', data: {}, }, ], }, }), }; } return undefined; }, }), } as unknown as Database; const result = await validateCapabilityAccess(manifest, mockDb); expect(result.success).toBe(true); expect(callCount).toBe(2); // Should query both capabilities }); }); // celilo#854 — the gate is scoped to secrets the consumer actually names. // // knot-unbound-internal declares dns_internal's `tsig_key` with // `readable_by: ["dns_internal"]`. Before this was fixed, ANY module listing // dns_internal under `requires.capabilities` and not itself PROVIDING // dns_internal was refused at import over a value it never references. // caddy-internal was the first module to hit it, and it declared the // capability under `optional` to get past the gate — a lie about the // dependency graph that six core services read. describe('celilo#854 — secret access is gated on reference, not on declaration', () => { const knotManifest: ModuleManifest = { celilo_contract: '1.0', id: 'knot-unbound-internal', name: 'Knot + Unbound', version: '1.0.0', description: 'Test', requires: { capabilities: [] }, provides: { capabilities: [ { name: 'dns_internal', version: '1.0.0', data: {}, secrets: [ { name: 'tsig_key', type: 'string', readable_by: ['dns_internal'], }, ], }, ], }, variables: { owns: [], imports: [] }, }; const knotDb = { prepare: () => ({ get: () => ({ manifest_data: JSON.stringify(knotManifest) }), }), } as unknown as Database; test('a consumer that requires the capability but never names the secret imports', async () => { const manifest: ModuleManifest = { celilo_contract: '1.0', id: 'caddy-internal', name: 'Caddy (fleet-only ingress)', version: '1.0.0', description: 'Test', requires: { capabilities: [{ name: 'dns_internal', version: '1.0.0' }], }, provides: { capabilities: [{ name: 'private_web', version: '1.0.0', data: {} }], }, variables: { owns: [], imports: [] }, }; const result = await validateCapabilityAccess(manifest, knotDb); expect(result.success).toBe(true); expect(result.error).toBeUndefined(); }); test('a consumer that DOES name the restricted secret is still refused', async () => { const manifest: ModuleManifest = { celilo_contract: '1.0', id: 'nosy-app', name: 'Nosy App', version: '1.0.0', description: 'Test', requires: { capabilities: [{ name: 'dns_internal', version: '1.0.0' }], }, provides: { capabilities: [{ name: 'private_web', version: '1.0.0', data: {} }], }, variables: { owns: [ { name: 'stolen_key', type: 'string', required: false, source: 'capability', derive_from: '$capability:dns_internal.tsig_key', }, ], imports: [], }, }; const result = await validateCapabilityAccess(manifest, knotDb); expect(result.success).toBe(false); expect(result.error).toContain("Module 'nosy-app' cannot access secret 'tsig_key'"); expect(result.error).toContain("capability 'dns_internal'"); }); /** * The gate is per-SECRET, not per-capability. A provider that declares two * restricted secrets must not put a consumer on the hook for the second * one's allow-list just because it named the first — that would move the * over-refusal from "requires the capability" down one level to "reads any * of its secrets", which is the same conflation wearing a smaller hat. */ const twoSecretManifest: ModuleManifest = { ...knotManifest, provides: { capabilities: [ { name: 'dns_internal', version: '1.0.0', data: {}, secrets: [ { name: 'tsig_key', type: 'string', readable_by: ['dns_internal'] }, { name: 'api_token', type: 'string', readable_by: ['private_web'] }, ], }, ], }, }; const twoSecretDb = { prepare: () => ({ get: () => ({ manifest_data: JSON.stringify(twoSecretManifest) }), }), } as unknown as Database; test('naming one secret enforces that secret allow-list and no other', async () => { function consumer(deriveFrom: string): ModuleManifest { return { celilo_contract: '1.0', id: 'caddy-internal', name: 'Caddy (fleet-only ingress)', version: '1.0.0', description: 'Test', requires: { capabilities: [{ name: 'dns_internal', version: '1.0.0' }] }, provides: { capabilities: [{ name: 'private_web', version: '1.0.0', data: {} }] }, variables: { owns: [ { name: 'borrowed', type: 'string', required: false, source: 'capability', derive_from: deriveFrom, }, ], imports: [], }, }; } // `api_token` is readable by `private_web`, which this consumer provides. // The unnamed `tsig_key`, which it does not satisfy, must not interfere. const allowed = await validateCapabilityAccess( consumer('$capability:dns_internal.api_token'), twoSecretDb, ); expect(allowed.success).toBe(true); // Naming `tsig_key` is still refused, by name. const refused = await validateCapabilityAccess( consumer('$capability:dns_internal.tsig_key'), twoSecretDb, ); expect(refused.success).toBe(false); expect(refused.error).toContain('tsig_key'); }); /** * A reference in a TEMPLATE counts too. `resolver.ts` does refuse one at * the point of use, so nothing is unsafe without this, but the refusal * lands at generation rather than at import. * * That gap sits exactly where the repo sends people. CLAUDE.md's module * Definition of Done says "Capability variable usage — Templates use * `$capability:` syntax" and gives a `.tf.tpl` example, so the first author * who follows that instruction with a RESTRICTED secret is the one who * finds out late. No module in the tree references a capability from a * template today, which is why the gap is currently invisible rather than * absent. */ test('a secret named only in a template is refused at import', async () => { const manifest: ModuleManifest = { celilo_contract: '1.0', id: 'nosy-app', name: 'Nosy App', version: '1.0.0', description: 'Test', requires: { capabilities: [{ name: 'dns_internal', version: '1.0.0' }] }, provides: { capabilities: [{ name: 'private_web', version: '1.0.0', data: {} }] }, variables: { owns: [], imports: [] }, }; // The manifest names nothing. Only the .tf.tpl does. const result = await validateCapabilityAccess(manifest, knotDb, ['dns_internal.tsig_key']); expect(result.success).toBe(false); expect(result.error).toContain("cannot access secret 'tsig_key'"); }); test('a non-secret template reference is still not a secret reference', async () => { const manifest: ModuleManifest = { celilo_contract: '1.0', id: 'caddy-internal', name: 'Caddy (fleet-only ingress)', version: '1.0.0', description: 'Test', requires: { capabilities: [{ name: 'dns_internal', version: '1.0.0' }] }, provides: { capabilities: [{ name: 'private_web', version: '1.0.0', data: {} }] }, variables: { owns: [], imports: [] }, }; const result = await validateCapabilityAccess(manifest, knotDb, [ 'dns_internal.server.ip.primary', ]); expect(result.success).toBe(true); }); }); });