mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-29 04:25:52 +01:00
## Thinking Path > - Paperclip is the open source control plane operators use to manage AI-agent companies. > - Operators can store managed secret values in AWS Secrets Manager provider vaults. > - The AWS JSON gateway already classifies safe provider failures such as access denial, expired credentials, throttling, and invalid requests. > - The outer provider wrapper was re-normalizing those typed failures, which collapsed an AWS access denial into a generic provider error before the service could produce actionable guidance. > - Missing runtime credentials also need to be recognized as an access problem so operators receive the correct recovery path. > - This pull request preserves typed provider errors, expands credential-related classification, and clarifies the safe credential/IAM remediation text. > - The benefit is that failed AWS secret creation tells operators whether to restore runtime credentials or grant the required capability without exposing AWS account or principal details. ## Linked Issues or Issue Description No public issue was found for this exact remaining server-side classification gap. Bug report: - What happened: an AWS Secrets Manager access-denied response could be classified correctly by the JSON gateway and then collapsed to `provider_error` by the outer provider wrapper; missing runtime credentials were also reported generically. - Expected behavior: safe typed AWS failures should retain their classification, and credential resolution failures should surface as access-denied guidance. - Steps to reproduce: configure an AWS provider vault with invalid or missing runtime credentials, or without `secretsmanager:CreateSecret`, then create a Paperclip-managed secret. - Paperclip version/commit: current `master` before this PR. - Deployment mode: Paperclip server with an AWS Secrets Manager provider vault. Related prior work: - Refs #9161 - Refs #9645 ## What Changed - Preserve `SecretProviderClientError` instances returned by the AWS JSON gateway instead of wrapping them as generic provider failures. - Classify missing, expired, invalid, or incorrectly signed AWS runtime credentials as access-denied failures. - Clarify sanitized remediation guidance to cover both credential resolution and `secretsmanager:CreateSecret` authorization. - Add regression coverage for gateway access-denied responses, missing runtime credentials, and the service-level actionable message. ## Verification - `pnpm exec vitest run server/src/__tests__/aws-secrets-manager-provider.test.ts server/src/__tests__/secrets-service.test.ts` — changed provider tests passed; the service file was skipped in the first no-DB focused run. - `pnpm --filter @paperclipai/server typecheck` — passed. - `pnpm -r typecheck` — passed after a frozen-lockfile dependency refresh. - `pnpm build` — passed. - `pnpm test:run` — 314 files / 3,443 tests passed; one unrelated existing test failed in `plugin-orchestration-apis.test.ts` because it observed `heartbeat.scheduling_suppressed` instead of `issue_commented`. The same failure reproduced in isolation and none of this PR's four changed files participate in that test. - `git diff --check public-gh/master...HEAD` — passed. ## Risks Low risk. The change affects only error classification and sanitized failure guidance for AWS Secrets Manager operations. Successful secret writes, schemas, migrations, API shapes, and stored secret material are unchanged. The regression tests assert safe classifications while keeping raw AWS account and principal details out of service responses. > For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and discuss it in `#dev` before opening the PR. Feature PRs that overlap with planned core work may need to be redirected — check the roadmap first. See `CONTRIBUTING.md`. ## Model Used OpenAI Codex, GPT-5 family (exact runtime build and context-window size were not exposed), with reasoning, repository editing, shell execution, Git, GitHub, and test execution enabled. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes (not applicable: no documented contract changed) - [x] I have considered and documented any risks above - [x] All Paperclip CI gates are green - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge Co-authored-by: Paperclip <noreply@paperclip.ing> Co-authored-by: commitperclip[bot] <284812584+commitperclip[bot]@users.noreply.github.com>
1174 lines
40 KiB
TypeScript
1174 lines
40 KiB
TypeScript
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
import { createAwsSecretsManagerProvider } from "../secrets/aws-secrets-manager-provider.js";
|
|
import { SecretProviderClientError } from "../secrets/types.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,
|
|
AWS_ACCESS_KEY_ID: process.env.AWS_ACCESS_KEY_ID,
|
|
AWS_SECRET_ACCESS_KEY: process.env.AWS_SECRET_ACCESS_KEY,
|
|
AWS_SESSION_TOKEN: process.env.AWS_SESSION_TOKEN,
|
|
AWS_PROFILE: process.env.AWS_PROFILE,
|
|
AWS_DEFAULT_PROFILE: process.env.AWS_DEFAULT_PROFILE,
|
|
AWS_CONFIG_FILE: process.env.AWS_CONFIG_FILE,
|
|
AWS_SHARED_CREDENTIALS_FILE: process.env.AWS_SHARED_CREDENTIALS_FILE,
|
|
AWS_SDK_LOAD_CONFIG: process.env.AWS_SDK_LOAD_CONFIG,
|
|
};
|
|
|
|
afterEach(() => {
|
|
vi.restoreAllMocks();
|
|
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 AWS secrets from selected provider vault config without deployment env fallback", async () => {
|
|
delete process.env.PAPERCLIP_SECRETS_AWS_REGION;
|
|
delete process.env.AWS_REGION;
|
|
delete process.env.AWS_DEFAULT_REGION;
|
|
delete process.env.PAPERCLIP_SECRETS_AWS_DEPLOYMENT_ID;
|
|
delete process.env.PAPERCLIP_SECRETS_AWS_KMS_KEY_ID;
|
|
delete process.env.AWS_ACCESS_KEY_ID;
|
|
delete process.env.AWS_SECRET_ACCESS_KEY;
|
|
delete process.env.AWS_SESSION_TOKEN;
|
|
|
|
const calls: Array<{ op: string; input: Record<string, unknown> }> = [];
|
|
const provider = createAwsSecretsManagerProvider({
|
|
gateway: {
|
|
async createSecret(input) {
|
|
calls.push({ op: "createSecret", input });
|
|
return {
|
|
ARN: "arn:aws:secretsmanager:us-west-2:123456789012:secret:clip/prod-us-west/company-1/openai-api-key",
|
|
VersionId: "aws-version-1",
|
|
};
|
|
},
|
|
async putSecretValue(input) {
|
|
calls.push({ op: "putSecretValue", input });
|
|
return { ARN: String(input.SecretId), VersionId: "unused" };
|
|
},
|
|
async getSecretValue(input) {
|
|
calls.push({ op: "getSecretValue", input });
|
|
return { SecretString: "resolved-value", VersionId: "unused" };
|
|
},
|
|
async deleteSecret(input) {
|
|
calls.push({ op: "deleteSecret", input });
|
|
return {};
|
|
},
|
|
},
|
|
});
|
|
|
|
const providerConfig = {
|
|
id: "vault-1",
|
|
provider: "aws_secrets_manager" as const,
|
|
status: "ready",
|
|
config: {
|
|
region: "us-west-2",
|
|
namespace: "prod-us-west",
|
|
secretNamePrefix: "clip",
|
|
ownerTag: "platform",
|
|
environmentTag: "production",
|
|
},
|
|
};
|
|
|
|
const health = await provider.healthCheck({ providerConfig });
|
|
const prepared = await provider.createSecret({
|
|
value: "super-secret-value",
|
|
providerConfig,
|
|
context: {
|
|
companyId: "company-1",
|
|
secretKey: "openai-api-key",
|
|
secretName: "OpenAI API Key",
|
|
version: 1,
|
|
},
|
|
});
|
|
|
|
expect(health.status).toBe("ok");
|
|
expect(health.details).toMatchObject({
|
|
region: "us-west-2",
|
|
prefix: "clip",
|
|
deploymentId: "prod-us-west",
|
|
kmsKeyConfigured: false,
|
|
});
|
|
expect(calls).toEqual([
|
|
expect.objectContaining({
|
|
op: "createSecret",
|
|
input: expect.objectContaining({
|
|
Name: "clip/prod-us-west/company-1/openai-api-key",
|
|
SecretString: "super-secret-value",
|
|
Tags: expect.arrayContaining([
|
|
{ Key: "paperclip:provider-owner", Value: "platform" },
|
|
{ Key: "paperclip:environment", Value: "production" },
|
|
]),
|
|
}),
|
|
}),
|
|
]);
|
|
expect(calls[0]?.input).not.toHaveProperty("KmsKeyId");
|
|
expect(JSON.stringify(prepared)).not.toContain("super-secret-value");
|
|
expect(prepared.externalRef).toContain("clip/prod-us-west/company-1/openai-api-key");
|
|
});
|
|
|
|
it("signs AWS Secrets Manager JSON requests with default runtime credentials", async () => {
|
|
delete process.env.AWS_PROFILE;
|
|
delete process.env.AWS_DEFAULT_PROFILE;
|
|
delete process.env.AWS_CONFIG_FILE;
|
|
delete process.env.AWS_SHARED_CREDENTIALS_FILE;
|
|
delete process.env.AWS_SDK_LOAD_CONFIG;
|
|
process.env.AWS_ACCESS_KEY_ID = "AKIA_TEST_ACCESS";
|
|
process.env.AWS_SECRET_ACCESS_KEY = "test-secret-key";
|
|
process.env.AWS_SESSION_TOKEN = "test-session-token";
|
|
|
|
const fetchMock = vi.spyOn(globalThis, "fetch").mockResolvedValue(
|
|
new Response(
|
|
JSON.stringify({
|
|
ARN: "arn:aws:secretsmanager:us-east-1:123456789012:secret:paperclip/prod/company-1/openai-api-key",
|
|
VersionId: "aws-version-1",
|
|
}),
|
|
{ status: 200 },
|
|
),
|
|
);
|
|
const provider = createAwsSecretsManagerProvider({
|
|
config: {
|
|
region: "us-east-1",
|
|
endpoint: "https://secretsmanager.us-east-1.amazonaws.com",
|
|
deploymentId: "prod",
|
|
prefix: "paperclip",
|
|
kmsKeyId: "arn:aws:kms:us-east-1:123456789012:key/test",
|
|
environmentTag: "production",
|
|
providerOwnerTag: "paperclip",
|
|
deleteRecoveryWindowDays: 30,
|
|
},
|
|
});
|
|
|
|
await provider.createSecret({
|
|
value: "super-secret-value",
|
|
context: {
|
|
companyId: "company-1",
|
|
secretKey: "openai-api-key",
|
|
secretName: "OpenAI API Key",
|
|
version: 1,
|
|
},
|
|
});
|
|
|
|
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
const [url, init] = fetchMock.mock.calls[0]!;
|
|
const headers = init?.headers as Record<string, string>;
|
|
expect(String(url)).toBe("https://secretsmanager.us-east-1.amazonaws.com/");
|
|
expect(headers["x-amz-target"]).toBe("secretsmanager.CreateSecret");
|
|
expect(headers["x-amz-security-token"]).toBe("test-session-token");
|
|
expect(headers.authorization).toContain("Credential=AKIA_TEST_ACCESS/");
|
|
expect(headers.authorization).toContain("/us-east-1/secretsmanager/aws4_request");
|
|
expect(headers.authorization).toContain("SignedHeaders=");
|
|
expect(headers.authorization).toContain("Signature=");
|
|
expect(init?.signal).toBeInstanceOf(AbortSignal);
|
|
});
|
|
|
|
it("preserves classified AWS create errors returned by the JSON gateway", async () => {
|
|
delete process.env.AWS_PROFILE;
|
|
delete process.env.AWS_DEFAULT_PROFILE;
|
|
delete process.env.AWS_CONFIG_FILE;
|
|
delete process.env.AWS_SHARED_CREDENTIALS_FILE;
|
|
delete process.env.AWS_SDK_LOAD_CONFIG;
|
|
process.env.AWS_ACCESS_KEY_ID = "AKIA_CREATE_ERROR_TEST";
|
|
process.env.AWS_SECRET_ACCESS_KEY = "test-secret-key";
|
|
|
|
vi.spyOn(globalThis, "fetch").mockResolvedValue(
|
|
new Response(
|
|
JSON.stringify({
|
|
__type: "AccessDeniedException",
|
|
message:
|
|
"User: arn:aws:sts::123456789012:assumed-role/prod/Paperclip is not authorized to perform secretsmanager:CreateSecret",
|
|
}),
|
|
{ status: 400 },
|
|
),
|
|
);
|
|
const provider = createAwsSecretsManagerProvider({
|
|
config: {
|
|
region: "us-west-1",
|
|
endpoint: "https://secretsmanager.us-west-1.amazonaws.com",
|
|
deploymentId: "prod",
|
|
prefix: "paperclip",
|
|
kmsKeyId: null,
|
|
environmentTag: "production",
|
|
providerOwnerTag: "paperclip",
|
|
deleteRecoveryWindowDays: 30,
|
|
},
|
|
});
|
|
|
|
await expect(
|
|
provider.createSecret({
|
|
value: "super-secret-value",
|
|
context: {
|
|
companyId: "company-1",
|
|
secretKey: "openai-api-key",
|
|
secretName: "OpenAI API Key",
|
|
version: 1,
|
|
},
|
|
}),
|
|
).rejects.toMatchObject({
|
|
code: "access_denied",
|
|
status: 403,
|
|
operation: "CreateSecret",
|
|
message: "AWS Secrets Manager denied the request. Check IAM permissions for this provider vault.",
|
|
rawMessage: expect.stringContaining("AccessDeniedException"),
|
|
});
|
|
});
|
|
|
|
it("classifies missing runtime AWS credentials as access denied", async () => {
|
|
const provider = createAwsSecretsManagerProvider({
|
|
config: {
|
|
region: "us-east-1",
|
|
endpoint: "https://secretsmanager.us-east-1.amazonaws.com",
|
|
deploymentId: "prod-use1",
|
|
prefix: "paperclip",
|
|
kmsKeyId: null,
|
|
environmentTag: "production",
|
|
providerOwnerTag: "paperclip",
|
|
deleteRecoveryWindowDays: 30,
|
|
},
|
|
gateway: {
|
|
async createSecret() {
|
|
throw new Error("CredentialsProviderError: Could not load credentials from any providers");
|
|
},
|
|
async putSecretValue() {
|
|
throw new Error("not used");
|
|
},
|
|
async getSecretValue() {
|
|
throw new Error("not used");
|
|
},
|
|
async deleteSecret() {
|
|
throw new Error("not used");
|
|
},
|
|
},
|
|
});
|
|
|
|
await expect(
|
|
provider.createSecret({
|
|
value: "super-secret-value",
|
|
context: {
|
|
companyId: "company-1",
|
|
secretKey: "openai-api-key",
|
|
secretName: "OpenAI API Key",
|
|
version: 1,
|
|
},
|
|
}),
|
|
).rejects.toMatchObject({
|
|
code: "access_denied",
|
|
status: 403,
|
|
rawMessage: "CredentialsProviderError: Could not load credentials from any providers",
|
|
});
|
|
});
|
|
|
|
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",
|
|
VersionStages: ["PAPERCLIP_PENDING"],
|
|
},
|
|
},
|
|
]);
|
|
expect(JSON.stringify(prepared)).not.toContain("rotated-secret-value");
|
|
expect(prepared.providerVersionRef).toBe("aws-version-2");
|
|
});
|
|
|
|
it("rejects 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 expect(
|
|
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,
|
|
},
|
|
}),
|
|
).rejects.toThrow(/drifted outside the derived deployment\/company scope/i);
|
|
|
|
expect(calls).toEqual([]);
|
|
});
|
|
|
|
it("stores linked external references as metadata-only provider material", async () => {
|
|
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,
|
|
},
|
|
});
|
|
|
|
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("rejects linked external references under the Paperclip-managed namespace", async () => {
|
|
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,
|
|
},
|
|
});
|
|
|
|
await expect(
|
|
provider.linkExternalSecret({
|
|
externalRef:
|
|
"arn:aws:secretsmanager:us-east-1:123456789012:secret:paperclip/prod-use1/company-2/openai-api-key",
|
|
providerVersionRef: "linked-version-7",
|
|
}),
|
|
).rejects.toThrow(/Paperclip-managed namespace/i);
|
|
});
|
|
|
|
it("writes new values through to externally referenced AWS secrets as AWSCURRENT", 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:shared/external",
|
|
VersionId: "aws-version-9",
|
|
};
|
|
},
|
|
async getSecretValue(input) {
|
|
calls.push({ op: "getSecretValue", input });
|
|
return { VersionId: "aws-version-8", SecretString: "previous-value" };
|
|
},
|
|
async deleteSecret() {
|
|
throw new Error("not used");
|
|
},
|
|
},
|
|
});
|
|
|
|
const prepared = await provider.updateExternalSecretValue!({
|
|
externalRef: "arn:aws:secretsmanager:us-east-1:123456789012:secret:shared/external",
|
|
value: "written-through-value",
|
|
context: {
|
|
companyId: "company-1",
|
|
secretKey: "neon-admin-api-key",
|
|
secretName: "paperclip-cloud/prod/provider/neon/admin-api-key",
|
|
version: 2,
|
|
},
|
|
});
|
|
|
|
// No VersionStages: the write must become AWSCURRENT for all consumers.
|
|
expect(calls).toEqual([
|
|
{
|
|
op: "getSecretValue",
|
|
input: {
|
|
SecretId: "arn:aws:secretsmanager:us-east-1:123456789012:secret:shared/external",
|
|
VersionStage: "AWSCURRENT",
|
|
},
|
|
},
|
|
{
|
|
op: "putSecretValue",
|
|
input: {
|
|
SecretId: "arn:aws:secretsmanager:us-east-1:123456789012:secret:shared/external",
|
|
SecretString: "written-through-value",
|
|
},
|
|
},
|
|
]);
|
|
expect(JSON.stringify(prepared.material)).not.toContain("written-through-value");
|
|
expect(prepared.externalRef).toBe(
|
|
"arn:aws:secretsmanager:us-east-1:123456789012:secret:shared/external",
|
|
);
|
|
// Resolution keeps tracking AWSCURRENT rather than pinning the written version.
|
|
expect(prepared.providerVersionRef).toBeNull();
|
|
expect(prepared.material.versionId).toBeNull();
|
|
expect(prepared.material.lastWrittenVersionId).toBe("aws-version-9");
|
|
expect(prepared.material.previousCurrentVersionId).toBe("aws-version-8");
|
|
expect(prepared.material.source).toBe("external_reference");
|
|
});
|
|
|
|
it("rejects external value writes under the Paperclip-managed namespace", async () => {
|
|
const provider = createAwsSecretsManagerProvider({
|
|
config: {
|
|
region: "us-east-1",
|
|
endpoint: "https://secretsmanager.us-east-1.amazonaws.com",
|
|
deploymentId: "prod-use1",
|
|
prefix: "paperclip",
|
|
kmsKeyId: null,
|
|
environmentTag: "production",
|
|
providerOwnerTag: "paperclip",
|
|
deleteRecoveryWindowDays: 30,
|
|
},
|
|
});
|
|
|
|
await expect(
|
|
provider.updateExternalSecretValue!({
|
|
externalRef:
|
|
"arn:aws:secretsmanager:us-east-1:123456789012:secret:paperclip/prod-use1/company-2/openai-api-key",
|
|
value: "new-value",
|
|
}),
|
|
).rejects.toThrow(/Paperclip-managed namespace/i);
|
|
});
|
|
|
|
it("restores the previous AWSCURRENT version when an external value write is rolled back", 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: null,
|
|
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() { throw new Error("not used"); },
|
|
async updateSecretVersionStage(input) {
|
|
calls.push({ op: "updateSecretVersionStage", input });
|
|
},
|
|
},
|
|
});
|
|
|
|
await provider.deleteOrArchive({
|
|
material: {
|
|
scheme: "aws_secrets_manager_v1",
|
|
secretId: "arn:aws:secretsmanager:us-east-1:123456789012:secret:shared/external",
|
|
versionId: null,
|
|
source: "external_reference",
|
|
lastWrittenVersionId: "aws-version-9",
|
|
previousCurrentVersionId: "aws-version-8",
|
|
},
|
|
externalRef: "arn:aws:secretsmanager:us-east-1:123456789012:secret:shared/external",
|
|
mode: "archive",
|
|
});
|
|
|
|
expect(calls).toEqual([
|
|
{
|
|
op: "updateSecretVersionStage",
|
|
input: {
|
|
SecretId: "arn:aws:secretsmanager:us-east-1:123456789012:secret:shared/external",
|
|
VersionStage: "AWSCURRENT",
|
|
MoveToVersionId: "aws-version-8",
|
|
RemoveFromVersionId: "aws-version-9",
|
|
},
|
|
},
|
|
]);
|
|
});
|
|
|
|
it("lists remote AWS secrets with metadata only and never resolves plaintext", 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("GetSecretValue must not be used for remote import preview");
|
|
},
|
|
async deleteSecret() {
|
|
throw new Error("not used");
|
|
},
|
|
async listSecrets(input) {
|
|
calls.push({ op: "listSecrets", input });
|
|
return {
|
|
NextToken: "token-2",
|
|
SecretList: [
|
|
{
|
|
ARN: "arn:aws:secretsmanager:us-east-1:123456789012:secret:prod/openai",
|
|
Name: "prod/openai",
|
|
Description: "OpenAI API key",
|
|
CreatedDate: new Date("2026-05-06T00:00:00.000Z"),
|
|
Tags: [{ Key: "team", Value: "platform" }],
|
|
},
|
|
],
|
|
};
|
|
},
|
|
},
|
|
});
|
|
|
|
const listed = await provider.listRemoteSecrets?.({
|
|
query: "openai",
|
|
nextToken: "token-1",
|
|
pageSize: 25,
|
|
});
|
|
|
|
expect(calls).toEqual([
|
|
{
|
|
op: "listSecrets",
|
|
input: {
|
|
MaxResults: 25,
|
|
NextToken: "token-1",
|
|
IncludePlannedDeletion: false,
|
|
Filters: [{ Key: "all", Values: ["openai"] }],
|
|
},
|
|
},
|
|
]);
|
|
expect(listed).toEqual({
|
|
nextToken: "token-2",
|
|
secrets: [
|
|
{
|
|
externalRef: "arn:aws:secretsmanager:us-east-1:123456789012:secret:prod/openai",
|
|
name: "prod/openai",
|
|
providerVersionRef: null,
|
|
metadata: expect.objectContaining({
|
|
createdDate: "2026-05-06T00:00:00.000Z",
|
|
hasDescription: true,
|
|
tagCount: 1,
|
|
}),
|
|
},
|
|
],
|
|
});
|
|
expect(JSON.stringify(listed)).not.toContain("SecretString");
|
|
expect(JSON.stringify(listed)).not.toContain("OpenAI API key");
|
|
expect(JSON.stringify(listed)).not.toContain("team");
|
|
});
|
|
|
|
it("discovers AWS provider vault prefill candidates from metadata without reading values", async () => {
|
|
const calls: Array<{ op: string; input: Record<string, unknown> }> = [];
|
|
const provider = createAwsSecretsManagerProvider({
|
|
gateway: {
|
|
async createSecret() {
|
|
throw new Error("not used");
|
|
},
|
|
async putSecretValue() {
|
|
throw new Error("not used");
|
|
},
|
|
async getSecretValue() {
|
|
throw new Error("GetSecretValue must not be used for provider vault discovery");
|
|
},
|
|
async deleteSecret() {
|
|
throw new Error("not used");
|
|
},
|
|
async listSecrets(input) {
|
|
calls.push({ op: "listSecrets", input });
|
|
return {
|
|
NextToken: "next-page",
|
|
SecretList: [
|
|
{
|
|
ARN: "arn:aws:secretsmanager:us-east-1:123456789012:secret:paperclip/prod-use1/company-1/openai",
|
|
Name: "paperclip/prod-use1/company-1/openai",
|
|
KmsKeyId: "arn:aws:kms:us-east-1:123456789012:key/prod",
|
|
Tags: [
|
|
{ Key: "paperclip:managed-by", Value: "paperclip" },
|
|
{ Key: "paperclip:deployment-id", Value: "prod-use1" },
|
|
{ Key: "paperclip:company-id", Value: "company-1" },
|
|
{ Key: "paperclip:environment", Value: "production" },
|
|
{ Key: "paperclip:provider-owner", Value: "platform" },
|
|
],
|
|
},
|
|
{
|
|
ARN: "arn:aws:secretsmanager:us-east-1:123456789012:secret:paperclip/prod-use1/company-2/stripe",
|
|
Name: "paperclip/prod-use1/company-2/stripe",
|
|
Tags: [
|
|
{ Key: "paperclip:managed-by", Value: "paperclip" },
|
|
{ Key: "paperclip:company-id", Value: "company-2" },
|
|
],
|
|
},
|
|
],
|
|
};
|
|
},
|
|
},
|
|
});
|
|
|
|
const preview = await provider.discoverProviderConfigs?.({
|
|
companyId: "company-1",
|
|
providerConfig: {
|
|
id: "draft",
|
|
provider: "aws_secrets_manager",
|
|
status: "ready",
|
|
config: { region: "us-east-1" },
|
|
},
|
|
query: "paperclip",
|
|
pageSize: 25,
|
|
});
|
|
|
|
expect(calls).toEqual([
|
|
{
|
|
op: "listSecrets",
|
|
input: {
|
|
MaxResults: 25,
|
|
NextToken: undefined,
|
|
IncludePlannedDeletion: false,
|
|
Filters: [{ Key: "all", Values: ["paperclip"] }],
|
|
},
|
|
},
|
|
]);
|
|
expect(preview).toMatchObject({
|
|
provider: "aws_secrets_manager",
|
|
nextToken: "next-page",
|
|
sampledSecretCount: 1,
|
|
skippedForeignPaperclipSampleCount: 1,
|
|
candidates: [
|
|
expect.objectContaining({
|
|
displayName: "AWS production",
|
|
config: expect.objectContaining({
|
|
region: "us-east-1",
|
|
namespace: "prod-use1",
|
|
secretNamePrefix: "paperclip",
|
|
kmsKeyId: "arn:aws:kms:us-east-1:123456789012:key/prod",
|
|
ownerTag: "platform",
|
|
environmentTag: "production",
|
|
}),
|
|
signals: expect.objectContaining({
|
|
paperclipManagedSampleCount: 1,
|
|
skippedForeignPaperclipSampleCount: 1,
|
|
}),
|
|
}),
|
|
],
|
|
});
|
|
expect(JSON.stringify(preview)).not.toContain("SecretString");
|
|
expect(JSON.stringify(preview)).not.toContain("company-2/stripe");
|
|
});
|
|
|
|
it("redacts AWS provider exception text when remote listing fails", async () => {
|
|
const rawProviderMessage =
|
|
"AccessDeniedException: User: arn:aws:sts::123456789012:assumed-role/prod/Paperclip is not authorized to perform secretsmanager:ListSecrets on arn:aws:secretsmanager:us-east-1:123456789012:secret:prod/openai";
|
|
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() {
|
|
throw new Error("not used");
|
|
},
|
|
async listSecrets() {
|
|
throw new Error(rawProviderMessage);
|
|
},
|
|
},
|
|
});
|
|
|
|
let thrown: unknown;
|
|
try {
|
|
await provider.listRemoteSecrets?.({});
|
|
} catch (error) {
|
|
thrown = error;
|
|
}
|
|
|
|
expect(thrown).toBeInstanceOf(SecretProviderClientError);
|
|
expect(thrown).toMatchObject({
|
|
code: "access_denied",
|
|
status: 403,
|
|
message: "AWS Secrets Manager denied the request. Check IAM permissions for this provider vault.",
|
|
rawMessage: rawProviderMessage,
|
|
});
|
|
expect(thrown instanceof Error ? thrown.message : String(thrown)).not.toContain("arn:aws");
|
|
expect(thrown instanceof Error ? thrown.message : String(thrown)).not.toContain("123456789012");
|
|
});
|
|
|
|
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("rejects managed resolve attempts when stored refs drift outside the derived scope", async () => {
|
|
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("should not be called");
|
|
},
|
|
async deleteSecret() {
|
|
throw new Error("not used");
|
|
},
|
|
},
|
|
});
|
|
|
|
await expect(
|
|
provider.resolveVersion({
|
|
material: {
|
|
scheme: "aws_secrets_manager_v1",
|
|
secretId:
|
|
"arn:aws:secretsmanager:us-east-1:123456789012:secret:paperclip/prod-use1/company-2/openai-api-key",
|
|
versionId: "aws-version-2",
|
|
source: "managed",
|
|
},
|
|
externalRef:
|
|
"arn:aws:secretsmanager:us-east-1:123456789012:secret:paperclip/prod-use1/company-2/openai-api-key",
|
|
providerVersionRef: "aws-version-2",
|
|
context: {
|
|
companyId: "company-1",
|
|
secretId: "secret-1",
|
|
secretKey: "openai-api-key",
|
|
version: 2,
|
|
},
|
|
}),
|
|
).rejects.toThrow(/drifted outside the derived deployment\/company scope/i);
|
|
});
|
|
|
|
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");
|
|
expect(health.message).toContain("missing PAPERCLIP_SECRETS_AWS_REGION");
|
|
expect(health.warnings).toEqual(
|
|
expect.arrayContaining([
|
|
expect.stringContaining("Missing required non-secret AWS provider config"),
|
|
expect.stringContaining("AWS bootstrap credentials must be available"),
|
|
expect.stringContaining("Do not store AWS root credentials"),
|
|
]),
|
|
);
|
|
expect(health.details).toMatchObject({
|
|
missingConfig: [
|
|
"PAPERCLIP_SECRETS_AWS_REGION or AWS_REGION/AWS_DEFAULT_REGION",
|
|
"PAPERCLIP_SECRETS_AWS_DEPLOYMENT_ID",
|
|
"PAPERCLIP_SECRETS_AWS_KMS_KEY_ID",
|
|
],
|
|
credentialSource: "AWS SDK default credential provider chain",
|
|
});
|
|
await expect(
|
|
provider.createSecret({
|
|
value: "super-secret-value",
|
|
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:paperclip/prod-use1/company-1/openai-api-key",
|
|
material: {
|
|
scheme: "aws_secrets_manager_v1",
|
|
secretId:
|
|
"arn:aws:secretsmanager:us-east-1:123456789012:secret:paperclip/prod-use1/company-1/openai-api-key",
|
|
versionId: null,
|
|
source: "managed",
|
|
},
|
|
context: {
|
|
companyId: "company-1",
|
|
secretKey: "openai-api-key",
|
|
secretName: "OpenAI API Key",
|
|
version: 2,
|
|
},
|
|
});
|
|
await expect(
|
|
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,
|
|
},
|
|
}),
|
|
).rejects.toThrow(/drifted outside the derived deployment\/company scope/i);
|
|
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:
|
|
"arn:aws:secretsmanager:us-east-1:123456789012:secret:paperclip/prod-use1/company-1/openai-api-key",
|
|
RecoveryWindowInDays: 30,
|
|
},
|
|
},
|
|
]);
|
|
});
|
|
|
|
it("archives pending Paperclip-managed AWS versions without deleting the secret", 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 {};
|
|
},
|
|
async updateSecretVersionStage(input) {
|
|
calls.push({ op: "updateSecretVersionStage", input });
|
|
return {};
|
|
},
|
|
},
|
|
});
|
|
|
|
await provider.deleteOrArchive({
|
|
mode: "archive",
|
|
externalRef:
|
|
"arn:aws:secretsmanager:us-east-1:123456789012:secret:paperclip/prod-use1/company-1/openai-api-key",
|
|
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",
|
|
},
|
|
context: {
|
|
companyId: "company-1",
|
|
secretKey: "openai-api-key",
|
|
secretName: "OpenAI API Key",
|
|
version: 2,
|
|
},
|
|
});
|
|
|
|
expect(calls).toEqual([
|
|
{
|
|
op: "updateSecretVersionStage",
|
|
input: {
|
|
SecretId:
|
|
"arn:aws:secretsmanager:us-east-1:123456789012:secret:paperclip/prod-use1/company-1/openai-api-key",
|
|
VersionStage: "PAPERCLIP_PENDING",
|
|
RemoveFromVersionId: "aws-version-2",
|
|
},
|
|
},
|
|
]);
|
|
});
|
|
});
|