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:
chick
2026-09-07 01:32:07 +08:00
parent c57dc81e42
commit e4c4bce75b
26 changed files with 605 additions and 76 deletions
@@ -10,10 +10,9 @@ import type {
SnapshotFreshness,
} from '@multi-simadmin/contracts';
import type { SecretStore } from '../../infrastructure/secrets/secret-store.js';
import { parseKeychainReference } from '../../infrastructure/secrets/keychain-secret-store.js';
import { parseSecretReference } from '../../infrastructure/secrets/secret-reference.js';
const PURPOSE = 'instance-password';
const PROVIDER = 'macos-keychain';
const MAX_TAGS = 50;
const MAX_TAG_LENGTH = 100;
@@ -408,7 +407,7 @@ export class InstanceService {
.prepare(
'INSERT INTO secret_references (id,instance_id,purpose,provider,external_reference,created_at,updated_at) VALUES (?,?,?,?,?,?,?)',
)
.run(id, instanceId, PURPOSE, PROVIDER, external, now, now);
.run(id, instanceId, PURPOSE, this.store.provider, external, now, now);
}
private async storeSecret(
instanceId: string,
@@ -422,7 +421,7 @@ export class InstanceService {
throw new InstanceServiceError('SECRET_STORE_FAILED', 'Could not store instance credential');
}
try {
const parsed = parseKeychainReference(external);
const parsed = parseSecretReference(external);
if (parsed.instanceId !== instanceId || parsed.purpose !== PURPOSE || parsed.slot !== slot)
throw new Error('secret store returned a reference with an invalid binding');
} catch {
@@ -504,7 +503,7 @@ export class InstanceService {
AND secret_cleanup_tasks.purpose=excluded.purpose
AND secret_cleanup_tasks.provider=excluded.provider`,
)
.run(reference, instanceId, PURPOSE, PROVIDER, now, now);
.run(reference, instanceId, PURPOSE, this.store.provider, now, now);
if (queued.changes !== 1)
throw new InstanceServiceError('DATABASE_FAILED', 'Cleanup task could not be persisted');
}
@@ -529,13 +528,13 @@ export class InstanceService {
);
}
private validateCleanupEntry(entry: CleanupEntry): void {
if (entry.purpose !== PURPOSE || entry.provider !== PROVIDER)
if (entry.purpose !== PURPOSE || entry.provider !== this.store.provider)
throw new InstanceServiceError('DATABASE_FAILED', 'Cleanup task is invalid');
this.validateCleanupReference(entry.instanceId, entry.reference);
}
private validateCleanupReference(instanceId: string, reference: string): void {
try {
const parsed = parseKeychainReference(reference);
const parsed = parseSecretReference(reference);
if (
parsed.instanceId !== instanceId ||
parsed.purpose !== PURPOSE ||