Fix secrets soft delete edge cases

This commit is contained in:
Dotta committed 2026-05-08 19:10:15 -05:00
1 parent 7af554aa71
commit 6bf5159d4f
2 files changed
+146 -5

No files matched your search

@@ -16,6 +16,7 @@ import {
} from "@paperclipai/db";
import { getEmbeddedPostgresTestSupport, startEmbeddedPostgresTestDatabase } from "./helpers/embedded-postgres.js";
import { awsSecretsManagerProvider } from "../secrets/aws-secrets-manager-provider.js";
import { localEncryptedProvider } from "../secrets/local-encrypted-provider.js";
import { SecretProviderClientError } from "../secrets/types.js";
import { secretService } from "../services/secrets.js";
@@ -243,6 +244,35 @@ describeEmbeddedPostgres("secretService", () => {
).rejects.toThrow(/not bound/i);
});
it("preserves the original resolution error when failure access logging fails", async () => {
const companyId = await seedCompany();
const svc = secretService(db);
const secret = await svc.create(companyId, {
name: `resolution-failure-${randomUUID()}`,
provider: "local_encrypted",
value: "runtime-secret",
});
await svc.createBinding({
companyId,
secretId: secret.id,
targetType: "system",
targetId: "system",
configPath: "env.API_KEY",
});
vi.spyOn(localEncryptedProvider, "resolveVersion").mockRejectedValueOnce(
new Error("provider resolution failed"),
);
await expect(
svc.resolveSecretValue(companyId, secret.id, "latest", {
consumerType: "system",
consumerId: "system",
configPath: "env.API_KEY",
heartbeatRunId: randomUUID(),
}),
).rejects.toThrow("provider resolution failed");
});
it("keeps one default provider vault per company provider", async () => {
const companyId = await seedCompany();
const svc = secretService(db);
@@ -359,6 +389,56 @@ describeEmbeddedPostgres("secretService", () => {
expect(result).toMatchObject({ importedCount: 1, skippedCount: 0, errorCount: 0 });
});
it("ignores soft-deleted name and key conflicts during remote import", 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 deleted = await svc.create(companyId, {
name: "Deleted external",
key: "deleted-external",
provider: "aws_secrets_manager",
providerConfigId: awsVault.id,
managedMode: "external_reference",
externalRef: "arn:aws:secretsmanager:us-east-1:123456789012:secret:prod/deleted-old",
});
await svc.update(deleted.id, { status: "deleted" });
vi.spyOn(awsSecretsManagerProvider, "listRemoteSecrets").mockResolvedValue({
secrets: [
{
externalRef: "arn:aws:secretsmanager:us-east-1:123456789012:secret:prod/deleted-new",
name: "Deleted external",
providerVersionRef: null,
metadata: {},
},
],
});
const preview = await svc.previewRemoteImport(companyId, {
providerConfigId: awsVault.id,
});
const result = await svc.importRemoteSecrets(companyId, {
providerConfigId: awsVault.id,
secrets: [
{
externalRef: "arn:aws:secretsmanager:us-east-1:123456789012:secret:prod/deleted-new",
name: "Deleted external",
key: "deleted-external",
},
],
});
expect(preview.candidates[0]).toMatchObject({
status: "ready",
importable: true,
conflicts: [],
});
expect(result).toMatchObject({ importedCount: 1, skippedCount: 0, errorCount: 0 });
});
it("rejects provider vaults from another company when creating a secret", async () => {
const companyA = await seedCompany("A");
const companyB = await seedCompany("B");
@@ -988,6 +1068,65 @@ describeEmbeddedPostgres("secretService", () => {
expect(persisted).toBeNull();
});
it("renames name and key during removal before provider deletion", async () => {
const companyId = await seedCompany();
const svc = secretService(db);
const externalRef =
"arn:aws:secretsmanager:us-east-1:123456789012:secret:paperclip/prod-use1/company-1/remove-failure";
const secret = await db
.insert(companySecrets)
.values({
companyId,
key: "remove-failure",
name: "Remove Failure",
provider: "aws_secrets_manager",
managedMode: "paperclip_managed",
externalRef,
latestVersion: 1,
status: "active",
})
.returning()
.then((rows) => rows[0]);
await db.insert(companySecretVersions).values({
secretId: secret.id,
version: 1,
material: {
scheme: "aws_secrets_manager_v1",
secretId: externalRef,
versionId: "aws-version-1",
source: "managed",
},
valueSha256: "value-sha-1",
fingerprintSha256: "fingerprint-sha-1",
providerVersionRef: "aws-version-1",
status: "current",
});
vi.spyOn(awsSecretsManagerProvider, "deleteOrArchive").mockRejectedValueOnce(
new Error("provider delete failed"),
);
await expect(svc.remove(secret.id)).rejects.toThrow("provider delete failed");
const persisted = await db
.select()
.from(companySecrets)
.where(eq(companySecrets.id, secret.id))
.then((rows) => rows[0] ?? null);
const recreated = await svc.create(companyId, {
name: "Remove Failure",
key: "remove-failure",
provider: "local_encrypted",
value: "replacement",
});
expect(persisted).toMatchObject({
status: "deleted",
key: `remove-failure__deleted__${secret.id}`,
name: `Remove Failure__deleted__${secret.id}`,
});
expect(recreated.id).not.toBe(secret.id);
});
it("removes DB rows even when the attached provider vault is disabled", async () => {
const companyId = await seedCompany();
const svc = secretService(db);
+7 -5
View File
@@ -528,7 +528,7 @@ export function secretService(db: Db) {
context,
outcome: "failure",
errorCode,
});
}).catch(() => undefined);
throw err;
}
}
@@ -776,12 +776,12 @@ export function secretService(db: Db) {
})
.from(companySecrets)
.where(eq(companySecrets.companyId, companyId));
const activeSecrets = existingSecrets.filter((secret) => secret.status !== "deleted");
return {
byProviderConfigExternalRef: new Map(
existingSecrets
activeSecrets
.filter((secret) =>
secret.provider === provider &&
secret.status !== "deleted" &&
typeof secret.externalRef === "string" &&
secret.externalRef.trim()
)
@@ -790,8 +790,8 @@ export function secretService(db: Db) {
secret,
]),
),
byName: new Map(existingSecrets.map((secret) => [secret.name, secret])),
byKey: new Map(existingSecrets.map((secret) => [secret.key, secret])),
byName: new Map(activeSecrets.map((secret) => [secret.name, secret])),
byKey: new Map(activeSecrets.map((secret) => [secret.key, secret])),
};
}
@@ -1865,6 +1865,8 @@ export function secretService(db: Db) {
await db
.update(companySecrets)
.set({
key: `${secret.key}__deleted__${secret.id}`,
name: `${secret.name}__deleted__${secret.id}`,
status: "deleted",
deletedAt: secret.deletedAt ?? new Date(),
updatedAt: new Date(),