Harden AWS managed secret refs

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-05-07 18:59:05 -05:00
1 parent d1a8a8e621
commit 70a86170f5
7 files changed
+980 -4

No files matched your search

+4
View File
@@ -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:*",
@@ -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<string, unknown> }> = [];
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<string, unknown> }> = [];
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<string, unknown> }> = [];
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<string, unknown> }> = [];
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<string, unknown> }> = [];
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,
},
},
]);
});
});
@@ -39,7 +39,7 @@ describe("secret provider registry", () => {
}),
expect.objectContaining({
id: "aws_secrets_manager",
supportsManagedValues: false,
supportsManagedValues: true,
supportsExternalReferences: true,
configured: false,
}),
@@ -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<SecretProviderWriteContext, "companyId" | "secretKey">;
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<unknown>;
}
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<string | null | undefined>;
}) {
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<T>(operation: string, payload: Record<string, unknown>): Promise<T> {
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<string, string>,
body,
});
const text = await response.text();
const parsed = text ? (JSON.parse(text) as Record<string, unknown>) : {};
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<SecretProviderValidationResult> {
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<SecretProviderHealthCheck> {
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();
+1 -1
View File
@@ -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";
+1
View File
@@ -79,6 +79,7 @@ export interface SecretProviderModule {
deleteOrArchive(input: {
material?: StoredSecretVersionMaterial | null;
externalRef: string | null;
context?: SecretProviderWriteContext;
mode: "archive" | "delete";
}): Promise<void>;
healthCheck(input?: {
+32 -2
View File
@@ -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<string, unknown> | 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));