/** * Capability Access Validation * Validates that consumer modules have permission to access capability secrets */ import type { Database } from 'bun:sqlite'; import type { ModuleManifest } from '../manifest/schema'; import { isPrivilegedCapability } from '../manifest/validate'; import { parseVariables } from '../variables/parser'; export interface ValidationResult { success: boolean; error?: string; details?: unknown; } /** * Validate that consumer module can access required capability secrets * * Execution function (Rule 10.1) - performs database queries * * @param manifest - Consumer module manifest * @param db - Database connection * @param templateReferences - `.` references found outside * the manifest, i.e. in the module's templates. Empty is the honest default * for a caller holding no template context; `module import` passes what the * template validator already parsed. * @returns Validation result */ export async function validateCapabilityAccess( manifest: ModuleManifest, db: Database, templateReferences: readonly string[] = [], ): Promise { // If module doesn't require capabilities, validation passes if (!manifest.requires?.capabilities || manifest.requires.capabilities.length === 0) { return { success: true }; } // Get list of capabilities this module provides const consumerCapabilities = (manifest.provides?.capabilities || []).map((cap) => cap.name); const references = collectCapabilityReferences(manifest, templateReferences); // Check each required capability for (const requiredCapability of manifest.requires.capabilities) { // Framework-granted privileges (e.g. cross_module_read) are not // provider-backed — they're gated separately by the allow-list in // validatePrivilegedCapabilities. Don't demand a providing module. if (isPrivilegedCapability(requiredCapability.name)) { continue; } // Get the provider module's manifest const providerManifest = getProviderManifest(requiredCapability.name, db); if (!providerManifest) { return { success: false, error: `Required capability '${requiredCapability.name}' not found. No module provides this capability.`, }; } // Check if any secrets in the capability require access permissions const capabilityDef = providerManifest.provides?.capabilities?.find( (cap) => cap.name === requiredCapability.name, ); if (!capabilityDef?.secrets || capabilityDef.secrets.length === 0) { // No secrets to validate continue; } // Check allowlist for each secret the consumer actually names (celilo#854). for (const secret of capabilityDef.secrets) { if (!referencesSecret(references, requiredCapability.name, secret.name)) { continue; } if (secret.readable_by && secret.readable_by.length > 0) { // Check if consumer provides any capability in the allowlist const hasAccess = checkAllowlist(consumerCapabilities, secret.readable_by); if (!hasAccess) { return { success: false, error: formatAccessDeniedError( manifest.id, requiredCapability.name, secret.name, consumerCapabilities, secret.readable_by, ), }; } } // If readable_by is empty or undefined, secret is accessible to all } } return { success: true }; } /** * Every `$capability:.` reference the consumer's manifest makes. * * Policy function (Rule 10.1) - parses only, no I/O. * * Serializing the manifest and parsing the result finds a reference wherever it * lives — a `variables.owns[].derive_from`, a default, a capability data block — * without this having to track which fields may hold one. * * Template references are included too, supplied by the caller. `resolver.ts` * does refuse a template reference at the point of use, so nothing is unsafe * without them, but the refusal lands at generation rather than at import. That * matters because a module's TEMPLATES are where this repo tells authors to put * these: the Definition of Done in CLAUDE.md says "Capability variable usage — * Templates use `$capability:` syntax" and gives a `.tf.tpl` example. A gate * that does not fire where the documentation sends people is a gate with a hole * in the shape of the instructions. */ function collectCapabilityReferences( manifest: ModuleManifest, templateReferences: readonly string[] = [], ): Set { return new Set([ ...parseVariables(JSON.stringify(manifest)) .filter((variable) => variable.type === 'capability') .map((variable) => variable.path), ...templateReferences, ]); } /** * Does the consumer name this capability secret? * * Policy function (Rule 10.1) - pure logic, no I/O. * * A reference of `dns_internal.tsig_key` names the `tsig_key` secret, and so * does `dns_internal.tsig_key.value` if the secret ever holds a structure. * Requiring the capability alone names nothing (celilo#854): a module refused * over a secret it never reads has to lie about its dependency graph to deploy. */ function referencesSecret( references: Set, capabilityName: string, secretName: string, ): boolean { const base = `${capabilityName}.${secretName}`; for (const reference of references) { if (reference === base || reference.startsWith(`${base}.`)) { return true; } } return false; } /** * Check if consumer capabilities match provider allowlist * * Policy function (Rule 10.1) - pure logic, no I/O * * @param consumerCapabilities - Capabilities provided by consumer module * @param allowlist - Allowlist from provider's secret definition * @returns True if any consumer capability is in allowlist */ export function checkAllowlist(consumerCapabilities: string[], allowlist: string[]): boolean { return consumerCapabilities.some((cap) => allowlist.includes(cap)); } /** * Get provider module manifest for a capability * * Execution function (Rule 10.1) - performs database query * * @param capabilityName - Name of the capability * @param db - Database connection * @returns Provider module manifest or null if not found */ export function getProviderManifest(capabilityName: string, db: Database): ModuleManifest | null { const result = db .prepare( `SELECT p.manifest_data FROM modules p JOIN capabilities c ON p.id = c.module_id WHERE c.capability_name = ? LIMIT 1`, ) .get(capabilityName) as { manifest_data: string } | undefined; if (!result) { return null; } try { return JSON.parse(result.manifest_data) as ModuleManifest; } catch (error) { throw new Error( `Failed to parse manifest for module providing capability ${capabilityName}: ${error instanceof Error ? error.message : 'Invalid JSON'}`, ); } } /** * Format access denied error message * * Presentation function (Rule 10.1) - formats output for user * * @param consumerModuleId - Consumer module ID * @param capabilityName - Capability name * @param secretName - Secret name * @param consumerCapabilities - Capabilities provided by consumer * @param allowlist - Allowlist from provider * @returns Formatted error message */ function formatAccessDeniedError( consumerModuleId: string, capabilityName: string, secretName: string, consumerCapabilities: string[], allowlist: string[], ): string { const lines = [ `Module '${consumerModuleId}' cannot access secret '${secretName}' from capability '${capabilityName}'.`, '', `The ${capabilityName} capability only allows access to modules that provide: ${allowlist.join(', ')}`, '', `This module provides: ${consumerCapabilities.length > 0 ? consumerCapabilities.join(', ') : '(none)'}`, '', `To grant access, update the provider module's manifest to add one of the consumer's capabilities to the readable_by list.`, ]; return lines.join('\n'); }