mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-11 13:24:55 +01:00
Harden AWS remote secret imports
This commit is contained in:
1 parent
02a5f13044
commit
ae538db38f
6 files changed
+413
-42
No files matched your search
@@ -269,7 +269,18 @@ describe("awsSecretsManagerProvider", () => {
|
||||
});
|
||||
|
||||
it("stores linked external references as metadata-only provider material", async () => {
|
||||
const provider = createAwsSecretsManagerProvider();
|
||||
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",
|
||||
@@ -283,6 +294,29 @@ describe("awsSecretsManagerProvider", () => {
|
||||
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("lists remote AWS secrets with metadata only and never resolves plaintext", async () => {
|
||||
const calls: Array<{ op: string; input: Record<string, unknown> }> = [];
|
||||
const provider = createAwsSecretsManagerProvider({
|
||||
@@ -352,15 +386,16 @@ describe("awsSecretsManagerProvider", () => {
|
||||
name: "prod/openai",
|
||||
providerVersionRef: null,
|
||||
metadata: expect.objectContaining({
|
||||
arn: "arn:aws:secretsmanager:us-east-1:123456789012:secret:prod/openai",
|
||||
name: "prod/openai",
|
||||
description: "OpenAI API key",
|
||||
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("resolves AWS secret values by provider version reference", async () => {
|
||||
|
||||
@@ -3,6 +3,7 @@ import request from "supertest";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { secretRoutes } from "../routes/secrets.js";
|
||||
import { errorHandler } from "../middleware/error-handler.js";
|
||||
import { unprocessable } from "../errors.js";
|
||||
|
||||
const mockSecretService = vi.hoisted(() => ({
|
||||
listProviders: vi.fn(),
|
||||
@@ -14,7 +15,9 @@ const mockSecretService = vi.hoisted(() => ({
|
||||
disableProviderConfig: vi.fn(),
|
||||
setDefaultProviderConfig: vi.fn(),
|
||||
checkProviderConfigHealth: vi.fn(),
|
||||
getById: vi.fn(),
|
||||
create: vi.fn(),
|
||||
update: vi.fn(),
|
||||
previewRemoteImport: vi.fn(),
|
||||
importRemoteSecrets: vi.fn(),
|
||||
}));
|
||||
@@ -357,4 +360,35 @@ describe("secret routes", () => {
|
||||
}));
|
||||
expect(JSON.stringify(mockLogActivity.mock.calls)).not.toContain("prod/openai");
|
||||
});
|
||||
|
||||
it("surfaces update-route externalRef retarget rejection without logging raw refs", async () => {
|
||||
mockSecretService.getById.mockResolvedValue({
|
||||
id: "22222222-2222-4222-8222-222222222222",
|
||||
companyId: "company-1",
|
||||
name: "OpenAI API key",
|
||||
key: "openai-api-key",
|
||||
provider: "aws_secrets_manager",
|
||||
managedMode: "external_reference",
|
||||
externalRef: "arn:aws:secretsmanager:us-east-1:123456789012:secret:shared/original",
|
||||
});
|
||||
mockSecretService.update.mockRejectedValue(
|
||||
unprocessable("External reference secrets cannot be retargeted through generic update"),
|
||||
);
|
||||
|
||||
const res = await request(createApp())
|
||||
.patch("/api/secrets/22222222-2222-4222-8222-222222222222")
|
||||
.send({
|
||||
externalRef: "arn:aws:secretsmanager:us-east-1:123456789012:secret:shared/repointed",
|
||||
});
|
||||
|
||||
expect(res.status).toBe(422);
|
||||
expect(mockSecretService.update).toHaveBeenCalledWith(
|
||||
"22222222-2222-4222-8222-222222222222",
|
||||
expect.objectContaining({
|
||||
externalRef: "arn:aws:secretsmanager:us-east-1:123456789012:secret:shared/repointed",
|
||||
}),
|
||||
);
|
||||
expect(mockLogActivity).not.toHaveBeenCalled();
|
||||
expect(JSON.stringify(mockLogActivity.mock.calls)).not.toContain("shared/repointed");
|
||||
});
|
||||
});
|
||||
@@ -206,17 +206,23 @@ describeEmbeddedPostgres("secretService", () => {
|
||||
it("stores external references without requiring or persisting secret values", async () => {
|
||||
const companyId = await seedCompany();
|
||||
const svc = secretService(db);
|
||||
const awsVault = await svc.createProviderConfig(companyId, {
|
||||
provider: "aws_secrets_manager",
|
||||
displayName: "AWS production",
|
||||
config: { region: "us-east-1", namespace: "prod-use1" },
|
||||
});
|
||||
|
||||
const secret = await svc.create(companyId, {
|
||||
name: `external-${randomUUID()}`,
|
||||
provider: "aws_secrets_manager",
|
||||
providerConfigId: awsVault.id,
|
||||
managedMode: "external_reference",
|
||||
externalRef: "arn:aws:secretsmanager:us-east-1:123456789012:secret:paperclip/test",
|
||||
externalRef: "arn:aws:secretsmanager:us-east-1:123456789012:secret:shared/test",
|
||||
providerVersionRef: "version-1",
|
||||
});
|
||||
|
||||
expect(secret.managedMode).toBe("external_reference");
|
||||
expect(secret.externalRef).toBe("arn:aws:secretsmanager:us-east-1:123456789012:secret:paperclip/test");
|
||||
expect(secret.externalRef).toBe("arn:aws:secretsmanager:us-east-1:123456789012:secret:shared/test");
|
||||
|
||||
const versions = await db
|
||||
.select()
|
||||
@@ -439,6 +445,7 @@ describeEmbeddedPostgres("secretService", () => {
|
||||
name: "prod/ready",
|
||||
key: "prod-ready",
|
||||
});
|
||||
expect(preview.candidates[2]?.providerMetadata).toBeNull();
|
||||
});
|
||||
|
||||
it("imports AWS remote references row-by-row without fetching plaintext", async () => {
|
||||
@@ -500,6 +507,7 @@ describeEmbeddedPostgres("secretService", () => {
|
||||
managedMode: "external_reference",
|
||||
externalRef: "arn:aws:secretsmanager:us-east-1:123456789012:secret:prod/openai",
|
||||
createdByUserId: "user-1",
|
||||
providerMetadata: null,
|
||||
});
|
||||
|
||||
const versions = await db
|
||||
@@ -511,6 +519,112 @@ describeEmbeddedPostgres("secretService", () => {
|
||||
expect(JSON.stringify(versions[0])).not.toContain("sk-");
|
||||
});
|
||||
|
||||
it("rejects Paperclip-managed AWS namespace refs during preview and import commit", async () => {
|
||||
const companyId = await seedCompany();
|
||||
const svc = secretService(db);
|
||||
const awsVault = await svc.createProviderConfig(companyId, {
|
||||
provider: "aws_secrets_manager",
|
||||
displayName: "AWS production",
|
||||
config: { region: "us-east-1", namespace: "prod-use1" },
|
||||
});
|
||||
|
||||
vi.spyOn(awsSecretsManagerProvider, "listRemoteSecrets").mockResolvedValue({
|
||||
secrets: [
|
||||
{
|
||||
externalRef:
|
||||
"arn:aws:secretsmanager:us-east-1:123456789012:secret:paperclip/prod-use1/company-b/openai",
|
||||
name: "paperclip/prod-use1/company-b/openai",
|
||||
providerVersionRef: null,
|
||||
metadata: {
|
||||
arn: "arn:aws:secretsmanager:us-east-1:123456789012:secret:paperclip/prod-use1/company-b/openai",
|
||||
description: "must not leak",
|
||||
tags: [{ Key: "paperclip:company-id", Value: "company-b" }],
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const preview = await svc.previewRemoteImport(companyId, {
|
||||
providerConfigId: awsVault.id,
|
||||
});
|
||||
|
||||
expect(preview.candidates[0]).toMatchObject({
|
||||
status: "conflict",
|
||||
importable: false,
|
||||
conflicts: [expect.objectContaining({ type: "provider_guardrail" })],
|
||||
providerMetadata: null,
|
||||
});
|
||||
expect(JSON.stringify(preview)).not.toContain("must not leak");
|
||||
expect(JSON.stringify(preview)).not.toContain("paperclip:company-id");
|
||||
|
||||
const result = await svc.importRemoteSecrets(companyId, {
|
||||
providerConfigId: awsVault.id,
|
||||
secrets: [
|
||||
{
|
||||
externalRef:
|
||||
"arn:aws:secretsmanager:us-east-1:123456789012:secret:paperclip/prod-use1/company-b/openai",
|
||||
name: "Foreign managed secret",
|
||||
key: "foreign-managed-secret",
|
||||
providerMetadata: {
|
||||
description: "client-submitted metadata must not persist",
|
||||
tags: [{ Key: "paperclip:company-id", Value: "company-b" }],
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(result).toMatchObject({
|
||||
importedCount: 0,
|
||||
skippedCount: 0,
|
||||
errorCount: 1,
|
||||
results: [expect.objectContaining({ status: "error" })],
|
||||
});
|
||||
expect(result.results[0]?.reason).toMatch(/Paperclip-managed namespace/i);
|
||||
const imported = await db.select().from(companySecrets).where(eq(companySecrets.key, "foreign-managed-secret"));
|
||||
expect(imported).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("skips duplicate AWS remote imports for the same provider vault and canonical ref", async () => {
|
||||
const companyId = await seedCompany();
|
||||
const svc = secretService(db);
|
||||
const awsVault = await svc.createProviderConfig(companyId, {
|
||||
provider: "aws_secrets_manager",
|
||||
displayName: "AWS production",
|
||||
config: { region: "us-east-1", namespace: "prod-use1" },
|
||||
});
|
||||
|
||||
const first = await svc.importRemoteSecrets(companyId, {
|
||||
providerConfigId: awsVault.id,
|
||||
secrets: [
|
||||
{
|
||||
externalRef: "arn:aws:secretsmanager:us-east-1:123456789012:secret:prod/openai",
|
||||
name: "OpenAI API key",
|
||||
key: "openai-api-key",
|
||||
},
|
||||
],
|
||||
});
|
||||
const second = await svc.importRemoteSecrets(companyId, {
|
||||
providerConfigId: awsVault.id,
|
||||
secrets: [
|
||||
{
|
||||
externalRef: "arn:aws:secretsmanager:us-east-1:123456789012:secret:prod/openai",
|
||||
name: "OpenAI API key duplicate",
|
||||
key: "openai-api-key-duplicate",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(first.importedCount).toBe(1);
|
||||
expect(second).toMatchObject({
|
||||
importedCount: 0,
|
||||
skippedCount: 1,
|
||||
errorCount: 0,
|
||||
results: [expect.objectContaining({ reason: "exact_reference_duplicate" })],
|
||||
});
|
||||
const imported = await db.select().from(companySecrets).where(eq(companySecrets.providerConfigId, awsVault.id));
|
||||
expect(imported).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("rejects remote import for disabled or cross-company provider vaults", async () => {
|
||||
const companyA = await seedCompany("A");
|
||||
const companyB = await seedCompany("B");
|
||||
@@ -558,6 +672,34 @@ describeEmbeddedPostgres("secretService", () => {
|
||||
).rejects.toThrow(/Managed secrets cannot override externalRef/i);
|
||||
});
|
||||
|
||||
it("rejects generic update retargeting for external reference secrets", async () => {
|
||||
const companyId = await seedCompany();
|
||||
const svc = secretService(db);
|
||||
const awsVault = await svc.createProviderConfig(companyId, {
|
||||
provider: "aws_secrets_manager",
|
||||
displayName: "AWS production",
|
||||
config: { region: "us-east-1", namespace: "prod-use1" },
|
||||
});
|
||||
const secret = await svc.create(companyId, {
|
||||
name: `external-${randomUUID()}`,
|
||||
provider: "aws_secrets_manager",
|
||||
providerConfigId: awsVault.id,
|
||||
managedMode: "external_reference",
|
||||
externalRef: "arn:aws:secretsmanager:us-east-1:123456789012:secret:shared/original",
|
||||
});
|
||||
|
||||
await expect(
|
||||
svc.update(secret.id, {
|
||||
externalRef: "arn:aws:secretsmanager:us-east-1:123456789012:secret:shared/repointed",
|
||||
}),
|
||||
).rejects.toThrow(/cannot be retargeted/i);
|
||||
|
||||
const persisted = await svc.getById(secret.id);
|
||||
expect(persisted?.externalRef).toBe(
|
||||
"arn:aws:secretsmanager:us-east-1:123456789012:secret:shared/original",
|
||||
);
|
||||
});
|
||||
|
||||
it("passes managed AWS secret context into provider delete during removal", async () => {
|
||||
const companyId = await seedCompany();
|
||||
const svc = secretService(db);
|
||||
|
||||
Reference in new issue
Block a user