Files
paperclip/server/src/__tests__/secrets-service.test.ts
T
Dotta d67347be77 [codex] Provider vault secrets UX (#6381)
## Thinking Path

> - Paperclip orchestrates AI agents that need scoped, auditable access
to secrets
> - Hosted and external deployments need provider vault configuration
without exposing secret values in Paperclip metadata
> - AWS Secrets Manager vault setup previously required too much manual
operator knowledge
> - Provider vault discovery and removal belong together as an
independent secrets-management improvement
> - This pull request adds AWS provider vault discovery/prefill plus
vault removal flows
> - The benefit is a safer operator path for configuring external secret
storage before higher-level cloud workflows depend on it

## What Changed

- Added shared validators/types for AWS provider vault discovery
payloads and safe provider metadata.
- Implemented AWS provider vault discovery preview on the server.
- Added provider vault removal service/route behavior.
- Added Secrets page UI for discovery prefill, removal messaging, and
related rendering coverage.
- Added Storybook provider-vault fixtures and captured screenshots for
the new UX states.

## Verification

- `pnpm install --frozen-lockfile --ignore-scripts`
- `pnpm exec vitest run packages/shared/src/validators/secret.test.ts
server/src/__tests__/aws-secrets-manager-provider.test.ts
server/src/__tests__/secrets-routes.test.ts
server/src/__tests__/secrets-service.test.ts
ui/src/pages/Secrets.render.test.tsx`
- Result: 4 files passed, 1 embedded Postgres-backed file skipped on
this host because local Postgres init was unavailable.
- `pnpm --filter @paperclipai/ui exec vitest run
src/pages/Secrets.render.test.tsx`
- `pnpm --filter @paperclipai/ui typecheck`
- Storybook screenshot capture against `Product/Secrets` on
`http://127.0.0.1:60381/iframe.html?id=product-secrets--secrets-inventory&viewMode=story&globals=theme:dark`

## Screenshots

Provider vaults tab after this change:

![Provider vaults
tab](https://raw.githubusercontent.com/paperclipai/paperclip/pap-9861-provider-vault-secrets/doc/screenshots/pr-6381/provider-vaults-tab.png)

AWS discovery candidate flow:

![AWS discovery candidate
flow](https://raw.githubusercontent.com/paperclipai/paperclip/pap-9861-provider-vault-secrets/doc/screenshots/pr-6381/aws-discovery-candidates.png)

Provider vault removal confirmation:

![Provider vault removal
confirmation](https://raw.githubusercontent.com/paperclipai/paperclip/pap-9861-provider-vault-secrets/doc/screenshots/pr-6381/remove-provider-vault-confirmation.png)

## Risks

- Secret provider metadata handling must remain non-sensitive;
validators reject credential-bearing Vault URLs and sensitive AWS
discovery keys.
- AWS discovery depends on deployment credentials being configured
correctly outside Paperclip-managed company secrets.

> 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-based coding agent with local shell/git/tool use.
Exact hosted model ID and context-window size are not exposed by the
local Paperclip adapter runtime.

## 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 run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] If this change affects the UI, I have included before/after
screenshots
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [x] I will address all Greptile and reviewer comments before
requesting merge

---------

Co-authored-by: Paperclip <noreply@paperclip.ing>
2026-05-19 15:50:23 -05:00

1917 lines
67 KiB
TypeScript

import { randomUUID } from "node:crypto";
import { mkdirSync, rmSync } from "node:fs";
import os from "node:os";
import path from "node:path";
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest";
import { eq } from "drizzle-orm";
import {
agents,
companies,
companySecretBindings,
companySecretProviderConfigs,
companySecretVersions,
companySecrets,
createDb,
secretAccessEvents,
} 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";
const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport();
const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip;
if (!embeddedPostgresSupport.supported) {
console.warn(
`Skipping secrets service tests on this host: ${embeddedPostgresSupport.reason ?? "unsupported environment"}`,
);
}
describeEmbeddedPostgres("secretService", () => {
let stopDb: (() => Promise<void>) | null = null;
let db!: ReturnType<typeof createDb>;
const previousKeyFile = process.env.PAPERCLIP_SECRETS_MASTER_KEY_FILE;
const secretsTmpDir = path.join(os.tmpdir(), `paperclip-secrets-service-${randomUUID()}`);
beforeAll(async () => {
mkdirSync(secretsTmpDir, { recursive: true });
process.env.PAPERCLIP_SECRETS_MASTER_KEY_FILE = path.join(secretsTmpDir, "master.key");
const started = await startEmbeddedPostgresTestDatabase("secrets-service");
stopDb = started.cleanup;
db = createDb(started.connectionString);
});
afterEach(async () => {
vi.restoreAllMocks();
await db.delete(secretAccessEvents);
await db.delete(companySecretBindings);
await db.delete(companySecretVersions);
await db.delete(companySecrets);
await db.delete(companySecretProviderConfigs);
await db.delete(agents);
await db.delete(companies);
});
afterAll(async () => {
await stopDb?.();
if (previousKeyFile === undefined) {
delete process.env.PAPERCLIP_SECRETS_MASTER_KEY_FILE;
} else {
process.env.PAPERCLIP_SECRETS_MASTER_KEY_FILE = previousKeyFile;
}
rmSync(secretsTmpDir, { recursive: true, force: true });
});
async function seedCompany(name = "Acme") {
const companyId = randomUUID();
await db.insert(companies).values({
id: companyId,
name,
issuePrefix: `T${companyId.slice(0, 7)}`.toUpperCase(),
status: "active",
createdAt: new Date(),
updatedAt: new Date(),
});
return companyId;
}
it("rejects cross-company secret references during env normalization", async () => {
const companyA = await seedCompany("A");
const companyB = await seedCompany("B");
const svc = secretService(db);
const foreignSecret = await svc.create(companyB, {
name: `foreign-${randomUUID()}`,
provider: "local_encrypted",
value: "secret-value",
});
await expect(
svc.normalizeEnvBindingsForPersistence(companyA, {
API_KEY: { type: "secret_ref", secretId: foreignSecret.id, version: "latest" },
}),
).rejects.toThrow(/same company/i);
});
it("prevents duplicate bindings for a target config path", async () => {
const companyId = await seedCompany();
const svc = secretService(db);
const firstSecret = await svc.create(companyId, {
name: `first-${randomUUID()}`,
provider: "local_encrypted",
value: "one",
});
const secondSecret = await svc.create(companyId, {
name: `second-${randomUUID()}`,
provider: "local_encrypted",
value: "two",
});
await svc.createBinding({
companyId,
secretId: firstSecret.id,
targetType: "agent",
targetId: "agent-1",
configPath: "env.API_KEY",
});
await expect(
svc.createBinding({
companyId,
secretId: secondSecret.id,
targetType: "agent",
targetId: "agent-1",
configPath: "env.API_KEY",
}),
).rejects.toThrow(/already exists/i);
});
it("reports reference counts and resolves binding target labels", async () => {
const companyId = await seedCompany();
const svc = secretService(db);
const secret = await svc.create(companyId, {
name: `referenced-${randomUUID()}`,
provider: "local_encrypted",
value: "runtime-secret",
});
const [agent] = await db
.insert(agents)
.values({
companyId,
name: "CodexCoder",
role: "engineer",
adapterType: "codex_local",
adapterConfig: {},
})
.returning();
await svc.syncEnvBindingsForTarget(
companyId,
{ targetType: "agent", targetId: agent!.id },
{
OPENAI_API_KEY: { type: "secret_ref", secretId: secret.id, version: "latest" },
},
);
const listed = await svc.list(companyId);
expect(listed.find((row) => row.id === secret.id)?.referenceCount).toBe(1);
const bindings = await svc.listBindingReferences(companyId, secret.id);
expect(bindings).toHaveLength(1);
expect(bindings[0]?.target).toMatchObject({
type: "agent",
id: agent!.id,
label: "CodexCoder",
href: "/agents/codexcoder",
status: "idle",
});
});
it("enforces binding context and records value-free access events", async () => {
const companyId = await seedCompany();
const svc = secretService(db);
const secret = await svc.create(companyId, {
name: `runtime-${randomUUID()}`,
provider: "local_encrypted",
value: "runtime-secret",
});
const env = {
API_KEY: { type: "secret_ref" as const, secretId: secret.id, version: "latest" as const },
};
await svc.syncEnvBindingsForTarget(companyId, { targetType: "agent", targetId: "agent-1" }, env);
await expect(
svc.resolveEnvBindings(companyId, env, {
consumerType: "agent",
consumerId: "agent-2",
actorType: "agent",
actorId: "agent-2",
}),
).rejects.toThrow(/not bound/i);
const resolved = await svc.resolveEnvBindings(companyId, env, {
consumerType: "agent",
consumerId: "agent-1",
actorType: "agent",
actorId: "agent-1",
});
expect(resolved.env.API_KEY).toBe("runtime-secret");
const events = await svc.listAccessEvents(companyId, secret.id);
expect(events).toHaveLength(2);
expect(events.map((event) => event.outcome).sort()).toEqual(["failure", "success"]);
expect(JSON.stringify(events)).not.toContain("runtime-secret");
});
it("resolves routine env secret refs through routine bindings and records value-free access metadata", async () => {
const companyId = await seedCompany();
const svc = secretService(db);
const secret = await svc.create(companyId, {
name: `routine-secret-${randomUUID()}`,
provider: "local_encrypted",
value: "routine-super-secret",
});
const env = {
ROUTINE_API_KEY: { type: "secret_ref" as const, secretId: secret.id, version: "latest" as const },
};
await svc.syncEnvBindingsForTarget(companyId, { targetType: "routine", targetId: "routine-1" }, env);
const resolved = await svc.resolveEnvBindings(companyId, env, {
consumerType: "routine",
consumerId: "routine-1",
actorType: "agent",
actorId: "agent-1",
});
expect(resolved.env.ROUTINE_API_KEY).toBe("routine-super-secret");
expect(resolved.manifest).toEqual([
expect.objectContaining({
configPath: "env.ROUTINE_API_KEY",
envKey: "ROUTINE_API_KEY",
secretId: secret.id,
outcome: "success",
}),
]);
const events = await svc.listAccessEvents(companyId, secret.id);
expect(events).toHaveLength(1);
expect(events[0]).toMatchObject({
companyId,
secretId: secret.id,
consumerType: "routine",
consumerId: "routine-1",
configPath: "env.ROUTINE_API_KEY",
actorType: "agent",
actorId: "agent-1",
outcome: "success",
});
expect(JSON.stringify(events)).not.toContain("routine-super-secret");
});
it("records stable redacted failure codes for routine env secret resolution", async () => {
const companyId = await seedCompany();
const svc = secretService(db);
const secret = await svc.create(companyId, {
name: `routine-failure-codes-${randomUUID()}`,
provider: "local_encrypted",
value: "routine-super-secret",
});
const env = {
ROUTINE_API_KEY: { type: "secret_ref" as const, secretId: secret.id, version: "latest" as const },
};
const context = {
consumerType: "routine" as const,
consumerId: "routine-1",
actorType: "agent" as const,
actorId: "agent-1",
};
await svc.syncEnvBindingsForTarget(companyId, { targetType: "routine", targetId: "routine-1" }, env);
await expect(
svc.resolveEnvBindings(companyId, env, { ...context, consumerId: "routine-2" }),
).rejects.toThrow(/not bound/i);
await db.update(companySecrets).set({ status: "disabled" }).where(eq(companySecrets.id, secret.id));
await expect(svc.resolveEnvBindings(companyId, env, context)).rejects.toThrow(/not active/i);
await db.update(companySecrets).set({ status: "active" }).where(eq(companySecrets.id, secret.id));
await expect(
svc.resolveSecretValue(companyId, secret.id, 999, {
...context,
configPath: "env.ROUTINE_API_KEY",
}),
).rejects.toThrow(/version not found/i);
await db
.update(companySecretVersions)
.set({ status: "disabled" })
.where(eq(companySecretVersions.secretId, secret.id));
await expect(svc.resolveEnvBindings(companyId, env, context)).rejects.toThrow(/version is not active/i);
await db
.update(companySecretVersions)
.set({ status: "current" })
.where(eq(companySecretVersions.secretId, secret.id));
vi.spyOn(localEncryptedProvider, "resolveVersion").mockRejectedValueOnce(
new Error("provider leaked value routine-super-secret"),
);
await expect(svc.resolveEnvBindings(companyId, env, context)).rejects.toThrow(/provider leaked value/i);
await db.update(companySecrets).set({ status: "deleted" }).where(eq(companySecrets.id, secret.id));
await expect(svc.resolveEnvBindings(companyId, env, context)).rejects.toThrow(/not found/i);
const events = await svc.listAccessEvents(companyId, secret.id);
expect(events.map((event) => event.errorCode).sort()).toEqual([
"binding_missing",
"provider_error",
"secret_deleted",
"secret_inactive",
"version_inactive",
"version_missing",
]);
expect(JSON.stringify(events)).not.toContain("routine-super-secret");
expect(JSON.stringify(events)).not.toContain("provider leaked value");
});
it("scopes env binding sync deletes to the env path prefix", async () => {
const companyId = await seedCompany();
const svc = secretService(db);
const runtimeSecret = await svc.create(companyId, {
name: `runtime-ref-${randomUUID()}`,
provider: "local_encrypted",
value: "runtime-secret",
});
const envSecret = await svc.create(companyId, {
name: `env-ref-${randomUUID()}`,
provider: "local_encrypted",
value: "env-secret",
});
await svc.createBinding({
companyId,
secretId: runtimeSecret.id,
targetType: "agent",
targetId: "agent-1",
configPath: "runtime.token",
});
await svc.syncEnvBindingsForTarget(
companyId,
{ targetType: "agent", targetId: "agent-1" },
{
API_KEY: { type: "secret_ref", secretId: envSecret.id, version: "latest" },
},
);
await svc.syncEnvBindingsForTarget(
companyId,
{ targetType: "agent", targetId: "agent-1" },
{},
);
const bindings = await db
.select()
.from(companySecretBindings)
.where(eq(companySecretBindings.targetId, "agent-1"));
expect(bindings.map((binding) => binding.configPath)).toEqual(["runtime.token"]);
});
it("returns resolved secrets even when success metadata writes fail", async () => {
const companyId = await seedCompany();
const svc = secretService(db);
const secret = await svc.create(companyId, {
name: `metadata-write-${randomUUID()}`,
provider: "local_encrypted",
value: "runtime-secret",
});
const env = {
API_KEY: { type: "secret_ref" as const, secretId: secret.id, version: "latest" as const },
};
await svc.syncEnvBindingsForTarget(companyId, { targetType: "agent", targetId: "agent-1" }, env);
vi.spyOn(db, "update").mockImplementationOnce(
() => ({
set: () => ({
where: () => Promise.reject(new Error("metadata write failed")),
}),
}) as ReturnType<typeof db.update>,
);
const resolved = await svc.resolveEnvBindings(companyId, env, {
consumerType: "agent",
consumerId: "agent-1",
actorType: "agent",
actorId: "agent-1",
});
expect(resolved.env.API_KEY).toBe("runtime-secret");
});
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:shared/test",
providerVersionRef: "version-1",
});
expect(secret.managedMode).toBe("external_reference");
expect(secret.externalRef).toBe("arn:aws:secretsmanager:us-east-1:123456789012:secret:shared/test");
const versions = await db
.select()
.from(companySecretVersions)
.where(eq(companySecretVersions.secretId, secret.id));
expect(versions).toHaveLength(1);
expect(versions[0]?.providerVersionRef).toBe("version-1");
expect(JSON.stringify(versions[0])).not.toContain("runtime-secret");
expect(JSON.stringify(versions[0])).not.toContain("sk-");
await expect(
svc.resolveSecretValue(companyId, secret.id, "latest", {
consumerType: "system",
consumerId: "system",
configPath: "env.EXTERNAL_SECRET",
}),
).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);
const first = await svc.createProviderConfig(companyId, {
provider: "local_encrypted",
displayName: "Local primary",
isDefault: true,
config: {},
});
const second = await svc.createProviderConfig(companyId, {
provider: "local_encrypted",
displayName: "Local secondary",
isDefault: true,
config: {},
});
const rows = await svc.listProviderConfigs(companyId);
expect(rows.find((row) => row.id === first.id)?.isDefault).toBe(false);
expect(rows.find((row) => row.id === second.id)?.isDefault).toBe(true);
});
it("does not set a disabled provider vault as default", async () => {
const companyId = await seedCompany();
const svc = secretService(db);
const vault = await svc.createProviderConfig(companyId, {
provider: "local_encrypted",
displayName: "Local disabled",
config: {},
});
await svc.disableProviderConfig(vault.id);
await expect(svc.setDefaultProviderConfig(vault.id)).rejects.toThrow(
/ready or warning/i,
);
});
it("removes provider vault config locally without deleting remote AWS secrets", async () => {
const companyId = await seedCompany();
const svc = secretService(db);
const vault = 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: vault.id,
managedMode: "external_reference",
externalRef: "arn:aws:secretsmanager:us-east-1:123456789012:secret:prod/external",
});
const deleteSpy = vi.spyOn(awsSecretsManagerProvider, "deleteOrArchive").mockResolvedValue();
const removed = await svc.removeProviderConfig(vault.id);
expect(removed?.id).toBe(vault.id);
await expect(svc.getProviderConfigById(vault.id)).resolves.toBeNull();
const [persistedSecret] = await db
.select()
.from(companySecrets)
.where(eq(companySecrets.id, secret.id));
expect(persistedSecret?.providerConfigId).toBeNull();
expect(deleteSpy).not.toHaveBeenCalled();
});
it("hides soft-deleted secrets and allows name/key reuse", async () => {
const companyId = await seedCompany();
const svc = secretService(db);
const secretName = `reusable-${randomUUID()}`;
const secret = await svc.create(companyId, {
name: secretName,
key: "reusable-key",
provider: "local_encrypted",
value: "first-value",
});
await svc.remove(secret.id);
const listed = await svc.list(companyId);
const recreated = await svc.create(companyId, {
name: secretName,
key: "reusable-key",
provider: "local_encrypted",
value: "second-value",
});
expect(listed.map((row) => row.id)).not.toContain(secret.id);
expect(recreated.id).not.toBe(secret.id);
expect(recreated.name).toBe(secretName);
expect(recreated.key).toBe("reusable-key");
});
it("rejects bindings and env refs to soft-deleted 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 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",
});
await svc.update(deleted.id, { status: "deleted" });
await expect(
svc.createBinding({
companyId,
secretId: deleted.id,
targetType: "agent",
targetId: "agent-1",
configPath: "env.API_KEY",
}),
).rejects.toThrow(/not found/i);
await expect(
svc.normalizeEnvBindingsForPersistence(companyId, {
API_KEY: { type: "secret_ref", secretId: deleted.id, version: "latest" },
}),
).rejects.toThrow(/not found/i);
});
it("rejects updates to already soft-deleted 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 deleted = await svc.create(companyId, {
name: "Deleted patch target",
key: "deleted-patch-target",
provider: "aws_secrets_manager",
providerConfigId: awsVault.id,
managedMode: "external_reference",
externalRef: "arn:aws:secretsmanager:us-east-1:123456789012:secret:prod/deleted-patch-target",
});
await svc.update(deleted.id, { status: "deleted" });
await expect(svc.update(deleted.id, { status: "active" })).rejects.toThrow(
/not found/i,
);
});
it("allows re-importing a remote secret after the prior external reference is soft-deleted", 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 externalRef =
"arn:aws:secretsmanager:us-east-1:123456789012:secret:prod/reimportable";
const deleted = await svc.create(companyId, {
name: "Deleted external",
key: "deleted-external",
provider: "aws_secrets_manager",
providerConfigId: awsVault.id,
managedMode: "external_reference",
externalRef,
});
await svc.update(deleted.id, { status: "deleted" });
vi.spyOn(awsSecretsManagerProvider, "listRemoteSecrets").mockResolvedValue({
secrets: [
{
externalRef,
name: "prod/reimportable",
providerVersionRef: null,
metadata: { arn: externalRef },
},
],
});
const preview = await svc.previewRemoteImport(companyId, {
providerConfigId: awsVault.id,
});
const result = await svc.importRemoteSecrets(companyId, {
providerConfigId: awsVault.id,
secrets: [
{
externalRef,
name: "Reimported external",
key: "reimported-external",
},
],
});
expect(preview.candidates[0]).toMatchObject({
status: "ready",
importable: true,
conflicts: [],
});
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");
const svc = secretService(db);
const foreignVault = await svc.createProviderConfig(companyB, {
provider: "local_encrypted",
displayName: "Foreign vault",
config: {},
});
await expect(
svc.create(companyA, {
name: `managed-${randomUUID()}`,
provider: "local_encrypted",
providerConfigId: foreignVault.id,
value: "runtime-secret",
}),
).rejects.toThrow(/same company/i);
});
it("blocks coming-soon provider vaults from secret selection", async () => {
const companyId = await seedCompany();
const svc = secretService(db);
const draftVault = await svc.createProviderConfig(companyId, {
provider: "gcp_secret_manager",
displayName: "GCP draft",
config: { projectId: "paperclip-prod1" },
});
expect(draftVault.status).toBe("coming_soon");
await expect(
svc.create(companyId, {
name: `draft-${randomUUID()}`,
provider: "gcp_secret_manager",
providerConfigId: draftVault.id,
value: "runtime-secret",
}),
).rejects.toThrow(/coming soon/i);
});
it("passes selected provider vault config through create, rotate, and resolve", 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",
secretNamePrefix: "paperclip",
},
});
const createSpy = vi.spyOn(awsSecretsManagerProvider, "createSecret").mockResolvedValue({
material: {
scheme: "aws_secrets_manager_v1",
secretId: "arn:aws:secretsmanager:us-east-1:123456789012:secret:paperclip/prod-use1/company/openai-api-key",
versionId: "aws-version-1",
source: "managed",
},
valueSha256: "value-sha-1",
fingerprintSha256: "fingerprint-sha-1",
externalRef: "arn:aws:secretsmanager:us-east-1:123456789012:secret:paperclip/prod-use1/company/openai-api-key",
providerVersionRef: "aws-version-1",
});
const createVersionSpy = vi.spyOn(awsSecretsManagerProvider, "createVersion").mockResolvedValue({
material: {
scheme: "aws_secrets_manager_v1",
secretId: "arn:aws:secretsmanager:us-east-1:123456789012:secret:paperclip/prod-use1/company/openai-api-key",
versionId: "aws-version-2",
source: "managed",
},
valueSha256: "value-sha-2",
fingerprintSha256: "fingerprint-sha-2",
externalRef: "arn:aws:secretsmanager:us-east-1:123456789012:secret:paperclip/prod-use1/company/openai-api-key",
providerVersionRef: "aws-version-2",
});
const resolveSpy = vi.spyOn(awsSecretsManagerProvider, "resolveVersion").mockResolvedValue("resolved-secret");
const secret = await svc.create(companyId, {
name: `aws-managed-${randomUUID()}`,
provider: "aws_secrets_manager",
providerConfigId: awsVault.id,
value: "runtime-secret",
});
const rotated = await svc.rotate(secret.id, { value: "rotated-runtime-secret" });
const resolved = await svc.resolveSecretValue(companyId, rotated.id, "latest");
expect(resolved).toBe("resolved-secret");
expect(createSpy).toHaveBeenCalledWith(expect.objectContaining({
providerConfig: expect.objectContaining({
id: awsVault.id,
provider: "aws_secrets_manager",
config: expect.objectContaining({ region: "us-east-1", namespace: "prod-use1" }),
}),
}));
expect(createVersionSpy).toHaveBeenCalledWith(expect.objectContaining({
providerConfig: expect.objectContaining({ id: awsVault.id }),
}));
expect(resolveSpy).toHaveBeenCalledWith(expect.objectContaining({
providerConfig: expect.objectContaining({ id: awsVault.id }),
providerVersionRef: "aws-version-2",
}));
expect(JSON.stringify(resolveSpy.mock.calls[0]?.[0])).not.toContain("resolved-secret");
});
it("cleans up managed provider secrets when create persistence fails", 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 prepared = {
material: {
scheme: "aws_secrets_manager_v1",
secretId:
"arn:aws:secretsmanager:us-east-1:123456789012:secret:paperclip/prod-use1/company/create-rollback",
versionId: "aws-version-1",
source: "managed",
},
valueSha256: "value-sha-1",
fingerprintSha256: "fingerprint-sha-1",
externalRef:
"arn:aws:secretsmanager:us-east-1:123456789012:secret:paperclip/prod-use1/company/create-rollback",
providerVersionRef: "aws-version-1",
};
vi.spyOn(awsSecretsManagerProvider, "createSecret").mockResolvedValue(prepared);
const deleteSpy = vi.spyOn(awsSecretsManagerProvider, "deleteOrArchive").mockResolvedValue();
vi.spyOn(db, "transaction").mockRejectedValueOnce(new Error("db insert failed"));
await expect(
svc.create(companyId, {
name: "Create Rollback",
key: "create-rollback",
provider: "aws_secrets_manager",
providerConfigId: awsVault.id,
value: "runtime-secret",
}),
).rejects.toThrow("db insert failed");
expect(deleteSpy).toHaveBeenCalledWith(expect.objectContaining({
material: prepared.material,
externalRef: prepared.externalRef,
mode: "delete",
providerConfig: expect.objectContaining({ id: awsVault.id }),
context: {
companyId,
secretKey: "create-rollback",
secretName: "Create Rollback",
version: 1,
},
}));
});
it("keeps a local cleanup handle when create rollback cleanup fails", 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 prepared = {
material: {
scheme: "aws_secrets_manager_v1",
secretId:
"arn:aws:secretsmanager:us-east-1:123456789012:secret:paperclip/prod-use1/company/create-cleanup-handle",
versionId: "aws-version-1",
source: "managed",
},
valueSha256: "value-sha-1",
fingerprintSha256: "fingerprint-sha-1",
externalRef:
"arn:aws:secretsmanager:us-east-1:123456789012:secret:paperclip/prod-use1/company/create-cleanup-handle",
providerVersionRef: "aws-version-1",
};
vi.spyOn(awsSecretsManagerProvider, "createSecret").mockResolvedValue(prepared);
vi.spyOn(awsSecretsManagerProvider, "deleteOrArchive").mockRejectedValue(
new Error("cleanup failed"),
);
vi.spyOn(db, "transaction").mockRejectedValueOnce(new Error("db activate failed"));
await expect(
svc.create(companyId, {
name: "Create Cleanup Handle",
key: "create-cleanup-handle",
provider: "aws_secrets_manager",
providerConfigId: awsVault.id,
value: "runtime-secret",
}),
).rejects.toThrow("db activate failed");
const persisted = await svc.getByName(companyId, "Create Cleanup Handle");
expect(persisted).toMatchObject({
key: "create-cleanup-handle",
status: "archived",
externalRef: prepared.externalRef,
latestVersion: 1,
});
const version = await db
.select()
.from(companySecretVersions)
.where(eq(companySecretVersions.secretId, persisted!.id))
.then((rows) => rows[0] ?? null);
expect(version).toMatchObject({
version: 1,
status: "disabled",
material: prepared.material,
});
});
it("archives managed provider versions when rotate persistence fails", 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, "createSecret").mockResolvedValue({
material: {
scheme: "aws_secrets_manager_v1",
secretId:
"arn:aws:secretsmanager:us-east-1:123456789012:secret:paperclip/prod-use1/company/rotate-rollback",
versionId: "aws-version-1",
source: "managed",
},
valueSha256: "value-sha-1",
fingerprintSha256: "fingerprint-sha-1",
externalRef:
"arn:aws:secretsmanager:us-east-1:123456789012:secret:paperclip/prod-use1/company/rotate-rollback",
providerVersionRef: "aws-version-1",
});
const secret = await svc.create(companyId, {
name: "Rotate Rollback",
key: "rotate-rollback",
provider: "aws_secrets_manager",
providerConfigId: awsVault.id,
value: "runtime-secret",
});
const prepared = {
material: {
scheme: "aws_secrets_manager_v1",
secretId:
"arn:aws:secretsmanager:us-east-1:123456789012:secret:paperclip/prod-use1/company/rotate-rollback",
versionId: "aws-version-2",
source: "managed",
},
valueSha256: "value-sha-2",
fingerprintSha256: "fingerprint-sha-2",
externalRef:
"arn:aws:secretsmanager:us-east-1:123456789012:secret:paperclip/prod-use1/company/rotate-rollback",
providerVersionRef: "aws-version-2",
};
vi.spyOn(awsSecretsManagerProvider, "createVersion").mockResolvedValue(prepared);
const deleteSpy = vi.spyOn(awsSecretsManagerProvider, "deleteOrArchive").mockResolvedValue();
vi.spyOn(db, "transaction").mockRejectedValueOnce(new Error("db rotate failed"));
await expect(svc.rotate(secret.id, { value: "rotated-runtime-secret" })).rejects.toThrow(
"db rotate failed",
);
expect(deleteSpy).toHaveBeenCalledWith(expect.objectContaining({
material: prepared.material,
externalRef: prepared.externalRef,
mode: "archive",
providerConfig: expect.objectContaining({ id: awsVault.id }),
context: {
companyId,
secretKey: "rotate-rollback",
secretName: "Rotate Rollback",
version: 2,
},
}));
});
it("keeps a disabled version cleanup handle when rotate rollback cleanup fails", 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, "createSecret").mockResolvedValue({
material: {
scheme: "aws_secrets_manager_v1",
secretId:
"arn:aws:secretsmanager:us-east-1:123456789012:secret:paperclip/prod-use1/company/rotate-cleanup-handle",
versionId: "aws-version-1",
source: "managed",
},
valueSha256: "value-sha-1",
fingerprintSha256: "fingerprint-sha-1",
externalRef:
"arn:aws:secretsmanager:us-east-1:123456789012:secret:paperclip/prod-use1/company/rotate-cleanup-handle",
providerVersionRef: "aws-version-1",
});
const secret = await svc.create(companyId, {
name: "Rotate Cleanup Handle",
key: "rotate-cleanup-handle",
provider: "aws_secrets_manager",
providerConfigId: awsVault.id,
value: "runtime-secret",
});
const prepared = {
material: {
scheme: "aws_secrets_manager_v1",
secretId:
"arn:aws:secretsmanager:us-east-1:123456789012:secret:paperclip/prod-use1/company/rotate-cleanup-handle",
versionId: "aws-version-2",
source: "managed",
},
valueSha256: "value-sha-2",
fingerprintSha256: "fingerprint-sha-2",
externalRef:
"arn:aws:secretsmanager:us-east-1:123456789012:secret:paperclip/prod-use1/company/rotate-cleanup-handle",
providerVersionRef: "aws-version-2",
};
vi.spyOn(awsSecretsManagerProvider, "createVersion").mockResolvedValue(prepared);
vi.spyOn(awsSecretsManagerProvider, "deleteOrArchive").mockRejectedValue(
new Error("cleanup failed"),
);
vi.spyOn(db, "transaction").mockRejectedValueOnce(new Error("db rotate failed"));
await expect(svc.rotate(secret.id, { value: "rotated-runtime-secret" })).rejects.toThrow(
"db rotate failed",
);
const persisted = await svc.getById(secret.id);
expect(persisted?.latestVersion).toBe(1);
const versions = await db
.select()
.from(companySecretVersions)
.where(eq(companySecretVersions.secretId, secret.id));
expect(versions).toEqual(expect.arrayContaining([
expect.objectContaining({ version: 1, status: "current" }),
expect.objectContaining({
version: 2,
status: "disabled",
material: prepared.material,
}),
]));
});
it("rejects generic provider vault reassignment for managed secrets", async () => {
const companyId = await seedCompany();
const svc = secretService(db);
const firstVault = await svc.createProviderConfig(companyId, {
provider: "aws_secrets_manager",
displayName: "AWS primary",
config: { region: "us-east-1", namespace: "prod-use1" },
});
const secondVault = await svc.createProviderConfig(companyId, {
provider: "aws_secrets_manager",
displayName: "AWS secondary",
config: { region: "us-west-2", namespace: "prod-usw2" },
});
vi.spyOn(awsSecretsManagerProvider, "createSecret").mockResolvedValue({
material: {
scheme: "aws_secrets_manager_v1",
secretId:
"arn:aws:secretsmanager:us-east-1:123456789012:secret:paperclip/prod-use1/company/vault-reassign",
versionId: "aws-version-1",
source: "managed",
},
valueSha256: "value-sha-1",
fingerprintSha256: "fingerprint-sha-1",
externalRef:
"arn:aws:secretsmanager:us-east-1:123456789012:secret:paperclip/prod-use1/company/vault-reassign",
providerVersionRef: "aws-version-1",
});
const secret = await svc.create(companyId, {
name: "Vault Reassign",
key: "vault-reassign",
provider: "aws_secrets_manager",
providerConfigId: firstVault.id,
value: "runtime-secret",
});
await expect(svc.update(secret.id, { providerConfigId: secondVault.id })).rejects.toThrow(
/managed secrets cannot change provider vault/i,
);
const persisted = await svc.getById(secret.id);
expect(persisted?.providerConfigId).toBe(firstVault.id);
});
it("rejects rotation for non-active secrets", async () => {
const companyId = await seedCompany();
const svc = secretService(db);
const secret = await svc.create(companyId, {
name: `disabled-rotation-${randomUUID()}`,
provider: "local_encrypted",
value: "runtime-secret",
});
await svc.update(secret.id, { status: "disabled" });
await expect(svc.rotate(secret.id, { value: "rotated-runtime-secret" })).rejects.toThrow(
/non-active/i,
);
const stored = await db
.select({ latestVersion: companySecrets.latestVersion })
.from(companySecrets)
.where(eq(companySecrets.id, secret.id))
.then((rows) => rows[0]);
expect(stored?.latestVersion).toBe(1);
});
it("previews AWS remote import candidates with duplicate and collision enrichment", 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 duplicate = await svc.create(companyId, {
name: "Existing duplicate",
provider: "aws_secrets_manager",
providerConfigId: awsVault.id,
managedMode: "external_reference",
externalRef: "arn:aws:secretsmanager:us-east-1:123456789012:secret:prod/duplicate",
});
const nameConflict = await svc.create(companyId, {
name: "Prod Conflict",
provider: "local_encrypted",
value: "runtime-secret",
});
const listSpy = vi.spyOn(awsSecretsManagerProvider, "listRemoteSecrets").mockResolvedValue({
nextToken: "next-page",
secrets: [
{
externalRef: duplicate.externalRef!,
name: "prod/duplicate",
providerVersionRef: null,
metadata: { arn: duplicate.externalRef },
},
{
externalRef: "arn:aws:secretsmanager:us-east-1:123456789012:secret:prod/conflict",
name: nameConflict.name,
providerVersionRef: null,
metadata: { arn: "arn:aws:secretsmanager:us-east-1:123456789012:secret:prod/conflict" },
},
{
externalRef: "arn:aws:secretsmanager:us-east-1:123456789012:secret:prod/ready",
name: "prod/ready",
providerVersionRef: null,
metadata: { arn: "arn:aws:secretsmanager:us-east-1:123456789012:secret:prod/ready" },
},
],
});
const preview = await svc.previewRemoteImport(companyId, {
providerConfigId: awsVault.id,
query: "prod",
pageSize: 25,
});
expect(listSpy).toHaveBeenCalledWith(expect.objectContaining({
providerConfig: expect.objectContaining({ id: awsVault.id }),
query: "prod",
pageSize: 25,
}));
expect(preview.nextToken).toBe("next-page");
expect(preview.candidates.map((candidate) => candidate.status)).toEqual([
"duplicate",
"conflict",
"ready",
]);
expect(preview.candidates[0]?.conflicts[0]).toMatchObject({
type: "exact_reference",
existingSecretId: duplicate.id,
});
expect(preview.candidates[1]?.conflicts[0]).toMatchObject({
type: "name",
existingSecretId: nameConflict.id,
});
expect(preview.candidates[2]).toMatchObject({
importable: true,
name: "prod/ready",
key: "prod-ready",
});
expect(preview.candidates[2]?.providerMetadata).toBeNull();
});
it("sanitizes AWS remote import preview provider errors before crossing the service boundary", 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 rawProviderMessage =
"AccessDeniedException: User: arn:aws:sts::123456789012:assumed-role/prod/Paperclip is not authorized to perform secretsmanager:ListSecrets";
vi.spyOn(awsSecretsManagerProvider, "listRemoteSecrets").mockRejectedValueOnce(
new SecretProviderClientError({
code: "access_denied",
provider: "aws_secrets_manager",
operation: "listSecrets",
message: "AWS Secrets Manager denied the request. Check IAM permissions for this provider vault.",
rawMessage: rawProviderMessage,
}),
);
let thrown: unknown;
try {
await svc.previewRemoteImport(companyId, { providerConfigId: awsVault.id });
} catch (error) {
thrown = error;
}
expect(thrown).toMatchObject({
status: 403,
message: "AWS Secrets Manager denied the request. Check IAM permissions for this provider vault.",
details: { code: "access_denied" },
});
expect(JSON.stringify(thrown)).not.toContain("arn:aws");
expect(JSON.stringify(thrown)).not.toContain("123456789012");
expect(thrown instanceof Error ? thrown.message : String(thrown)).not.toContain("arn:aws");
});
it("previews AWS provider vault discovery from draft config without persisting a provider vault", async () => {
const companyId = await seedCompany();
const svc = secretService(db);
const discoverSpy = vi.spyOn(awsSecretsManagerProvider, "discoverProviderConfigs").mockResolvedValue({
provider: "aws_secrets_manager",
nextToken: null,
sampledSecretCount: 1,
skippedForeignPaperclipSampleCount: 0,
candidates: [
{
provider: "aws_secrets_manager",
displayName: "AWS production",
config: {
region: "us-east-1",
namespace: "prod-use1",
secretNamePrefix: "paperclip",
kmsKeyId: null,
ownerTag: "platform",
environmentTag: "production",
},
sampleCount: 1,
samples: [
{ name: "paperclip/prod-use1/company-1/openai", hasKmsKey: false, tagKeys: ["paperclip:environment"] },
],
signals: {
namespace: "prod-use1",
secretNamePrefix: "paperclip",
environmentTag: "production",
ownerTag: "platform",
kmsKeyId: null,
hasKmsKey: false,
sampleCount: 1,
paperclipManagedSampleCount: 0,
skippedForeignPaperclipSampleCount: 0,
},
warnings: [],
},
],
warnings: [],
});
const preview = await svc.previewProviderConfigDiscovery(companyId, {
provider: "aws_secrets_manager",
config: { region: "us-east-1" },
query: "openai",
pageSize: 25,
});
expect(discoverSpy).toHaveBeenCalledWith({
companyId,
providerConfig: {
id: `discovery-preview-${companyId}`,
provider: "aws_secrets_manager",
status: "ready",
config: { region: "us-east-1" },
},
query: "openai",
nextToken: undefined,
pageSize: 25,
});
expect(preview.candidates[0]?.config).toMatchObject({
region: "us-east-1",
namespace: "prod-use1",
});
expect(JSON.stringify(preview)).not.toContain("runtime-secret");
const persistedVaults = await db.select().from(companySecretProviderConfigs);
expect(persistedVaults).toHaveLength(0);
});
it("sanitizes AWS provider vault discovery errors before crossing the service boundary", async () => {
const companyId = await seedCompany();
const svc = secretService(db);
const rawProviderMessage =
"AccessDeniedException: User: arn:aws:sts::123456789012:assumed-role/prod/Paperclip is not authorized to perform secretsmanager:ListSecrets";
vi.spyOn(awsSecretsManagerProvider, "discoverProviderConfigs").mockRejectedValueOnce(
new SecretProviderClientError({
code: "access_denied",
provider: "aws_secrets_manager",
operation: "discoverProviderConfigs",
message: "AWS Secrets Manager denied the request. Check IAM permissions for this provider vault.",
rawMessage: rawProviderMessage,
}),
);
let thrown: unknown;
try {
await svc.previewProviderConfigDiscovery(companyId, {
provider: "aws_secrets_manager",
config: { region: "us-east-1" },
});
} catch (error) {
thrown = error;
}
expect(thrown).toMatchObject({
status: 403,
message: "AWS Secrets Manager denied the request. Check IAM permissions for this provider vault.",
details: { code: "access_denied" },
});
expect(JSON.stringify(thrown)).not.toContain("arn:aws");
expect(JSON.stringify(thrown)).not.toContain("123456789012");
expect(thrown instanceof Error ? thrown.message : String(thrown)).not.toContain("arn:aws");
});
it("imports AWS remote references row-by-row without fetching plaintext", 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 duplicate = await svc.create(companyId, {
name: "Existing duplicate",
provider: "aws_secrets_manager",
providerConfigId: awsVault.id,
managedMode: "external_reference",
externalRef: "arn:aws:secretsmanager:us-east-1:123456789012:secret:prod/duplicate",
});
const resolveSpy = vi.spyOn(awsSecretsManagerProvider, "resolveVersion");
const result = await svc.importRemoteSecrets(
companyId,
{
providerConfigId: awsVault.id,
secrets: [
{
externalRef: duplicate.externalRef!,
name: "Existing duplicate",
key: "existing-duplicate",
},
{
externalRef: "arn:aws:secretsmanager:us-east-1:123456789012:secret:prod/openai",
name: "OpenAI API key",
key: "openai-api-key",
description: " Operator-entered production OpenAI key ",
providerMetadata: { arn: "arn:aws:secretsmanager:us-east-1:123456789012:secret:prod/openai" },
},
],
},
{ userId: "user-1" },
);
expect(result.importedCount).toBe(1);
expect(result.skippedCount).toBe(1);
expect(result.results.map((row) => row.status)).toEqual(["skipped", "imported"]);
expect(result.results[0]).toMatchObject({
reason: "exact_reference_duplicate",
conflicts: [expect.objectContaining({ type: "exact_reference", existingSecretId: duplicate.id })],
});
expect(resolveSpy).not.toHaveBeenCalled();
const imported = await db
.select()
.from(companySecrets)
.where(eq(companySecrets.key, "openai-api-key"))
.then((rows) => rows[0]);
expect(imported).toMatchObject({
companyId,
provider: "aws_secrets_manager",
providerConfigId: awsVault.id,
managedMode: "external_reference",
externalRef: "arn:aws:secretsmanager:us-east-1:123456789012:secret:prod/openai",
createdByUserId: "user-1",
providerMetadata: null,
description: "Operator-entered production OpenAI key",
});
const versions = await db
.select()
.from(companySecretVersions)
.where(eq(companySecretVersions.secretId, imported!.id));
expect(versions).toHaveLength(1);
expect(JSON.stringify(versions[0])).not.toContain("runtime-secret");
expect(JSON.stringify(versions[0])).not.toContain("sk-");
});
it("sanitizes AWS remote import row provider errors", 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 rawProviderMessage =
"AccessDeniedException: User: arn:aws:sts::123456789012:assumed-role/prod/Paperclip is not authorized to perform secretsmanager:DescribeSecret on arn:aws:secretsmanager:us-east-1:123456789012:secret:prod/openai";
vi.spyOn(awsSecretsManagerProvider, "linkExternalSecret").mockRejectedValueOnce(
new SecretProviderClientError({
code: "access_denied",
provider: "aws_secrets_manager",
operation: "linkExternalSecret",
message: "AWS Secrets Manager denied the request. Check IAM permissions for this provider vault.",
rawMessage: rawProviderMessage,
}),
);
const result = 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",
},
],
});
expect(result).toMatchObject({
importedCount: 0,
skippedCount: 0,
errorCount: 1,
results: [
expect.objectContaining({
status: "error",
reason: "AWS Secrets Manager denied the request. Check IAM permissions for this provider vault.",
}),
],
});
expect(JSON.stringify(result)).not.toContain(rawProviderMessage);
expect(JSON.stringify(result.results[0]?.reason)).not.toContain("arn:aws");
expect(JSON.stringify(result.results[0]?.reason)).not.toContain("123456789012");
});
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");
const svc = secretService(db);
const disabledVault = await svc.createProviderConfig(companyA, {
provider: "aws_secrets_manager",
displayName: "AWS disabled",
status: "disabled",
config: { region: "us-east-1" },
});
const foreignVault = await svc.createProviderConfig(companyB, {
provider: "aws_secrets_manager",
displayName: "AWS foreign",
config: { region: "us-east-1" },
});
await expect(
svc.previewRemoteImport(companyA, { providerConfigId: disabledVault.id }),
).rejects.toThrow(/disabled/i);
await expect(
svc.previewRemoteImport(companyA, { providerConfigId: foreignVault.id }),
).rejects.toThrow(/same company/i);
});
it("rejects externalRef overrides on managed secrets", async () => {
const companyId = await seedCompany();
const svc = secretService(db);
const secret = await svc.create(companyId, {
name: `managed-${randomUUID()}`,
provider: "local_encrypted",
value: "runtime-secret",
});
await expect(
svc.update(secret.id, {
externalRef: "arn:aws:secretsmanager:us-east-1:123456789012:secret:paperclip/prod/company-b/openai-api-key",
}),
).rejects.toThrow(/Managed secrets cannot override externalRef/i);
await expect(
svc.rotate(secret.id, {
value: "rotated-runtime-secret",
externalRef: "arn:aws:secretsmanager:us-east-1:123456789012:secret:paperclip/prod/company-b/openai-api-key",
}),
).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("rejects generic soft deletion for managed secrets", async () => {
const companyId = await seedCompany();
const svc = secretService(db);
const secret = await svc.create(companyId, {
name: `managed-delete-${randomUUID()}`,
provider: "local_encrypted",
value: "runtime-secret",
});
await expect(svc.update(secret.id, { status: "deleted" })).rejects.toThrow(
/DELETE \/secrets\/:id/i,
);
const persisted = await svc.getById(secret.id);
expect(persisted?.status).toBe("active");
});
it("passes managed AWS secret context into provider delete during removal", async () => {
const companyId = await seedCompany();
const svc = secretService(db);
const externalRef =
"arn:aws:secretsmanager:us-east-1:123456789012:secret:paperclip/prod-use1/company-1/openai-api-key";
const secret = await db
.insert(companySecrets)
.values({
companyId,
key: "openai-api-key",
name: "OpenAI API Key",
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",
});
const deleteSpy = vi.spyOn(awsSecretsManagerProvider, "deleteOrArchive").mockResolvedValue();
const removed = await svc.remove(secret.id);
const persisted = await db
.select()
.from(companySecrets)
.where(eq(companySecrets.id, secret.id))
.then((rows) => rows[0] ?? null);
expect(removed?.id).toBe(secret.id);
expect(deleteSpy).toHaveBeenCalledTimes(1);
expect(deleteSpy).toHaveBeenCalledWith({
material: {
scheme: "aws_secrets_manager_v1",
secretId: externalRef,
versionId: "aws-version-1",
source: "managed",
},
externalRef,
context: {
companyId,
secretKey: "openai-api-key",
secretName: "OpenAI API Key",
version: 1,
},
mode: "delete",
providerConfig: null,
});
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("treats missing provider secrets as already removed during removal retry", async () => {
const companyId = await seedCompany();
const svc = secretService(db);
const externalRef =
"arn:aws:secretsmanager:us-east-1:123456789012:secret:paperclip/prod-use1/company-1/retry-delete";
const secretId = randomUUID();
await db.insert(companySecrets).values({
id: secretId,
companyId,
key: `retry-delete__deleted__${secretId}`,
name: `Retry Delete__deleted__${secretId}`,
provider: "aws_secrets_manager",
managedMode: "paperclip_managed",
externalRef,
latestVersion: 1,
status: "deleted",
deletedAt: new Date(),
});
await db.insert(companySecretVersions).values({
secretId,
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",
});
const deleteSpy = vi.spyOn(awsSecretsManagerProvider, "deleteOrArchive").mockRejectedValueOnce(
new SecretProviderClientError({
code: "not_found",
provider: "aws_secrets_manager",
operation: "delete_secret",
message: "Secret not found.",
}),
);
await expect(svc.remove(secretId)).resolves.toMatchObject({ id: secretId });
const persisted = await db
.select()
.from(companySecrets)
.where(eq(companySecrets.id, secretId))
.then((rows) => rows[0] ?? null);
expect(deleteSpy).toHaveBeenCalledTimes(1);
expect(persisted).toBeNull();
});
it("removes DB rows even when the attached provider vault is disabled", async () => {
const companyId = await seedCompany();
const svc = secretService(db);
const vault = await svc.createProviderConfig(companyId, {
provider: "aws_secrets_manager",
displayName: "AWS disabled later",
config: {
region: "us-east-1",
namespace: "prod",
},
});
const externalRef =
"arn:aws:secretsmanager:us-east-1:123456789012:secret:paperclip/prod/company-1/openai-api-key";
const secret = await db
.insert(companySecrets)
.values({
companyId,
key: "openai-api-key",
name: "OpenAI API Key",
provider: "aws_secrets_manager",
providerConfigId: vault.id,
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",
});
await svc.disableProviderConfig(vault.id);
const deleteSpy = vi.spyOn(awsSecretsManagerProvider, "deleteOrArchive").mockResolvedValue();
await expect(svc.remove(secret.id)).resolves.toMatchObject({ id: secret.id });
const persisted = await db
.select()
.from(companySecrets)
.where(eq(companySecrets.id, secret.id))
.then((rows) => rows[0] ?? null);
expect(deleteSpy).not.toHaveBeenCalled();
expect(persisted).toBeNull();
});
it("refuses to resolve secrets once they are disabled or archived", async () => {
const companyId = await seedCompany();
const svc = secretService(db);
const secret = await svc.create(companyId, {
name: `managed-${randomUUID()}`,
provider: "local_encrypted",
value: "runtime-secret",
});
await svc.update(secret.id, { status: "disabled" });
await expect(svc.resolveSecretValue(companyId, secret.id, "latest")).rejects.toThrow(
/not active/i,
);
await svc.update(secret.id, { status: "archived" });
await expect(svc.resolveSecretValue(companyId, secret.id, "latest")).rejects.toThrow(
/not active/i,
);
});
});