mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-29 04:25:52 +01:00
Harden AWS managed secret refs
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
1 parent
d1a8a8e621
commit
70a86170f5
7 files changed
+980
-4
No files matched your search
@@ -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,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";
|
||||
|
||||
@@ -79,6 +79,7 @@ export interface SecretProviderModule {
|
||||
deleteOrArchive(input: {
|
||||
material?: StoredSecretVersionMaterial | null;
|
||||
externalRef: string | null;
|
||||
context?: SecretProviderWriteContext;
|
||||
mode: "archive" | "delete";
|
||||
}): Promise<void>;
|
||||
healthCheck(input?: {
|
||||
|
||||
@@ -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));
|
||||
|
||||
Reference in new issue
Block a user