Files
PaperClipAI/server/src/__tests__/aws-secrets-manager-provider.test.ts
T
585d37d4f2 [codex] Preserve AWS secret provider error classification (#10946)
## 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>
2026-10-26 20:54:18 -07:00

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",
},
},
]);
});
});