diff --git a/packages/shared/src/validators/secret.ts b/packages/shared/src/validators/secret.ts index e42f2a8dc45..ee2c5a45fe2 100644 --- a/packages/shared/src/validators/secret.ts +++ b/packages/shared/src/validators/secret.ts @@ -71,6 +71,7 @@ export const rotateSecretSchema = z.object({ value: z.string().min(1).optional().nullable(), externalRef: z.string().optional().nullable(), providerVersionRef: z.string().optional().nullable(), + providerConfigId: z.string().uuid().optional().nullable(), }); export type RotateSecret = z.infer; diff --git a/server/src/__tests__/aws-secrets-manager-provider.test.ts b/server/src/__tests__/aws-secrets-manager-provider.test.ts index 23354776ec7..2a463db632e 100644 --- a/server/src/__tests__/aws-secrets-manager-provider.test.ts +++ b/server/src/__tests__/aws-secrets-manager-provider.test.ts @@ -81,6 +81,88 @@ describe("awsSecretsManagerProvider", () => { expect(prepared.providerVersionRef).toBe("aws-version-1"); }); + it("creates AWS secrets from selected provider vault config without deployment env fallback", async () => { + delete process.env.PAPERCLIP_SECRETS_AWS_REGION; + delete process.env.AWS_REGION; + delete process.env.AWS_DEFAULT_REGION; + delete process.env.PAPERCLIP_SECRETS_AWS_DEPLOYMENT_ID; + delete process.env.PAPERCLIP_SECRETS_AWS_KMS_KEY_ID; + + const calls: Array<{ op: string; input: Record }> = []; + const provider = createAwsSecretsManagerProvider({ + gateway: { + async createSecret(input) { + calls.push({ op: "createSecret", input }); + return { + ARN: "arn:aws:secretsmanager:us-west-2:123456789012:secret:clip/prod-us-west/company-1/openai-api-key", + VersionId: "aws-version-1", + }; + }, + async putSecretValue(input) { + calls.push({ op: "putSecretValue", input }); + return { ARN: String(input.SecretId), VersionId: "unused" }; + }, + async getSecretValue(input) { + calls.push({ op: "getSecretValue", input }); + return { SecretString: "resolved-value", VersionId: "unused" }; + }, + async deleteSecret(input) { + calls.push({ op: "deleteSecret", input }); + return {}; + }, + }, + }); + + const providerConfig = { + id: "vault-1", + provider: "aws_secrets_manager" as const, + status: "ready", + config: { + region: "us-west-2", + namespace: "prod-us-west", + secretNamePrefix: "clip", + ownerTag: "platform", + environmentTag: "production", + }, + }; + + const health = await provider.healthCheck({ providerConfig }); + const prepared = await provider.createSecret({ + value: "super-secret-value", + providerConfig, + context: { + companyId: "company-1", + secretKey: "openai-api-key", + secretName: "OpenAI API Key", + version: 1, + }, + }); + + expect(health.status).toBe("ok"); + expect(health.details).toMatchObject({ + region: "us-west-2", + prefix: "clip", + deploymentId: "prod-us-west", + kmsKeyConfigured: false, + }); + expect(calls).toEqual([ + expect.objectContaining({ + op: "createSecret", + input: expect.objectContaining({ + Name: "clip/prod-us-west/company-1/openai-api-key", + SecretString: "super-secret-value", + Tags: expect.arrayContaining([ + { Key: "paperclip:provider-owner", Value: "platform" }, + { Key: "paperclip:environment", Value: "production" }, + ]), + }), + }), + ]); + expect(calls[0]?.input).not.toHaveProperty("KmsKeyId"); + expect(JSON.stringify(prepared)).not.toContain("super-secret-value"); + expect(prepared.externalRef).toContain("clip/prod-us-west/company-1/openai-api-key"); + }); + it("creates new AWS secret versions against a namespace-valid existing secret reference", async () => { const calls: Array<{ op: string; input: Record }> = []; const provider = createAwsSecretsManagerProvider({ @@ -324,6 +406,22 @@ describe("awsSecretsManagerProvider", () => { const health = await provider.healthCheck(); expect(health.status).toBe("warn"); + expect(health.message).toContain("missing PAPERCLIP_SECRETS_AWS_REGION"); + expect(health.warnings).toEqual( + expect.arrayContaining([ + expect.stringContaining("Missing required non-secret AWS provider config"), + expect.stringContaining("AWS bootstrap credentials must be available"), + expect.stringContaining("Do not store AWS root credentials"), + ]), + ); + expect(health.details).toMatchObject({ + missingConfig: [ + "PAPERCLIP_SECRETS_AWS_REGION or AWS_REGION/AWS_DEFAULT_REGION", + "PAPERCLIP_SECRETS_AWS_DEPLOYMENT_ID", + "PAPERCLIP_SECRETS_AWS_KMS_KEY_ID", + ], + credentialSource: "AWS SDK default credential provider chain", + }); await expect( provider.createSecret({ value: "super-secret-value", diff --git a/server/src/__tests__/secrets-service.test.ts b/server/src/__tests__/secrets-service.test.ts index 624085cb7ce..5ba3e6a08f4 100644 --- a/server/src/__tests__/secrets-service.test.ts +++ b/server/src/__tests__/secrets-service.test.ts @@ -255,6 +255,72 @@ describeEmbeddedPostgres("secretService", () => { ).rejects.toThrow(/coming soon/i); }); + it("passes selected provider vault config through create, rotate, and resolve", async () => { + const companyId = await seedCompany(); + const svc = secretService(db); + const awsVault = await svc.createProviderConfig(companyId, { + provider: "aws_secrets_manager", + displayName: "AWS production", + config: { + region: "us-east-1", + namespace: "prod-use1", + secretNamePrefix: "paperclip", + }, + }); + + const createSpy = vi.spyOn(awsSecretsManagerProvider, "createSecret").mockResolvedValue({ + material: { + scheme: "aws_secrets_manager_v1", + secretId: "arn:aws:secretsmanager:us-east-1:123456789012:secret:paperclip/prod-use1/company/openai-api-key", + versionId: "aws-version-1", + source: "managed", + }, + valueSha256: "value-sha-1", + fingerprintSha256: "fingerprint-sha-1", + externalRef: "arn:aws:secretsmanager:us-east-1:123456789012:secret:paperclip/prod-use1/company/openai-api-key", + providerVersionRef: "aws-version-1", + }); + const createVersionSpy = vi.spyOn(awsSecretsManagerProvider, "createVersion").mockResolvedValue({ + material: { + scheme: "aws_secrets_manager_v1", + secretId: "arn:aws:secretsmanager:us-east-1:123456789012:secret:paperclip/prod-use1/company/openai-api-key", + versionId: "aws-version-2", + source: "managed", + }, + valueSha256: "value-sha-2", + fingerprintSha256: "fingerprint-sha-2", + externalRef: "arn:aws:secretsmanager:us-east-1:123456789012:secret:paperclip/prod-use1/company/openai-api-key", + providerVersionRef: "aws-version-2", + }); + const resolveSpy = vi.spyOn(awsSecretsManagerProvider, "resolveVersion").mockResolvedValue("resolved-secret"); + + const secret = await svc.create(companyId, { + name: `aws-managed-${randomUUID()}`, + provider: "aws_secrets_manager", + providerConfigId: awsVault.id, + value: "runtime-secret", + }); + const rotated = await svc.rotate(secret.id, { value: "rotated-runtime-secret" }); + const resolved = await svc.resolveSecretValue(companyId, rotated.id, "latest"); + + expect(resolved).toBe("resolved-secret"); + expect(createSpy).toHaveBeenCalledWith(expect.objectContaining({ + providerConfig: expect.objectContaining({ + id: awsVault.id, + provider: "aws_secrets_manager", + config: expect.objectContaining({ region: "us-east-1", namespace: "prod-use1" }), + }), + })); + expect(createVersionSpy).toHaveBeenCalledWith(expect.objectContaining({ + providerConfig: expect.objectContaining({ id: awsVault.id }), + })); + expect(resolveSpy).toHaveBeenCalledWith(expect.objectContaining({ + providerConfig: expect.objectContaining({ id: awsVault.id }), + providerVersionRef: "aws-version-2", + })); + expect(JSON.stringify(resolveSpy.mock.calls[0]?.[0])).not.toContain("resolved-secret"); + }); + it("rejects externalRef overrides on managed secrets", async () => { const companyId = await seedCompany(); const svc = secretService(db); @@ -340,6 +406,7 @@ describeEmbeddedPostgres("secretService", () => { version: 1, }, mode: "delete", + providerConfig: null, }); expect(persisted).toBeNull(); }); diff --git a/server/src/routes/secrets.ts b/server/src/routes/secrets.ts index eba97021948..d39247a379f 100644 --- a/server/src/routes/secrets.ts +++ b/server/src/routes/secrets.ts @@ -282,6 +282,7 @@ export function secretRoutes(db: Db) { value: req.body.value, externalRef: req.body.externalRef, providerVersionRef: req.body.providerVersionRef, + providerConfigId: req.body.providerConfigId, }, { userId: req.actor.userId ?? "board", agentId: null }, ); diff --git a/server/src/secrets/aws-secrets-manager-provider.ts b/server/src/secrets/aws-secrets-manager-provider.ts index 257250611fb..808032d92c8 100644 --- a/server/src/secrets/aws-secrets-manager-provider.ts +++ b/server/src/secrets/aws-secrets-manager-provider.ts @@ -10,6 +10,7 @@ import type { SecretProviderHealthCheck, SecretProviderModule, SecretProviderValidationResult, + SecretProviderVaultRuntimeConfig, SecretProviderWriteContext, StoredSecretVersionMaterial, } from "./types.js"; @@ -19,6 +20,10 @@ const DEFAULT_PREFIX = "paperclip"; const DEFAULT_OWNER_TAG = "paperclip"; const DEFAULT_VERSION_STAGE = "AWSCURRENT"; const DEFAULT_DELETE_RECOVERY_WINDOW_DAYS = 30; +const AWS_RUNTIME_CREDENTIAL_WARNING = + "AWS bootstrap credentials must be available to the Paperclip server runtime through the AWS SDK default credential provider chain: IAM role/workload identity, AWS_PROFILE/SSO/shared credentials, web identity, container/instance metadata, or short-lived shell credentials."; +const AWS_CREDENTIAL_CUSTODY_WARNING = + "Do not store AWS root credentials or long-lived IAM user access keys in Paperclip company_secrets; the AWS provider bootstrap belongs in deployment infrastructure, the process environment, an AWS profile, or the orchestrator secret store."; interface AwsSecretsManagerMaterial extends StoredSecretVersionMaterial { scheme: typeof AWS_SECRETS_MANAGER_SCHEME; @@ -32,7 +37,7 @@ interface AwsSecretsManagerConfig { endpoint: string; deploymentId: string; prefix: string; - kmsKeyId: string; + kmsKeyId: string | null; environmentTag: string; providerOwnerTag: string; deleteRecoveryWindowDays: number; @@ -49,7 +54,7 @@ interface AwsSecretsManagerGateway { createSecret(input: { Name: string; SecretString: string; - KmsKeyId: string; + KmsKeyId?: string; Description?: string; Tags: AwsSecretsManagerTag[]; }): Promise<{ @@ -97,18 +102,109 @@ function configuredAwsSecretsManagerDescriptor() { } function canLoadAwsSecretsManagerConfig() { - return Boolean( - ( - process.env.PAPERCLIP_SECRETS_AWS_REGION ?? - process.env.AWS_REGION ?? - process.env.AWS_DEFAULT_REGION - )?.trim() && - process.env.PAPERCLIP_SECRETS_AWS_DEPLOYMENT_ID?.trim() && - process.env.PAPERCLIP_SECRETS_AWS_KMS_KEY_ID?.trim(), - ); + return getAwsConfigReadiness().missingConfig.length === 0; +} + +function asOptionalNonEmptyString(value: unknown): string | null { + return typeof value === "string" && value.trim().length > 0 ? value.trim() : null; +} + +function readProviderVaultConfig(input: SecretProviderVaultRuntimeConfig): AwsSecretsManagerConfig { + if (input.provider !== "aws_secrets_manager") { + throw unprocessable("AWS Secrets Manager provider received a mismatched provider vault"); + } + if (input.status === "disabled") { + throw unprocessable("AWS Secrets Manager provider vault is disabled"); + } + if (input.status === "coming_soon") { + throw unprocessable("AWS Secrets Manager provider vault runtime is locked while coming soon"); + } + const region = asOptionalNonEmptyString(input.config.region); + if (!region) { + throw unprocessable("AWS Secrets Manager provider vault requires non-secret config: region"); + } + const recoveryWindowRaw = process.env.PAPERCLIP_SECRETS_AWS_DELETE_RECOVERY_DAYS?.trim(); + const recoveryWindow = recoveryWindowRaw ? Number(recoveryWindowRaw) : DEFAULT_DELETE_RECOVERY_WINDOW_DAYS; + if (!Number.isFinite(recoveryWindow) || recoveryWindow < 7 || recoveryWindow > 30) { + throw unprocessable( + "PAPERCLIP_SECRETS_AWS_DELETE_RECOVERY_DAYS must be an integer between 7 and 30", + ); + } + + return { + region, + endpoint: + process.env.PAPERCLIP_SECRETS_AWS_ENDPOINT?.trim() || + `https://secretsmanager.${region}.amazonaws.com`, + deploymentId: sanitizePathSegment( + asOptionalNonEmptyString(input.config.namespace) ?? input.id, + ), + prefix: sanitizePathSegment( + asOptionalNonEmptyString(input.config.secretNamePrefix) || DEFAULT_PREFIX, + ), + kmsKeyId: asOptionalNonEmptyString(input.config.kmsKeyId), + environmentTag: + asOptionalNonEmptyString(input.config.environmentTag) || + process.env.NODE_ENV?.trim() || + "unknown", + providerOwnerTag: + asOptionalNonEmptyString(input.config.ownerTag) || DEFAULT_OWNER_TAG, + deleteRecoveryWindowDays: recoveryWindow, + }; +} + +function getAwsConfigReadiness() { + const region = ( + process.env.PAPERCLIP_SECRETS_AWS_REGION ?? + process.env.AWS_REGION ?? + process.env.AWS_DEFAULT_REGION + )?.trim(); + const deploymentId = process.env.PAPERCLIP_SECRETS_AWS_DEPLOYMENT_ID?.trim(); + const kmsKeyId = process.env.PAPERCLIP_SECRETS_AWS_KMS_KEY_ID?.trim(); + const missingConfig: string[] = []; + + if (!region) { + missingConfig.push("PAPERCLIP_SECRETS_AWS_REGION or AWS_REGION/AWS_DEFAULT_REGION"); + } + if (!deploymentId) { + missingConfig.push("PAPERCLIP_SECRETS_AWS_DEPLOYMENT_ID"); + } + if (!kmsKeyId) { + missingConfig.push("PAPERCLIP_SECRETS_AWS_KMS_KEY_ID"); + } + + return { + missingConfig, + region: region || null, + deploymentId: deploymentId || null, + kmsKeyConfigured: Boolean(kmsKeyId), + credentialSources: describeDetectedAwsCredentialSources(), + }; +} + +function describeDetectedAwsCredentialSources() { + const sources: string[] = []; + if (process.env.AWS_PROFILE?.trim()) sources.push("AWS_PROFILE/shared config"); + if (process.env.AWS_ACCESS_KEY_ID?.trim() && process.env.AWS_SECRET_ACCESS_KEY?.trim()) { + sources.push("temporary AWS_ACCESS_KEY_ID/AWS_SECRET_ACCESS_KEY environment credentials"); + } + if (process.env.AWS_WEB_IDENTITY_TOKEN_FILE?.trim() && process.env.AWS_ROLE_ARN?.trim()) { + sources.push("AWS web identity token"); + } + if ( + process.env.AWS_CONTAINER_CREDENTIALS_RELATIVE_URI?.trim() || + process.env.AWS_CONTAINER_CREDENTIALS_FULL_URI?.trim() + ) { + sources.push("AWS container credentials endpoint"); + } + if (process.env.AWS_SHARED_CREDENTIALS_FILE?.trim() || process.env.AWS_CONFIG_FILE?.trim()) { + sources.push("custom AWS shared credentials/config file"); + } + return sources; } function loadAwsSecretsManagerConfig(): AwsSecretsManagerConfig { + const readiness = getAwsConfigReadiness(); const region = process.env.PAPERCLIP_SECRETS_AWS_REGION?.trim() || process.env.AWS_REGION?.trim() || @@ -116,6 +212,11 @@ function loadAwsSecretsManagerConfig(): AwsSecretsManagerConfig { const deploymentId = process.env.PAPERCLIP_SECRETS_AWS_DEPLOYMENT_ID?.trim(); const kmsKeyId = process.env.PAPERCLIP_SECRETS_AWS_KMS_KEY_ID?.trim(); + if (readiness.missingConfig.length > 0) { + throw unprocessable( + `AWS Secrets Manager provider requires non-secret config: ${readiness.missingConfig.join(", ")}`, + ); + } if (!region) { throw unprocessable( "AWS Secrets Manager provider requires PAPERCLIP_SECRETS_AWS_REGION or AWS_REGION", @@ -322,7 +423,7 @@ class AwsSecretsManagerJsonGateway implements AwsSecretsManagerGateway { createSecret(input: { Name: string; SecretString: string; - KmsKeyId: string; + KmsKeyId?: string; Description?: string; Tags: AwsSecretsManagerTag[]; }) { @@ -404,7 +505,8 @@ export function createAwsSecretsManagerProvider( gateway?: AwsSecretsManagerGateway; }, ): SecretProviderModule { - function resolveConfig() { + function resolveConfig(providerConfig?: SecretProviderVaultRuntimeConfig | null) { + if (providerConfig) return readProviderVaultConfig(providerConfig); return options?.config ?? loadAwsSecretsManagerConfig(); } @@ -413,13 +515,17 @@ export function createAwsSecretsManagerProvider( } async function validateConfig( - input?: { deploymentMode?: DeploymentMode; strictMode?: boolean }, + input?: { + deploymentMode?: DeploymentMode; + strictMode?: boolean; + providerConfig?: SecretProviderVaultRuntimeConfig | null; + }, ): Promise { const warnings: string[] = []; if (input?.deploymentMode === "authenticated" && input.strictMode !== true) { warnings.push("Strict secret mode should be enabled for authenticated deployments"); } - const config = resolveConfig(); + const config = resolveConfig(input?.providerConfig); if (!config.prefix) { warnings.push("PAPERCLIP_SECRETS_AWS_PREFIX should be set to a deployment-scoped prefix"); } @@ -427,20 +533,38 @@ export function createAwsSecretsManagerProvider( } async function healthCheck( - input?: { deploymentMode?: DeploymentMode; strictMode?: boolean }, + input?: { + deploymentMode?: DeploymentMode; + strictMode?: boolean; + providerConfig?: SecretProviderVaultRuntimeConfig | null; + }, ): Promise { try { const validation = await validateConfig(input); + const config = resolveConfig(input?.providerConfig); + const readiness = getAwsConfigReadiness(); + const warnings = [...validation.warnings]; + if ( + process.env.AWS_ACCESS_KEY_ID?.trim() && + process.env.AWS_SECRET_ACCESS_KEY?.trim() + ) { + warnings.push( + "AWS static environment credentials are visible to this process; use only short-lived shell credentials locally and prefer IAM role/workload identity for hosted deployments.", + ); + } return { provider: "aws_secrets_manager", - status: validation.warnings.length > 0 ? "warn" : "ok", - message: "AWS Secrets Manager provider is configured", - warnings: validation.warnings, + status: warnings.length > 0 ? "warn" : "ok", + message: + "AWS Secrets Manager provider config is present; AWS credentials are resolved by the server runtime through the AWS SDK default credential provider chain.", + warnings, details: { - region: resolveConfig().region, - prefix: resolveConfig().prefix, - deploymentId: resolveConfig().deploymentId, - kmsKeyConfigured: true, + region: config.region, + prefix: config.prefix, + deploymentId: config.deploymentId, + kmsKeyConfigured: Boolean(config.kmsKeyId), + credentialSource: "AWS SDK default credential provider chain", + detectedCredentialSources: readiness.credentialSources, }, backupGuidance: [ "Back up Paperclip metadata separately from AWS-managed secrets.", @@ -448,13 +572,47 @@ export function createAwsSecretsManagerProvider( ], }; } catch (error) { + const readiness = getAwsConfigReadiness(); + const providerConfigMissing = input?.providerConfig && !asOptionalNonEmptyString(input.providerConfig.config.region) + ? ["region"] + : []; + const missingConfig = input?.providerConfig ? providerConfigMissing : readiness.missingConfig; return { provider: "aws_secrets_manager", status: "warn", - message: error instanceof Error ? error.message : String(error), + message: + missingConfig.length > 0 + ? `AWS Secrets Manager provider is not ready: missing ${missingConfig.join(", ")}.` + : error instanceof Error + ? error.message + : String(error), warnings: [ + ...(missingConfig.length > 0 + ? [`Missing required non-secret AWS provider config: ${missingConfig.join(", ")}.`] + : []), + AWS_RUNTIME_CREDENTIAL_WARNING, + AWS_CREDENTIAL_CUSTODY_WARNING, "Managed secret create/rotate/resolve calls will fail until AWS provider configuration is complete.", ], + details: { + missingConfig, + requiredProviderConfig: input?.providerConfig + ? ["region"] + : [ + "PAPERCLIP_SECRETS_AWS_REGION or AWS_REGION/AWS_DEFAULT_REGION", + "PAPERCLIP_SECRETS_AWS_DEPLOYMENT_ID", + "PAPERCLIP_SECRETS_AWS_KMS_KEY_ID", + ], + optionalProviderConfig: [ + "PAPERCLIP_SECRETS_AWS_PREFIX", + "PAPERCLIP_SECRETS_AWS_ENVIRONMENT", + "PAPERCLIP_SECRETS_AWS_PROVIDER_OWNER", + "PAPERCLIP_SECRETS_AWS_ENDPOINT", + "PAPERCLIP_SECRETS_AWS_DELETE_RECOVERY_DAYS", + ], + credentialSource: "AWS SDK default credential provider chain", + detectedCredentialSources: readiness.credentialSources, + }, }; } } @@ -466,18 +624,21 @@ export function createAwsSecretsManagerProvider( }, validateConfig, async createSecret(input) { - const config = resolveConfig(); + const config = resolveConfig(input.providerConfig); const gateway = resolveGateway(config); const valueSha256 = sha256Hex(input.value); const secretId = buildManagedSecretId(config, input.context); try { - const created = await gateway.createSecret({ + const createInput = { Name: secretId, SecretString: input.value, - KmsKeyId: config.kmsKeyId, + ...(config.kmsKeyId ? { KmsKeyId: config.kmsKeyId } : {}), Description: input.context ? `Paperclip secret ${input.context.secretName}` : undefined, Tags: buildManagedSecretTags(config, input.context), + }; + const created = await gateway.createSecret({ + ...createInput, }); const normalizedSecretId = created.ARN ?? created.Name ?? secretId; return { @@ -492,7 +653,7 @@ export function createAwsSecretsManagerProvider( } }, async createVersion(input) { - const config = resolveConfig(); + const config = resolveConfig(input.providerConfig); const gateway = resolveGateway(config); const valueSha256 = sha256Hex(input.value); const secretId = resolveManagedSecretRef({ @@ -522,7 +683,7 @@ export function createAwsSecretsManagerProvider( return createExternalReferenceMaterial(input.externalRef, input.providerVersionRef ?? null); }, async resolveVersion(input) { - const config = resolveConfig(); + const config = resolveConfig(input.providerConfig); const gateway = resolveGateway(config); const material = asAwsSecretsManagerMaterial(input.material); const secretId = @@ -557,7 +718,7 @@ export function createAwsSecretsManagerProvider( if (input.mode !== "delete" || material?.source !== "managed") return; - const config = resolveConfig(); + const config = resolveConfig(input.providerConfig); const gateway = resolveGateway(config); const secretId = resolveManagedSecretRef({ config, diff --git a/server/src/secrets/types.ts b/server/src/secrets/types.ts index a792fa24159..6ad97d1d8d1 100644 --- a/server/src/secrets/types.ts +++ b/server/src/secrets/types.ts @@ -36,6 +36,13 @@ export interface SecretProviderRuntimeContext { version: number; } +export interface SecretProviderVaultRuntimeConfig { + id: string; + provider: SecretProvider; + status: string; + config: Record; +} + export interface SecretProviderWriteContext { companyId: string; secretKey: string; @@ -49,41 +56,49 @@ export interface SecretProviderModule { validateConfig(input?: { deploymentMode?: DeploymentMode; strictMode?: boolean; + providerConfig?: SecretProviderVaultRuntimeConfig | null; }): Promise; createSecret(input: { value: string; externalRef?: string | null; context?: SecretProviderWriteContext; + providerConfig?: SecretProviderVaultRuntimeConfig | null; }): Promise; createVersion(input: { value: string; externalRef?: string | null; context?: SecretProviderWriteContext; + providerConfig?: SecretProviderVaultRuntimeConfig | null; }): Promise; linkExternalSecret(input: { externalRef: string; providerVersionRef?: string | null; context?: SecretProviderWriteContext; + providerConfig?: SecretProviderVaultRuntimeConfig | null; }): Promise; resolveVersion(input: { material: StoredSecretVersionMaterial; externalRef: string | null; providerVersionRef?: string | null; context?: SecretProviderRuntimeContext; + providerConfig?: SecretProviderVaultRuntimeConfig | null; }): Promise; rotate?(input: { material: StoredSecretVersionMaterial; externalRef: string | null; providerVersionRef?: string | null; + providerConfig?: SecretProviderVaultRuntimeConfig | null; }): Promise; deleteOrArchive(input: { material?: StoredSecretVersionMaterial | null; externalRef: string | null; context?: SecretProviderWriteContext; mode: "archive" | "delete"; + providerConfig?: SecretProviderVaultRuntimeConfig | null; }): Promise; healthCheck(input?: { deploymentMode?: DeploymentMode; strictMode?: boolean; + providerConfig?: SecretProviderVaultRuntimeConfig | null; }): Promise; } diff --git a/server/src/services/secrets.ts b/server/src/services/secrets.ts index a895a41d020..0bdeaa8de5f 100644 --- a/server/src/services/secrets.ts +++ b/server/src/services/secrets.ts @@ -29,6 +29,7 @@ import { getSecretProvider, listSecretProviders, } from "../secrets/provider-registry.js"; +import type { SecretProviderHealthCheck, SecretProviderVaultRuntimeConfig } from "../secrets/types.js"; const ENV_KEY_RE = /^[A-Za-z_][A-Za-z0-9_]*$/; const SENSITIVE_ENV_KEY_RE = @@ -256,6 +257,31 @@ export function secretService(db: Db) { return providerConfig; } + function toProviderVaultRuntimeConfig( + providerConfig: Awaited> | null, + ): SecretProviderVaultRuntimeConfig | null { + if (!providerConfig) return null; + return { + id: providerConfig.id, + provider: providerConfig.provider as SecretProvider, + status: providerConfig.status, + config: providerConfig.config ?? {}, + }; + } + + async function getSelectableRuntimeProviderConfig(input: { + companyId: string; + provider: SecretProvider; + providerConfigId: string | null | undefined; + }) { + const providerConfig = await assertProviderConfigForSecret( + input.companyId, + input.provider, + input.providerConfigId, + ); + return toProviderVaultRuntimeConfig(providerConfig); + } + function validateProviderConfigPayload( provider: SecretProvider, config: Record, @@ -272,7 +298,7 @@ export function secretService(db: Db) { provider: SecretProvider; status: SecretProviderConfigStatus; config: Record; - }): Omit { + }): Omit | null { if (input.status === "disabled") { return { configId: input.id, @@ -295,33 +321,34 @@ export function secretService(db: Db) { }, }; } - if (input.provider === "aws_secrets_manager" && typeof input.config.region !== "string") { - return { - configId: input.id, - provider: input.provider, - status: "warning", - message: "AWS Secrets Manager vault is missing region routing metadata.", - details: { - code: "missing_config", - message: "AWS Secrets Manager vault is missing region routing metadata.", - missingFields: ["region"], - }, - }; - } + return null; + } + + function mapProviderModuleHealth(input: { + configId: string; + provider: SecretProvider; + providerStatus: SecretProviderConfigStatus; + health: SecretProviderHealthCheck; + }): Omit { + const status: SecretProviderConfigHealthStatus = + input.health.status === "ok" + ? input.providerStatus === "warning" ? "warning" : "ready" + : input.health.status === "error" + ? "error" + : "warning"; + const guidance = [ + ...(input.health.warnings ?? []), + ...(input.health.backupGuidance ?? []), + ]; return { - configId: input.id, + configId: input.configId, provider: input.provider, - status: input.status === "warning" ? "warning" : "ready", - message: - input.status === "warning" - ? "Provider vault is saved but needs operator attention." - : "Provider vault metadata is ready.", + status, + message: input.health.message, details: { - code: input.status === "warning" ? "needs_attention" : "metadata_ready", - message: - input.status === "warning" - ? "Provider vault is saved but needs operator attention." - : "Provider vault metadata is ready.", + code: input.health.status === "ok" ? "provider_ready" : "provider_needs_attention", + message: input.health.message, + guidance: guidance.length > 0 ? guidance : undefined, }, }; } @@ -347,10 +374,16 @@ export function secretService(db: Db) { throw unprocessable("Secret version is not active"); } const provider = getSecretProvider(providerId); + const providerConfig = await getSelectableRuntimeProviderConfig({ + companyId, + provider: providerId, + providerConfigId: secret.providerConfigId, + }); const value = await provider.resolveVersion({ material: versionRow.material as Record, externalRef: secret.externalRef, providerVersionRef: versionRow.providerVersionRef, + providerConfig, context: { companyId, secretId: secret.id, @@ -618,12 +651,21 @@ export function secretService(db: Db) { const existing = await getProviderConfigById(id); if (!existing) return null; const checkedAt = new Date(); - const health = providerConfigHealth({ + const staticHealth = providerConfigHealth({ id: existing.id, provider: existing.provider as SecretProvider, status: existing.status as SecretProviderConfigStatus, config: existing.config ?? {}, }); + const provider = getSecretProvider(existing.provider as SecretProvider); + const health = staticHealth ?? mapProviderModuleHealth({ + configId: existing.id, + provider: existing.provider as SecretProvider, + providerStatus: existing.status as SecretProviderConfigStatus, + health: await provider.healthCheck({ + providerConfig: toProviderVaultRuntimeConfig(existing), + }), + }); await db .update(companySecretProviderConfigs) .set({ @@ -695,7 +737,11 @@ export function secretService(db: Db) { const managedMode = input.managedMode ?? "paperclip_managed"; const provider = getSecretProvider(input.provider); - await assertProviderConfigForSecret(companyId, input.provider, input.providerConfigId); + const providerConfig = await getSelectableRuntimeProviderConfig({ + companyId, + provider: input.provider, + providerConfigId: input.providerConfigId, + }); if (managedMode === "external_reference" && !input.externalRef?.trim()) { throw unprocessable("External reference secrets require externalRef"); } @@ -710,6 +756,7 @@ export function secretService(db: Db) { ? await provider.linkExternalSecret({ externalRef: input.externalRef ?? "", providerVersionRef: input.providerVersionRef ?? null, + providerConfig, context: { companyId, secretKey: key, @@ -720,6 +767,7 @@ export function secretService(db: Db) { : await provider.createSecret({ value: input.value ?? "", externalRef: null, + providerConfig, context: { companyId, secretKey: key, @@ -768,12 +816,25 @@ export function secretService(db: Db) { rotate: async ( secretId: string, - input: { value?: string | null; externalRef?: string | null; providerVersionRef?: string | null }, + input: { + value?: string | null; + externalRef?: string | null; + providerVersionRef?: string | null; + providerConfigId?: string | null; + }, actor?: { userId?: string | null; agentId?: string | null }, ) => { const secret = await getById(secretId); if (!secret) throw notFound("Secret not found"); - const provider = getSecretProvider(secret.provider as SecretProvider); + const providerId = secret.provider as SecretProvider; + const provider = getSecretProvider(providerId); + const providerConfigId = + input.providerConfigId === undefined ? secret.providerConfigId : input.providerConfigId; + const providerConfig = await getSelectableRuntimeProviderConfig({ + companyId: secret.companyId, + provider: providerId, + providerConfigId, + }); const nextVersion = secret.latestVersion + 1; if (secret.managedMode === "external_reference" && !(input.externalRef ?? secret.externalRef)?.trim()) { throw unprocessable("External reference secrets require externalRef"); @@ -789,6 +850,7 @@ export function secretService(db: Db) { ? await provider.linkExternalSecret({ externalRef: input.externalRef ?? secret.externalRef ?? "", providerVersionRef: input.providerVersionRef ?? null, + providerConfig, context: { companyId: secret.companyId, secretKey: secret.key, @@ -799,6 +861,7 @@ export function secretService(db: Db) { : await provider.createVersion({ value: input.value ?? "", externalRef: secret.externalRef ?? null, + providerConfig, context: { companyId: secret.companyId, secretKey: secret.key, @@ -829,6 +892,7 @@ export function secretService(db: Db) { .set({ latestVersion: nextVersion, externalRef: prepared.externalRef, + providerConfigId, lastRotatedAt: new Date(), updatedAt: new Date(), }) @@ -1059,10 +1123,17 @@ export function secretService(db: Db) { const secret = await getById(secretId); if (!secret) return null; const versionRow = await getSecretVersion(secret.id, secret.latestVersion); - const provider = getSecretProvider(secret.provider as SecretProvider); + const providerId = secret.provider as SecretProvider; + const provider = getSecretProvider(providerId); + const providerConfig = await getSelectableRuntimeProviderConfig({ + companyId: secret.companyId, + provider: providerId, + providerConfigId: secret.providerConfigId, + }); await provider.deleteOrArchive({ material: versionRow?.material as Record | undefined, externalRef: secret.externalRef, + providerConfig, context: { companyId: secret.companyId, secretKey: secret.key,