Harden AWS remote secret imports

This commit is contained in:
Dotta committed 2026-05-07 18:59:05 -05:00
1 parent 02a5f13044
commit ae538db38f
6 files changed
+413 -42

No files matched your search

+1 -1
View File
@@ -198,7 +198,7 @@ export interface SecretAccessEvent {
export type RemoteSecretImportCandidateStatus = "ready" | "duplicate" | "conflict";
export interface RemoteSecretImportConflict {
type: "exact_reference" | "name" | "key";
type: "exact_reference" | "name" | "key" | "provider_guardrail";
message: string;
existingSecretId?: string;
}
@@ -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");
});
});
+144 -2
View File
@@ -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);
@@ -337,6 +337,32 @@ function isManagedSecretRefForContext(
return new RegExp(`^${escapeRegExp(expectedName)}(?:-[A-Za-z0-9]{6})?$`).test(actualName);
}
function isManagedSecretNamespaceRef(
config: AwsSecretsManagerConfig,
externalRef: string | null | undefined,
) {
if (!externalRef?.trim()) return false;
const namespacePrefix = [
sanitizePathSegment(config.prefix),
sanitizePathSegment(config.deploymentId),
]
.filter(Boolean)
.join("/");
if (!namespacePrefix) return false;
const actualName = extractAwsSecretName(externalRef);
return actualName === namespacePrefix || actualName.startsWith(`${namespacePrefix}/`);
}
function assertNotManagedNamespaceExternalRef(
config: AwsSecretsManagerConfig,
externalRef: string,
) {
if (!isManagedSecretNamespaceRef(config, externalRef)) return;
throw unprocessable(
"AWS Paperclip-managed namespace secrets cannot be imported as external references",
);
}
function resolveManagedSecretRef(input: {
config: AwsSecretsManagerConfig;
context: ManagedSecretNamespaceContext | undefined;
@@ -414,15 +440,13 @@ function serializeAwsDate(value: string | number | Date | undefined): string | n
function createRemoteSecretMetadata(entry: AwsSecretsManagerListSecretEntry): Record<string, unknown> {
return {
arn: entry.ARN ?? null,
name: entry.Name ?? null,
description: entry.Description ?? null,
kmsKeyId: entry.KmsKeyId ?? null,
createdDate: serializeAwsDate(entry.CreatedDate),
lastAccessedDate: serializeAwsDate(entry.LastAccessedDate),
lastChangedDate: serializeAwsDate(entry.LastChangedDate),
deletedDate: serializeAwsDate(entry.DeletedDate),
tags: entry.Tags ?? [],
hasDescription: Boolean(entry.Description),
hasKmsKey: Boolean(entry.KmsKeyId),
tagCount: Array.isArray(entry.Tags) ? entry.Tags.length : 0,
};
}
@@ -740,6 +764,8 @@ export function createAwsSecretsManagerProvider(
}
},
async linkExternalSecret(input) {
const config = resolveConfig(input.providerConfig);
assertNotManagedNamespaceExternalRef(config, input.externalRef);
return createExternalReferenceMaterial(input.externalRef, input.providerVersionRef ?? null);
},
async listRemoteSecrets(input): Promise<RemoteSecretListResult> {
+164 -30
View File
@@ -42,7 +42,11 @@ import {
getSecretProvider,
listSecretProviders,
} from "../secrets/provider-registry.js";
import type { SecretProviderHealthCheck, SecretProviderVaultRuntimeConfig } from "../secrets/types.js";
import type {
PreparedSecretVersion,
SecretProviderHealthCheck,
SecretProviderVaultRuntimeConfig,
} from "../secrets/types.js";
const ENV_KEY_RE = /^[A-Za-z_][A-Za-z0-9_]*$/;
const SENSITIVE_ENV_KEY_RE =
@@ -689,29 +693,61 @@ export function secretService(db: Db) {
name: companySecrets.name,
key: companySecrets.key,
provider: companySecrets.provider,
providerConfigId: companySecrets.providerConfigId,
externalRef: companySecrets.externalRef,
})
.from(companySecrets)
.where(eq(companySecrets.companyId, companyId));
return {
byExternalRef: new Map(
byProviderConfigExternalRef: new Map(
existingSecrets
.filter((secret) => secret.provider === provider && typeof secret.externalRef === "string" && secret.externalRef.trim())
.map((secret) => [secret.externalRef!.trim(), secret]),
.filter((secret) =>
secret.provider === provider &&
typeof secret.externalRef === "string" &&
secret.externalRef.trim()
)
.map((secret) => [
remoteImportExternalRefKey(secret.providerConfigId, secret.externalRef!),
secret,
]),
),
byName: new Map(existingSecrets.map((secret) => [secret.name, secret])),
byKey: new Map(existingSecrets.map((secret) => [secret.key, secret])),
};
}
function remoteImportExternalRefKey(providerConfigId: string | null | undefined, externalRef: string) {
return `${providerConfigId ?? "default"}\0${externalRef.trim()}`;
}
function sanitizeRemoteProviderMetadata(
provider: SecretProvider,
metadata: Record<string, unknown> | null | undefined,
): Record<string, unknown> | null {
if (!metadata || provider !== "aws_secrets_manager") return null;
const safe: Record<string, unknown> = {};
for (const key of ["createdDate", "lastAccessedDate", "lastChangedDate", "deletedDate"]) {
const value = metadata[key];
if (typeof value === "string" || value === null) safe[key] = value;
}
for (const key of ["hasDescription", "hasKmsKey", "tagCount"]) {
const value = metadata[key];
if (typeof value === "boolean" || typeof value === "number") safe[key] = value;
}
return Object.keys(safe).length > 0 ? safe : null;
}
function remoteImportConflictsFor(input: {
providerConfigId: string | null;
externalRef: string;
name: string;
key: string;
maps: Awaited<ReturnType<typeof buildRemoteImportConflictMaps>>;
}): RemoteSecretImportConflict[] {
const conflicts: RemoteSecretImportConflict[] = [];
const duplicate = input.maps.byExternalRef.get(input.externalRef);
const duplicate = input.maps.byProviderConfigExternalRef.get(
remoteImportExternalRefKey(input.providerConfigId, input.externalRef),
);
if (duplicate) {
conflicts.push({
type: "exact_reference",
@@ -1009,26 +1045,54 @@ export function secretService(db: Db) {
pageSize: input.pageSize,
});
const maps = await buildRemoteImportConflictMaps(companyId, providerId);
const candidates: RemoteSecretImportCandidate[] = listed.secrets.map((remote) => {
const candidates: RemoteSecretImportCandidate[] = [];
for (const remote of listed.secrets) {
const externalRef = remote.externalRef.trim();
const remoteName = remote.name.trim() || deriveSecretNameFromExternalRef(externalRef);
const name = remoteName || deriveSecretNameFromExternalRef(externalRef);
const key = normalizeSecretKey(name);
const conflicts = remoteImportConflictsFor({ externalRef, name, key, maps });
let canonicalExternalRef = externalRef;
const conflicts: RemoteSecretImportConflict[] = [];
try {
const prepared = await provider.linkExternalSecret({
externalRef,
providerVersionRef: remote.providerVersionRef ?? null,
providerConfig: runtimeConfig,
context: {
companyId,
secretKey: key || "remote-import-preview",
secretName: name,
version: 1,
},
});
canonicalExternalRef = prepared.externalRef ?? externalRef;
} catch (error) {
conflicts.push({
type: "provider_guardrail",
message: error instanceof Error ? error.message : "Provider rejected this external reference",
});
}
conflicts.push(...remoteImportConflictsFor({
providerConfigId: providerConfig.id,
externalRef: canonicalExternalRef,
name,
key,
maps,
}));
const hasDuplicate = conflicts.some((conflict) => conflict.type === "exact_reference");
const hasConflict = conflicts.length > 0;
return {
candidates.push({
externalRef,
remoteName,
name,
key,
providerVersionRef: remote.providerVersionRef ?? null,
providerMetadata: remote.metadata ?? null,
providerMetadata: sanitizeRemoteProviderMetadata(providerId, remote.metadata),
status: hasDuplicate ? "duplicate" : hasConflict ? "conflict" : "ready",
importable: !hasConflict,
conflicts,
};
});
});
}
return {
providerConfigId: providerConfig.id,
provider: providerId,
@@ -1066,7 +1130,14 @@ export function secretService(db: Db) {
const externalRef = selection.externalRef.trim();
const name = selection.name?.trim() || deriveSecretNameFromExternalRef(externalRef);
const key = normalizeSecretKey(selection.key?.trim() || name);
const conflicts = remoteImportConflictsFor({ externalRef, name, key, maps });
let prepared: PreparedSecretVersion | undefined;
const conflicts = remoteImportConflictsFor({
providerConfigId: providerConfig.id,
externalRef,
name,
key,
maps,
});
if (!key) {
results.push({
externalRef,
@@ -1079,6 +1150,42 @@ export function secretService(db: Db) {
});
continue;
}
if (conflicts.length === 0) {
try {
prepared = await provider.linkExternalSecret({
externalRef,
providerVersionRef: selection.providerVersionRef ?? null,
providerConfig: runtimeConfig,
context: {
companyId,
secretKey: key,
secretName: name,
version: 1,
},
});
const canonicalDuplicate = maps.byProviderConfigExternalRef.get(
remoteImportExternalRefKey(providerConfig.id, prepared.externalRef ?? externalRef),
);
if (canonicalDuplicate) {
conflicts.push({
type: "exact_reference",
existingSecretId: canonicalDuplicate.id,
message: "An existing secret already links this exact provider reference.",
});
}
} catch (error) {
results.push({
externalRef,
name,
key,
status: "error",
reason: error instanceof Error ? error.message : "Provider rejected this external reference",
secretId: null,
conflicts: [],
});
continue;
}
}
if (conflicts.length > 0) {
results.push({
externalRef,
@@ -1095,17 +1202,23 @@ export function secretService(db: Db) {
}
try {
const prepared = await provider.linkExternalSecret({
externalRef,
providerVersionRef: selection.providerVersionRef ?? null,
providerConfig: runtimeConfig,
context: {
companyId,
secretKey: key,
secretName: name,
version: 1,
},
});
if (!prepared) {
prepared = await provider.linkExternalSecret({
externalRef,
providerVersionRef: selection.providerVersionRef ?? null,
providerConfig: runtimeConfig,
context: {
companyId,
secretKey: key,
secretName: name,
version: 1,
},
});
}
if (!prepared) {
throw unprocessable("Provider rejected this external reference");
}
const preparedSecret = prepared;
const secret = await db.transaction(async (tx) => {
const inserted = await tx
.insert(companySecrets)
@@ -1117,8 +1230,8 @@ export function secretService(db: Db) {
providerConfigId: providerConfig.id,
status: "active",
managedMode: "external_reference",
externalRef: prepared.externalRef,
providerMetadata: selection.providerMetadata ?? null,
externalRef: preparedSecret.externalRef,
providerMetadata: null,
latestVersion: 1,
description: null,
lastRotatedAt: new Date(),
@@ -1130,17 +1243,20 @@ export function secretService(db: Db) {
await tx.insert(companySecretVersions).values({
secretId: inserted.id,
version: 1,
material: prepared.material,
valueSha256: prepared.valueSha256,
fingerprintSha256: prepared.fingerprintSha256 ?? prepared.valueSha256,
providerVersionRef: prepared.providerVersionRef ?? null,
material: preparedSecret.material,
valueSha256: preparedSecret.valueSha256,
fingerprintSha256: preparedSecret.fingerprintSha256 ?? preparedSecret.valueSha256,
providerVersionRef: preparedSecret.providerVersionRef ?? null,
status: "current",
createdByAgentId: actor?.agentId ?? null,
createdByUserId: actor?.userId ?? null,
});
return inserted;
});
maps.byExternalRef.set(externalRef, secret);
maps.byProviderConfigExternalRef.set(
remoteImportExternalRefKey(providerConfig.id, preparedSecret.externalRef ?? externalRef),
secret,
);
maps.byName.set(name, secret);
maps.byKey.set(key, secret);
results.push({
@@ -1413,6 +1529,24 @@ export function secretService(db: Db) {
if (secret.managedMode !== "external_reference" && patch.externalRef !== undefined) {
throw unprocessable("Managed secrets cannot override externalRef");
}
if (
secret.managedMode === "external_reference" &&
patch.externalRef !== undefined &&
patch.externalRef !== secret.externalRef
) {
throw unprocessable(
"External reference secrets cannot be retargeted through generic update",
);
}
if (
secret.managedMode === "external_reference" &&
patch.providerConfigId !== undefined &&
patch.providerConfigId !== secret.providerConfigId
) {
throw unprocessable(
"External reference secrets cannot change provider vault through generic update",
);
}
if (patch.providerConfigId !== undefined) {
await assertProviderConfigForSecret(
secret.companyId,