feat(secrets): add a 0600 file secret backend so production runs on Linux
The production composition hardwired the macOS Keychain (/usr/bin/security), which made Linux deployment impossible. Now: - SecretStore gains a stable provider identity persisted in secret_references.provider; services stop hardcoding 'macos-keychain' - shared reference codec (secret-reference.ts) understands both keychain:// and secret-file:// references - FileSecretStore: single 0600 JSON map under the data root, atomic temp+rename writes, serialized in-process, same validation envelope - production composition picks the backend via MULTI_SIMADMIN_SECRET_BACKEND (default: Keychain on darwin, file store elsewhere) and readiness probes the matching backend
This commit is contained in:
@@ -0,0 +1,78 @@
|
||||
import { stat } from 'node:fs/promises';
|
||||
import { FileSecretStore } from './file-secret-store.js';
|
||||
import { MacOSKeychainSecretStore } from './keychain-secret-store.js';
|
||||
import type { SecretStore } from './secret-store.js';
|
||||
|
||||
export type SecretBackend = 'macos-keychain' | 'secret-file';
|
||||
|
||||
const BACKENDS: readonly SecretBackend[] = ['macos-keychain', 'secret-file'];
|
||||
|
||||
export class SecretBackendError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = 'SecretBackendError';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves the secret backend: explicit MULTI_SIMADMIN_SECRET_BACKEND override,
|
||||
* otherwise the macOS Keychain on darwin and the 0600 file store elsewhere.
|
||||
*/
|
||||
export function resolveSecretBackend(
|
||||
env: Readonly<Record<string, string | undefined>> = process.env,
|
||||
platform: NodeJS.Platform = process.platform,
|
||||
): SecretBackend {
|
||||
const raw = env.MULTI_SIMADMIN_SECRET_BACKEND;
|
||||
if (raw !== undefined) {
|
||||
if (!BACKENDS.includes(raw as SecretBackend))
|
||||
throw new SecretBackendError(
|
||||
'MULTI_SIMADMIN_SECRET_BACKEND must be one of: macos-keychain, secret-file',
|
||||
);
|
||||
return raw as SecretBackend;
|
||||
}
|
||||
return platform === 'darwin' ? 'macos-keychain' : 'secret-file';
|
||||
}
|
||||
|
||||
export interface CreateDefaultSecretStoreOptions {
|
||||
readonly env?: Readonly<Record<string, string | undefined>>;
|
||||
/** Absolute path of the file store map; required for the secret-file backend. */
|
||||
readonly secretFilePath: string;
|
||||
readonly platform?: NodeJS.Platform;
|
||||
}
|
||||
|
||||
export function createDefaultSecretStore(options: CreateDefaultSecretStoreOptions): SecretStore {
|
||||
const platform = options.platform ?? process.platform;
|
||||
const backend = resolveSecretBackend(options.env, platform);
|
||||
if (backend === 'secret-file') return new FileSecretStore(options.secretFilePath);
|
||||
if (platform !== 'darwin')
|
||||
throw new SecretBackendError(
|
||||
'The macos-keychain backend requires /usr/bin/security (macOS only)',
|
||||
);
|
||||
return new MacOSKeychainSecretStore();
|
||||
}
|
||||
|
||||
interface SecretFileMetadata {
|
||||
isFile(): boolean;
|
||||
readonly mode: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Readiness metadata probe for the file backend. Availability only — this must
|
||||
* never read secret material.
|
||||
*/
|
||||
export async function defaultSecretFileMetadataCheck(
|
||||
filePath: string,
|
||||
platform: NodeJS.Platform = process.platform,
|
||||
statFn: (path: string) => Promise<SecretFileMetadata> = stat,
|
||||
): Promise<boolean> {
|
||||
try {
|
||||
const info = await statFn(filePath);
|
||||
if (!info.isFile()) return false;
|
||||
// Windows keeps no POSIX mode bits; NTFS ACLs govern access there.
|
||||
if (platform !== 'win32' && (info.mode & 0o777) !== 0o600) return false;
|
||||
return true;
|
||||
} catch (error) {
|
||||
// A missing map is healthy: secrets are added lazily by instance setup.
|
||||
return (error as NodeJS.ErrnoException).code === 'ENOENT';
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user