diff --git a/server/package.json b/server/package.json index bd19e53fcf4..e3c93757d40 100644 --- a/server/package.json +++ b/server/package.json @@ -44,7 +44,11 @@ }, "dependencies": { "@aws-sdk/client-s3": "^3.888.0", + "@aws-sdk/credential-provider-node": "^3.972.10", "@paperclipai/adapter-acpx-local": "workspace:*", + "@smithy/hash-node": "^4.2.8", + "@smithy/protocol-http": "^5.3.8", + "@smithy/signature-v4": "^5.3.8", "@paperclipai/adapter-claude-local": "workspace:*", "@paperclipai/adapter-codex-local": "workspace:*", "@paperclipai/adapter-cursor-local": "workspace:*", diff --git a/server/src/__tests__/aws-secrets-manager-provider.test.ts b/server/src/__tests__/aws-secrets-manager-provider.test.ts new file mode 100644 index 00000000000..0baa0a4fd30 --- /dev/null +++ b/server/src/__tests__/aws-secrets-manager-provider.test.ts @@ -0,0 +1,369 @@ +import { afterEach, describe, expect, it } from "vitest"; +import { createAwsSecretsManagerProvider } from "../secrets/aws-secrets-manager-provider.js"; + +describe("awsSecretsManagerProvider", () => { + const previousEnv = { + PAPERCLIP_SECRETS_AWS_REGION: process.env.PAPERCLIP_SECRETS_AWS_REGION, + AWS_REGION: process.env.AWS_REGION, + AWS_DEFAULT_REGION: process.env.AWS_DEFAULT_REGION, + PAPERCLIP_SECRETS_AWS_DEPLOYMENT_ID: process.env.PAPERCLIP_SECRETS_AWS_DEPLOYMENT_ID, + PAPERCLIP_SECRETS_AWS_KMS_KEY_ID: process.env.PAPERCLIP_SECRETS_AWS_KMS_KEY_ID, + }; + + afterEach(() => { + for (const [key, value] of Object.entries(previousEnv)) { + if (value === undefined) { + delete process.env[key]; + } else { + process.env[key] = value; + } + } + }); + + it("creates Paperclip-managed AWS secrets without persisting plaintext in provider material", async () => { + const calls: Array<{ op: string; input: Record }> = []; + const provider = createAwsSecretsManagerProvider({ + config: { + region: "us-east-1", + endpoint: "https://secretsmanager.us-east-1.amazonaws.com", + deploymentId: "prod-use1", + prefix: "paperclip", + kmsKeyId: "arn:aws:kms:us-east-1:123456789012:key/test", + environmentTag: "production", + providerOwnerTag: "paperclip", + deleteRecoveryWindowDays: 30, + }, + gateway: { + async createSecret(input) { + calls.push({ op: "createSecret", input }); + return { + ARN: "arn:aws:secretsmanager:us-east-1:123456789012:secret:paperclip/prod-use1/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 prepared = await provider.createSecret({ + value: "super-secret-value", + externalRef: "arn:aws:secretsmanager:us-east-1:123456789012:secret:shared/attacker", + context: { + companyId: "company-1", + secretKey: "openai-api-key", + secretName: "OpenAI API Key", + version: 1, + }, + }); + + expect(calls).toEqual([ + expect.objectContaining({ + op: "createSecret", + input: expect.objectContaining({ + Name: "paperclip/prod-use1/company-1/openai-api-key", + KmsKeyId: "arn:aws:kms:us-east-1:123456789012:key/test", + }), + }), + ]); + expect(JSON.stringify(prepared)).not.toContain("super-secret-value"); + expect(prepared.externalRef).toContain("paperclip/prod-use1/company-1/openai-api-key"); + expect(prepared.providerVersionRef).toBe("aws-version-1"); + }); + + it("creates new AWS secret versions against a namespace-valid existing secret reference", async () => { + const calls: Array<{ op: string; input: Record }> = []; + const provider = createAwsSecretsManagerProvider({ + config: { + region: "us-east-1", + endpoint: "https://secretsmanager.us-east-1.amazonaws.com", + deploymentId: "prod-use1", + prefix: "paperclip", + kmsKeyId: "arn:aws:kms:us-east-1:123456789012:key/test", + environmentTag: "production", + providerOwnerTag: "paperclip", + deleteRecoveryWindowDays: 30, + }, + gateway: { + async createSecret() { + throw new Error("not used"); + }, + async putSecretValue(input) { + calls.push({ op: "putSecretValue", input }); + return { + ARN: "arn:aws:secretsmanager:us-east-1:123456789012:secret:paperclip/prod-use1/company-1/openai-api-key", + VersionId: "aws-version-2", + }; + }, + async getSecretValue() { + throw new Error("not used"); + }, + async deleteSecret() { + throw new Error("not used"); + }, + }, + }); + + const prepared = await provider.createVersion({ + value: "rotated-secret-value", + externalRef: + "arn:aws:secretsmanager:us-east-1:123456789012:secret:paperclip/prod-use1/company-1/openai-api-key", + context: { + companyId: "company-1", + secretKey: "openai-api-key", + secretName: "OpenAI API Key", + version: 2, + }, + }); + + expect(calls).toEqual([ + { + op: "putSecretValue", + input: { + SecretId: + "arn:aws:secretsmanager:us-east-1:123456789012:secret:paperclip/prod-use1/company-1/openai-api-key", + SecretString: "rotated-secret-value", + }, + }, + ]); + expect(JSON.stringify(prepared)).not.toContain("rotated-secret-value"); + expect(prepared.providerVersionRef).toBe("aws-version-2"); + }); + + it("ignores out-of-namespace refs for managed AWS secret version writes", async () => { + const calls: Array<{ op: string; input: Record }> = []; + const provider = createAwsSecretsManagerProvider({ + config: { + region: "us-east-1", + endpoint: "https://secretsmanager.us-east-1.amazonaws.com", + deploymentId: "prod-use1", + prefix: "paperclip", + kmsKeyId: "arn:aws:kms:us-east-1:123456789012:key/test", + environmentTag: "production", + providerOwnerTag: "paperclip", + deleteRecoveryWindowDays: 30, + }, + gateway: { + async createSecret() { + throw new Error("not used"); + }, + async putSecretValue(input) { + calls.push({ op: "putSecretValue", input }); + return { Name: String(input.SecretId), VersionId: "aws-version-2" }; + }, + async getSecretValue() { + throw new Error("not used"); + }, + async deleteSecret() { + throw new Error("not used"); + }, + }, + }); + + await provider.createVersion({ + value: "rotated-secret-value", + externalRef: "arn:aws:secretsmanager:us-east-1:123456789012:secret:shared/attacker", + context: { + companyId: "company-1", + secretKey: "openai-api-key", + secretName: "OpenAI API Key", + version: 2, + }, + }); + + expect(calls).toEqual([ + { + op: "putSecretValue", + input: { + SecretId: "paperclip/prod-use1/company-1/openai-api-key", + SecretString: "rotated-secret-value", + }, + }, + ]); + }); + + it("stores linked external references as metadata-only provider material", async () => { + const provider = createAwsSecretsManagerProvider(); + + const prepared = await provider.linkExternalSecret({ + externalRef: "arn:aws:secretsmanager:us-east-1:123456789012:secret:shared/external", + providerVersionRef: "linked-version-7", + }); + + expect(prepared.externalRef).toBe( + "arn:aws:secretsmanager:us-east-1:123456789012:secret:shared/external", + ); + expect(prepared.providerVersionRef).toBe("linked-version-7"); + expect(prepared.valueSha256).toBeTruthy(); + }); + + it("resolves AWS secret values by provider version reference", async () => { + const calls: Array<{ op: string; input: Record }> = []; + const provider = createAwsSecretsManagerProvider({ + config: { + region: "us-east-1", + endpoint: "https://secretsmanager.us-east-1.amazonaws.com", + deploymentId: "prod-use1", + prefix: "paperclip", + kmsKeyId: "arn:aws:kms:us-east-1:123456789012:key/test", + environmentTag: "production", + providerOwnerTag: "paperclip", + deleteRecoveryWindowDays: 30, + }, + gateway: { + async createSecret() { + throw new Error("not used"); + }, + async putSecretValue() { + throw new Error("not used"); + }, + async getSecretValue(input) { + calls.push({ op: "getSecretValue", input }); + return { SecretString: "resolved-secret-value", VersionId: "aws-version-2" }; + }, + async deleteSecret() { + throw new Error("not used"); + }, + }, + }); + + const resolved = await provider.resolveVersion({ + material: { + scheme: "aws_secrets_manager_v1", + secretId: "arn:aws:secretsmanager:us-east-1:123456789012:secret:paperclip/prod-use1/company-1/openai-api-key", + versionId: "aws-version-2", + source: "managed", + }, + externalRef: + "arn:aws:secretsmanager:us-east-1:123456789012:secret:paperclip/prod-use1/company-1/openai-api-key", + providerVersionRef: "aws-version-2", + context: { + companyId: "company-1", + secretId: "secret-1", + secretKey: "openai-api-key", + version: 2, + }, + }); + + expect(resolved).toBe("resolved-secret-value"); + expect(calls).toEqual([ + { + op: "getSecretValue", + input: { + SecretId: + "arn:aws:secretsmanager:us-east-1:123456789012:secret:paperclip/prod-use1/company-1/openai-api-key", + VersionId: "aws-version-2", + VersionStage: undefined, + }, + }, + ]); + }); + + it("warns when AWS provider configuration is incomplete and blocks managed writes", 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 provider = createAwsSecretsManagerProvider(); + const health = await provider.healthCheck(); + + expect(health.status).toBe("warn"); + await expect( + provider.createSecret({ + value: "super-secret-value", + context: { + companyId: "company-1", + secretKey: "openai-api-key", + secretName: "OpenAI API Key", + version: 1, + }, + }), + ).rejects.toThrow(/PAPERCLIP_SECRETS_AWS_REGION|AWS_REGION/i); + }); + + it("deletes only Paperclip-managed AWS secrets", async () => { + const calls: Array<{ op: string; input: Record }> = []; + const provider = createAwsSecretsManagerProvider({ + config: { + region: "us-east-1", + endpoint: "https://secretsmanager.us-east-1.amazonaws.com", + deploymentId: "prod-use1", + prefix: "paperclip", + kmsKeyId: "arn:aws:kms:us-east-1:123456789012:key/test", + environmentTag: "production", + providerOwnerTag: "paperclip", + deleteRecoveryWindowDays: 30, + }, + gateway: { + async createSecret() { + throw new Error("not used"); + }, + async putSecretValue() { + throw new Error("not used"); + }, + async getSecretValue() { + throw new Error("not used"); + }, + async deleteSecret(input) { + calls.push({ op: "deleteSecret", input }); + return {}; + }, + }, + }); + + await provider.deleteOrArchive({ + mode: "delete", + externalRef: "arn:aws:secretsmanager:us-east-1:123456789012:secret:shared/attacker", + material: { + scheme: "aws_secrets_manager_v1", + secretId: "arn:aws:secretsmanager:us-east-1:123456789012:secret:shared/attacker", + versionId: null, + source: "managed", + }, + context: { + companyId: "company-1", + secretKey: "openai-api-key", + secretName: "OpenAI API Key", + version: 2, + }, + }); + await provider.deleteOrArchive({ + mode: "delete", + externalRef: "arn:aws:secretsmanager:us-east-1:123456789012:secret:shared/external", + material: { + scheme: "aws_secrets_manager_v1", + secretId: "arn:aws:secretsmanager:us-east-1:123456789012:secret:shared/external", + versionId: "linked-version-7", + source: "external_reference", + }, + context: { + companyId: "company-1", + secretKey: "openai-api-key", + secretName: "OpenAI API Key", + version: 2, + }, + }); + + expect(calls).toEqual([ + { + op: "deleteSecret", + input: { + SecretId: "paperclip/prod-use1/company-1/openai-api-key", + RecoveryWindowInDays: 30, + }, + }, + ]); + }); +}); diff --git a/server/src/__tests__/secret-provider-registry.test.ts b/server/src/__tests__/secret-provider-registry.test.ts index 207c5ea127c..326fd40682a 100644 --- a/server/src/__tests__/secret-provider-registry.test.ts +++ b/server/src/__tests__/secret-provider-registry.test.ts @@ -39,7 +39,7 @@ describe("secret provider registry", () => { }), expect.objectContaining({ id: "aws_secrets_manager", - supportsManagedValues: false, + supportsManagedValues: true, supportsExternalReferences: true, configured: false, }), diff --git a/server/src/secrets/aws-secrets-manager-provider.ts b/server/src/secrets/aws-secrets-manager-provider.ts new file mode 100644 index 00000000000..48241888267 --- /dev/null +++ b/server/src/secrets/aws-secrets-manager-provider.ts @@ -0,0 +1,572 @@ +import { createHash } from "node:crypto"; +import { defaultProvider as defaultAwsCredentialsProvider } from "@aws-sdk/credential-provider-node"; +import { Hash } from "@smithy/hash-node"; +import { HttpRequest } from "@smithy/protocol-http"; +import { SignatureV4 } from "@smithy/signature-v4"; +import type { DeploymentMode } from "@paperclipai/shared"; +import { conflict, notFound, unprocessable } from "../errors.js"; +import type { + PreparedSecretVersion, + SecretProviderHealthCheck, + SecretProviderModule, + SecretProviderValidationResult, + SecretProviderWriteContext, + StoredSecretVersionMaterial, +} from "./types.js"; + +const AWS_SECRETS_MANAGER_SCHEME = "aws_secrets_manager_v1"; +const DEFAULT_PREFIX = "paperclip"; +const DEFAULT_OWNER_TAG = "paperclip"; +const DEFAULT_VERSION_STAGE = "AWSCURRENT"; +const DEFAULT_DELETE_RECOVERY_WINDOW_DAYS = 30; + +interface AwsSecretsManagerMaterial extends StoredSecretVersionMaterial { + scheme: typeof AWS_SECRETS_MANAGER_SCHEME; + secretId: string; + versionId: string | null; + source: "managed" | "external_reference"; +} + +interface AwsSecretsManagerConfig { + region: string; + endpoint: string; + deploymentId: string; + prefix: string; + kmsKeyId: string; + environmentTag: string; + providerOwnerTag: string; + deleteRecoveryWindowDays: number; +} + +interface AwsSecretsManagerTag { + Key: string; + Value: string; +} + +type ManagedSecretNamespaceContext = Pick; + +interface AwsSecretsManagerGateway { + createSecret(input: { + Name: string; + SecretString: string; + KmsKeyId: string; + Description?: string; + Tags: AwsSecretsManagerTag[]; + }): Promise<{ + ARN?: string; + Name?: string; + VersionId?: string; + }>; + putSecretValue(input: { + SecretId: string; + SecretString: string; + }): Promise<{ + ARN?: string; + Name?: string; + VersionId?: string; + }>; + getSecretValue(input: { + SecretId: string; + VersionId?: string; + VersionStage?: string; + }): Promise<{ + SecretString?: string; + ARN?: string; + Name?: string; + VersionId?: string; + }>; + deleteSecret(input: { + SecretId: string; + RecoveryWindowInDays: number; + }): Promise; +} + +function sha256Hex(value: string): string { + return createHash("sha256").update(value).digest("hex"); +} + +function configuredAwsSecretsManagerDescriptor() { + return { + id: "aws_secrets_manager" as const, + label: "AWS Secrets Manager", + requiresExternalRef: false, + supportsManagedValues: true, + supportsExternalReferences: true, + configured: canLoadAwsSecretsManagerConfig(), + }; +} + +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(), + ); +} + +function loadAwsSecretsManagerConfig(): AwsSecretsManagerConfig { + const region = + process.env.PAPERCLIP_SECRETS_AWS_REGION?.trim() || + process.env.AWS_REGION?.trim() || + 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(); + + if (!region) { + throw unprocessable( + "AWS Secrets Manager provider requires PAPERCLIP_SECRETS_AWS_REGION or AWS_REGION", + ); + } + if (!deploymentId) { + throw unprocessable( + "AWS Secrets Manager provider requires PAPERCLIP_SECRETS_AWS_DEPLOYMENT_ID", + ); + } + if (!kmsKeyId) { + throw unprocessable( + "AWS Secrets Manager provider requires PAPERCLIP_SECRETS_AWS_KMS_KEY_ID", + ); + } + + 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, + prefix: sanitizePathSegment(process.env.PAPERCLIP_SECRETS_AWS_PREFIX?.trim() || DEFAULT_PREFIX), + kmsKeyId, + environmentTag: + process.env.PAPERCLIP_SECRETS_AWS_ENVIRONMENT?.trim() || + process.env.NODE_ENV?.trim() || + "unknown", + providerOwnerTag: + process.env.PAPERCLIP_SECRETS_AWS_PROVIDER_OWNER?.trim() || DEFAULT_OWNER_TAG, + deleteRecoveryWindowDays: recoveryWindow, + }; +} + +function sanitizePathSegment(input: string) { + return input + .trim() + .replace(/[^A-Za-z0-9/_+=.@-]+/g, "-") + .replace(/\/+/g, "/") + .replace(/^\/+|\/+$/g, ""); +} + +function buildManagedSecretName( + config: AwsSecretsManagerConfig, + context: ManagedSecretNamespaceContext | undefined, +) { + if (!context) { + throw unprocessable("AWS Secrets Manager provider requires secret context for managed values"); + } + return [ + sanitizePathSegment(config.prefix), + sanitizePathSegment(config.deploymentId), + sanitizePathSegment(context.companyId), + sanitizePathSegment(context.secretKey), + ] + .filter(Boolean) + .join("/"); +} + +function buildManagedSecretId( + config: AwsSecretsManagerConfig, + context: ManagedSecretNamespaceContext | undefined, +) { + return buildManagedSecretName(config, context); +} + +function escapeRegExp(value: string) { + return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} + +function extractAwsSecretName(externalRef: string) { + const trimmed = externalRef.trim(); + const arnMatch = /^arn:[^:]+:secretsmanager:[^:]*:[^:]*:secret:(.+)$/i.exec(trimmed); + return arnMatch?.[1] ?? trimmed; +} + +function isManagedSecretRefForContext( + config: AwsSecretsManagerConfig, + context: ManagedSecretNamespaceContext | undefined, + externalRef: string | null | undefined, +) { + if (!externalRef?.trim()) return false; + const expectedName = buildManagedSecretName(config, context); + const actualName = extractAwsSecretName(externalRef); + return new RegExp(`^${escapeRegExp(expectedName)}(?:-[A-Za-z0-9]{6})?$`).test(actualName); +} + +function resolveManagedSecretRef(input: { + config: AwsSecretsManagerConfig; + context: ManagedSecretNamespaceContext | undefined; + externalRefs: Array; +}) { + for (const externalRef of input.externalRefs) { + if (externalRef?.trim() && isManagedSecretRefForContext(input.config, input.context, externalRef)) { + return externalRef.trim(); + } + } + return buildManagedSecretId(input.config, input.context); +} + +function buildManagedSecretTags( + config: AwsSecretsManagerConfig, + context: SecretProviderWriteContext | undefined, +): AwsSecretsManagerTag[] { + if (!context) return []; + return [ + { Key: "paperclip:managed-by", Value: "paperclip" }, + { Key: "paperclip:provider-owner", Value: config.providerOwnerTag }, + { Key: "paperclip:deployment-id", Value: config.deploymentId }, + { Key: "paperclip:company-id", Value: context.companyId }, + { Key: "paperclip:secret-key", Value: context.secretKey }, + { Key: "paperclip:environment", Value: config.environmentTag }, + ]; +} + +function createExternalReferenceMaterial( + externalRef: string, + providerVersionRef: string | null, +): PreparedSecretVersion { + const normalizedExternalRef = externalRef.trim(); + const normalizedProviderVersionRef = providerVersionRef?.trim() || null; + const fingerprint = sha256Hex( + `${AWS_SECRETS_MANAGER_SCHEME}:${normalizedExternalRef}:${normalizedProviderVersionRef ?? ""}`, + ); + return { + material: { + scheme: AWS_SECRETS_MANAGER_SCHEME, + secretId: normalizedExternalRef, + versionId: normalizedProviderVersionRef, + source: "external_reference", + }, + valueSha256: fingerprint, + fingerprintSha256: fingerprint, + externalRef: normalizedExternalRef, + providerVersionRef: normalizedProviderVersionRef, + }; +} + +function createManagedMaterial(secretId: string, versionId: string | null): AwsSecretsManagerMaterial { + return { + scheme: AWS_SECRETS_MANAGER_SCHEME, + secretId, + versionId, + source: "managed", + }; +} + +function asAwsSecretsManagerMaterial(value: StoredSecretVersionMaterial): AwsSecretsManagerMaterial { + if ( + value && + typeof value === "object" && + value.scheme === AWS_SECRETS_MANAGER_SCHEME && + typeof value.secretId === "string" && + (typeof value.versionId === "string" || value.versionId === null) && + (value.source === "managed" || value.source === "external_reference") + ) { + return value as AwsSecretsManagerMaterial; + } + throw unprocessable("Invalid AWS Secrets Manager material"); +} + +function normalizeAwsError(operation: string, error: unknown): never { + const message = error instanceof Error ? error.message : String(error); + if (/ResourceExistsException/i.test(message)) { + throw conflict(`AWS Secrets Manager ${operation} conflict: ${message}`); + } + if (/ResourceNotFoundException/i.test(message)) { + throw notFound(`AWS Secrets Manager ${operation} failed: ${message}`); + } + throw unprocessable(`AWS Secrets Manager ${operation} failed: ${message}`); +} + +class AwsSecretsManagerJsonGateway implements AwsSecretsManagerGateway { + private readonly endpoint: URL; + private readonly signer: SignatureV4; + + constructor(private readonly config: AwsSecretsManagerConfig) { + this.endpoint = new URL(config.endpoint); + this.signer = new SignatureV4({ + credentials: defaultAwsCredentialsProvider(), + region: config.region, + service: "secretsmanager", + sha256: Hash.bind(null, "sha256"), + }); + } + + createSecret(input: { + Name: string; + SecretString: string; + KmsKeyId: string; + Description?: string; + Tags: AwsSecretsManagerTag[]; + }) { + return this.call<{ + ARN?: string; + Name?: string; + VersionId?: string; + }>("CreateSecret", input); + } + + putSecretValue(input: { + SecretId: string; + SecretString: string; + }) { + return this.call<{ + ARN?: string; + Name?: string; + VersionId?: string; + }>("PutSecretValue", input); + } + + getSecretValue(input: { + SecretId: string; + VersionId?: string; + VersionStage?: string; + }) { + return this.call<{ + SecretString?: string; + ARN?: string; + Name?: string; + VersionId?: string; + }>("GetSecretValue", input); + } + + deleteSecret(input: { + SecretId: string; + RecoveryWindowInDays: number; + }) { + return this.call("DeleteSecret", input); + } + + private async call(operation: string, payload: Record): Promise { + const body = JSON.stringify(payload); + const request = new HttpRequest({ + protocol: this.endpoint.protocol, + hostname: this.endpoint.hostname, + port: this.endpoint.port ? Number(this.endpoint.port) : undefined, + method: "POST", + path: this.endpoint.pathname || "/", + headers: { + "content-type": "application/x-amz-json-1.1", + host: this.endpoint.host, + "x-amz-target": `secretsmanager.${operation}`, + }, + body, + }); + const signed = await this.signer.sign(request); + const response = await fetch(this.endpoint, { + method: "POST", + headers: signed.headers as Record, + body, + }); + const text = await response.text(); + const parsed = text ? (JSON.parse(text) as Record) : {}; + + if (!response.ok) { + const code = String(parsed.__type ?? parsed.code ?? parsed.Code ?? response.statusText ?? "UnknownError"); + const message = String(parsed.message ?? parsed.Message ?? code); + throw new Error(`${code}: ${message}`); + } + + return parsed as T; + } +} + +export function createAwsSecretsManagerProvider( + options?: { + config?: AwsSecretsManagerConfig; + gateway?: AwsSecretsManagerGateway; + }, +): SecretProviderModule { + function resolveConfig() { + return options?.config ?? loadAwsSecretsManagerConfig(); + } + + function resolveGateway(config: AwsSecretsManagerConfig) { + return options?.gateway ?? new AwsSecretsManagerJsonGateway(config); + } + + async function validateConfig( + input?: { deploymentMode?: DeploymentMode; strictMode?: boolean }, + ): 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(); + if (!config.prefix) { + warnings.push("PAPERCLIP_SECRETS_AWS_PREFIX should be set to a deployment-scoped prefix"); + } + return { ok: true, warnings }; + } + + async function healthCheck( + input?: { deploymentMode?: DeploymentMode; strictMode?: boolean }, + ): Promise { + try { + const validation = await validateConfig(input); + return { + provider: "aws_secrets_manager", + status: validation.warnings.length > 0 ? "warn" : "ok", + message: "AWS Secrets Manager provider is configured", + warnings: validation.warnings, + details: { + region: resolveConfig().region, + prefix: resolveConfig().prefix, + deploymentId: resolveConfig().deploymentId, + kmsKeyConfigured: true, + }, + backupGuidance: [ + "Back up Paperclip metadata separately from AWS-managed secrets.", + "Restoring access requires the Paperclip database plus the same AWS secret namespace and KMS permissions.", + ], + }; + } catch (error) { + return { + provider: "aws_secrets_manager", + status: "warn", + message: error instanceof Error ? error.message : String(error), + warnings: [ + "Managed secret create/rotate/resolve calls will fail until AWS provider configuration is complete.", + ], + }; + } + } + + return { + id: "aws_secrets_manager", + descriptor() { + return configuredAwsSecretsManagerDescriptor(); + }, + validateConfig, + async createSecret(input) { + const config = resolveConfig(); + const gateway = resolveGateway(config); + const valueSha256 = sha256Hex(input.value); + const secretId = buildManagedSecretId(config, input.context); + + try { + const created = await gateway.createSecret({ + Name: secretId, + SecretString: input.value, + KmsKeyId: config.kmsKeyId, + Description: input.context ? `Paperclip secret ${input.context.secretName}` : undefined, + Tags: buildManagedSecretTags(config, input.context), + }); + const normalizedSecretId = created.ARN ?? created.Name ?? secretId; + return { + material: createManagedMaterial(normalizedSecretId, created.VersionId ?? null), + valueSha256, + fingerprintSha256: valueSha256, + externalRef: normalizedSecretId, + providerVersionRef: created.VersionId ?? null, + }; + } catch (error) { + normalizeAwsError("createSecret", error); + } + }, + async createVersion(input) { + const config = resolveConfig(); + const gateway = resolveGateway(config); + const valueSha256 = sha256Hex(input.value); + const secretId = resolveManagedSecretRef({ + config, + context: input.context, + externalRefs: [input.externalRef], + }); + + try { + const created = await gateway.putSecretValue({ + SecretId: secretId, + SecretString: input.value, + }); + const normalizedSecretId = created.ARN ?? created.Name ?? secretId; + return { + material: createManagedMaterial(normalizedSecretId, created.VersionId ?? null), + valueSha256, + fingerprintSha256: valueSha256, + externalRef: normalizedSecretId, + providerVersionRef: created.VersionId ?? null, + }; + } catch (error) { + normalizeAwsError("createVersion", error); + } + }, + async linkExternalSecret(input) { + return createExternalReferenceMaterial(input.externalRef, input.providerVersionRef ?? null); + }, + async resolveVersion(input) { + const config = resolveConfig(); + const gateway = resolveGateway(config); + const material = asAwsSecretsManagerMaterial(input.material); + const secretId = + material.source === "managed" + ? resolveManagedSecretRef({ + config, + context: input.context, + externalRefs: [input.externalRef, material.secretId], + }) + : (input.externalRef ?? material.secretId); + + try { + const resolved = await gateway.getSecretValue({ + SecretId: secretId, + VersionId: input.providerVersionRef ?? material.versionId ?? undefined, + VersionStage: + input.providerVersionRef || material.versionId ? undefined : DEFAULT_VERSION_STAGE, + }); + if (typeof resolved.SecretString !== "string") { + throw new Error("SecretString was empty"); + } + return resolved.SecretString; + } catch (error) { + normalizeAwsError("resolveVersion", error); + } + }, + async deleteOrArchive(input) { + const material = + input.material && typeof input.material === "object" + ? asAwsSecretsManagerMaterial(input.material) + : null; + + if (input.mode !== "delete" || material?.source !== "managed") return; + + const config = resolveConfig(); + const gateway = resolveGateway(config); + const secretId = resolveManagedSecretRef({ + config, + context: input.context, + externalRefs: [input.externalRef, material.secretId], + }); + + try { + await gateway.deleteSecret({ + SecretId: secretId, + RecoveryWindowInDays: config.deleteRecoveryWindowDays, + }); + } catch (error) { + normalizeAwsError("deleteSecret", error); + } + }, + healthCheck, + }; +} + +export const awsSecretsManagerProvider = createAwsSecretsManagerProvider(); diff --git a/server/src/secrets/provider-registry.ts b/server/src/secrets/provider-registry.ts index 390a13ada8f..f181b8b8293 100644 --- a/server/src/secrets/provider-registry.ts +++ b/server/src/secrets/provider-registry.ts @@ -1,7 +1,7 @@ import type { SecretProvider, SecretProviderDescriptor } from "@paperclipai/shared"; +import { awsSecretsManagerProvider } from "./aws-secrets-manager-provider.js"; import { localEncryptedProvider } from "./local-encrypted-provider.js"; import { - awsSecretsManagerProvider, gcpSecretManagerProvider, vaultProvider, } from "./external-stub-providers.js"; diff --git a/server/src/secrets/types.ts b/server/src/secrets/types.ts index 56545e0bd2b..a792fa24159 100644 --- a/server/src/secrets/types.ts +++ b/server/src/secrets/types.ts @@ -79,6 +79,7 @@ export interface SecretProviderModule { deleteOrArchive(input: { material?: StoredSecretVersionMaterial | null; externalRef: string | null; + context?: SecretProviderWriteContext; mode: "archive" | "delete"; }): Promise; healthCheck(input?: { diff --git a/server/src/services/secrets.ts b/server/src/services/secrets.ts index f5cb389a959..24a2f01c5ff 100644 --- a/server/src/services/secrets.ts +++ b/server/src/services/secrets.ts @@ -377,10 +377,22 @@ export function secretService(db: Db) { ? await provider.linkExternalSecret({ externalRef: input.externalRef ?? "", providerVersionRef: input.providerVersionRef ?? null, + context: { + companyId, + secretKey: key, + secretName: input.name, + version: 1, + }, }) : await provider.createSecret({ value: input.value ?? "", - externalRef: input.externalRef ?? null, + externalRef: null, + context: { + companyId, + secretKey: key, + secretName: input.name, + version: 1, + }, }); return db.transaction(async (tx) => { @@ -440,10 +452,22 @@ export function secretService(db: Db) { ? await provider.linkExternalSecret({ externalRef: input.externalRef ?? secret.externalRef ?? "", providerVersionRef: input.providerVersionRef ?? null, + context: { + companyId: secret.companyId, + secretKey: secret.key, + secretName: secret.name, + version: nextVersion, + }, }) : await provider.createVersion({ value: input.value ?? "", - externalRef: input.externalRef ?? secret.externalRef ?? null, + externalRef: secret.externalRef ?? null, + context: { + companyId: secret.companyId, + secretKey: secret.key, + secretName: secret.name, + version: nextVersion, + }, }); return db.transaction(async (tx) => { @@ -633,6 +657,12 @@ export function secretService(db: Db) { await provider.deleteOrArchive({ material: versionRow?.material as Record | undefined, externalRef: secret.externalRef, + context: { + companyId: secret.companyId, + secretKey: secret.key, + secretName: secret.name, + version: secret.latestVersion, + }, mode: "delete", }); await db.delete(companySecrets).where(eq(companySecrets.id, secretId));