diff --git a/dashboard/src/v2/components/settings/__tests__/AutomationCredentialManager.test.tsx b/dashboard/src/v2/components/settings/__tests__/AutomationCredentialManager.test.tsx
index 873c537554..e40c582b8c 100644
--- a/dashboard/src/v2/components/settings/__tests__/AutomationCredentialManager.test.tsx
+++ b/dashboard/src/v2/components/settings/__tests__/AutomationCredentialManager.test.tsx
@@ -10,6 +10,6 @@ vi.mock("../../../lib/automation-credential-api.js",()=>({
}));
describe("AutomationCredentialManager",()=>{
- beforeEach(()=>{vi.clearAllMocks();vi.mocked(fetchAutomationCredentials).mockResolvedValue([{id:"credential-1",name:"Deployment token",kind:"api-token",scope:"project",projectId:"project-1",allowedProjectIds:[],capabilities:["read"],status:"active",configured:true,keyId:"root",keyVersion:1,version:1,lastValidatedAt:null,validationStatus:"untested",createdAt:"now",updatedAt:"now"}]);vi.mocked(fetchCredentialHealth).mockResolvedValue({available:false,secure:false,provider:"electron-safe-storage",keyId:null,keyVersion:null,reason:"OS secure storage is unavailable."});});
+ beforeEach(()=>{vi.clearAllMocks();vi.mocked(fetchAutomationCredentials).mockResolvedValue([{id:"credential-1",name:"Deployment token",kind:"api-token",scope:"project",projectId:"project-1",managementProjectId:"project-1",allowedProjectIds:[],capabilities:["read"],status:"active",configured:true,keyId:"root",keyVersion:1,version:1,lastValidatedAt:null,validationStatus:"untested",createdAt:"now",updatedAt:"now"}]);vi.mocked(fetchCredentialHealth).mockResolvedValue({available:false,secure:false,provider:"electron-safe-storage",keyId:null,keyVersion:null,reason:"OS secure storage is unavailable."});});
it("renders metadata and disables secret writes when secure storage is unavailable",async()=>{render();expect(await screen.findByText("Deployment token")).toBeTruthy();expect(screen.getByRole("alert").textContent).toContain("OS secure storage is unavailable.");await waitFor(()=>expect((screen.getByRole("button",{name:"Store credential"}) as HTMLButtonElement).disabled).toBe(true));expect(document.body.textContent).not.toContain("plain-secret");});
});
diff --git a/docs-web/content/docs/operations-credential-security.mdx b/docs-web/content/docs/operations-credential-security.mdx
index d075c473f7..3c0104b783 100644
--- a/docs-web/content/docs/operations-credential-security.mdx
+++ b/docs-web/content/docs/operations-credential-security.mdx
@@ -5,28 +5,36 @@ Code UX resolves canonical node credential IDs and named project binding keys th
## Scope and policy
- Project credentials are owned by one project.
-- Global credentials require an explicit project allowlist.
+- Global credentials require an explicit project allowlist and retain the configuring project as their management owner. Other allowlisted projects may bind and resolve the credential but cannot mutate it.
- Both the binding and credential must approve the requested capability.
- Revoked, unavailable, missing, cross-project, or insufficiently capable credentials fail closed.
Create, rotate, and replace requests are write-only. API responses contain configuration and status metadata but never stored values.
+Runtime validation bounds names, identifiers, capabilities, list counts, and secret size (64 KiB UTF-8). Malformed arrays and control characters are rejected rather than coerced.
+
## Runtime redaction boundary
Node-flow credentials exist in plaintext only for the active node attempt. Exact resolved values are replaced with `[REDACTED]` before provider responses, HTTP bodies, retry errors, external-effect payloads, diagnostics, invocation messages, attempts, node outputs, or run summaries are stored. Credential IDs and non-secret metadata remain available for auditability.
The same redactor protects provider activity and raw usage telemetry. Temporary credential references are cleared after the attempt and are never logged as redaction input. Custom-node outputs, stderr logs, and diagnostics follow the same rule.
+Authorization is rechecked after decryption. Concurrent revocation, rotation, restriction, promotion, or rebinding clears the plaintext buffer and causes a retry or denial instead of returning stale access.
+
## Encryption and key custody
The SQLite secret store uses AES-256-GCM envelope encryption with a unique data key, payload nonce, and key-wrapping nonce for every write. Credential ownership and workspace context are authenticated. SQLite stores ciphertext, authentication tags, wrapped keys, nonces, and key identifiers/versions—not root keys.
-Headless mode requires `CODE_UX_CREDENTIAL_KEY_FILE` to point to a mounted file containing a base64- or hexadecimal-encoded 32-byte key. Electron uses the OS `safeStorage` boundary. Vault and KMS adapters report explicit health states. If secure key material is unavailable, credential operations fail closed; there is no plaintext fallback.
+Headless mode requires `CODE_UX_CREDENTIAL_KEY_FILE` to point to a regular, owner-only mounted file containing an exact base64 or hexadecimal encoding of a 32-byte key. Electron serializes first-use key creation and atomically persists only the OS-protected blob. Vault and KMS adapters validate key material and report the active key id/version. If secure key material is unavailable, credential operations fail closed; there is no plaintext fallback.
## Recovery and rotation
-Back up root keys separately from `app.db`; the database alone cannot recover credentials. Rotation creates a fresh encrypted envelope, increments the credential version, and records metadata about the transition. Revocation prevents subsequent resolution while retaining audit metadata.
+Back up root keys separately from `app.db`; the database alone cannot recover credentials. Creation, rotation/replacement, and promotion commit ciphertext and metadata atomically. Version compare-and-swap permits only one overlapping value change to commit. Revocation also wins against an in-flight resolution while retaining audit metadata.
+
+Legacy global records use their first valid allowlisted project as the migrated management owner; verify that owner before expanding an old global allowlist.
## Dashboard API
Credential management uses project-scoped dashboard routes. List, health, and mutation responses return metadata only. Secret values are accepted only by create, rotate, and replace operations.
+
+Validation failures return `400`, project/management denials return `403`, and concurrent-write conflicts return `409` for a safe caller retry.
diff --git a/docs-web/operations/credential-security.md b/docs-web/operations/credential-security.md
index d075c473f7..3c0104b783 100644
--- a/docs-web/operations/credential-security.md
+++ b/docs-web/operations/credential-security.md
@@ -5,28 +5,36 @@ Code UX resolves canonical node credential IDs and named project binding keys th
## Scope and policy
- Project credentials are owned by one project.
-- Global credentials require an explicit project allowlist.
+- Global credentials require an explicit project allowlist and retain the configuring project as their management owner. Other allowlisted projects may bind and resolve the credential but cannot mutate it.
- Both the binding and credential must approve the requested capability.
- Revoked, unavailable, missing, cross-project, or insufficiently capable credentials fail closed.
Create, rotate, and replace requests are write-only. API responses contain configuration and status metadata but never stored values.
+Runtime validation bounds names, identifiers, capabilities, list counts, and secret size (64 KiB UTF-8). Malformed arrays and control characters are rejected rather than coerced.
+
## Runtime redaction boundary
Node-flow credentials exist in plaintext only for the active node attempt. Exact resolved values are replaced with `[REDACTED]` before provider responses, HTTP bodies, retry errors, external-effect payloads, diagnostics, invocation messages, attempts, node outputs, or run summaries are stored. Credential IDs and non-secret metadata remain available for auditability.
The same redactor protects provider activity and raw usage telemetry. Temporary credential references are cleared after the attempt and are never logged as redaction input. Custom-node outputs, stderr logs, and diagnostics follow the same rule.
+Authorization is rechecked after decryption. Concurrent revocation, rotation, restriction, promotion, or rebinding clears the plaintext buffer and causes a retry or denial instead of returning stale access.
+
## Encryption and key custody
The SQLite secret store uses AES-256-GCM envelope encryption with a unique data key, payload nonce, and key-wrapping nonce for every write. Credential ownership and workspace context are authenticated. SQLite stores ciphertext, authentication tags, wrapped keys, nonces, and key identifiers/versions—not root keys.
-Headless mode requires `CODE_UX_CREDENTIAL_KEY_FILE` to point to a mounted file containing a base64- or hexadecimal-encoded 32-byte key. Electron uses the OS `safeStorage` boundary. Vault and KMS adapters report explicit health states. If secure key material is unavailable, credential operations fail closed; there is no plaintext fallback.
+Headless mode requires `CODE_UX_CREDENTIAL_KEY_FILE` to point to a regular, owner-only mounted file containing an exact base64 or hexadecimal encoding of a 32-byte key. Electron serializes first-use key creation and atomically persists only the OS-protected blob. Vault and KMS adapters validate key material and report the active key id/version. If secure key material is unavailable, credential operations fail closed; there is no plaintext fallback.
## Recovery and rotation
-Back up root keys separately from `app.db`; the database alone cannot recover credentials. Rotation creates a fresh encrypted envelope, increments the credential version, and records metadata about the transition. Revocation prevents subsequent resolution while retaining audit metadata.
+Back up root keys separately from `app.db`; the database alone cannot recover credentials. Creation, rotation/replacement, and promotion commit ciphertext and metadata atomically. Version compare-and-swap permits only one overlapping value change to commit. Revocation also wins against an in-flight resolution while retaining audit metadata.
+
+Legacy global records use their first valid allowlisted project as the migrated management owner; verify that owner before expanding an old global allowlist.
## Dashboard API
Credential management uses project-scoped dashboard routes. List, health, and mutation responses return metadata only. Secret values are accepted only by create, rotate, and replace operations.
+
+Validation failures return `400`, project/management denials return `403`, and concurrent-write conflicts return `409` for a safe caller retry.
diff --git a/docs/operations/credential-security.md b/docs/operations/credential-security.md
index 07a3431b80..787f9cf673 100644
--- a/docs/operations/credential-security.md
+++ b/docs/operations/credential-security.md
@@ -5,32 +5,40 @@ Code UX stores automation credentials through a broker rather than exposing secr
## Scope and policy
- Project credentials can be managed only through their owning project.
-- Global credentials are opt-in and require an explicit project allowlist containing the configuring project.
+- Global credentials are opt-in and require an explicit project allowlist containing the configuring project. The configuring project remains the credential's management owner after promotion; other allowlisted projects may bind and resolve it but cannot rotate, replace, revoke, promote, or restrict it.
- Resolution succeeds only when both the credential and binding approve the requested capability.
- Revoked, unavailable, missing, cross-project, or insufficiently capable credentials fail closed.
The dashboard accepts secret values only on create, rotate, and replace requests. Responses contain configuration, scope, status, key-version, and validation metadata but never stored values. Access-event rows contain identifiers, binding keys, capabilities, outcomes, and denial reasons; they never contain secret material.
+Management inputs are validated at runtime rather than trusted from TypeScript types. Names, kinds, binding keys, project ids, capabilities, and list counts are bounded; malformed arrays and control characters are rejected instead of being silently coerced. A stored value is limited to 64 KiB of UTF-8 data. Global allowlists must explicitly retain the management owner.
+
## Runtime redaction boundary
Node-flow execution resolves credential values only for the active node attempt. Before any provider response, HTTP body, retry error, external-effect payload, diagnostic, invocation message, attempt, node output, or run summary is persisted, the runtime replaces exact resolved values with `[REDACTED]` in addition to masking secret-shaped keys. Credential IDs and non-secret metadata remain available for audit and attempt correlation.
Provider activity persistence uses the same invocation-scoped redactor, including raw usage telemetry and provider session identifiers. Temporary credential references are cleared after each attempt and are never included in redaction logs or diagnostics. Custom-node containers apply the equivalent policy to structured output, stderr logs, and diagnostics before returning control to the flow runtime.
+Resolution authorization is checked both before and after decryption. If a credential is revoked, rotated, restricted, promoted, or rebound while a read is in flight, the plaintext buffer is cleared and the broker denies or retries against the current version; stale authorization is never returned to the caller.
+
## Encryption and key custody
The SQLite secret store uses AES-256-GCM envelope encryption. Each write generates a unique 256-bit data key, payload nonce, and key-wrapping nonce. Credential ownership and workspace context are authenticated as additional data. SQLite stores only ciphertext, authentication tags, wrapped keys, nonces, and key identifiers/versions.
-Root keys are never stored in SQLite. Headless mode requires `CODE_UX_CREDENTIAL_KEY_FILE` to identify a mounted file whose contents decode from base64 or hexadecimal to exactly 32 bytes. The environment variable contains a path, not key material. Keep the mount readable only by the Code UX process and outside the project workspace.
+Root keys are never stored in SQLite. Headless mode requires `CODE_UX_CREDENTIAL_KEY_FILE` to identify a regular, owner-only mounted file whose contents are an exact base64 or hexadecimal encoding of 32 bytes. Oversized or permissively decodable key files are rejected. The environment variable contains a path, not key material. Keep the mount readable only by the Code UX process and outside the project workspace.
-Electron uses the OS-backed `safeStorage` boundary and refuses credential operations when secure OS storage is unavailable. Vault and KMS adapters expose explicit health states. No provider silently falls back to plaintext or an insecure locally derived key.
+Electron serializes first-use root-key creation, persists only the OS-protected blob through an atomic owner-only file replacement, and refuses credential operations when `safeStorage` is unavailable. Vault and KMS adapters validate 32-byte caller-owned key material and report the active key id/version in health results. No provider silently falls back to plaintext or an insecure locally derived key.
## Recovery and rotation
Back up root keys independently from `app.db`. Losing a required key version makes its ciphertext unrecoverable by design. Restoring only SQLite is insufficient.
-Credential rotation writes a fresh envelope with a new data key and nonces, increments the credential version, and records metadata about the transition. Root-key providers must retain old key IDs and versions until envelopes are rewrapped. Revocation prevents resolution immediately while preserving audit metadata.
+Credential creation commits metadata and its first envelope in one SQLite transaction. Rotation/replacement and promotion likewise commit the new envelope, metadata, version, and rotation record atomically. Compare-and-swap guards allow only one overlapping value change to commit; losing callers must retry instead of overwriting a newer secret. Root-key providers must retain old key IDs and versions until envelopes are rewrapped. Revocation wins against in-flight resolutions and preserves audit metadata.
+
+Existing global credentials created before management ownership was stored are migrated with their first valid allowlisted project as the management owner. Operators should verify that owner before expanding a legacy global credential's allowlist.
## API surface
Project-scoped routes live under `/api/projects/:projectId/credentials`. Supported operations are create, bind, test, rotate, replace, revoke, promote, and restrict. List and health endpoints return metadata only. Existing dashboard authentication and middleware apply before these routes.
+
+Runtime validation failures return `400`, project/management denials return `403`, and compare-and-swap conflicts return `409` so callers can retry without treating policy failures as server crashes.
diff --git a/src/app/lifecycle/dashboard-lifecycle-service.ts b/src/app/lifecycle/dashboard-lifecycle-service.ts
index 0b8dbccf67..11f2b19fdb 100644
--- a/src/app/lifecycle/dashboard-lifecycle-service.ts
+++ b/src/app/lifecycle/dashboard-lifecycle-service.ts
@@ -100,6 +100,7 @@ import type {
LocalMcpSetupInfo,
} from "../../services/local-mcp-cli-config-service.js";
import type { ProjectInitializationStateService } from "../../services/project-initialization-state-service.js";
+import type { CredentialBroker } from "../../services/credentials/credential-broker.js";
const updateCheckerService = new UpdateCheckerService();
@@ -143,6 +144,7 @@ export interface BootDashboardDeps {
speechModelManager: SpeechModelManager;
chatProviderOutboundService?: ChatProviderOutboundService;
nodeFlowService?: NodeFlowService;
+ credentialBroker: CredentialBroker;
headlessAuthService: HeadlessAuthService;
automationAuditService: AutomationAuditExportService;
headlessReadinessService: HeadlessOperationalReadinessService;
@@ -502,6 +504,7 @@ export async function bootDashboard(deps: BootDashboardDeps): Promise {
- try { return await readFile(this.filePath); }
- catch (error) {
+ let handle;
+ try {
+ handle = await open(this.filePath, "r");
+ const info = await handle.stat();
+ if (!info.isFile()) throw new Error("Protected credential key path must resolve to a regular file.");
+ if ((info.mode & 0o077) !== 0) throw new Error("Protected credential key file must use owner-only permissions.");
+ if (info.size > 64 * 1024) throw new Error("Protected credential key file is unexpectedly large.");
+ return await handle.readFile();
+ } catch (error) {
if ((error as NodeJS.ErrnoException).code === "ENOENT") return null;
throw error;
+ } finally {
+ await handle?.close().catch(() => undefined);
}
}
- async write(value: Buffer): Promise {
- await mkdir(dirname(this.filePath), { recursive: true });
- const temporaryPath = `${this.filePath}.tmp`;
- await writeFile(temporaryPath, value, { mode: 0o600 });
- await rename(temporaryPath, this.filePath);
+ async writeIfAbsent(value: Buffer): Promise {
+ await mkdir(dirname(this.filePath), { recursive: true, mode: 0o700 });
+ const temporaryPath = `${this.filePath}.${randomUUID()}.tmp`;
+ try {
+ const handle = await open(temporaryPath, "wx", 0o600);
+ try {
+ await handle.writeFile(value);
+ await handle.sync();
+ } finally {
+ await handle.close();
+ }
+ try {
+ await link(temporaryPath, this.filePath);
+ return true;
+ } catch (error) {
+ if ((error as NodeJS.ErrnoException).code === "EEXIST") return false;
+ throw error;
+ }
+ } catch (error) {
+ throw error;
+ } finally {
+ await unlink(temporaryPath).catch(() => undefined);
+ }
}
}
diff --git a/src/infrastructure/security/electron-safe-storage-key-provider.ts b/src/infrastructure/security/electron-safe-storage-key-provider.ts
index 2efc89aebb..14245002dc 100644
--- a/src/infrastructure/security/electron-safe-storage-key-provider.ts
+++ b/src/infrastructure/security/electron-safe-storage-key-provider.ts
@@ -10,11 +10,13 @@ export interface ElectronSafeStorageBoundary {
export interface ProtectedKeyPersistence {
read(): Promise;
- write(value: Buffer): Promise;
+ /** Atomically installs the value only when no protected key exists yet. */
+ writeIfAbsent(value: Buffer): Promise;
}
export class ElectronSafeStorageKeyProvider implements KeyProvider {
readonly providerName = "electron-safe-storage";
+ private initialization: Promise | null = null;
constructor(private readonly safeStorage: ElectronSafeStorageBoundary, private readonly persistence: ProtectedKeyPersistence, private readonly keyId = "electron-root", private readonly version = 1) {}
async health(): Promise {
@@ -25,13 +27,11 @@ export class ElectronSafeStorageKeyProvider implements KeyProvider {
async getActiveKey(): Promise {
if (!this.safeStorage.isEncryptionAvailable()) throw new KeyProviderUnavailableError("OS secure storage is unavailable.");
- let protectedValue = await this.persistence.read();
- if (!protectedValue) {
- const generated = randomBytes(32);
- try { protectedValue = this.safeStorage.encryptString(generated.toString("base64")); await this.persistence.write(protectedValue); }
- finally { generated.fill(0); }
- }
- const key = Buffer.from(this.safeStorage.decryptString(protectedValue), "base64");
+ await this.ensureInitialized();
+ const protectedValue = await this.persistence.read();
+ if (!protectedValue) throw new KeyProviderUnavailableError("Protected credential root key disappeared after initialization.");
+ const encoded = this.safeStorage.decryptString(protectedValue);
+ const key = /^[a-zA-Z0-9+/]{43}=$/.test(encoded) ? Buffer.from(encoded, "base64") : Buffer.alloc(0);
if (key.length !== 32) { key.fill(0); throw new KeyProviderUnavailableError("Protected credential root key is invalid."); }
return { key, keyId: this.keyId, version: this.version };
}
@@ -40,4 +40,25 @@ export class ElectronSafeStorageKeyProvider implements KeyProvider {
if (keyId !== this.keyId || version !== this.version) throw new KeyProviderUnavailableError("Requested OS-protected key version is unavailable.");
return this.getActiveKey();
}
+
+ private async ensureInitialized(): Promise {
+ if (await this.persistence.read()) return;
+ if (!this.initialization) {
+ this.initialization = this.initialize().finally(() => {
+ this.initialization = null;
+ });
+ }
+ await this.initialization;
+ }
+
+ private async initialize(): Promise {
+ if (await this.persistence.read()) return;
+ const generated = randomBytes(32);
+ try {
+ const protectedValue = this.safeStorage.encryptString(generated.toString("base64"));
+ await this.persistence.writeIfAbsent(protectedValue);
+ } finally {
+ generated.fill(0);
+ }
+ }
}
diff --git a/src/infrastructure/security/encrypted-sqlite-secret-store.ts b/src/infrastructure/security/encrypted-sqlite-secret-store.ts
index 20193715aa..204e94367f 100644
--- a/src/infrastructure/security/encrypted-sqlite-secret-store.ts
+++ b/src/infrastructure/security/encrypted-sqlite-secret-store.ts
@@ -5,11 +5,11 @@ import type { AutomationCredentialRepository } from "../../repositories/automati
export class EncryptedSqliteSecretStore implements SecretStore {
constructor(private readonly repository: AutomationCredentialRepository, private readonly keyProvider: KeyProvider) {}
- async put(context: SecretContext, plaintext: Buffer): Promise {
+ async seal(context: SecretContext, plaintext: Buffer): Promise {
const health = await this.keyProvider.health();
if (!health.available || !health.secure) throw new Error(health.reason ?? "Secure key provider is unavailable.");
const rootKey = await this.keyProvider.getActiveKey();
- try { const envelope=encryptEnvelope(context,plaintext,rootKey); this.repository.putEnvelope(envelope); return envelope; }
+ try { return encryptEnvelope(context, plaintext, rootKey); }
finally { rootKey.key.fill(0); }
}
async get(context: SecretContext): Promise {
@@ -17,5 +17,4 @@ export class EncryptedSqliteSecretStore implements SecretStore {
const rootKey=await this.keyProvider.getKey(envelope.keyId,envelope.keyVersion);
try { return decryptEnvelope(context,envelope,rootKey); } finally { rootKey.key.fill(0); }
}
- async delete(credentialId: string): Promise { this.repository.deleteEnvelope(credentialId); }
}
diff --git a/src/infrastructure/security/external-key-provider-adapters.ts b/src/infrastructure/security/external-key-provider-adapters.ts
index cb12b8658b..f12fa3ed77 100644
--- a/src/infrastructure/security/external-key-provider-adapters.ts
+++ b/src/infrastructure/security/external-key-provider-adapters.ts
@@ -3,7 +3,9 @@ import { KeyProviderUnavailableError, type KeyMaterial, type KeyProvider } from
export interface ExternalKeyServiceClient {
health(): Promise<{ available: boolean; reason?: string }>;
+ /** Returned key bytes are caller-owned and will be zeroed after use. */
activeKey(): Promise;
+ /** Returned key bytes are caller-owned and will be zeroed after use. */
key(keyId: string, version: number): Promise;
}
@@ -11,11 +13,43 @@ export class ExternalKeyProviderAdapter implements KeyProvider {
constructor(readonly providerName: "vault" | "kms", private readonly client?: ExternalKeyServiceClient) {}
async health(): Promise {
if (!this.client) return { available: false, secure: true, provider: this.providerName, keyId: null, keyVersion: null, reason: `${this.providerName} integration is not configured.` };
- const health = await this.client.health();
- return { available: health.available, secure: true, provider: this.providerName, keyId: null, keyVersion: null, reason: health.reason };
+ try {
+ const health = await this.client.health();
+ if (!health.available) return { available: false, secure: true, provider: this.providerName, keyId: null, keyVersion: null, reason: health.reason };
+ const material = this.validate(await this.client.activeKey());
+ try {
+ return { available: true, secure: true, provider: this.providerName, keyId: material.keyId, keyVersion: material.version };
+ } finally {
+ material.key.fill(0);
+ }
+ } catch (error) {
+ return { available: false, secure: true, provider: this.providerName, keyId: null, keyVersion: null, reason: error instanceof Error ? error.message : String(error) };
+ }
+ }
+ async getActiveKey(): Promise {
+ if (!this.client) throw new KeyProviderUnavailableError(`${this.providerName} integration is unavailable.`);
+ return this.validate(await this.client.activeKey());
+ }
+ async getKey(keyId: string, version: number): Promise {
+ if (!this.client) throw new KeyProviderUnavailableError(`${this.providerName} integration is unavailable.`);
+ return this.validate(await this.client.key(keyId, version), keyId, version);
+ }
+
+ private validate(material: KeyMaterial | null | undefined, expectedKeyId?: string, expectedVersion?: number): KeyMaterial {
+ if (!material || !Buffer.isBuffer(material.key) || material.key.length !== 32) {
+ material?.key?.fill?.(0);
+ throw new KeyProviderUnavailableError(`${this.providerName} returned invalid root-key material.`);
+ }
+ if (!material.keyId || !Number.isSafeInteger(material.version) || material.version < 1) {
+ material.key.fill(0);
+ throw new KeyProviderUnavailableError(`${this.providerName} returned invalid root-key metadata.`);
+ }
+ if ((expectedKeyId !== undefined && material.keyId !== expectedKeyId) || (expectedVersion !== undefined && material.version !== expectedVersion)) {
+ material.key.fill(0);
+ throw new KeyProviderUnavailableError(`${this.providerName} returned a different root-key version than requested.`);
+ }
+ return material;
}
- getActiveKey(): Promise { if (!this.client) throw new KeyProviderUnavailableError(`${this.providerName} integration is unavailable.`); return this.client.activeKey(); }
- getKey(keyId: string, version: number): Promise { if (!this.client) throw new KeyProviderUnavailableError(`${this.providerName} integration is unavailable.`); return this.client.key(keyId, version); }
}
export class VaultKeyProviderAdapter extends ExternalKeyProviderAdapter { constructor(client?: ExternalKeyServiceClient) { super("vault", client); } }
diff --git a/src/infrastructure/security/mounted-key-file-provider.ts b/src/infrastructure/security/mounted-key-file-provider.ts
index d7d1061b35..26dc4ef221 100644
--- a/src/infrastructure/security/mounted-key-file-provider.ts
+++ b/src/infrastructure/security/mounted-key-file-provider.ts
@@ -1,4 +1,4 @@
-import { readFile, stat } from "node:fs/promises";
+import { open } from "node:fs/promises";
import type { CredentialBackendHealth } from "../../contracts/automation-credential-types.js";
import { KeyProviderUnavailableError, type KeyMaterial, type KeyProvider } from "../../services/credentials/key-provider.js";
@@ -25,13 +25,30 @@ export class MountedKeyFileProvider implements KeyProvider {
private async read(): Promise {
if (!this.filePath) throw new KeyProviderUnavailableError("No mounted credential key file is configured.");
- try { const info=await stat(this.filePath); if ((info.mode & 0o077) !== 0) throw new KeyProviderUnavailableError("Mounted credential key file has insecure permissions; expected owner-only access."); }
- catch (error) { if (error instanceof KeyProviderUnavailableError) throw error; throw new KeyProviderUnavailableError("Mounted credential key file is unavailable."); }
+ let handle;
+ try {
+ handle = await open(this.filePath, "r");
+ const info = await handle.stat();
+ if (!info.isFile()) throw new KeyProviderUnavailableError("Mounted credential key path must resolve to a regular file.");
+ if ((info.mode & 0o077) !== 0) throw new KeyProviderUnavailableError("Mounted credential key file has insecure permissions; expected owner-only access.");
+ if (info.size > 256) throw new KeyProviderUnavailableError("Mounted credential key file is unexpectedly large.");
+ }
+ catch (error) {
+ await handle?.close().catch(() => undefined);
+ if (error instanceof KeyProviderUnavailableError) throw error;
+ throw new KeyProviderUnavailableError("Mounted credential key file is unavailable.");
+ }
let raw: Buffer;
- try { raw = await readFile(this.filePath); } catch { throw new KeyProviderUnavailableError("Mounted credential key file is unavailable."); }
+ try { raw = await handle.readFile(); }
+ catch { throw new KeyProviderUnavailableError("Mounted credential key file is unavailable."); }
+ finally { await handle.close().catch(() => undefined); }
const trimmed = raw.toString("utf8").trim();
raw.fill(0);
- const key = /^[a-f\d]{64}$/i.test(trimmed) ? Buffer.from(trimmed, "hex") : Buffer.from(trimmed, "base64");
+ const key = /^[a-f\d]{64}$/i.test(trimmed)
+ ? Buffer.from(trimmed, "hex")
+ : /^[a-zA-Z0-9+/]{43}=?$/.test(trimmed)
+ ? Buffer.from(trimmed, "base64")
+ : Buffer.alloc(0);
if (key.length !== 32) { key.fill(0); throw new KeyProviderUnavailableError("Mounted credential key must decode to exactly 32 bytes."); }
return { key, keyId: this.keyId, version: this.version };
}
diff --git a/src/repositories/automation-credential-repository.ts b/src/repositories/automation-credential-repository.ts
index 1048e8f4f7..1fceeb5acc 100644
--- a/src/repositories/automation-credential-repository.ts
+++ b/src/repositories/automation-credential-repository.ts
@@ -5,10 +5,17 @@ import type { DatabaseAdapter } from "./db/database-adapter.js";
import { EntityNotFoundError, ValidationError, toNumber } from "./repository-utils.js";
import type { StoredSecretEnvelope } from "../services/credentials/secret-store.js";
-interface CredentialRow { id: string; name: string; kind: string; scope: AutomationCredentialScope; project_id: string | null; allowed_project_ids_json: string; capabilities_json: string; status: AutomationCredentialStatus; key_id: string; key_version: number; version: number; last_validated_at: string | null; validation_status: AutomationCredentialMetadata["validationStatus"]; created_at: string; updated_at: string; configured?: number }
+interface CredentialRow { id: string; name: string; kind: string; scope: AutomationCredentialScope; project_id: string | null; management_project_id: string | null; allowed_project_ids_json: string; capabilities_json: string; status: AutomationCredentialStatus; key_id: string; key_version: number; version: number; last_validated_at: string | null; validation_status: AutomationCredentialMetadata["validationStatus"]; created_at: string; updated_at: string; configured?: number }
interface SecretRow { credential_id: string; ciphertext: Buffer; nonce: Buffer; auth_tag: Buffer; wrapped_data_key: Buffer; wrap_nonce: Buffer; wrap_auth_tag: Buffer; key_id: string; key_version: number }
interface BindingRow { id: string; credential_id: string; project_id: string; binding_key: string; required_capabilities_json: string; created_at: string; updated_at: string }
+export class CredentialConcurrentModificationError extends Error {
+ constructor(message: string) {
+ super(message);
+ this.name = "CredentialConcurrentModificationError";
+ }
+}
+
export class AutomationCredentialRepository {
private readonly db: DatabaseAdapter;
constructor(storage: AppDbStorage = new AppDbStorage()) { this.db = storage.getDatabase(); }
@@ -33,20 +40,53 @@ export class AutomationCredentialRepository {
return row ? this.mapCredential(row) : null;
}
- create(input: { id?: string; name: string; kind: string; scope: AutomationCredentialScope; projectId: string | null; allowedProjectIds: string[]; capabilities: string[]; keyId: string; keyVersion: number }): AutomationCredentialMetadata {
+ private create(input: { id?: string; name: string; kind: string; scope: AutomationCredentialScope; projectId: string | null; managementProjectId: string; allowedProjectIds: string[]; capabilities: string[]; keyId: string; keyVersion: number }): AutomationCredentialMetadata {
if (input.scope === "project" && !input.projectId) throw new ValidationError("Project credentials require a projectId.");
if (input.scope === "global" && input.projectId) throw new ValidationError("Global credentials cannot have an owning projectId.");
if (input.projectId) this.requireProject(input.projectId);
+ this.requireProject(input.managementProjectId);
for (const projectId of input.allowedProjectIds) this.requireProject(projectId);
const id = input.id ?? randomUUID(); const now = new Date().toISOString();
- this.db.prepare(`INSERT INTO automation_credentials (id,name,kind,scope,project_id,allowed_project_ids_json,capabilities_json,status,key_id,key_version,version,created_at,updated_at) VALUES (?,?,?,?,?,?,?,'active',?,?,1,?,?)`).run(id, input.name, input.kind, input.scope, input.projectId, JSON.stringify(input.allowedProjectIds), JSON.stringify(input.capabilities), input.keyId, input.keyVersion, now, now);
+ this.db.prepare(`INSERT INTO automation_credentials (id,name,kind,scope,project_id,management_project_id,allowed_project_ids_json,capabilities_json,status,key_id,key_version,version,created_at,updated_at) VALUES (?,?,?,?,?,?,?,?,'active',?,?,1,?,?)`).run(id, input.name, input.kind, input.scope, input.projectId, input.managementProjectId, JSON.stringify(input.allowedProjectIds), JSON.stringify(input.capabilities), input.keyId, input.keyVersion, now, now);
return this.get(id)!;
}
- updateSecretMetadata(id: string, keyId: string, keyVersion: number, version: number): AutomationCredentialMetadata {
- const now = new Date().toISOString();
- this.db.prepare("UPDATE automation_credentials SET key_id=?, key_version=?, version=?, status='active', validation_status='untested', last_validated_at=NULL, updated_at=? WHERE id=?").run(keyId, keyVersion, version, now, id);
- const result = this.get(id); if (!result) throw new EntityNotFoundError(`Credential not found: ${id}`); return result;
+ createWithEnvelope(input: { id: string; name: string; kind: string; scope: AutomationCredentialScope; projectId: string | null; managementProjectId: string; allowedProjectIds: string[]; capabilities: string[] }, envelope: StoredSecretEnvelope): AutomationCredentialMetadata {
+ if (envelope.credentialId !== input.id) throw new ValidationError("Credential envelope id does not match its metadata.");
+ return this.db.transaction(() => {
+ const created = this.create({ ...input, keyId: envelope.keyId, keyVersion: envelope.keyVersion });
+ this.putEnvelope(envelope);
+ return { ...created, configured: true };
+ });
+ }
+
+ replaceEnvelope(input: { credentialId: string; expectedVersion: number; expectedStatus: AutomationCredentialStatus; envelope: StoredSecretEnvelope; recordRotation: boolean }): AutomationCredentialMetadata {
+ if (input.envelope.credentialId !== input.credentialId) throw new ValidationError("Credential envelope id does not match its metadata.");
+ return this.db.transaction(() => {
+ const now = new Date().toISOString();
+ const nextVersion = input.expectedVersion + 1;
+ const update = this.db.prepare("UPDATE automation_credentials SET key_id=?, key_version=?, version=?, status='active', validation_status='untested', last_validated_at=NULL, updated_at=? WHERE id=? AND version=? AND status=?").run(
+ input.envelope.keyId,
+ input.envelope.keyVersion,
+ nextVersion,
+ now,
+ input.credentialId,
+ input.expectedVersion,
+ input.expectedStatus,
+ );
+ if (update.changes !== 1) throw new CredentialConcurrentModificationError("Credential changed while its value was being replaced; retry the operation.");
+ this.putEnvelope(input.envelope);
+ if (input.recordRotation) {
+ this.recordRotation({
+ credentialId: input.credentialId,
+ fromVersion: input.expectedVersion,
+ toVersion: nextVersion,
+ keyId: input.envelope.keyId,
+ keyVersion: input.envelope.keyVersion,
+ });
+ }
+ return this.get(input.credentialId)!;
+ });
}
updateStatus(id: string, status: AutomationCredentialStatus): AutomationCredentialMetadata {
@@ -66,18 +106,31 @@ export class AutomationCredentialRepository {
return this.get(id)!;
}
- promote(id: string, allowedProjectIds: string[]): AutomationCredentialMetadata {
- const credential = this.get(id); if (!credential) throw new EntityNotFoundError(`Credential not found: ${id}`);
- for (const projectId of allowedProjectIds) this.requireProject(projectId);
- this.db.prepare("UPDATE automation_credentials SET scope='global', project_id=NULL, allowed_project_ids_json=?, updated_at=? WHERE id=?").run(JSON.stringify(allowedProjectIds), new Date().toISOString(), id);
- return this.get(id)!;
+ promoteWithEnvelope(input: { credentialId: string; managementProjectId: string; expectedVersion: number; expectedStatus: AutomationCredentialStatus; allowedProjectIds: string[]; envelope: StoredSecretEnvelope }): AutomationCredentialMetadata {
+ if (input.envelope.credentialId !== input.credentialId) throw new ValidationError("Credential envelope id does not match its metadata.");
+ for (const projectId of input.allowedProjectIds) this.requireProject(projectId);
+ return this.db.transaction(() => {
+ const update = this.db.prepare("UPDATE automation_credentials SET scope='global', project_id=NULL, allowed_project_ids_json=?, key_id=?, key_version=?, updated_at=? WHERE id=? AND scope='project' AND project_id=? AND management_project_id=? AND version=? AND status=?").run(
+ JSON.stringify(input.allowedProjectIds),
+ input.envelope.keyId,
+ input.envelope.keyVersion,
+ new Date().toISOString(),
+ input.credentialId,
+ input.managementProjectId,
+ input.managementProjectId,
+ input.expectedVersion,
+ input.expectedStatus,
+ );
+ if (update.changes !== 1) throw new CredentialConcurrentModificationError("Credential changed while it was being promoted; retry the operation.");
+ this.putEnvelope(input.envelope);
+ return this.get(input.credentialId)!;
+ });
}
- putEnvelope(envelope: StoredSecretEnvelope): void {
+ private putEnvelope(envelope: StoredSecretEnvelope): void {
this.db.prepare(`INSERT INTO automation_credential_secrets (credential_id,ciphertext,nonce,auth_tag,wrapped_data_key,wrap_nonce,wrap_auth_tag,key_id,key_version,updated_at) VALUES (?,?,?,?,?,?,?,?,?,?) ON CONFLICT(credential_id) DO UPDATE SET ciphertext=excluded.ciphertext,nonce=excluded.nonce,auth_tag=excluded.auth_tag,wrapped_data_key=excluded.wrapped_data_key,wrap_nonce=excluded.wrap_nonce,wrap_auth_tag=excluded.wrap_auth_tag,key_id=excluded.key_id,key_version=excluded.key_version,updated_at=excluded.updated_at`).run(envelope.credentialId,envelope.ciphertext,envelope.nonce,envelope.authTag,envelope.wrappedDataKey,envelope.wrapNonce,envelope.wrapAuthTag,envelope.keyId,envelope.keyVersion,new Date().toISOString());
}
getEnvelope(credentialId: string): StoredSecretEnvelope | null { const row=this.db.prepare("SELECT * FROM automation_credential_secrets WHERE credential_id=?").get(credentialId) as SecretRow|undefined; return row ? {credentialId:row.credential_id,ciphertext:row.ciphertext,nonce:row.nonce,authTag:row.auth_tag,wrappedDataKey:row.wrapped_data_key,wrapNonce:row.wrap_nonce,wrapAuthTag:row.wrap_auth_tag,keyId:row.key_id,keyVersion:toNumber(row.key_version)} : null; }
- deleteEnvelope(id: string): void { this.db.prepare("DELETE FROM automation_credential_secrets WHERE credential_id=?").run(id); }
bind(credentialId: string, projectId: string, bindingKey: string, requiredCapabilities: string[]): AutomationCredentialBinding {
this.requireProject(projectId); const now=new Date().toISOString(); const id=randomUUID();
@@ -88,6 +141,6 @@ export class AutomationCredentialRepository {
recordAccess(input: Omit): void { this.db.prepare(`INSERT INTO automation_credential_access_events (id,credential_id,project_id,binding_key,capability,operation,outcome,reason,created_at) VALUES (?,?,?,?,?,?,?,?,?)`).run(randomUUID(),input.credentialId,input.projectId,input.bindingKey,input.capability,input.operation,input.outcome,input.reason,new Date().toISOString()); }
recordRotation(input: Omit): void { this.db.prepare(`INSERT INTO automation_credential_rotations (id,credential_id,from_version,to_version,key_id,key_version,rotated_at) VALUES (?,?,?,?,?,?,?)`).run(randomUUID(),input.credentialId,input.fromVersion,input.toVersion,input.keyId,input.keyVersion,new Date().toISOString()); }
- private mapCredential(row: CredentialRow): AutomationCredentialMetadata { return {id:row.id,name:row.name,kind:row.kind,scope:row.scope,projectId:row.project_id,allowedProjectIds:JSON.parse(row.allowed_project_ids_json) as string[],capabilities:JSON.parse(row.capabilities_json) as string[],status:row.status,configured:toNumber(row.configured)===1,keyId:row.key_id,keyVersion:toNumber(row.key_version),version:toNumber(row.version),lastValidatedAt:row.last_validated_at,validationStatus:row.validation_status,createdAt:row.created_at,updatedAt:row.updated_at}; }
+ private mapCredential(row: CredentialRow): AutomationCredentialMetadata { return {id:row.id,name:row.name,kind:row.kind,scope:row.scope,projectId:row.project_id,managementProjectId:row.management_project_id,allowedProjectIds:JSON.parse(row.allowed_project_ids_json) as string[],capabilities:JSON.parse(row.capabilities_json) as string[],status:row.status,configured:toNumber(row.configured)===1,keyId:row.key_id,keyVersion:toNumber(row.key_version),version:toNumber(row.version),lastValidatedAt:row.last_validated_at,validationStatus:row.validation_status,createdAt:row.created_at,updatedAt:row.updated_at}; }
private mapBinding(row: BindingRow): AutomationCredentialBinding { return {id:row.id,credentialId:row.credential_id,projectId:row.project_id,bindingKey:row.binding_key,requiredCapabilities:JSON.parse(row.required_capabilities_json) as string[],createdAt:row.created_at,updatedAt:row.updated_at}; }
}
diff --git a/src/repositories/db/app-db-migrations.ts b/src/repositories/db/app-db-migrations.ts
index e4508fcd33..026edafe55 100644
--- a/src/repositories/db/app-db-migrations.ts
+++ b/src/repositories/db/app-db-migrations.ts
@@ -565,6 +565,7 @@ export function ensureAutomationCredentialTables(db: DatabaseAdapter): void {
kind TEXT NOT NULL,
scope TEXT NOT NULL CHECK (scope IN ('project', 'global')),
project_id TEXT,
+ management_project_id TEXT NOT NULL,
allowed_project_ids_json TEXT NOT NULL DEFAULT '[]',
capabilities_json TEXT NOT NULL DEFAULT '[]',
status TEXT NOT NULL DEFAULT 'active',
@@ -576,9 +577,32 @@ export function ensureAutomationCredentialTables(db: DatabaseAdapter): void {
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
FOREIGN KEY (project_id) REFERENCES projects(id) ON DELETE CASCADE,
+ FOREIGN KEY (management_project_id) REFERENCES projects(id) ON DELETE CASCADE,
CHECK ((scope = 'project' AND project_id IS NOT NULL) OR (scope = 'global' AND project_id IS NULL))
)
`);
+ ensureColumn(db, "automation_credentials", "management_project_id", "TEXT");
+ db.exec(`
+ UPDATE automation_credentials
+ SET management_project_id = CASE
+ WHEN project_id IS NOT NULL THEN project_id
+ ELSE (
+ SELECT projects.id
+ FROM json_each(automation_credentials.allowed_project_ids_json)
+ JOIN projects ON projects.id = json_each.value
+ ORDER BY CAST(json_each.key AS INTEGER)
+ LIMIT 1
+ )
+ END
+ WHERE management_project_id IS NULL
+ `);
+ db.exec(`
+ CREATE TRIGGER IF NOT EXISTS delete_automation_credentials_for_management_project
+ AFTER DELETE ON projects
+ BEGIN
+ DELETE FROM automation_credentials WHERE management_project_id = OLD.id;
+ END
+ `);
db.exec(`
CREATE TABLE IF NOT EXISTS automation_credential_secrets (
credential_id TEXT PRIMARY KEY,
diff --git a/src/server/automation-credential-routes.ts b/src/server/automation-credential-routes.ts
index 3eb0cf3b7c..2a14d3c5aa 100644
--- a/src/server/automation-credential-routes.ts
+++ b/src/server/automation-credential-routes.ts
@@ -5,17 +5,16 @@ import { requireTrimmedString } from "./request-parsers.js";
import type { CreateAutomationCredentialInput } from "../contracts/automation-credential-types.js";
function broker(deps: DashboardDependencies) { if (!deps.credentialBroker) throw new Error("Credential broker is not enabled."); return deps.credentialBroker; }
-const strings=(value:unknown):string[]=>Array.isArray(value)?value.filter((item):item is string=>typeof item === "string"&&item.trim().length>0).map((item)=>item.trim()):[];
export function registerAutomationCredentialRoutes(app: Express,deps:DashboardDependencies):void{
app.get("/api/credentials/health",asyncRoute(async(_req,res)=>{res.json(await broker(deps).health());}));
app.get("/api/projects/:projectId/credentials",asyncRoute(async(req,res)=>{res.json(broker(deps).list(requireTrimmedString(req.params.projectId,"projectId")));}));
app.post("/api/projects/:projectId/credentials",asyncRoute(async(req,res)=>{res.status(201).json(await broker(deps).create(requireTrimmedString(req.params.projectId,"projectId"),req.body as CreateAutomationCredentialInput));}));
- app.post("/api/projects/:projectId/credentials/:credentialId/bind",asyncRoute(async(req,res)=>{const body=req.body as Record;res.json(broker(deps).bind(requireTrimmedString(req.params.projectId,"projectId"),requireTrimmedString(req.params.credentialId,"credentialId"),requireTrimmedString(body.bindingKey,"bindingKey"),strings(body.capabilities)));}));
+ app.post("/api/projects/:projectId/credentials/:credentialId/bind",asyncRoute(async(req,res)=>{const body=req.body as Record;res.json(broker(deps).bind(requireTrimmedString(req.params.projectId,"projectId"),requireTrimmedString(req.params.credentialId,"credentialId"),requireTrimmedString(body.bindingKey,"bindingKey"),body.capabilities));}));
app.post("/api/projects/:projectId/credentials/:credentialId/test",asyncRoute(async(req,res)=>{res.json(await broker(deps).test(requireTrimmedString(req.params.projectId,"projectId"),requireTrimmedString(req.params.credentialId,"credentialId")));}));
app.post("/api/projects/:projectId/credentials/:credentialId/rotate",asyncRoute(async(req,res)=>{res.json(await broker(deps).rotate(requireTrimmedString(req.params.projectId,"projectId"),requireTrimmedString(req.params.credentialId,"credentialId"),requireTrimmedString((req.body as Record).value,"value")));}));
app.post("/api/projects/:projectId/credentials/:credentialId/replace",asyncRoute(async(req,res)=>{res.json(await broker(deps).replace(requireTrimmedString(req.params.projectId,"projectId"),requireTrimmedString(req.params.credentialId,"credentialId"),requireTrimmedString((req.body as Record).value,"value")));}));
app.post("/api/projects/:projectId/credentials/:credentialId/revoke",asyncRoute(async(req,res)=>{res.json(broker(deps).revoke(requireTrimmedString(req.params.projectId,"projectId"),requireTrimmedString(req.params.credentialId,"credentialId")));}));
- app.post("/api/projects/:projectId/credentials/:credentialId/promote",asyncRoute(async(req,res)=>{res.json(await broker(deps).promote(requireTrimmedString(req.params.projectId,"projectId"),requireTrimmedString(req.params.credentialId,"credentialId"),strings((req.body as Record).allowedProjectIds)));}));
- app.post("/api/projects/:projectId/credentials/:credentialId/restrict",asyncRoute(async(req,res)=>{const body=req.body as Record;res.json(broker(deps).restrict(requireTrimmedString(req.params.projectId,"projectId"),requireTrimmedString(req.params.credentialId,"credentialId"),strings(body.allowedProjectIds),strings(body.capabilities)));}));
+ app.post("/api/projects/:projectId/credentials/:credentialId/promote",asyncRoute(async(req,res)=>{res.json(await broker(deps).promote(requireTrimmedString(req.params.projectId,"projectId"),requireTrimmedString(req.params.credentialId,"credentialId"),(req.body as Record).allowedProjectIds));}));
+ app.post("/api/projects/:projectId/credentials/:credentialId/restrict",asyncRoute(async(req,res)=>{const body=req.body as Record;res.json(broker(deps).restrict(requireTrimmedString(req.params.projectId,"projectId"),requireTrimmedString(req.params.credentialId,"credentialId"),body.allowedProjectIds,body.capabilities));}));
}
diff --git a/src/server/code-ux-server.ts b/src/server/code-ux-server.ts
index a74226faa4..0ede0da2c5 100644
--- a/src/server/code-ux-server.ts
+++ b/src/server/code-ux-server.ts
@@ -104,6 +104,7 @@ import type { HeadlessAuthService } from "../services/headless-auth-service.js";
import type { AutomationAuditExportService } from "../services/automation-audit-export-service.js";
import type { HeadlessOperationalReadinessService } from "../services/headless-operational-readiness-service.js";
import type { AutomationSloService } from "../services/automation-slo-service.js";
+import type { CredentialBroker } from "../services/credentials/credential-broker.js";
import { ProjectInitializationStateService } from "../services/project-initialization-state-service.js";
function detectMergeConflictMessage(message: string | null | undefined): boolean {
@@ -234,6 +235,7 @@ export class CodeUxServer {
private readonly mcpApprovalTracker = new McpApprovalTracker();
private readonly localMcpCliConfigService = new LocalMcpCliConfigService();
private readonly headlessAuthService: HeadlessAuthService;
+ private readonly credentialBroker: CredentialBroker;
private readonly automationAuditService: AutomationAuditExportService;
private readonly headlessReadinessService: HeadlessOperationalReadinessService;
private readonly automationSloService: AutomationSloService;
@@ -286,6 +288,7 @@ export class CodeUxServer {
this.cliWorkflowService = deps.cliWorkflowService;
this.managementToolHandler = deps.managementToolHandler;
this.headlessAuthService = deps.headlessAuthService;
+ this.credentialBroker = deps.credentialBroker;
this.automationAuditService = deps.automationAuditService;
this.headlessReadinessService = deps.headlessReadinessService;
this.automationSloService = deps.automationSloService;
@@ -1446,6 +1449,7 @@ export class CodeUxServer {
projectSetupService: this.projectSetupService,
schedulerService: this.schedulerService,
nodeFlowService: this.nodeFlowService,
+ credentialBroker: this.credentialBroker,
headlessAuthService: this.headlessAuthService,
automationAuditService: this.automationAuditService,
headlessReadinessService: this.headlessReadinessService,
diff --git a/src/server/http-errors.ts b/src/server/http-errors.ts
index a6be7a0ef1..39f43d625a 100644
--- a/src/server/http-errors.ts
+++ b/src/server/http-errors.ts
@@ -26,6 +26,16 @@ export function toHttpRouteError(error: unknown): HttpRouteError {
return new HttpRouteError(404, msg);
}
+ if (error && typeof error === "object" && "name" in error && error.name === "CredentialAccessDeniedError") {
+ const msg = "message" in error && typeof error.message === "string" ? error.message : "Credential access denied";
+ return new HttpRouteError(403, msg);
+ }
+
+ if (error && typeof error === "object" && "name" in error && error.name === "CredentialConcurrentModificationError") {
+ const msg = "message" in error && typeof error.message === "string" ? error.message : "Credential changed concurrently";
+ return new HttpRouteError(409, msg);
+ }
+
if (error && typeof error === "object" && "name" in error && error.name === "ProviderRoutingError") {
const msg = "message" in (error as any) ? (error as any).message : "Provider routing failed";
return new HttpRouteError(409, msg);
diff --git a/src/services/credentials/credential-broker.ts b/src/services/credentials/credential-broker.ts
index 1614c0640b..bc04930a40 100644
--- a/src/services/credentials/credential-broker.ts
+++ b/src/services/credentials/credential-broker.ts
@@ -1,91 +1,372 @@
import { randomUUID } from "node:crypto";
-import type { AutomationCredentialMetadata, CreateAutomationCredentialInput, CredentialBackendHealth, CredentialResolutionRequest, ResolvedCredential } from "../../contracts/automation-credential-types.js";
+import type {
+ AutomationCredentialBinding,
+ AutomationCredentialMetadata,
+ AutomationCredentialStatus,
+ CreateAutomationCredentialInput,
+ CredentialBackendHealth,
+ CredentialResolutionRequest,
+ ResolvedCredential,
+} from "../../contracts/automation-credential-types.js";
import type { AutomationCredentialRepository } from "../../repositories/automation-credential-repository.js";
-import type { KeyProvider } from "./key-provider.js";
-import type { SecretStore } from "./secret-store.js";
+import { ValidationError } from "../../repositories/repository-utils.js";
import type { AutomationAuditExportService } from "../automation-audit-export-service.js";
+import type { KeyProvider } from "./key-provider.js";
+import type { SecretContext, SecretStore } from "./secret-store.js";
+
+const MAX_NAME_LENGTH = 128;
+const MAX_KIND_LENGTH = 128;
+const MAX_IDENTIFIER_LENGTH = 256;
+const MAX_CAPABILITY_LENGTH = 128;
+const MAX_LIST_ITEMS = 128;
+const MAX_SECRET_BYTES = 64 * 1024;
+const MAX_RESOLUTION_RETRIES = 3;
+const CONTROL_CHARACTERS = /[\u0000-\u001f\u007f]/;
+const CREDENTIAL_KIND = /^[a-zA-Z0-9][a-zA-Z0-9._:-]*$/;
export class CredentialAccessDeniedError extends Error {
- constructor(message: string) { super(message); this.name = "CredentialAccessDeniedError"; }
+ constructor(message: string) {
+ super(message);
+ this.name = "CredentialAccessDeniedError";
+ }
+}
+
+function boundedString(value: unknown, label: string, maxLength: number): string {
+ if (typeof value !== "string") throw new ValidationError(`${label} must be a string.`);
+ const normalized = value.trim();
+ if (!normalized) throw new ValidationError(`${label} is required.`);
+ if (normalized.length > maxLength) throw new ValidationError(`${label} must be at most ${maxLength} characters.`);
+ if (CONTROL_CHARACTERS.test(normalized)) throw new ValidationError(`${label} cannot contain control characters.`);
+ return normalized;
+}
+
+function boundedList(value: unknown, label: string, itemMaxLength: number): string[] {
+ if (value === undefined) return [];
+ if (!Array.isArray(value)) throw new ValidationError(`${label} must be an array of strings.`);
+ if (value.length > MAX_LIST_ITEMS) throw new ValidationError(`${label} cannot contain more than ${MAX_LIST_ITEMS} entries.`);
+ const normalized = value.map((item) => boundedString(item, `${label} entry`, itemMaxLength));
+ return [...new Set(normalized)];
+}
+
+function secretValue(value: unknown, label = "value"): string {
+ if (typeof value !== "string" || value.length === 0) throw new ValidationError(`A non-empty ${label} is required.`);
+ if (Buffer.byteLength(value, "utf8") > MAX_SECRET_BYTES) throw new ValidationError(`${label} must be at most ${MAX_SECRET_BYTES} UTF-8 bytes.`);
+ return value;
+}
+
+function sameCredentialSnapshot(left: AutomationCredentialMetadata, right: AutomationCredentialMetadata): boolean {
+ return left.id === right.id
+ && left.version === right.version
+ && left.status === right.status
+ && left.scope === right.scope
+ && left.projectId === right.projectId
+ && left.managementProjectId === right.managementProjectId
+ && left.allowedProjectIds.length === right.allowedProjectIds.length
+ && left.allowedProjectIds.every((projectId, index) => projectId === right.allowedProjectIds[index])
+ && left.capabilities.length === right.capabilities.length
+ && left.capabilities.every((capability, index) => capability === right.capabilities[index]);
}
export class CredentialBroker {
- constructor(private readonly repository: AutomationCredentialRepository, private readonly secretStore: SecretStore, private readonly keyProvider: KeyProvider, private readonly auditService?: AutomationAuditExportService) {}
+ constructor(
+ private readonly repository: AutomationCredentialRepository,
+ private readonly secretStore: SecretStore,
+ private readonly keyProvider: KeyProvider,
+ private readonly auditService?: AutomationAuditExportService,
+ ) {}
- health(): Promise { return this.keyProvider.health(); }
- list(projectId: string): AutomationCredentialMetadata[] { return this.repository.list(projectId); }
+ health(): Promise {
+ return this.keyProvider.health();
+ }
+
+ list(projectId: string): AutomationCredentialMetadata[] {
+ return this.repository.list(boundedString(projectId, "projectId", MAX_IDENTIFIER_LENGTH));
+ }
- async create(projectId: string, input: CreateAutomationCredentialInput): Promise {
+ async create(projectIdValue: string, input: CreateAutomationCredentialInput): Promise {
+ const projectId = boundedString(projectIdValue, "projectId", MAX_IDENTIFIER_LENGTH);
this.repository.requireProject(projectId);
- const name=input.name?.trim(); const kind=input.kind?.trim(); const value=input.value;
- if (!name || !kind || typeof value !== "string" || value.length === 0) throw new Error("name, kind, and a non-empty value are required.");
- const scope=input.scope ?? "project";
- const allowedProjectIds=scope === "global" ? [...new Set(input.allowedProjectIds ?? [])] : [];
- if (scope === "global" && !allowedProjectIds.includes(projectId)) throw new Error("Global credentials require an explicit allowlist containing the configuring project.");
- const health=await this.keyProvider.health();
- if (!health.available || !health.secure || !health.keyId || health.keyVersion === null) throw new Error(health.reason ?? "Secure credential storage is unavailable.");
- const id=randomUUID();
- const metadata=this.repository.create({id,name,kind,scope,projectId:scope === "project" ? projectId:null,allowedProjectIds,capabilities:[...new Set(input.capabilities ?? [])],keyId:health.keyId,keyVersion:health.keyVersion});
- const plaintext=Buffer.from(value,"utf8");
- try { await this.secretStore.put(this.context(metadata),plaintext); }
- catch (error) { this.repository.updateStatus(id,"unavailable"); throw error; }
- finally { plaintext.fill(0); }
- return this.repository.get(id)!;
- }
-
- bind(projectId: string, credentialId: string, bindingKey: string, capabilities: string[]) {
- const credential=this.requireAccessible(projectId,credentialId);
+ const name = boundedString(input?.name, "name", MAX_NAME_LENGTH);
+ const kind = boundedString(input?.kind, "kind", MAX_KIND_LENGTH);
+ if (!CREDENTIAL_KIND.test(kind)) throw new ValidationError("kind may contain only letters, numbers, dots, underscores, colons, and hyphens.");
+ const value = secretValue(input?.value);
+ const scope = input?.scope ?? "project";
+ if (scope !== "project" && scope !== "global") throw new ValidationError("scope must be either project or global.");
+ const requestedProjects = boundedList(input?.allowedProjectIds, "allowedProjectIds", MAX_IDENTIFIER_LENGTH);
+ if (scope === "global" && !requestedProjects.includes(projectId)) {
+ throw new ValidationError("Global credentials require an explicit allowlist containing the configuring project.");
+ }
+ const allowedProjectIds = scope === "global"
+ ? [projectId, ...requestedProjects.filter((candidate) => candidate !== projectId)]
+ : [];
+ const capabilities = boundedList(input?.capabilities, "capabilities", MAX_CAPABILITY_LENGTH);
+ const id = randomUUID();
+ const context = this.contextFor(id, scope === "project" ? projectId : null);
+ const plaintext = Buffer.from(value, "utf8");
+ try {
+ const envelope = await this.secretStore.seal(context, plaintext);
+ return this.repository.createWithEnvelope({
+ id,
+ name,
+ kind,
+ scope,
+ projectId: scope === "project" ? projectId : null,
+ managementProjectId: projectId,
+ allowedProjectIds,
+ capabilities,
+ }, envelope);
+ } finally {
+ plaintext.fill(0);
+ }
+ }
+
+ bind(projectIdValue: string, credentialIdValue: string, bindingKeyValue: string, capabilitiesValue: unknown): AutomationCredentialBinding {
+ const projectId = boundedString(projectIdValue, "projectId", MAX_IDENTIFIER_LENGTH);
+ const credentialId = boundedString(credentialIdValue, "credentialId", MAX_IDENTIFIER_LENGTH);
+ const bindingKey = boundedString(bindingKeyValue, "bindingKey", MAX_IDENTIFIER_LENGTH);
+ const credential = this.requireAccessible(projectId, credentialId);
if (credential.status !== "active") throw new CredentialAccessDeniedError("Only active credentials can be bound.");
- const required=[...new Set(capabilities)];
- if (required.some((capability)=>!credential.capabilities.includes(capability))) throw new CredentialAccessDeniedError("Binding requests capabilities the credential does not grant.");
- return this.repository.bind(credentialId,projectId,bindingKey.trim(),required);
+ const required = boundedList(capabilitiesValue, "capabilities", MAX_CAPABILITY_LENGTH);
+ if (required.some((capability) => !credential.capabilities.includes(capability))) {
+ throw new CredentialAccessDeniedError("Binding requests capabilities the credential does not grant.");
+ }
+ return this.repository.bind(credentialId, projectId, bindingKey, required);
}
- async test(projectId: string, credentialId: string): Promise {
- const credential=this.requireAccessible(projectId,credentialId);
- try { const plaintext=await this.secretStore.get(this.context(credential)); plaintext.fill(0); return this.repository.updateValidation(credentialId,"valid"); }
- catch { this.repository.updateValidation(credentialId,"invalid"); throw new Error("Credential validation failed."); }
+ async test(projectIdValue: string, credentialIdValue: string): Promise {
+ const projectId = boundedString(projectIdValue, "projectId", MAX_IDENTIFIER_LENGTH);
+ const credentialId = boundedString(credentialIdValue, "credentialId", MAX_IDENTIFIER_LENGTH);
+ const credential = this.requireAccessible(projectId, credentialId);
+ let plaintext: Buffer | null = null;
+ try {
+ plaintext = await this.secretStore.get(this.context(credential));
+ const current = this.repository.get(credentialId);
+ if (!current || !sameCredentialSnapshot(credential, current) || !this.canAccess(current, projectId) || current.status !== "active") {
+ throw new CredentialAccessDeniedError("Credential changed while it was being tested; retry the operation.");
+ }
+ return this.repository.updateValidation(credentialId, "valid");
+ } catch (error) {
+ if (error instanceof CredentialAccessDeniedError) throw error;
+ this.repository.updateValidation(credentialId, "invalid");
+ throw new Error("Credential validation failed.");
+ } finally {
+ plaintext?.fill(0);
+ }
}
- async rotate(projectId: string, credentialId: string, value: string): Promise { return this.replaceValue(projectId,credentialId,value,true); }
- async replace(projectId: string, credentialId: string, value: string): Promise { return this.replaceValue(projectId,credentialId,value,false); }
- revoke(projectId: string, credentialId: string): AutomationCredentialMetadata { this.requireAccessible(projectId,credentialId); return this.repository.updateStatus(credentialId,"revoked"); }
- async promote(projectId: string, credentialId: string, allowedProjectIds: string[]): Promise { const credential=this.requireAccessible(projectId,credentialId); if (credential.scope !== "project" || credential.projectId !== projectId) throw new CredentialAccessDeniedError("Only the owning project can promote this credential."); if (!allowedProjectIds.includes(projectId)) throw new Error("The global allowlist must retain the owning project."); const plaintext=await this.secretStore.get(this.context(credential)); const promoted=this.repository.promote(credentialId,[...new Set(allowedProjectIds)]); try { await this.secretStore.put(this.context(promoted),plaintext); return this.repository.get(credentialId)!; } catch(error){this.repository.updateStatus(credentialId,"unavailable");throw error;} finally { plaintext.fill(0); } }
- restrict(projectId: string, credentialId: string, allowedProjectIds: string[], capabilities: string[]): AutomationCredentialMetadata { const credential=this.requireAccessible(projectId,credentialId); if (credential.scope === "global" && !allowedProjectIds.includes(projectId)) throw new Error("The configuring project must remain allowlisted."); return this.repository.restrict(credentialId,[...new Set(allowedProjectIds)],[...new Set(capabilities)]); }
+ async rotate(projectId: string, credentialId: string, value: string): Promise {
+ return this.replaceValue(projectId, credentialId, value, true);
+ }
- async resolve(request: CredentialResolutionRequest): Promise {
- const binding=this.repository.getBinding(request.projectId,request.bindingKey);
- if (!binding) return this.deny(request,null,"No credential binding exists.");
- const credential=this.repository.get(binding.credentialId);
- if (!credential) return this.deny(request,binding.credentialId,"Bound credential is missing.");
- if (!this.canAccess(credential,request.projectId)) return this.deny(request,credential.id,"Credential is outside the project scope.");
- if (credential.status !== "active") return this.deny(request,credential.id,"Credential is not active.");
- if (!credential.capabilities.includes(request.capability) || !binding.requiredCapabilities.includes(request.capability)) return this.deny(request,credential.id,"Required capability is not approved.");
+ async replace(projectId: string, credentialId: string, value: string): Promise {
+ return this.replaceValue(projectId, credentialId, value, false);
+ }
+
+ revoke(projectIdValue: string, credentialIdValue: string): AutomationCredentialMetadata {
+ const projectId = boundedString(projectIdValue, "projectId", MAX_IDENTIFIER_LENGTH);
+ const credentialId = boundedString(credentialIdValue, "credentialId", MAX_IDENTIFIER_LENGTH);
+ this.requireManageable(projectId, credentialId);
+ return this.repository.updateStatus(credentialId, "revoked");
+ }
+
+ async promote(projectIdValue: string, credentialIdValue: string, allowedProjectIdsValue: unknown): Promise {
+ const projectId = boundedString(projectIdValue, "projectId", MAX_IDENTIFIER_LENGTH);
+ const credentialId = boundedString(credentialIdValue, "credentialId", MAX_IDENTIFIER_LENGTH);
+ const credential = this.requireManageable(projectId, credentialId);
+ if (credential.scope !== "project" || credential.projectId !== projectId) {
+ throw new CredentialAccessDeniedError("Only the managing project can promote a project credential.");
+ }
+ if (credential.status !== "active") throw new CredentialAccessDeniedError("Only active credentials can be promoted.");
+ const requestedProjects = boundedList(allowedProjectIdsValue, "allowedProjectIds", MAX_IDENTIFIER_LENGTH);
+ if (!requestedProjects.includes(projectId)) throw new ValidationError("The global allowlist must retain the managing project.");
+ const allowedProjectIds = [projectId, ...requestedProjects.filter((candidate) => candidate !== projectId)];
+ for (const allowedProjectId of allowedProjectIds) this.repository.requireProject(allowedProjectId);
+ const plaintext = await this.secretStore.get(this.context(credential));
try {
- const secret=await this.secretStore.get(this.context(credential)); const value=secret.toString("utf8"); secret.fill(0);
- this.repository.recordAccess({credentialId:credential.id,projectId:request.projectId,bindingKey:request.bindingKey,capability:request.capability,operation:"resolve",outcome:"granted",reason:null});
- this.auditService?.recordSystem({ action: "credential.access", resourceType: "automation_credential", resourceId: credential.id, projectId: request.projectId, outcome: "succeeded", metadata: { bindingKey: request.bindingKey, capability: request.capability, credentialVersion: credential.version } });
- return {credentialId:credential.id,value,version:credential.version};
- } catch { return this.deny(request,credential.id,"Credential backend is unavailable or authentication failed."); }
+ const envelope = await this.secretStore.seal(this.contextFor(credential.id, null), plaintext);
+ return this.repository.promoteWithEnvelope({
+ credentialId,
+ managementProjectId: projectId,
+ expectedVersion: credential.version,
+ expectedStatus: credential.status,
+ allowedProjectIds,
+ envelope,
+ });
+ } finally {
+ plaintext.fill(0);
+ }
+ }
+
+ restrict(projectIdValue: string, credentialIdValue: string, allowedProjectIdsValue: unknown, capabilitiesValue: unknown): AutomationCredentialMetadata {
+ const projectId = boundedString(projectIdValue, "projectId", MAX_IDENTIFIER_LENGTH);
+ const credentialId = boundedString(credentialIdValue, "credentialId", MAX_IDENTIFIER_LENGTH);
+ const credential = this.requireManageable(projectId, credentialId);
+ const requestedProjects = boundedList(allowedProjectIdsValue, "allowedProjectIds", MAX_IDENTIFIER_LENGTH);
+ if (credential.scope === "global" && !requestedProjects.includes(projectId)) {
+ throw new ValidationError("The global allowlist must retain the managing project.");
+ }
+ const allowedProjectIds = credential.scope === "global"
+ ? [projectId, ...requestedProjects.filter((candidate) => candidate !== projectId)]
+ : [];
+ const capabilities = boundedList(capabilitiesValue, "capabilities", MAX_CAPABILITY_LENGTH);
+ return this.repository.restrict(credentialId, allowedProjectIds, capabilities);
+ }
+
+ async resolve(request: CredentialResolutionRequest): Promise {
+ for (let attempt = 0; attempt < MAX_RESOLUTION_RETRIES; attempt += 1) {
+ const binding = this.repository.getBinding(request.projectId, request.bindingKey);
+ if (!binding) return this.deny(request, null, "No credential binding exists.");
+ const credential = this.repository.get(binding.credentialId);
+ this.authorizeBoundResolution(request, binding, credential);
+ const plaintext = await this.readSecretOrDeny(request, credential!);
+ const currentBinding = this.repository.getBinding(request.projectId, request.bindingKey);
+ const currentCredential = currentBinding ? this.repository.get(currentBinding.credentialId) : null;
+ const stable = currentBinding?.credentialId === binding.credentialId
+ && currentBinding.requiredCapabilities.includes(request.capability)
+ && currentCredential !== null
+ && sameCredentialSnapshot(credential!, currentCredential);
+ if (stable) return this.grant(request, currentCredential!, plaintext);
+ plaintext.fill(0);
+ this.authorizeBoundResolution(request, currentBinding, currentCredential);
+ }
+ return this.deny(request, null, "Credential changed repeatedly while access was being authorized.");
}
async resolveCredentialId(request: CredentialResolutionRequest & { credentialId: string }): Promise {
- const credential=this.repository.get(request.credentialId);
- if (!credential) return this.deny(request,request.credentialId,"Credential is missing.");
- if (!this.canAccess(credential,request.projectId)) return this.deny(request,credential.id,"Credential is outside the project scope.");
- if (credential.status !== "active") return this.deny(request,credential.id,"Credential is not active.");
- if (!credential.capabilities.includes(request.capability)) return this.deny(request,credential.id,"Required capability is not approved.");
+ for (let attempt = 0; attempt < MAX_RESOLUTION_RETRIES; attempt += 1) {
+ const credential = this.repository.get(request.credentialId);
+ this.authorizeDirectResolution(request, credential);
+ const plaintext = await this.readSecretOrDeny(request, credential!);
+ const current = this.repository.get(request.credentialId);
+ if (current && sameCredentialSnapshot(credential!, current)) return this.grant(request, current, plaintext);
+ plaintext.fill(0);
+ this.authorizeDirectResolution(request, current);
+ }
+ return this.deny(request, request.credentialId, "Credential changed repeatedly while access was being authorized.");
+ }
+
+ private async replaceValue(projectIdValue: string, credentialIdValue: string, valueValue: string, rotation: boolean): Promise {
+ const projectId = boundedString(projectIdValue, "projectId", MAX_IDENTIFIER_LENGTH);
+ const credentialId = boundedString(credentialIdValue, "credentialId", MAX_IDENTIFIER_LENGTH);
+ const credential = this.requireManageable(projectId, credentialId);
+ if (rotation && credential.status !== "active") throw new CredentialAccessDeniedError("Only active credentials can be rotated.");
+ const value = secretValue(valueValue, "replacement value");
+ const plaintext = Buffer.from(value, "utf8");
+ try {
+ const envelope = await this.secretStore.seal(this.context(credential), plaintext);
+ return this.repository.replaceEnvelope({
+ credentialId,
+ expectedVersion: credential.version,
+ expectedStatus: credential.status,
+ envelope,
+ recordRotation: rotation,
+ });
+ } finally {
+ plaintext.fill(0);
+ }
+ }
+
+ private context(credential: AutomationCredentialMetadata): SecretContext {
+ return this.contextFor(credential.id, credential.projectId);
+ }
+
+ private contextFor(credentialId: string, projectId: string | null): SecretContext {
+ const owner = projectId ?? "global";
+ return { credentialId, projectId: owner, workspaceId: owner };
+ }
+
+ private canAccess(credential: AutomationCredentialMetadata, projectId: string): boolean {
+ return credential.scope === "project"
+ ? credential.projectId === projectId
+ : credential.allowedProjectIds.includes(projectId);
+ }
+
+ private requireAccessible(projectId: string, credentialId: string): AutomationCredentialMetadata {
+ this.repository.requireProject(projectId);
+ const credential = this.repository.get(credentialId);
+ if (!credential || !this.canAccess(credential, projectId)) {
+ throw new CredentialAccessDeniedError("Credential is not available to this project.");
+ }
+ return credential;
+ }
+
+ private requireManageable(projectId: string, credentialId: string): AutomationCredentialMetadata {
+ this.repository.requireProject(projectId);
+ const credential = this.repository.get(credentialId);
+ if (!credential || credential.managementProjectId !== projectId) {
+ throw new CredentialAccessDeniedError("Credential is not managed by this project.");
+ }
+ return credential;
+ }
+
+ private authorizeBoundResolution(request: CredentialResolutionRequest, binding: AutomationCredentialBinding | null, credential: AutomationCredentialMetadata | null): void {
+ if (!binding) return this.deny(request, null, "No credential binding exists.");
+ if (!credential) return this.deny(request, binding.credentialId, "Bound credential is missing.");
+ if (!this.canAccess(credential, request.projectId)) return this.deny(request, credential.id, "Credential is outside the project scope.");
+ if (credential.status !== "active") return this.deny(request, credential.id, "Credential is not active.");
+ if (!credential.capabilities.includes(request.capability) || !binding.requiredCapabilities.includes(request.capability)) {
+ return this.deny(request, credential.id, "Required capability is not approved.");
+ }
+ }
+
+ private authorizeDirectResolution(request: CredentialResolutionRequest & { credentialId: string }, credential: AutomationCredentialMetadata | null): void {
+ if (!credential) return this.deny(request, request.credentialId, "Credential is missing.");
+ if (!this.canAccess(credential, request.projectId)) return this.deny(request, credential.id, "Credential is outside the project scope.");
+ if (credential.status !== "active") return this.deny(request, credential.id, "Credential is not active.");
+ if (!credential.capabilities.includes(request.capability)) return this.deny(request, credential.id, "Required capability is not approved.");
+ }
+
+ private async readSecretOrDeny(request: CredentialResolutionRequest, credential: AutomationCredentialMetadata): Promise {
+ try {
+ return await this.secretStore.get(this.context(credential));
+ } catch {
+ return this.deny(request, credential.id, "Credential backend is unavailable or authentication failed.");
+ }
+ }
+
+ private grant(request: CredentialResolutionRequest, credential: AutomationCredentialMetadata, plaintext: Buffer): ResolvedCredential {
try {
- const secret=await this.secretStore.get(this.context(credential)); const value=secret.toString("utf8"); secret.fill(0);
- this.repository.recordAccess({credentialId:credential.id,projectId:request.projectId,bindingKey:request.bindingKey,capability:request.capability,operation:"resolve",outcome:"granted",reason:null});
- this.auditService?.recordSystem({ action: "credential.access", resourceType: "automation_credential", resourceId: credential.id, projectId: request.projectId, outcome: "succeeded", metadata: { bindingKey: request.bindingKey, capability: request.capability, credentialVersion: credential.version } });
- return {credentialId:credential.id,value,version:credential.version};
- } catch { return this.deny(request,credential.id,"Credential backend is unavailable or authentication failed."); }
- }
-
- private async replaceValue(projectId:string,credentialId:string,value:string,rotation:boolean):Promise{ const credential=this.requireAccessible(projectId,credentialId); if (!value) throw new Error("A non-empty replacement value is required."); const plaintext=Buffer.from(value,"utf8"); try { const envelope=await this.secretStore.put(this.context(credential),plaintext); const next=this.repository.updateSecretMetadata(credentialId,envelope.keyId,envelope.keyVersion,credential.version+1); if(rotation)this.repository.recordRotation({credentialId,fromVersion:credential.version,toVersion:next.version,keyId:envelope.keyId,keyVersion:envelope.keyVersion}); return next; } finally { plaintext.fill(0); } }
- private context(credential:AutomationCredentialMetadata){ return {credentialId:credential.id,projectId:credential.projectId ?? "global",workspaceId:credential.projectId ?? "global"}; }
- private canAccess(credential:AutomationCredentialMetadata,projectId:string):boolean{return credential.scope === "project" ? credential.projectId === projectId : credential.allowedProjectIds.includes(projectId);}
- private requireAccessible(projectId:string,credentialId:string):AutomationCredentialMetadata{this.repository.requireProject(projectId);const credential=this.repository.get(credentialId);if(!credential||!this.canAccess(credential,projectId))throw new CredentialAccessDeniedError("Credential is not available to this project.");return credential;}
- private deny(request:CredentialResolutionRequest,credentialId:string|null,reason:string):never{this.repository.recordAccess({credentialId,projectId:request.projectId,bindingKey:request.bindingKey,capability:request.capability,operation:"resolve",outcome:"denied",reason});this.auditService?.recordSystem({action:"credential.access",resourceType:"automation_credential",resourceId:credentialId,projectId:request.projectId,outcome:"denied",metadata:{bindingKey:request.bindingKey,capability:request.capability,reason}});throw new CredentialAccessDeniedError(reason);}
+ this.repository.recordAccess({
+ credentialId: credential.id,
+ projectId: request.projectId,
+ bindingKey: request.bindingKey,
+ capability: request.capability,
+ operation: "resolve",
+ outcome: "granted",
+ reason: null,
+ });
+ this.auditService?.recordSystem({
+ action: "credential.access",
+ resourceType: "automation_credential",
+ resourceId: credential.id,
+ projectId: request.projectId,
+ outcome: "succeeded",
+ metadata: { bindingKey: request.bindingKey, capability: request.capability, credentialVersion: credential.version },
+ });
+ return { credentialId: credential.id, value: plaintext.toString("utf8"), version: credential.version };
+ } finally {
+ plaintext.fill(0);
+ }
+ }
+
+ private deny(request: CredentialResolutionRequest, credentialId: string | null, reason: string): never {
+ this.repository.recordAccess({
+ credentialId,
+ projectId: request.projectId,
+ bindingKey: request.bindingKey,
+ capability: request.capability,
+ operation: "resolve",
+ outcome: "denied",
+ reason,
+ });
+ this.auditService?.recordSystem({
+ action: "credential.access",
+ resourceType: "automation_credential",
+ resourceId: credentialId,
+ projectId: request.projectId,
+ outcome: "denied",
+ metadata: { bindingKey: request.bindingKey, capability: request.capability, reason },
+ });
+ throw new CredentialAccessDeniedError(reason);
+ }
}
diff --git a/src/services/credentials/secret-store.ts b/src/services/credentials/secret-store.ts
index 2c6935557c..5bc43a8b71 100644
--- a/src/services/credentials/secret-store.ts
+++ b/src/services/credentials/secret-store.ts
@@ -17,7 +17,7 @@ export interface StoredSecretEnvelope {
}
export interface SecretStore {
- put(context: SecretContext, plaintext: Buffer): Promise;
+ /** Encrypts a value without persisting it so callers can commit it atomically with metadata. */
+ seal(context: SecretContext, plaintext: Buffer): Promise;
get(context: SecretContext): Promise;
- delete(credentialId: string): Promise;
}
diff --git a/tests/backend/app/lifecycle/dashboard-lifecycle-service.test.ts b/tests/backend/app/lifecycle/dashboard-lifecycle-service.test.ts
index 29e16fa932..b90102ccbf 100644
--- a/tests/backend/app/lifecycle/dashboard-lifecycle-service.test.ts
+++ b/tests/backend/app/lifecycle/dashboard-lifecycle-service.test.ts
@@ -240,6 +240,7 @@ describe("dashboard-lifecycle-service", () => {
embeddingService: {} as any,
memoryRepository: {} as any,
knowledgeService: {} as any,
+ credentialBroker: { health: vi.fn() } as any,
chatProviderOutboundService: {
start: vi.fn(),
stop: vi.fn(),
@@ -284,6 +285,7 @@ describe("dashboard-lifecycle-service", () => {
dashboardDir: path.join("/project-root", "dashboard"),
port: 3000,
liveActivityCacheMs: 500,
+ credentialBroker: mockDeps.credentialBroker,
realtimeService: mockDeps.dashboardRealtimeService,
getUpdateStatus: expect.any(Function),
cancelThreadTurn: expect.any(Function),
diff --git a/tests/backend/repositories/automation-credential-repository.test.ts b/tests/backend/repositories/automation-credential-repository.test.ts
index 9b8004c2cd..e098ef6888 100644
--- a/tests/backend/repositories/automation-credential-repository.test.ts
+++ b/tests/backend/repositories/automation-credential-repository.test.ts
@@ -8,13 +8,204 @@ import { AutomationCredentialRepository } from "../../../src/repositories/automa
import { MountedKeyFileProvider } from "../../../src/infrastructure/security/mounted-key-file-provider.js";
import { EncryptedSqliteSecretStore } from "../../../src/infrastructure/security/encrypted-sqlite-secret-store.js";
import { CredentialBroker } from "../../../src/services/credentials/credential-broker.js";
+import type { SecretContext, SecretStore } from "../../../src/services/credentials/secret-store.js";
-const dirs:string[]=[];
-async function fixture(){const dir=await mkdtemp(join(tmpdir(),"credential-test-"));dirs.push(dir);const dbPath=join(dir,"app.db");const keyPath=join(dir,"root.key");await writeFile(keyPath,Buffer.alloc(32,9).toString("base64"),{mode:0o600});const storage=new AppDbStorage(dbPath);const projects=new ProjectManagementRepository(storage);const first=projects.createProject({name:"First",sourceType:"local",sourceRef:join(dir,"first")});const second=projects.createProject({name:"Second",sourceType:"local",sourceRef:join(dir,"second")});const repository=new AutomationCredentialRepository(storage);const provider=new MountedKeyFileProvider(keyPath);const broker=new CredentialBroker(repository,new EncryptedSqliteSecretStore(repository,provider),provider);return{dir,dbPath,storage,repository,broker,first,second};}
-afterEach(async()=>{await Promise.all(dirs.splice(0).map((dir)=>rm(dir,{recursive:true,force:true})))});
+const dirs: string[] = [];
-describe("automation credential repository and broker",()=>{
- it("persists only encrypted material, resolves capabilities, rotates, and audits metadata",async()=>{const f=await fixture();const secret="plain-secret-marker";const created=await f.broker.create(f.first.id,{name:"Token",kind:"api-token",value:secret,capabilities:["read"]});expect(JSON.stringify(created)).not.toContain(secret);const persisted=f.storage.getDatabase().prepare("SELECT * FROM automation_credential_secrets WHERE credential_id=?").get(created.id) as Record;expect(JSON.stringify(persisted)).not.toContain(secret);f.broker.bind(f.first.id,created.id,"node.http",["read"]);expect((await f.broker.resolve({projectId:f.first.id,bindingKey:"node.http",capability:"read",workspaceId:"run"})).value).toBe(secret);const rotated=await f.broker.rotate(f.first.id,created.id,"replacement");expect(rotated.version).toBe(2);expect((await f.broker.resolve({projectId:f.first.id,bindingKey:"node.http",capability:"read",workspaceId:"run"})).value).toBe("replacement");const event=f.storage.getDatabase().prepare("SELECT * FROM automation_credential_access_events ORDER BY created_at DESC LIMIT 1").get() as Record;expect(JSON.stringify(event)).not.toContain(secret);expect(f.storage.getDatabase().prepare("SELECT COUNT(*) AS count FROM automation_credential_rotations").get()).toMatchObject({count:1});f.storage.close();});
- it("fails closed for cross-project, revoked, missing, and insecure providers",async()=>{const f=await fixture();const created=await f.broker.create(f.first.id,{name:"Token",kind:"api-token",value:"secret",capabilities:["read"]});expect(()=>f.broker.bind(f.second.id,created.id,"node.http",["read"])).toThrow(/not available/);f.broker.bind(f.first.id,created.id,"node.http",["read"]);f.broker.revoke(f.first.id,created.id);await expect(f.broker.resolve({projectId:f.first.id,bindingKey:"node.http",capability:"read",workspaceId:"run"})).rejects.toThrow(/not active/);const insecurePath=join(f.dir,"insecure.key");await writeFile(insecurePath,Buffer.alloc(32,4).toString("base64"),{mode:0o644});const insecureHealth=await new MountedKeyFileProvider(insecurePath).health();expect(insecureHealth).toMatchObject({available:false,secure:false});f.storage.close();const unavailable=new MountedKeyFileProvider(undefined);expect((await unavailable.health()).available).toBe(false);});
- it("requires explicit project allowlists and re-encrypts promoted credentials",async()=>{const f=await fixture();await expect(f.broker.create(f.first.id,{name:"Global",kind:"token",value:"secret",scope:"global",allowedProjectIds:[f.second.id],capabilities:["read"]})).rejects.toThrow(/explicit allowlist/);const projectCredential=await f.broker.create(f.first.id,{name:"Promoted",kind:"token",value:"secret",capabilities:["read"]});const promoted=await f.broker.promote(f.first.id,projectCredential.id,[f.first.id,f.second.id]);expect(promoted.scope).toBe("global");f.broker.bind(f.second.id,promoted.id,"node.global",["read"]);expect((await f.broker.resolve({projectId:f.second.id,bindingKey:"node.global",capability:"read",workspaceId:"run"})).value).toBe("secret");f.storage.close();});
+async function fixture() {
+ const dir = await mkdtemp(join(tmpdir(), "credential-test-"));
+ dirs.push(dir);
+ const dbPath = join(dir, "app.db");
+ const keyPath = join(dir, "root.key");
+ await writeFile(keyPath, Buffer.alloc(32, 9).toString("base64"), { mode: 0o600 });
+ const storage = new AppDbStorage(dbPath);
+ const projects = new ProjectManagementRepository(storage);
+ const first = projects.createProject({ name: "First", sourceType: "local", sourceRef: join(dir, "first") });
+ const second = projects.createProject({ name: "Second", sourceType: "local", sourceRef: join(dir, "second") });
+ const repository = new AutomationCredentialRepository(storage);
+ const provider = new MountedKeyFileProvider(keyPath);
+ const secretStore = new EncryptedSqliteSecretStore(repository, provider);
+ const broker = new CredentialBroker(repository, secretStore, provider);
+ return { dir, dbPath, storage, repository, provider, secretStore, broker, first, second };
+}
+
+afterEach(async () => {
+ await Promise.all(dirs.splice(0).map((dir) => rm(dir, { recursive: true, force: true })));
+});
+
+describe("automation credential repository and broker", () => {
+ it("persists only encrypted material, resolves capabilities, rotates, and audits metadata", async () => {
+ const f = await fixture();
+ const secret = "plain-secret-marker";
+ const created = await f.broker.create(f.first.id, { name: "Token", kind: "api-token", value: secret, capabilities: ["read"] });
+ expect(created).toMatchObject({ configured: true, managementProjectId: f.first.id });
+ expect(JSON.stringify(created)).not.toContain(secret);
+ const persisted = f.storage.getDatabase().prepare("SELECT * FROM automation_credential_secrets WHERE credential_id=?").get(created.id) as Record;
+ expect(JSON.stringify(persisted)).not.toContain(secret);
+ f.broker.bind(f.first.id, created.id, "node.http", ["read"]);
+ expect((await f.broker.resolve({ projectId: f.first.id, bindingKey: "node.http", capability: "read", workspaceId: "run" })).value).toBe(secret);
+ const rotated = await f.broker.rotate(f.first.id, created.id, "replacement");
+ expect(rotated.version).toBe(2);
+ expect((await f.broker.resolve({ projectId: f.first.id, bindingKey: "node.http", capability: "read", workspaceId: "run" })).value).toBe("replacement");
+ const event = f.storage.getDatabase().prepare("SELECT * FROM automation_credential_access_events ORDER BY created_at DESC LIMIT 1").get() as Record;
+ expect(JSON.stringify(event)).not.toContain(secret);
+ expect(f.storage.getDatabase().prepare("SELECT COUNT(*) AS count FROM automation_credential_rotations").get()).toMatchObject({ count: 1 });
+ f.storage.close();
+ });
+
+ it("fails closed for cross-project, revoked, missing, and insecure providers", async () => {
+ const f = await fixture();
+ const created = await f.broker.create(f.first.id, { name: "Token", kind: "api-token", value: "secret", capabilities: ["read"] });
+ expect(() => f.broker.bind(f.second.id, created.id, "node.http", ["read"])).toThrow(/not available/);
+ f.broker.bind(f.first.id, created.id, "node.http", ["read"]);
+ f.broker.revoke(f.first.id, created.id);
+ await expect(f.broker.resolve({ projectId: f.first.id, bindingKey: "node.http", capability: "read", workspaceId: "run" })).rejects.toThrow(/not active/);
+ const insecurePath = join(f.dir, "insecure.key");
+ await writeFile(insecurePath, Buffer.alloc(32, 4).toString("base64"), { mode: 0o644 });
+ const insecureHealth = await new MountedKeyFileProvider(insecurePath).health();
+ expect(insecureHealth).toMatchObject({ available: false, secure: false });
+ f.storage.close();
+ const unavailable = new MountedKeyFileProvider(undefined);
+ expect((await unavailable.health()).available).toBe(false);
+ });
+
+ it("requires explicit project allowlists and re-encrypts promoted credentials", async () => {
+ const f = await fixture();
+ await expect(f.broker.create(f.first.id, {
+ name: "Global",
+ kind: "token",
+ value: "secret",
+ scope: "global",
+ allowedProjectIds: [f.second.id],
+ capabilities: ["read"],
+ })).rejects.toThrow(/explicit allowlist/);
+ const projectCredential = await f.broker.create(f.first.id, { name: "Promoted", kind: "token", value: "secret", capabilities: ["read"] });
+ const promoted = await f.broker.promote(f.first.id, projectCredential.id, [f.first.id, f.second.id]);
+ expect(promoted).toMatchObject({ scope: "global", managementProjectId: f.first.id });
+ f.broker.bind(f.second.id, promoted.id, "node.global", ["read"]);
+ expect((await f.broker.resolve({ projectId: f.second.id, bindingKey: "node.global", capability: "read", workspaceId: "run" })).value).toBe("secret");
+ expect(() => f.broker.revoke(f.second.id, promoted.id)).toThrow(/not managed/);
+ await expect(f.broker.rotate(f.second.id, promoted.id, "stolen")).rejects.toThrow(/not managed/);
+ expect(() => f.broker.restrict(f.second.id, promoted.id, [f.first.id, f.second.id], ["read"])).toThrow(/not managed/);
+ f.storage.close();
+ });
+
+ it("removes a global credential when its management project is deleted", async () => {
+ const f = await fixture();
+ const created = await f.broker.create(f.first.id, {
+ name: "Global",
+ kind: "token",
+ value: "secret",
+ scope: "global",
+ allowedProjectIds: [f.first.id, f.second.id],
+ capabilities: ["read"],
+ });
+ f.storage.getDatabase().prepare("DELETE FROM projects WHERE id = ?").run(f.first.id);
+ expect(f.repository.get(created.id)).toBeNull();
+ f.storage.close();
+ });
+
+ it("rolls back credential metadata when initial envelope persistence fails", async () => {
+ const f = await fixture();
+ f.storage.getDatabase().exec(`
+ CREATE TRIGGER reject_credential_secret_insert
+ BEFORE INSERT ON automation_credential_secrets
+ BEGIN
+ SELECT RAISE(ABORT, 'forced secret insert failure');
+ END
+ `);
+ await expect(f.broker.create(f.first.id, { name: "Token", kind: "token", value: "secret", capabilities: ["read"] })).rejects.toThrow(/forced secret insert failure/);
+ expect(f.storage.getDatabase().prepare("SELECT COUNT(*) AS count FROM automation_credentials").get()).toMatchObject({ count: 0 });
+ f.storage.close();
+ });
+
+ it("rolls back version metadata and keeps the old value when envelope replacement fails", async () => {
+ const f = await fixture();
+ const created = await f.broker.create(f.first.id, { name: "Token", kind: "token", value: "original", capabilities: ["read"] });
+ f.broker.bind(f.first.id, created.id, "node.http", ["read"]);
+ f.storage.getDatabase().exec(`
+ CREATE TRIGGER reject_credential_secret_update
+ BEFORE UPDATE ON automation_credential_secrets
+ BEGIN
+ SELECT RAISE(ABORT, 'forced secret update failure');
+ END
+ `);
+ await expect(f.broker.rotate(f.first.id, created.id, "replacement")).rejects.toThrow(/forced secret update failure/);
+ expect(f.repository.get(created.id)).toMatchObject({ version: 1, status: "active" });
+ expect((await f.broker.resolve({ projectId: f.first.id, bindingKey: "node.http", capability: "read", workspaceId: "run" })).value).toBe("original");
+ expect(f.storage.getDatabase().prepare("SELECT COUNT(*) AS count FROM automation_credential_rotations").get()).toMatchObject({ count: 0 });
+ f.storage.close();
+ });
+
+ it("rolls back scope changes when promoted-envelope persistence fails", async () => {
+ const f = await fixture();
+ const created = await f.broker.create(f.first.id, { name: "Token", kind: "token", value: "original", capabilities: ["read"] });
+ f.storage.getDatabase().exec(`
+ CREATE TRIGGER reject_promoted_secret_update
+ BEFORE UPDATE ON automation_credential_secrets
+ BEGIN
+ SELECT RAISE(ABORT, 'forced promotion failure');
+ END
+ `);
+ await expect(f.broker.promote(f.first.id, created.id, [f.first.id, f.second.id])).rejects.toThrow(/forced promotion failure/);
+ expect(f.repository.get(created.id)).toMatchObject({ scope: "project", projectId: f.first.id, managementProjectId: f.first.id });
+ f.broker.bind(f.first.id, created.id, "node.http", ["read"]);
+ expect((await f.broker.resolve({ projectId: f.first.id, bindingKey: "node.http", capability: "read", workspaceId: "run" })).value).toBe("original");
+ f.storage.close();
+ });
+
+ it("allows only one overlapping rotation to commit", async () => {
+ const f = await fixture();
+ const created = await f.broker.create(f.first.id, { name: "Token", kind: "token", value: "original", capabilities: ["read"] });
+ f.broker.bind(f.first.id, created.id, "node.http", ["read"]);
+ const results = await Promise.allSettled([
+ f.broker.rotate(f.first.id, created.id, "replacement-a"),
+ f.broker.rotate(f.first.id, created.id, "replacement-b"),
+ ]);
+ expect(results.filter((result) => result.status === "fulfilled")).toHaveLength(1);
+ expect(results.filter((result) => result.status === "rejected")).toHaveLength(1);
+ expect(f.repository.get(created.id)).toMatchObject({ version: 2 });
+ const resolved = await f.broker.resolve({ projectId: f.first.id, bindingKey: "node.http", capability: "read", workspaceId: "run" });
+ expect(["replacement-a", "replacement-b"]).toContain(resolved.value);
+ expect(f.storage.getDatabase().prepare("SELECT COUNT(*) AS count FROM automation_credential_rotations").get()).toMatchObject({ count: 1 });
+ f.storage.close();
+ });
+
+ it("rechecks authorization after decryption so revocation wins an in-flight resolve", async () => {
+ const f = await fixture();
+ const created = await f.broker.create(f.first.id, { name: "Token", kind: "token", value: "secret", capabilities: ["read"] });
+ f.broker.bind(f.first.id, created.id, "node.http", ["read"]);
+ let release!: () => void;
+ let entered!: () => void;
+ const gate = new Promise((resolve) => { release = resolve; });
+ const readStarted = new Promise((resolve) => { entered = resolve; });
+ let delayed = true;
+ const delayedStore: SecretStore = {
+ seal: (context, plaintext) => f.secretStore.seal(context, plaintext),
+ get: async (context: SecretContext): Promise => {
+ if (delayed) {
+ delayed = false;
+ entered();
+ await gate;
+ }
+ return f.secretStore.get(context);
+ },
+ };
+ const resolvingBroker = new CredentialBroker(f.repository, delayedStore, f.provider);
+ const pending = resolvingBroker.resolve({ projectId: f.first.id, bindingKey: "node.http", capability: "read", workspaceId: "run" });
+ await readStarted;
+ f.broker.revoke(f.first.id, created.id);
+ release();
+ await expect(pending).rejects.toThrow(/not active/);
+ f.storage.close();
+ });
+
+ it("rejects malformed and oversized runtime inputs instead of coercing them", async () => {
+ const f = await fixture();
+ await expect(f.broker.create(f.first.id, { name: "Token", kind: "token", value: "x".repeat(64 * 1024 + 1) })).rejects.toThrow(/65536/);
+ await expect(f.broker.create(f.first.id, { name: "Token", kind: "token", value: "secret", capabilities: "read" as unknown as string[] })).rejects.toThrow(/array of strings/);
+ const created = await f.broker.create(f.first.id, { name: "Token", kind: "token", value: "secret", capabilities: ["read"] });
+ expect(() => f.broker.bind(f.first.id, created.id, "node.http", ["read", 7] as unknown)).toThrow(/must be a string/);
+ f.storage.close();
+ });
});
diff --git a/tests/backend/repositories/db/app-db-schema.test.ts b/tests/backend/repositories/db/app-db-schema.test.ts
index 6006efdc0d..e901b6d164 100644
--- a/tests/backend/repositories/db/app-db-schema.test.ts
+++ b/tests/backend/repositories/db/app-db-schema.test.ts
@@ -208,6 +208,7 @@ describe("AppDbSchema", () => {
expect(getTable("node_flow_node_runs")).toBeDefined();
expect(getColumnNames("node_flow_runs")).toContain("execution_invocation_id");
expect(getColumnNames("node_flow_node_runs")).toContain("execution_invocation_id");
+ expect(getColumnNames("automation_credentials")).toContain("management_project_id");
expect(getColumnNames("sprint_linked_issues")).toEqual(expect.arrayContaining([
"issue_body_markdown",
"issue_conversation_markdown",
diff --git a/tests/backend/server/automation-credential-routes.test.ts b/tests/backend/server/automation-credential-routes.test.ts
index 922e14cb03..406d8ad5e4 100644
--- a/tests/backend/server/automation-credential-routes.test.ts
+++ b/tests/backend/server/automation-credential-routes.test.ts
@@ -2,7 +2,15 @@ import express from "express";
import request from "supertest";
import { describe, expect, it, vi } from "vitest";
import { registerAutomationCredentialRoutes } from "../../../src/server/automation-credential-routes.js";
+import { toHttpRouteError } from "../../../src/server/http-errors.js";
+import { CredentialAccessDeniedError } from "../../../src/services/credentials/credential-broker.js";
+import { CredentialConcurrentModificationError } from "../../../src/repositories/automation-credential-repository.js";
describe("automation credential routes",()=>{
it("passes secret values only into write operations and returns metadata",async()=>{const metadata={id:"credential-1",name:"Token",kind:"api-token",scope:"project",projectId:"project-1",allowedProjectIds:[],capabilities:["read"],status:"active",configured:true,keyId:"root",keyVersion:1,version:1,lastValidatedAt:null,validationStatus:"untested",createdAt:"now",updatedAt:"now"};const credentialBroker={create:vi.fn().mockResolvedValue(metadata)};const app=express();app.use(express.json());registerAutomationCredentialRoutes(app,{credentialBroker} as any);const response=await request(app).post("/api/projects/project-1/credentials").send({name:"Token",kind:"api-token",value:"super-secret",capabilities:["read"]});expect(response.status).toBe(201);expect(response.body).toEqual(metadata);expect(JSON.stringify(response.body)).not.toContain("super-secret");expect(credentialBroker.create).toHaveBeenCalledWith("project-1",expect.objectContaining({value:"super-secret"}));});
+
+ it("maps credential denials and concurrent writes to explicit HTTP outcomes", () => {
+ expect(toHttpRouteError(new CredentialAccessDeniedError("Credential is not managed by this project."))).toMatchObject({ status: 403 });
+ expect(toHttpRouteError(new CredentialConcurrentModificationError("Credential changed; retry."))).toMatchObject({ status: 409 });
+ });
});
diff --git a/tests/backend/server/jules-agent-server.test.ts b/tests/backend/server/jules-agent-server.test.ts
index a8b2a1489a..684e299810 100644
--- a/tests/backend/server/jules-agent-server.test.ts
+++ b/tests/backend/server/jules-agent-server.test.ts
@@ -917,6 +917,7 @@ describe("CodeUxServer", () => {
expect(bootDashboardArgs).toBeDefined();
expect(bootDashboardArgs.getDashboardPort()).toBeDefined();
+ expect(bootDashboardArgs.credentialBroker).toBe((runServer as any).credentialBroker);
try { await bootDashboardArgs.getLiveActivitiesForActiveTasks(); } catch (e) {}
try { await bootDashboardArgs.getGitStatus(); } catch (e) {}
diff --git a/tests/backend/services/credential-key-providers.test.ts b/tests/backend/services/credential-key-providers.test.ts
new file mode 100644
index 0000000000..953feb8f81
--- /dev/null
+++ b/tests/backend/services/credential-key-providers.test.ts
@@ -0,0 +1,110 @@
+import { afterEach, describe, expect, it, vi } from "vitest";
+import { mkdtemp, rm, stat, writeFile } from "node:fs/promises";
+import { tmpdir } from "node:os";
+import { join } from "node:path";
+import { ElectronCredentialKeyPersistence } from "../../../src/electron/credential-key-persistence.js";
+import { ElectronSafeStorageKeyProvider } from "../../../src/infrastructure/security/electron-safe-storage-key-provider.js";
+import { KmsKeyProviderAdapter } from "../../../src/infrastructure/security/external-key-provider-adapters.js";
+import { MountedKeyFileProvider } from "../../../src/infrastructure/security/mounted-key-file-provider.js";
+
+const dirs: string[] = [];
+
+async function tempDir(): Promise {
+ const dir = await mkdtemp(join(tmpdir(), "credential-key-provider-"));
+ dirs.push(dir);
+ return dir;
+}
+
+afterEach(async () => {
+ await Promise.all(dirs.splice(0).map((dir) => rm(dir, { recursive: true, force: true })));
+});
+
+describe("credential key providers", () => {
+ it("strictly validates mounted key files", async () => {
+ const dir = await tempDir();
+ const validPath = join(dir, "valid.key");
+ await writeFile(validPath, Buffer.alloc(32, 3).toString("base64"), { mode: 0o600 });
+ await expect(new MountedKeyFileProvider(validPath).health()).resolves.toMatchObject({ available: true, secure: true });
+ const unpaddedPath = join(dir, "valid-unpadded.key");
+ await writeFile(unpaddedPath, Buffer.alloc(32, 4).toString("base64").replace(/=$/, ""), { mode: 0o600 });
+ await expect(new MountedKeyFileProvider(unpaddedPath).health()).resolves.toMatchObject({ available: true, secure: true });
+
+ const malformedPath = join(dir, "malformed.key");
+ await writeFile(malformedPath, `${"A".repeat(43)}!`, { mode: 0o600 });
+ await expect(new MountedKeyFileProvider(malformedPath).health()).resolves.toMatchObject({ available: false });
+
+ const oversizedPath = join(dir, "oversized.key");
+ await writeFile(oversizedPath, "A".repeat(300), { mode: 0o600 });
+ await expect(new MountedKeyFileProvider(oversizedPath).health()).resolves.toMatchObject({ available: false });
+
+ await expect(new MountedKeyFileProvider(dir).health()).resolves.toMatchObject({ available: false });
+ });
+
+ it("serializes Electron root-key initialization and returns independent key buffers", async () => {
+ let protectedValue: Buffer | null = null;
+ const writeIfAbsent = vi.fn(async (value: Buffer) => {
+ await Promise.resolve();
+ if (protectedValue) return false;
+ protectedValue = Buffer.from(value);
+ return true;
+ });
+ const persistence = {
+ read: vi.fn(async () => protectedValue ? Buffer.from(protectedValue) : null),
+ writeIfAbsent,
+ };
+ const safeStorage = {
+ isEncryptionAvailable: () => true,
+ encryptString: (value: string) => Buffer.from(value, "utf8"),
+ decryptString: (value: Buffer) => value.toString("utf8"),
+ };
+ const provider = new ElectronSafeStorageKeyProvider(safeStorage, persistence);
+ const keys = await Promise.all(Array.from({ length: 16 }, () => provider.getActiveKey()));
+ expect(writeIfAbsent).toHaveBeenCalledTimes(1);
+ expect(keys.every((material) => material.key.equals(keys[0]!.key))).toBe(true);
+ keys[0]!.key.fill(0);
+ expect(keys[1]!.key.equals(Buffer.alloc(32))).toBe(false);
+ keys.forEach((material) => material.key.fill(0));
+ });
+
+ it("reports active external key identity and rejects mismatched versions", async () => {
+ const client = {
+ health: vi.fn(async () => ({ available: true })),
+ activeKey: vi.fn(async () => ({ key: Buffer.alloc(32, 5), keyId: "kms-root", version: 4 })),
+ key: vi.fn(async () => ({ key: Buffer.alloc(32, 5), keyId: "kms-root", version: 4 })),
+ };
+ const provider = new KmsKeyProviderAdapter(client);
+ await expect(provider.health()).resolves.toMatchObject({ available: true, secure: true, keyId: "kms-root", keyVersion: 4 });
+ const key = await provider.getKey("kms-root", 4);
+ expect(key.key).toHaveLength(32);
+ key.key.fill(0);
+ await expect(provider.getKey("kms-root", 3)).rejects.toThrow(/different root-key version/);
+ });
+
+ it("writes Electron protected-key blobs atomically with owner-only permissions", async () => {
+ const dir = await tempDir();
+ const filePath = join(dir, "nested", "credential-root-key.bin");
+ const persistence = new ElectronCredentialKeyPersistence(filePath);
+ const protectedValue = Buffer.from("os-protected-value");
+ await expect(persistence.writeIfAbsent(protectedValue)).resolves.toBe(true);
+ await expect(persistence.writeIfAbsent(Buffer.from("different-protected-value"))).resolves.toBe(false);
+ await expect(persistence.read()).resolves.toEqual(protectedValue);
+ const info = await stat(filePath);
+ expect(info.mode & 0o077).toBe(0);
+ });
+
+ it("keeps one recoverable Electron key across competing provider instances", async () => {
+ const dir = await tempDir();
+ const filePath = join(dir, "credential-root-key.bin");
+ const safeStorage = {
+ isEncryptionAvailable: () => true,
+ encryptString: (value: string) => Buffer.from(value, "utf8"),
+ decryptString: (value: Buffer) => value.toString("utf8"),
+ };
+ const first = new ElectronSafeStorageKeyProvider(safeStorage, new ElectronCredentialKeyPersistence(filePath));
+ const second = new ElectronSafeStorageKeyProvider(safeStorage, new ElectronCredentialKeyPersistence(filePath));
+ const [firstKey, secondKey] = await Promise.all([first.getActiveKey(), second.getActiveKey()]);
+ expect(firstKey.key.equals(secondKey.key)).toBe(true);
+ firstKey.key.fill(0);
+ secondKey.key.fill(0);
+ });
+});