Files
fusion/packages/core/src/secrets-store.ts
Fusion (runfusion.ai) cbac381f65 feat(FN-4912): complete Step 3 — secrets env export listing
Fusion-Task-Id: FN-4912
Fusion-Task-Lineage: 943d0651-052a-41b5-8069-4c60f4db1ba7
2026-05-17 23:29:20 -07:00

365 lines
12 KiB
TypeScript

import { randomUUID } from "node:crypto";
import type { Database as ProjectDatabase } from "./db.js";
import type { CentralDatabase } from "./central-db.js";
import { createSecretCipher, SecretCryptoError, type MasterKeyProvider } from "./secrets-crypto.js";
export type SecretScope = "project" | "global";
export function isSecretScope(value: unknown): value is SecretScope {
return value === "project" || value === "global";
}
export type SecretAccessPolicy = "auto" | "prompt" | "deny";
export interface SecretRecord {
id: string;
key: string;
scope: SecretScope;
description: string | null;
accessPolicy: SecretAccessPolicy;
envExportable: boolean;
envExportKey: string | null;
createdAt: string;
updatedAt: string;
lastReadAt: string | null;
lastReadBy: string | null;
}
export interface EnvExportableSecret {
id: string;
key: string;
exportKey: string;
scope: SecretScope;
plaintextValue: string;
}
interface SecretRow {
id: string;
key: string;
description: string | null;
access_policy: SecretAccessPolicy;
env_exportable: number;
env_export_key: string | null;
created_at: string;
updated_at: string;
last_read_at: string | null;
last_read_by: string | null;
}
interface SecretCipherRow extends SecretRow {
value_ciphertext: Buffer;
nonce: Buffer;
}
type SecretsDb = Pick<ProjectDatabase, "prepare" | "bumpLastModified"> | Pick<CentralDatabase, "prepare" | "bumpLastModified">;
type SecretsStoreAuditEvent = {
mutationType: "secret:create" | "secret:update" | "secret:delete" | "secret:read";
scope: SecretScope;
secretId: string;
key: string;
actor?: { agentId?: string | null; userId?: string | null };
};
export interface SecretsStoreOptions {
/** Optional non-blocking audit emitter. Errors are swallowed/warned so CRUD paths continue. */
auditEmitter?: (event: SecretsStoreAuditEvent) => void;
}
export class SecretsStoreError extends Error {
readonly code: "duplicate-key" | "not-found" | "invalid-policy" | "invalid-key" | "decrypt-failed";
constructor(params: {
code: "duplicate-key" | "not-found" | "invalid-policy" | "invalid-key" | "decrypt-failed";
message: string;
}) {
super(params.message);
this.name = "SecretsStoreError";
this.code = params.code;
}
}
function tableForScope(scope: SecretScope): "secrets" | "secrets_global" {
return scope === "project" ? "secrets" : "secrets_global";
}
function isSqliteUniqueError(error: unknown): boolean {
return error instanceof Error && /UNIQUE constraint failed/u.test(error.message);
}
function isAccessPolicy(value: string): value is SecretAccessPolicy {
return value === "auto" || value === "prompt" || value === "deny";
}
export class SecretsStore {
private readonly cipher: ReturnType<typeof createSecretCipher>;
constructor(
private readonly projectDb: Pick<ProjectDatabase, "prepare" | "bumpLastModified">,
private readonly centralDb: Pick<CentralDatabase, "prepare" | "bumpLastModified">,
masterKeyProvider: MasterKeyProvider,
private readonly options: SecretsStoreOptions = {},
) {
this.cipher = createSecretCipher(masterKeyProvider);
}
private emitAudit(event: SecretsStoreAuditEvent): void {
if (!this.options.auditEmitter) return;
try {
this.options.auditEmitter(event);
} catch (error) {
console.warn("[secrets-store] audit emitter failed", error);
}
}
private dbForScope(scope: SecretScope): SecretsDb {
return scope === "project" ? this.projectDb : this.centralDb;
}
private rowToRecord(row: SecretRow, scope: SecretScope): SecretRecord {
return {
id: row.id,
key: row.key,
scope,
description: row.description,
accessPolicy: row.access_policy,
envExportable: row.env_exportable === 1,
envExportKey: row.env_export_key,
createdAt: row.created_at,
updatedAt: row.updated_at,
lastReadAt: row.last_read_at,
lastReadBy: row.last_read_by,
};
}
listSecrets(scope?: SecretScope): SecretRecord[] {
if (scope) {
const db = this.dbForScope(scope);
const table = tableForScope(scope);
const rows = db.prepare(`SELECT id, key, description, access_policy, env_exportable, env_export_key, created_at, updated_at, last_read_at, last_read_by FROM ${table} ORDER BY key COLLATE NOCASE ASC`).all() as SecretRow[];
return rows.map((row) => this.rowToRecord(row, scope));
}
return [...this.listSecrets("project"), ...this.listSecrets("global")];
}
async listEnvExportable(opts?: { keyPrefix?: string }): Promise<EnvExportableSecret[]> {
const keyPrefix = opts?.keyPrefix;
const projectRows = this.listSecrets("project");
const globalRows = this.listSecrets("global");
const exported = new Map<string, EnvExportableSecret>();
const collect = async (row: SecretRecord): Promise<void> => {
if (!row.envExportable) return;
if (keyPrefix && !row.key.startsWith(keyPrefix)) return;
const exportKey = row.envExportKey?.trim() || row.key;
if (exported.has(exportKey)) {
if (row.scope === "global") {
console.debug(`[secrets-store] dropping global env export key due to project override: ${exportKey}`);
}
return;
}
try {
const revealed = await this.revealSecret(row.id, row.scope, {
agentId: null,
userId: "fusion:secrets-env-writer",
});
exported.set(exportKey, {
id: row.id,
key: row.key,
exportKey,
scope: row.scope,
plaintextValue: revealed.plaintextValue,
});
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
console.warn(`[secrets-store] failed to reveal env exportable secret ${row.scope}:${row.key}: ${message}`);
}
};
for (const row of projectRows) {
await collect(row);
}
for (const row of globalRows) {
await collect(row);
}
return [...exported.values()];
}
getSecretMetadata(id: string, scope: SecretScope): SecretRecord | null {
const db = this.dbForScope(scope);
const table = tableForScope(scope);
const row = db.prepare(`SELECT id, key, description, access_policy, env_exportable, env_export_key, created_at, updated_at, last_read_at, last_read_by FROM ${table} WHERE id = ?`).get(id) as SecretRow | undefined;
return row ? this.rowToRecord(row, scope) : null;
}
async createSecret(input: {
scope: SecretScope;
key: string;
plaintextValue: string;
description?: string | null;
accessPolicy?: SecretAccessPolicy;
envExportable?: boolean;
envExportKey?: string | null;
}): Promise<SecretRecord> {
const key = input.key.trim();
if (!key) {
throw new SecretsStoreError({ code: "invalid-key", message: "Secret key is required" });
}
if (input.accessPolicy && !isAccessPolicy(input.accessPolicy)) {
throw new SecretsStoreError({ code: "invalid-policy", message: "Invalid access policy" });
}
const now = new Date().toISOString();
const id = randomUUID();
const encrypted = await this.cipher.encrypt(input.plaintextValue);
const scope = input.scope;
const db = this.dbForScope(scope);
const table = tableForScope(scope);
try {
db.prepare(`INSERT INTO ${table} (id, key, value_ciphertext, nonce, description, access_policy, env_exportable, env_export_key, created_at, updated_at, last_read_at, last_read_by) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NULL, NULL)`)
.run(
id,
key,
encrypted.ciphertext,
encrypted.nonce,
input.description ?? null,
input.accessPolicy ?? "auto",
input.envExportable ? 1 : 0,
input.envExportKey ?? null,
now,
now,
);
db.bumpLastModified();
} catch (error) {
if (isSqliteUniqueError(error)) {
throw new SecretsStoreError({ code: "duplicate-key", message: "Secret key already exists" });
}
throw error;
}
const created = this.getSecretMetadata(id, scope)!;
this.emitAudit({ mutationType: "secret:create", scope, secretId: created.id, key: created.key });
return created;
}
async updateSecret(id: string, scope: SecretScope, patch: {
key?: string;
plaintextValue?: string;
description?: string | null;
accessPolicy?: SecretAccessPolicy;
envExportable?: boolean;
envExportKey?: string | null;
}): Promise<SecretRecord> {
const existing = this.getSecretMetadata(id, scope);
if (!existing) {
throw new SecretsStoreError({ code: "not-found", message: "Secret not found" });
}
const updates: string[] = ["updated_at = ?"];
const params: Array<string | number | Buffer | null> = [new Date().toISOString()];
if (patch.key !== undefined) {
const key = patch.key.trim();
if (!key) {
throw new SecretsStoreError({ code: "invalid-key", message: "Secret key is required" });
}
updates.push("key = ?");
params.push(key);
}
if (patch.description !== undefined) {
updates.push("description = ?");
params.push(patch.description ?? null);
}
if (patch.accessPolicy !== undefined) {
if (!isAccessPolicy(patch.accessPolicy)) {
throw new SecretsStoreError({ code: "invalid-policy", message: "Invalid access policy" });
}
updates.push("access_policy = ?");
params.push(patch.accessPolicy);
}
if (patch.envExportable !== undefined) {
updates.push("env_exportable = ?");
params.push(patch.envExportable ? 1 : 0);
}
if (patch.envExportKey !== undefined) {
updates.push("env_export_key = ?");
params.push(patch.envExportKey ?? null);
}
if (patch.plaintextValue !== undefined) {
const encrypted = await this.cipher.encrypt(patch.plaintextValue);
updates.push("value_ciphertext = ?", "nonce = ?");
params.push(encrypted.ciphertext, encrypted.nonce);
}
const db = this.dbForScope(scope);
const table = tableForScope(scope);
try {
params.push(id);
db.prepare(`UPDATE ${table} SET ${updates.join(", ")} WHERE id = ?`).run(...params);
db.bumpLastModified();
} catch (error) {
if (isSqliteUniqueError(error)) {
throw new SecretsStoreError({ code: "duplicate-key", message: "Secret key already exists" });
}
throw error;
}
const updated = this.getSecretMetadata(id, scope)!;
this.emitAudit({ mutationType: "secret:update", scope, secretId: updated.id, key: updated.key });
return updated;
}
deleteSecret(id: string, scope: SecretScope): void {
const existing = this.getSecretMetadata(id, scope);
if (!existing) {
throw new SecretsStoreError({ code: "not-found", message: "Secret not found" });
}
const db = this.dbForScope(scope);
const table = tableForScope(scope);
db.prepare(`DELETE FROM ${table} WHERE id = ?`).run(id);
db.bumpLastModified();
this.emitAudit({ mutationType: "secret:delete", scope, secretId: id, key: existing.key });
}
async revealSecret(
id: string,
scope: SecretScope,
reader: { agentId?: string | null; userId?: string | null },
): Promise<{ key: string; plaintextValue: string }> {
const db = this.dbForScope(scope);
const table = tableForScope(scope);
const row = db.prepare(`SELECT id, key, value_ciphertext, nonce, description, access_policy, env_exportable, env_export_key, created_at, updated_at, last_read_at, last_read_by FROM ${table} WHERE id = ?`).get(id) as SecretCipherRow | undefined;
if (!row) {
throw new SecretsStoreError({ code: "not-found", message: "Secret not found" });
}
let plaintextValue: string;
try {
plaintextValue = await this.cipher.decrypt({ ciphertext: row.value_ciphertext, nonce: row.nonce });
} catch (error) {
if (error instanceof SecretCryptoError && error.code === "decryption-failed") {
throw new SecretsStoreError({ code: "decrypt-failed", message: "Secret decryption failed" });
}
throw new SecretsStoreError({ code: "decrypt-failed", message: "Secret decryption failed" });
}
const now = new Date().toISOString();
const lastReadBy = reader.userId ?? reader.agentId ?? null;
db.prepare(`UPDATE ${table} SET last_read_at = ?, last_read_by = ?, updated_at = ? WHERE id = ?`).run(now, lastReadBy, now, id);
db.bumpLastModified();
this.emitAudit({ mutationType: "secret:read", scope, secretId: id, key: row.key, actor: reader });
return { key: row.key, plaintextValue };
}
}