mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-23 09:07:46 +02:00
Fix secrets soft delete edge cases
This commit is contained in:
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);
|
||||
|
||||
@@ -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(),
|
||||
|
||||
Reference in new issue
Block a user