Files
PaperClipAI/server/src/__tests__/secrets-service.test.ts
T
Nicky LeachandPaperclip 9064cfd09e feat(codex-local): give each Codex account its own home and path secret (#12709)
## Thinking Path

> - Paperclip is the control plane for companies that use AI agents for
work
> - Local adapters connect Paperclip agents to provider command line
tools
> - The Codex adapter stores login data in a shared company home
> - A shared home cannot keep credentials for more than one Codex
account
> - This pull request gives each account a safe home and a matching
company secret
> - The benefit is that one company can use multiple Codex accounts at
the same time

## Linked Issues or Issue Description

**Problem or motivation**

A company can hold only one Codex subscription credential because device
login uses one shared home. A second account cannot log in without
replacing or conflicting with the first credential.

**Proposed solution**

This change validates the vendor account identifier, stores each
credential in its own home, and creates a company secret that points to
that home. Repeat login calls return success when the matching secret
already exists.

**Roadmap alignment**

The change supports the roadmap goal for centrally managed secrets with
scoped access and audited resolution.

**Additional context**

The security review returned approve with no blocking finding. The
branch adds shared account-handle validation and tests for device login
and the Codex local adapter.

## What Changed

- Add strict allowlist validation for Codex account handles.
- Store each Codex account credential in a separate home under the Codex
cache root.
- Verify that the resolved account home stays inside the cache root.
- Create the `CODEX_HOME_<handle>` company secret for each account.
- Keep repeat and concurrent login calls safe and idempotent.
- Add shared helper and route, adapter, and validation tests.

## Verification

- `pnpm --filter @paperclipai/adapter-codex-local test` passes with 343
tests.
- `pnpm --filter @paperclipai/server test
src/__tests__/agent-device-login-routes.test.ts` passes with 25 tests.
- The adapter suite passes with 23 tests.
- The shared package and Codex adapter typechecks pass.
- Continuous integration must pass on every check before merge.

## Risks

The account handle becomes part of a directory path and secret name. The
strict allowlist and root containment check reduce path traversal risk.
Existing single-account homes remain unchanged unless a new device login
creates an account-specific home.

## Model Used

OpenAI GPT-5 (exact runtime model ID: gpt-5), with tool use and code
execution. The runtime context window is not exposed in this run.

## 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 searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [x] All Paperclip CI gates are green
- [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge

---------

Co-authored-by: Paperclip <noreply@paperclip.ing>
2026-09-02 14:46:53 -07:00

4232 lines
149 KiB
TypeScript

import { randomUUID } from "node:crypto";
import { mkdirSync, rmSync } from "node:fs";
import { mkdir, rm } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest";
import { and, eq } from "drizzle-orm";
import { resolveCodexAuthCacheDir, withAccountHomeSecretMutationLock } from "@paperclipai/adapter-codex-local/server";
import {
activityLog,
agents,
companies,
companyMemberships,
companySecretBindings,
companySecretProviderConfigs,
companySecretVersions,
companySecrets,
createDb,
heartbeatRuns,
secretAccessEvents,
userSecretDeclarations,
userSecretDefinitions,
} from "@paperclipai/db";
import { LOW_TRUST_REVIEW_PRESET } from "@paperclipai/shared";
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(activityLog);
await db.delete(secretAccessEvents);
await db.delete(userSecretDeclarations);
await db.delete(companySecretBindings);
await db.delete(companySecretVersions);
await db.delete(companySecrets);
await db.delete(userSecretDefinitions);
await db.delete(companySecretProviderConfigs);
await db.delete(companyMemberships);
await db.delete(heartbeatRuns);
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;
}
// Creates a real directory under this company's Codex account-home cache
// root, so a test can prove the write-time directory-validity check reads
// an actual filesystem entry, the same way the production cleanup and the
// production secret write do.
async function makeAccountHomeDir(companyId: string, accountHandle: string): Promise<string> {
const accountHomeDir = path.join(resolveCodexAuthCacheDir(undefined, companyId), accountHandle);
await mkdir(accountHomeDir, { recursive: true });
return accountHomeDir;
}
async function seedCompanyMember(
companyId: string,
userId: string,
membershipRole: "owner" | "member" | "viewer" = "member",
) {
await db.insert(companyMemberships).values({
companyId,
principalType: "user",
principalId: userId,
status: "active",
membershipRole,
createdAt: new Date(),
updatedAt: new Date(),
});
}
async function seedAgentRun(companyId: string, permissions: Record<string, unknown> = {}) {
const agentId = randomUUID();
await db.insert(agents).values({
id: agentId,
companyId,
name: "Secret reader",
role: "engineer",
adapterType: "codex_local",
adapterConfig: {},
permissions,
status: "idle",
createdAt: new Date(),
updatedAt: new Date(),
});
const heartbeatRunId = randomUUID();
await db.insert(heartbeatRuns).values({
id: heartbeatRunId,
companyId,
agentId,
status: "running",
startedAt: new Date(),
createdAt: new Date(),
updatedAt: new Date(),
});
return { agentId, heartbeatRunId };
}
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("replaceSecretRefsForInstanceTarget moves the binding to the referenced secret's company", async () => {
const companyA = await seedCompany("A");
const companyB = await seedCompany("B");
const svc = secretService(db);
const secretA = await svc.create(companyA, {
name: `provider-key-a-${randomUUID()}`,
provider: "local_encrypted",
value: "a",
});
const secretB = await svc.create(companyB, {
name: `provider-key-b-${randomUUID()}`,
provider: "local_encrypted",
value: "b",
});
const envSecretA = await svc.create(companyA, {
name: `env-var-a-${randomUUID()}`,
provider: "local_encrypted",
value: "env",
});
const environmentId = randomUUID();
await svc.createBinding({
companyId: companyA,
secretId: secretA.id,
targetType: "environment",
targetId: environmentId,
configPath: "apiKey",
});
// A company-scoped env-var binding on the same environment target must
// survive config-ref replacement untouched.
await db.insert(companySecretBindings).values({
companyId: companyA,
secretId: envSecretA.id,
targetType: "environment",
targetId: environmentId,
configPath: "env.EXTRA",
versionSelector: "latest",
required: true,
projectionClass: "unclassified",
});
await svc.replaceSecretRefsForInstanceTarget(
{ targetType: "environment", targetId: environmentId },
[{ secretId: secretB.id, configPath: "apiKey" }],
);
const rows = await db
.select()
.from(companySecretBindings)
.where(
and(
eq(companySecretBindings.targetType, "environment"),
eq(companySecretBindings.targetId, environmentId),
),
);
const configRows = rows.filter((row) => row.configPath === "apiKey");
expect(configRows).toHaveLength(1);
expect(configRows[0]?.companyId).toBe(companyB);
expect(configRows[0]?.secretId).toBe(secretB.id);
expect(rows.filter((row) => row.configPath === "env.EXTRA")).toHaveLength(1);
});
it("replaceSecretRefsForInstanceTarget writes each binding under its own secret's company", async () => {
const companyA = await seedCompany("A");
const companyB = await seedCompany("B");
const svc = secretService(db);
const secretA = await svc.create(companyA, {
name: `api-key-${randomUUID()}`,
provider: "local_encrypted",
value: "a",
});
const secretB = await svc.create(companyB, {
name: `ssh-key-${randomUUID()}`,
provider: "local_encrypted",
value: "b",
});
const environmentId = randomUUID();
const refs = await svc.replaceSecretRefsForInstanceTarget(
{ targetType: "environment", targetId: environmentId },
[
{ secretId: secretA.id, configPath: "apiKey" },
{ secretId: secretB.id, configPath: "privateKeySecretRef" },
],
);
expect(refs.map((ref) => ref.companyId).sort()).toEqual([companyA, companyB].sort());
const rows = await db
.select()
.from(companySecretBindings)
.where(
and(
eq(companySecretBindings.targetType, "environment"),
eq(companySecretBindings.targetId, environmentId),
),
);
expect(rows).toHaveLength(2);
expect(rows.find((row) => row.configPath === "apiKey")?.companyId).toBe(companyA);
expect(rows.find((row) => row.configPath === "privateKeySecretRef")?.companyId).toBe(companyB);
});
it("replaceSecretRefsForInstanceTarget rejects unknown secrets without touching existing bindings", async () => {
const companyA = await seedCompany("A");
const svc = secretService(db);
const secretA = await svc.create(companyA, {
name: `provider-key-${randomUUID()}`,
provider: "local_encrypted",
value: "a",
});
const environmentId = randomUUID();
await svc.createBinding({
companyId: companyA,
secretId: secretA.id,
targetType: "environment",
targetId: environmentId,
configPath: "apiKey",
});
await expect(
svc.replaceSecretRefsForInstanceTarget(
{ targetType: "environment", targetId: environmentId },
[{ secretId: randomUUID(), configPath: "apiKey" }],
),
).rejects.toThrow(/was not found/i);
const rows = await db
.select()
.from(companySecretBindings)
.where(
and(
eq(companySecretBindings.targetType, "environment"),
eq(companySecretBindings.targetId, environmentId),
),
);
expect(rows).toHaveLength(1);
expect(rows[0]?.secretId).toBe(secretA.id);
expect(rows[0]?.companyId).toBe(companyA);
});
it("describeSecretRefs names secrets across companies and omits unknown ids", async () => {
const companyA = await seedCompany("Alpha");
const companyB = await seedCompany("Beta");
const svc = secretService(db);
const secretA = await svc.create(companyA, {
name: "PROVIDER_KEY_A",
provider: "local_encrypted",
value: "a",
});
const secretB = await svc.create(companyB, {
name: "PROVIDER_KEY_B",
provider: "local_encrypted",
value: "b",
});
const described = await svc.describeSecretRefs([
{ secretId: secretA.id, configPath: "apiKey" },
{ secretId: secretB.id, configPath: "privateKeySecretRef" },
{ secretId: randomUUID(), configPath: "token" },
]);
expect(described).toEqual([
{
configPath: "apiKey",
secretId: secretA.id,
name: "PROVIDER_KEY_A",
status: "active",
companyId: companyA,
companyName: "Alpha",
},
{
configPath: "privateKeySecretRef",
secretId: secretB.id,
name: "PROVIDER_KEY_B",
status: "active",
companyId: companyB,
companyName: "Beta",
},
]);
});
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("serializes two local_encrypted secret creates in the same company so their writes never overlap", async () => {
// An account-home cleanup's claimant scan and a `local_encrypted` secret
// write share one lock (`withAccountHomeSecretMutationLock`), so a write
// can never commit inside the exact window the scan already used to
// decide no secret claims a directory it is about to delete. This proves
// the lock itself enforces that: two `local_encrypted` creates in the
// SAME company never run their provider write at the same time,
// whichever caller goes first.
const companyId = await seedCompany();
const svc = secretService(db);
const events: string[] = [];
let releaseFirstWrite!: () => void;
const firstWriteGate = new Promise<void>((resolve) => {
releaseFirstWrite = resolve;
});
const originalCreateSecret = localEncryptedProvider.createSecret.bind(localEncryptedProvider);
vi.spyOn(localEncryptedProvider, "createSecret")
.mockImplementationOnce(async (input) => {
events.push("first-provider-enter");
await firstWriteGate;
events.push("first-provider-exit");
return originalCreateSecret(input);
})
.mockImplementationOnce(async (input) => {
events.push("second-provider-enter");
return originalCreateSecret(input);
});
const firstCreate = svc.create(companyId, {
name: `account-home-${randomUUID()}`,
provider: "local_encrypted",
value: "/company/codex-home/acct-a",
});
// Give the first call a chance to acquire the lock and enter its provider
// write before the second call starts racing for the same lock.
await new Promise((resolve) => setTimeout(resolve, 20));
const secondCreate = svc.create(companyId, {
name: `hand-named-${randomUUID()}`,
provider: "local_encrypted",
value: "/company/codex-home/acct-a",
});
// The second call must stay blocked on the lock while the first call
// still holds it: it must never enter its own provider write before the
// first call's provider write exits.
await new Promise((resolve) => setTimeout(resolve, 20));
expect(events).toEqual(["first-provider-enter"]);
releaseFirstWrite();
await Promise.all([firstCreate, secondCreate]);
expect(events).toEqual(["first-provider-enter", "first-provider-exit", "second-provider-enter"]);
});
it("serializes a local_encrypted secret rotate against a concurrent create of a different secret in the same company", async () => {
// Same lock, the other write path: a rotate that writes a new
// `local_encrypted` value must serialize against a concurrent create the
// same way a create serializes against another create.
const companyId = await seedCompany();
const svc = secretService(db);
const existing = await svc.create(companyId, {
name: `existing-${randomUUID()}`,
provider: "local_encrypted",
value: "/company/codex-home/acct-b",
});
const events: string[] = [];
let releaseRotateWrite!: () => void;
const rotateWriteGate = new Promise<void>((resolve) => {
releaseRotateWrite = resolve;
});
const originalCreateVersion = localEncryptedProvider.createVersion.bind(localEncryptedProvider);
vi.spyOn(localEncryptedProvider, "createVersion").mockImplementationOnce(async (input) => {
events.push("rotate-provider-enter");
await rotateWriteGate;
events.push("rotate-provider-exit");
return originalCreateVersion(input);
});
const originalCreateSecret = localEncryptedProvider.createSecret.bind(localEncryptedProvider);
vi.spyOn(localEncryptedProvider, "createSecret").mockImplementationOnce(async (input) => {
events.push("create-provider-enter");
return originalCreateSecret(input);
});
const rotateCall = svc.rotate(existing.id, { value: "/company/codex-home/acct-b-rotated" });
await new Promise((resolve) => setTimeout(resolve, 20));
const createCall = svc.create(companyId, {
name: `hand-named-${randomUUID()}`,
provider: "local_encrypted",
value: "/company/codex-home/acct-b",
});
await new Promise((resolve) => setTimeout(resolve, 20));
expect(events).toEqual(["rotate-provider-enter"]);
releaseRotateWrite();
await Promise.all([rotateCall, createCall]);
expect(events).toEqual(["rotate-provider-enter", "rotate-provider-exit", "create-provider-enter"]);
});
it("fails a queued local_encrypted create when an account-home cleanup removes its directory first", async () => {
// The mutation lock alone stops a write and an account-home cleanup's
// check-and-delete from interleaving; it does not stop them from running
// in either order. When the cleanup wins the lock first, deletes the
// directory, and releases the lock, a create that was only queued behind
// it must not go on to commit that now-deleted directory as a secret
// value. It must fail instead.
const companyId = await seedCompany();
const svc = secretService(db);
const accountHomeDir = await makeAccountHomeDir(companyId, "acct-queued-create");
const events: string[] = [];
let releaseCleanup!: () => void;
const cleanupGate = new Promise<void>((resolve) => {
releaseCleanup = resolve;
});
const cleanupCall = withAccountHomeSecretMutationLock(undefined, companyId, async () => {
events.push("cleanup-enter");
await cleanupGate;
await rm(accountHomeDir, { recursive: true, force: true });
events.push("cleanup-exit");
});
// Give the cleanup a chance to acquire the lock before the create starts
// racing for the same lock.
await new Promise((resolve) => setTimeout(resolve, 20));
const createCall = svc.create(companyId, {
name: `account-home-${randomUUID()}`,
provider: "local_encrypted",
value: accountHomeDir,
});
// The create must stay queued behind the held lock.
await new Promise((resolve) => setTimeout(resolve, 20));
expect(events).toEqual(["cleanup-enter"]);
releaseCleanup();
await cleanupCall;
await expect(createCall).rejects.toThrow(/no longer exists/);
});
it("fails a queued local_encrypted rotate when an account-home cleanup removes its directory first", async () => {
// Same race, the other write path: a rotate that would commit a
// now-deleted account-home directory must fail the same way a create
// does.
const companyId = await seedCompany();
const svc = secretService(db);
const existing = await svc.create(companyId, {
name: `hand-named-${randomUUID()}`,
provider: "local_encrypted",
value: "/some/unrelated/placeholder/value",
});
const accountHomeDir = await makeAccountHomeDir(companyId, "acct-queued-rotate");
const events: string[] = [];
let releaseCleanup!: () => void;
const cleanupGate = new Promise<void>((resolve) => {
releaseCleanup = resolve;
});
const cleanupCall = withAccountHomeSecretMutationLock(undefined, companyId, async () => {
events.push("cleanup-enter");
await cleanupGate;
await rm(accountHomeDir, { recursive: true, force: true });
events.push("cleanup-exit");
});
await new Promise((resolve) => setTimeout(resolve, 20));
const rotateCall = svc.rotate(existing.id, { value: accountHomeDir });
await new Promise((resolve) => setTimeout(resolve, 20));
expect(events).toEqual(["cleanup-enter"]);
releaseCleanup();
await cleanupCall;
await expect(rotateCall).rejects.toThrow(/no longer exists/);
});
it("serializes an aws_secrets_manager secret create against a concurrent account-home mutation lock holder", async () => {
// A plain string value can equal a Codex account-home path regardless of
// which provider stores it. Prove a non-local (`aws_secrets_manager`)
// create now takes the SAME `withAccountHomeSecretMutationLock` a
// `local_encrypted` create takes, not only when the create's own
// provider is `local_encrypted`.
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/aws-secret",
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/aws-secret",
providerVersionRef: "aws-version-1",
});
const events: string[] = [];
let releaseCleanup!: () => void;
const cleanupGate = new Promise<void>((resolve) => {
releaseCleanup = resolve;
});
const cleanupCall = withAccountHomeSecretMutationLock(undefined, companyId, async () => {
events.push("cleanup-enter");
await cleanupGate;
events.push("cleanup-exit");
});
// Give the cleanup a chance to acquire the lock before the AWS create
// starts racing for the same lock.
await new Promise((resolve) => setTimeout(resolve, 20));
const createCall = svc.create(companyId, {
name: `aws-secret-${randomUUID()}`,
provider: "aws_secrets_manager",
providerConfigId: awsVault.id,
value: "runtime-secret",
});
// The AWS create must stay queued behind the held lock, the same way a
// `local_encrypted` create does.
await new Promise((resolve) => setTimeout(resolve, 20));
expect(events).toEqual(["cleanup-enter"]);
releaseCleanup();
await cleanupCall;
const created = await createCall;
expect(events).toEqual(["cleanup-enter", "cleanup-exit"]);
expect(created.status).toBe("active");
});
it("serializes an aws_secrets_manager secret rotate against a concurrent account-home mutation lock holder", async () => {
// Same reasoning as the create test above, the other write path: a
// non-local rotate must also take the lock, not only a
// `local_encrypted` rotate.
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/aws-secret-rotate",
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/aws-secret-rotate",
providerVersionRef: "aws-version-1",
});
vi.spyOn(awsSecretsManagerProvider, "createVersion").mockResolvedValue({
material: {
scheme: "aws_secrets_manager_v1",
secretId: "arn:aws:secretsmanager:us-east-1:123456789012:secret:paperclip/prod-use1/company/aws-secret-rotate",
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/aws-secret-rotate",
providerVersionRef: "aws-version-2",
});
const existing = await svc.create(companyId, {
name: `aws-secret-rotate-${randomUUID()}`,
provider: "aws_secrets_manager",
providerConfigId: awsVault.id,
value: "runtime-secret",
});
const events: string[] = [];
let releaseCleanup!: () => void;
const cleanupGate = new Promise<void>((resolve) => {
releaseCleanup = resolve;
});
const cleanupCall = withAccountHomeSecretMutationLock(undefined, companyId, async () => {
events.push("cleanup-enter");
await cleanupGate;
events.push("cleanup-exit");
});
await new Promise((resolve) => setTimeout(resolve, 20));
const rotateCall = svc.rotate(existing.id, { value: "rotated-runtime-secret" });
await new Promise((resolve) => setTimeout(resolve, 20));
expect(events).toEqual(["cleanup-enter"]);
releaseCleanup();
await cleanupCall;
const rotated = await rotateCall;
expect(events).toEqual(["cleanup-enter", "cleanup-exit"]);
expect(rotated.latestVersion).toBe(2);
});
it("serializes a rename, archive, or disable update against a concurrent account-home mutation lock holder", async () => {
// A rename or a status change to `archived` or `disabled` can stop the
// generated account-home secret from resolving to the value a
// device-login promotion already validated. Prove `update` now holds
// the SAME lock a cleanup (or a promotion's terminal-commit re-check)
// holds for its whole critical section, so an update can never land
// inside that section.
const companyId = await seedCompany();
const svc = secretService(db);
const secret = await svc.create(companyId, {
name: `CODEX_HOME_update-${randomUUID()}`,
provider: "local_encrypted",
value: "/company/codex-home/acct-update",
});
let releaseCleanup!: () => void;
const cleanupGate = new Promise<void>((resolve) => {
releaseCleanup = resolve;
});
const cleanupCall = withAccountHomeSecretMutationLock(undefined, companyId, async () => {
await cleanupGate;
});
await new Promise((resolve) => setTimeout(resolve, 20));
let updateResolved = false;
const updateCall = svc.update(secret.id, { status: "archived" }).then((result) => {
updateResolved = true;
return result;
});
// The update must stay queued behind the held lock.
await new Promise((resolve) => setTimeout(resolve, 20));
expect(updateResolved).toBe(false);
releaseCleanup();
await cleanupCall;
const updated = await updateCall;
expect(updateResolved).toBe(true);
expect(updated?.status).toBe("archived");
});
it("serializes a secret delete against a concurrent account-home mutation lock holder", async () => {
// Same reasoning as the update test above: a delete must also take the
// lock, so it can never land inside a cleanup's, or a promotion's
// terminal-commit re-check's, critical section.
const companyId = await seedCompany();
const svc = secretService(db);
const secret = await svc.create(companyId, {
name: `CODEX_HOME_delete-${randomUUID()}`,
provider: "local_encrypted",
value: "/company/codex-home/acct-delete",
});
let releaseCleanup!: () => void;
const cleanupGate = new Promise<void>((resolve) => {
releaseCleanup = resolve;
});
const cleanupCall = withAccountHomeSecretMutationLock(undefined, companyId, async () => {
await cleanupGate;
});
await new Promise((resolve) => setTimeout(resolve, 20));
let removeResolved = false;
const removeCall = svc.remove(secret.id).then((result) => {
removeResolved = true;
return result;
});
// The delete must stay queued behind the held lock.
await new Promise((resolve) => setTimeout(resolve, 20));
expect(removeResolved).toBe(false);
releaseCleanup();
await cleanupCall;
await removeCall;
expect(removeResolved).toBe(true);
const remaining = await svc.getById(secret.id);
expect(remaining).toBeNull();
});
it("commits a local_encrypted secret naming an account-home directory that still exists", async () => {
// A regression guard for the check above: a create whose value names a
// directory that is still present must keep succeeding, unblocked.
const companyId = await seedCompany();
const svc = secretService(db);
const accountHomeDir = await makeAccountHomeDir(companyId, "acct-still-present");
const created = await svc.create(companyId, {
name: `account-home-${randomUUID()}`,
provider: "local_encrypted",
value: accountHomeDir,
});
expect(created.status).toBe("active");
});
it("fails a queued adapter-schema-secret create when an account-home cleanup removes its directory first", async () => {
// `normalizeAdapterConfigForPersistence` creates an adapter config secret
// through a separate managed-create path (`createManagedLocalSecret`),
// not `svc.create`. It must run the same queued-behind-the-lock
// directory check as `svc.create` and `svc.rotate`, so an adapter
// schema secret can never commit an account-home directory a cleanup
// already removed while this call waited for the lock.
const companyId = await seedCompany();
const svc = secretService(db);
const accountHomeDir = await makeAccountHomeDir(companyId, "acct-queued-adapter-secret");
const events: string[] = [];
let releaseCleanup!: () => void;
const cleanupGate = new Promise<void>((resolve) => {
releaseCleanup = resolve;
});
const cleanupCall = withAccountHomeSecretMutationLock(undefined, companyId, async () => {
events.push("cleanup-enter");
await cleanupGate;
await rm(accountHomeDir, { recursive: true, force: true });
events.push("cleanup-exit");
});
// Give the cleanup a chance to acquire the lock before the adapter
// config normalization starts racing for the same lock.
await new Promise((resolve) => setTimeout(resolve, 20));
const normalizeCall = svc.normalizeAdapterConfigForPersistence(
companyId,
{ apiKey: accountHomeDir },
{ adapterType: "hermes_gateway" },
);
// The queued create must stay blocked behind the held lock.
await new Promise((resolve) => setTimeout(resolve, 20));
expect(events).toEqual(["cleanup-enter"]);
releaseCleanup();
await cleanupCall;
await expect(normalizeCall).rejects.toThrow(/no longer exists/);
});
it("keeps an adapter-schema-secret create working for a value outside the account-home cache root", async () => {
// A regression guard for the check above: an adapter schema secret whose
// value is not an account-home directory (an ordinary API key, for
// example) must keep succeeding, unblocked.
const companyId = await seedCompany();
const svc = secretService(db);
const normalized = await svc.normalizeAdapterConfigForPersistence(
companyId,
{ apiKey: `plain-api-key-${randomUUID()}` },
{ adapterType: "hermes_gateway" },
);
const apiKeyBinding = (normalized as Record<string, unknown>).apiKey as { type: string; secretId: string };
expect(apiKeyBinding.type).toBe("secret_ref");
const secret = await svc.getById(apiKeyBinding.secretId);
expect(secret?.status).toBe("active");
});
it("validates the access namespace as agent-only with env-style aliases", async () => {
const companyId = await seedCompany();
const svc = secretService(db);
const secret = await svc.create(companyId, {
name: `access-validation-${randomUUID()}`,
provider: "local_encrypted",
value: "runtime-secret",
});
await expect(svc.createBinding({
companyId,
secretId: secret.id,
targetType: "project",
targetId: randomUUID(),
configPath: "access.API_KEY",
})).rejects.toThrow(/must target an agent/i);
await expect(svc.createBinding({
companyId,
secretId: secret.id,
targetType: "agent",
targetId: randomUUID(),
configPath: "access.invalid-alias",
})).rejects.toThrow(/invalid agent secret access alias/i);
});
it("resolves env and access bindings through the run-bound agent resolver with dual audit", async () => {
const companyId = await seedCompany();
const svc = secretService(db);
const { agentId, heartbeatRunId } = await seedAgentRun(companyId);
const secret = await svc.create(companyId, {
name: `agent-read-${randomUUID()}`,
provider: "local_encrypted",
value: "runtime-secret",
});
await svc.createBinding({
companyId,
secretId: secret.id,
targetType: "agent",
targetId: agentId,
configPath: "access.API_KEY",
});
await svc.createBinding({
companyId,
secretId: secret.id,
targetType: "agent",
targetId: agentId,
configPath: "env.API_KEY",
});
const redactedValues: string[] = [];
for (const configPath of ["access.API_KEY", "env.API_KEY"]) {
await expect(svc.resolveSecretValueForAgentAccess(companyId, secret.id, "latest", {
agentId,
configPath,
actorSource: "agent_jwt",
heartbeatRunId,
registerForRedaction: (value) => redactedValues.push(value),
})).resolves.toEqual({ value: "runtime-secret", version: 1 });
}
expect(redactedValues).toEqual(["runtime-secret", "runtime-secret"]);
const events = await svc.listAccessEvents(companyId, secret.id);
expect(events).toHaveLength(2);
expect(events).toEqual(expect.arrayContaining([
expect.objectContaining({
consumerType: "agent_api",
consumerId: agentId,
configPath: "access.API_KEY",
actorType: "agent",
actorId: agentId,
heartbeatRunId,
outcome: "success",
}),
expect.objectContaining({
consumerType: "agent_api",
consumerId: agentId,
configPath: "env.API_KEY",
outcome: "success",
}),
]));
const activities = await db
.select()
.from(activityLog)
.where(eq(activityLog.entityId, secret.id));
expect(activities).toHaveLength(2);
expect(activities.every((entry) => entry.action === "secret.value.read")).toBe(true);
expect(activities.every((entry) => entry.runId === heartbeatRunId)).toBe(true);
expect(JSON.stringify([...events, ...activities])).not.toContain("runtime-secret");
});
it("rejects long-lived, mismatched-run, and unbound agent secret reads", async () => {
const companyId = await seedCompany();
const svc = secretService(db);
const { agentId, heartbeatRunId } = await seedAgentRun(companyId);
const secret = await svc.create(companyId, {
name: `agent-read-denied-${randomUUID()}`,
provider: "local_encrypted",
value: "runtime-secret",
});
await svc.createBinding({
companyId,
secretId: secret.id,
targetType: "agent",
targetId: agentId,
configPath: "access.GRANTED",
});
const registerForRedaction = vi.fn();
await expect(svc.resolveSecretValueForAgentAccess(companyId, secret.id, "latest", {
agentId,
configPath: "access.API_KEY",
actorSource: "agent_key",
heartbeatRunId,
registerForRedaction,
})).rejects.toThrow(/run-bound agent token/i);
await expect(svc.resolveSecretValueForAgentAccess(companyId, secret.id, "latest", {
agentId,
configPath: "access.GRANTED",
actorSource: "agent_jwt",
keyScope: { kind: "skill_test", issueId: randomUUID() },
heartbeatRunId,
registerForRedaction,
})).rejects.toThrow(/skill-test.*secret/i);
await expect(svc.resolveSecretValueForAgentAccess(companyId, secret.id, "latest", {
agentId,
configPath: "access.API_KEY",
actorSource: "agent_jwt",
heartbeatRunId: randomUUID(),
registerForRedaction,
})).rejects.toThrow(/verified heartbeat run/i);
await expect(svc.resolveSecretValueForAgentAccess(companyId, secret.id, "latest", {
agentId,
configPath: "access.API_KEY",
actorSource: "agent_jwt",
heartbeatRunId,
registerForRedaction,
})).rejects.toThrow(/not granted/i);
await db.update(heartbeatRuns).set({ status: "succeeded" }).where(eq(heartbeatRuns.id, heartbeatRunId));
await expect(svc.listAgentSecretAccess(companyId, {
agentId,
actorSource: "agent_jwt",
heartbeatRunId,
})).rejects.toThrow(/verified heartbeat run/i);
await expect(svc.resolveSecretValueForAgentAccess(companyId, secret.id, "latest", {
agentId,
configPath: "access.GRANTED",
actorSource: "agent_jwt",
heartbeatRunId,
registerForRedaction,
})).rejects.toThrow(/verified heartbeat run/i);
expect(registerForRedaction).not.toHaveBeenCalled();
const events = await svc.listAccessEvents(companyId, secret.id);
expect(events).toEqual([
expect.objectContaining({
consumerType: "agent_api",
consumerId: agentId,
configPath: "access.API_KEY",
outcome: "failure",
errorCode: "binding_missing",
}),
]);
});
it("preserves low-trust authorization denial for agent secret reads", async () => {
const companyId = await seedCompany();
const svc = secretService(db);
const { agentId, heartbeatRunId } = await seedAgentRun(companyId, {
trustPreset: LOW_TRUST_REVIEW_PRESET,
authorizationPolicy: {
trustBoundary: {
mode: LOW_TRUST_REVIEW_PRESET,
projectIds: [randomUUID()],
},
},
});
const secret = await svc.create(companyId, {
name: `low-trust-agent-read-${randomUUID()}`,
provider: "local_encrypted",
value: "runtime-secret",
});
await svc.createBinding({
companyId,
secretId: secret.id,
targetType: "agent",
targetId: agentId,
configPath: "access.API_KEY",
});
await expect(svc.resolveSecretValueForAgentAccess(companyId, secret.id, "latest", {
agentId,
configPath: "access.API_KEY",
actorSource: "agent_jwt",
heartbeatRunId,
registerForRedaction: vi.fn(),
})).rejects.toThrow(/low[_-]trust.*secrets:read/i);
expect(await svc.listAccessEvents(companyId, secret.id)).toEqual([]);
});
it("syncs top-level secret refs idempotently", async () => {
const companyId = await seedCompany();
const svc = secretService(db);
const firstSecret = await svc.create(companyId, {
name: `top-level-first-${randomUUID()}`,
provider: "local_encrypted",
value: "one",
});
const secondSecret = await svc.create(companyId, {
name: `top-level-second-${randomUUID()}`,
provider: "local_encrypted",
value: "two",
});
const target = { targetType: "environment" as const, targetId: "env-1" };
await svc.syncSecretRefsForTarget(companyId, target, [
{ secretId: firstSecret.id, configPath: "apiKey" },
]);
await svc.syncSecretRefsForTarget(companyId, target, [
{ secretId: firstSecret.id, configPath: "apiKey" },
]);
await svc.syncSecretRefsForTarget(companyId, target, [
{ secretId: secondSecret.id, configPath: "apiKey" },
]);
const bindings = await db
.select()
.from(companySecretBindings)
.where(eq(companySecretBindings.targetId, target.targetId));
expect(bindings).toHaveLength(1);
expect(bindings[0]).toMatchObject({
companyId,
targetType: "environment",
targetId: target.targetId,
configPath: "apiKey",
secretId: secondSecret.id,
});
});
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("collects declared secret refs that have no binding without resolving values", async () => {
const companyId = await seedCompany();
const svc = secretService(db);
const secretName = `unbound-${randomUUID()}`;
const secret = await svc.create(companyId, {
name: secretName,
provider: "local_encrypted",
value: "runtime-secret",
});
const env = {
API_KEY: { type: "secret_ref" as const, secretId: secret.id, version: "latest" as const },
PLAIN_VALUE: "not-a-secret",
};
const missing = await svc.collectMissingRuntimeBindings(companyId, env, {
consumerType: "agent",
consumerId: "agent-1",
});
expect(missing).toHaveLength(1);
expect(missing[0]).toMatchObject({
consumerType: "agent",
consumerId: "agent-1",
configPath: "env.API_KEY",
envKey: "API_KEY",
secretId: secret.id,
secretName,
});
// Value-free validation: no access events recorded.
expect(await svc.listAccessEvents(companyId, secret.id)).toHaveLength(0);
await svc.syncEnvBindingsForTarget(companyId, { targetType: "agent", targetId: "agent-1" }, env);
const afterBinding = await svc.collectMissingRuntimeBindings(companyId, env, {
consumerType: "agent",
consumerId: "agent-1",
});
expect(afterBinding).toEqual([]);
});
it("denies runtime secret resolution outside the low-trust binding allowlist", async () => {
const companyId = await seedCompany();
const svc = secretService(db);
const secret = await svc.create(companyId, {
name: `low-trust-${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);
const [binding] = await svc.listBindings(companyId, secret.id);
expect(binding?.id).toBeTruthy();
await expect(
svc.resolveEnvBindings(companyId, env, {
consumerType: "agent",
consumerId: "agent-1",
actorType: "agent",
actorId: "agent-1",
allowedBindingIds: ["11111111-1111-4111-8111-111111111111"],
}),
).rejects.toMatchObject({
status: 422,
details: { code: "binding_not_allowed" },
});
const resolved = await svc.resolveEnvBindings(companyId, env, {
consumerType: "agent",
consumerId: "agent-1",
actorType: "agent",
actorId: "agent-1",
allowedBindingIds: [binding!.id],
});
expect(resolved.env.API_KEY).toBe("runtime-secret");
expect(resolved.manifest[0]?.bindingId).toBe(binding!.id);
});
it("fails closed at runtime for class-3 env lease rows outside the allowlist", async () => {
const companyId = await seedCompany();
const svc = secretService(db);
const secret = await svc.create(companyId, {
name: `runtime-class3-${randomUUID()}`,
provider: "local_encrypted",
value: "runtime-secret",
});
const env = {
GITHUB_TOKEN: {
type: "secret_ref" as const,
secretId: secret.id,
version: "latest" as const,
projectionClass: "class_3_static_lease" as const,
projectionAllowlistKey: "github.token",
},
};
await db.insert(companySecretBindings).values({
companyId,
secretId: secret.id,
targetType: "agent",
targetId: "agent-1",
configPath: "env.GITHUB_TOKEN",
projectionClass: "class_3_static_lease",
projectionAllowlistKey: "github.token",
});
await expect(
svc.resolveEnvBindings(companyId, env, {
consumerType: "agent",
consumerId: "agent-1",
actorType: "agent",
actorId: "agent-1",
}),
).rejects.toMatchObject({
status: 422,
details: { code: "class_3_static_lease_not_allowed" },
});
});
it("denies user secret resolution outside the low-trust declaration allowlist", async () => {
const companyId = await seedCompany();
await seedCompanyMember(companyId, "user-1", "owner");
const svc = secretService(db);
const definition = await svc.createUserSecretDefinition(companyId, {
key: "github_token",
name: "GitHub token",
provider: "local_encrypted",
});
const env = {
GITHUB_TOKEN: { type: "user_secret_ref" as const, key: "github_token", version: "latest" as const },
};
await svc.syncEnvBindingsForTarget(companyId, { targetType: "agent", targetId: "agent-1" }, env);
await svc.createCurrentUserSecretValue(companyId, "user-1", {
definitionKey: "github_token",
value: "user-one-secret",
});
const [declaration] = await db
.select()
.from(userSecretDeclarations)
.where(eq(userSecretDeclarations.userSecretDefinitionId, definition.id));
expect(declaration?.id).toBeTruthy();
await expect(
svc.resolveEnvBindings(companyId, env, {
consumerType: "agent",
consumerId: "agent-1",
actorType: "agent",
actorId: "agent-1",
responsibleUserId: "user-1",
allowedBindingIds: ["11111111-1111-4111-8111-111111111111"],
}),
).rejects.toMatchObject({
status: 422,
details: { code: "binding_not_allowed" },
});
const resolved = await svc.resolveEnvBindings(companyId, env, {
consumerType: "agent",
consumerId: "agent-1",
actorType: "agent",
actorId: "agent-1",
responsibleUserId: "user-1",
allowedBindingIds: [declaration!.id],
});
expect(resolved.env.GITHUB_TOKEN).toBe("user-one-secret");
expect(resolved.manifest[0]?.bindingId).toBe(declaration!.id);
});
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("resolves user secret refs through responsible-user values and records owner metadata", async () => {
const companyId = await seedCompany();
await seedCompanyMember(companyId, "user-1", "owner");
await seedCompanyMember(companyId, "user-2", "member");
const svc = secretService(db);
const definition = await svc.createUserSecretDefinition(companyId, {
key: "github_token",
name: "GitHub token",
provider: "local_encrypted",
});
const env = {
GITHUB_TOKEN: { type: "user_secret_ref" as const, key: "github_token", version: "latest" as const },
};
await svc.syncEnvBindingsForTarget(companyId, { targetType: "agent", targetId: "agent-1" }, env);
const userOneSecret = await svc.createCurrentUserSecretValue(companyId, "user-1", {
definitionKey: "github_token",
value: "user-one-secret",
});
await expect(
svc.resolveEnvBindings(companyId, env, {
consumerType: "agent",
consumerId: "agent-1",
actorType: "agent",
actorId: "agent-1",
responsibleUserId: "user-2",
}),
).rejects.toThrow(/not configured/i);
await expect(
svc.collectMissingRuntimeBindings(companyId, env, {
consumerType: "agent",
consumerId: "agent-1",
responsibleUserId: "user-2",
}),
).resolves.toEqual([
expect.objectContaining({
bindingType: "user_secret_ref",
configPath: "env.GITHUB_TOKEN",
envKey: "GITHUB_TOKEN",
userSecretDefinitionId: definition.id,
userSecretDefinitionKey: "github_token",
responsibleUserId: "user-2",
errorCode: "user_secret_missing",
}),
]);
const optionalEnv = {
OPTIONAL_GITHUB_TOKEN: {
type: "user_secret_ref" as const,
key: "github_token",
version: "latest" as const,
required: false,
},
};
await svc.syncEnvBindingsForTarget(companyId, { targetType: "agent", targetId: "agent-optional" }, optionalEnv);
await expect(
svc.collectMissingRuntimeBindings(companyId, optionalEnv, {
consumerType: "agent",
consumerId: "agent-optional",
responsibleUserId: "user-2",
}),
).resolves.toEqual([]);
await expect(
svc.resolveEnvBindings(companyId, optionalEnv, {
consumerType: "agent",
consumerId: "agent-optional",
actorType: "agent",
actorId: "agent-optional",
responsibleUserId: "user-2",
}),
).resolves.toMatchObject({
env: {},
manifest: [],
});
await db
.update(userSecretDefinitions)
.set({ status: "disabled" })
.where(eq(userSecretDefinitions.id, definition.id));
await expect(
svc.collectMissingRuntimeBindings(companyId, env, {
consumerType: "agent",
consumerId: "agent-1",
responsibleUserId: "user-2",
}),
).resolves.toEqual([
expect.objectContaining({
bindingType: "user_secret_ref",
configPath: "env.GITHUB_TOKEN",
envKey: "GITHUB_TOKEN",
userSecretDefinitionId: definition.id,
userSecretDefinitionKey: "github_token",
userSecretDefinitionName: "GitHub token",
responsibleUserId: "user-2",
errorCode: "user_secret_definition_inactive",
}),
]);
await expect(
svc.resolveEnvBindings(companyId, optionalEnv, {
consumerType: "agent",
consumerId: "agent-optional",
actorType: "agent",
actorId: "agent-optional",
responsibleUserId: "user-2",
}),
).resolves.toMatchObject({
env: {},
manifest: [],
});
await db
.update(userSecretDefinitions)
.set({ status: "deleted", deletedAt: new Date() })
.where(eq(userSecretDefinitions.id, definition.id));
await expect(
svc.resolveEnvBindings(companyId, optionalEnv, {
consumerType: "agent",
consumerId: "agent-optional",
actorType: "agent",
actorId: "agent-optional",
responsibleUserId: "user-2",
}),
).resolves.toMatchObject({
env: {},
manifest: [],
});
await db
.update(userSecretDefinitions)
.set({ status: "active", deletedAt: null })
.where(eq(userSecretDefinitions.id, definition.id));
const resolved = await svc.resolveEnvBindings(companyId, env, {
consumerType: "agent",
consumerId: "agent-1",
actorType: "agent",
actorId: "agent-1",
responsibleUserId: "user-1",
});
expect(resolved.env.GITHUB_TOKEN).toBe("user-one-secret");
expect(resolved.manifest[0]).toMatchObject({
configPath: "env.GITHUB_TOKEN",
envKey: "GITHUB_TOKEN",
secretId: userOneSecret.id,
secretKey: userOneSecret.key,
outcome: "success",
});
expect((await svc.list(companyId)).map((secret) => secret.id)).not.toContain(userOneSecret.id);
await expect(
svc.resolveSecretValue(companyId, userOneSecret.id, "latest", {
consumerType: "agent",
consumerId: "agent-1",
configPath: "env.GITHUB_TOKEN",
}),
).rejects.toThrow(/User-scoped secrets/i);
const events = await db
.select()
.from(secretAccessEvents)
.where(eq(secretAccessEvents.secretId, userOneSecret.id));
expect(events).toHaveLength(1);
expect(events[0]).toMatchObject({
companyId,
secretId: userOneSecret.id,
userSecretDefinitionId: definition.id,
secretScope: "user",
responsibleUserId: "user-1",
credentialOwnerUserId: "user-1",
credentialSubjectType: "user",
credentialSubjectId: "user-1",
outcome: "success",
});
expect(JSON.stringify(events)).not.toContain("user-one-secret");
});
it("can skip user-secret refs while resolving adapter config for non-runtime skill discovery", async () => {
const companyId = await seedCompany();
const svc = secretService(db);
await svc.createUserSecretDefinition(companyId, {
key: "github_token",
name: "GitHub token",
provider: "local_encrypted",
});
const companySecret = await svc.create(companyId, {
name: `company-token-${randomUUID()}`,
provider: "local_encrypted",
value: "company-secret-value",
});
const adapterConfig = {
apiBaseUrl: "http://127.0.0.1:9119/api",
apiKey: {
type: "user_secret_ref" as const,
key: "github_token",
version: "latest" as const,
required: true,
},
env: {
HOME: "/home/agent",
COMPANY_TOKEN: {
type: "secret_ref" as const,
secretId: companySecret.id,
version: "latest" as const,
},
GH_TOKEN: {
type: "user_secret_ref" as const,
key: "github_token",
version: "latest" as const,
required: true,
},
},
};
await expect(
svc.resolveAdapterConfigForRuntime(companyId, adapterConfig, undefined, { adapterType: "hermes_gateway" }),
).rejects.toMatchObject({
status: 422,
details: { code: "responsible_user_missing" },
});
const resolved = await svc.resolveAdapterConfigForRuntime(
companyId,
adapterConfig,
undefined,
{ adapterType: "hermes_gateway", skipUserSecrets: true },
);
expect(resolved.config).not.toHaveProperty("apiKey");
expect(resolved.config.env).toEqual({
HOME: "/home/agent",
COMPANY_TOKEN: "company-secret-value",
});
expect(resolved.secretKeys).toEqual(new Set(["COMPANY_TOKEN"]));
expect(resolved.manifest).toEqual([
expect.objectContaining({
secretId: companySecret.id,
outcome: "success",
}),
]);
});
it("returns conflict when concurrent user secret value creation races the unique index", async () => {
const companyId = await seedCompany();
await seedCompanyMember(companyId, "user-1", "owner");
const svc = secretService(db);
const definition = await svc.createUserSecretDefinition(companyId, {
key: "github_token",
name: "GitHub token",
provider: "local_encrypted",
});
const results = await Promise.allSettled([
svc.createCurrentUserSecretValue(companyId, "user-1", {
definitionId: definition.id,
value: "first-secret",
}),
svc.createCurrentUserSecretValue(companyId, "user-1", {
definitionId: definition.id,
value: "second-secret",
}),
]);
expect(results.filter((result) => result.status === "fulfilled")).toHaveLength(1);
const rejected = results.find((result) => result.status === "rejected");
expect(rejected).toBeTruthy();
if (rejected?.status === "rejected") {
expect(rejected.reason).toMatchObject({
status: 409,
message: "User secret value already exists",
});
}
const rows = await db
.select()
.from(companySecrets)
.where(eq(companySecrets.userSecretDefinitionId, definition.id));
expect(rows.filter((row) => row.ownerUserId === "user-1" && row.status === "active")).toHaveLength(1);
});
it("reports current-user secret rollback failures when AWS create cleanup cannot remove the reserved row", async () => {
const companyId = await seedCompany();
await seedCompanyMember(companyId, "user-1", "owner");
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 definition = await svc.createUserSecretDefinition(companyId, {
key: "github_token",
name: "GitHub token",
provider: "aws_secrets_manager",
providerConfigId: awsVault.id,
});
vi.spyOn(awsSecretsManagerProvider, "createSecret").mockRejectedValueOnce(
new SecretProviderClientError({
code: "access_denied",
provider: "aws_secrets_manager",
operation: "createSecret",
message: "AWS Secrets Manager denied the request. Check IAM permissions for this provider vault.",
rawMessage:
"AccessDeniedException: arn:aws:sts::123456789012:assumed-role/prod/Paperclip cannot create secret",
}),
);
vi.spyOn(db, "delete").mockImplementationOnce(() => {
throw new Error("reserved row delete failed");
});
await expect(
svc.createCurrentUserSecretValue(companyId, "user-1", {
definitionId: definition.id,
value: "runtime-secret",
}),
).rejects.toMatchObject({
status: 500,
message: "Secret create failed and Paperclip could not roll back the local secret reservation.",
details: {
code: "secret_create_rollback_failed",
provider: "aws_secrets_manager",
operation: "secret.create",
providerConfigId: awsVault.id,
providerError: {
status: 403,
message: "AWS Secrets Manager denied the request. Check IAM permissions for this provider vault.",
details: {
code: "access_denied",
requiredCapability: "secretsmanager:CreateSecret",
},
},
},
});
const persisted = await db
.select()
.from(companySecrets)
.where(eq(companySecrets.companyId, companyId));
expect(persisted).toHaveLength(1);
expect(JSON.stringify(persisted)).not.toContain("runtime-secret");
});
it("reports current-user secret persistence rollback failures when local cleanup cannot remove the reserved row", async () => {
const companyId = await seedCompany();
await seedCompanyMember(companyId, "user-1", "owner");
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 definition = await svc.createUserSecretDefinition(companyId, {
key: "github_token",
name: "GitHub token",
provider: "aws_secrets_manager",
providerConfigId: awsVault.id,
});
const externalRef =
"arn:aws:secretsmanager:us-east-1:123456789012:secret:paperclip/prod-use1/user/github-token";
vi.spyOn(awsSecretsManagerProvider, "createSecret").mockResolvedValue({
material: {
scheme: "aws_secrets_manager_v1",
secretId: externalRef,
versionId: "aws-version-1",
source: "managed",
},
valueSha256: "value-sha-1",
fingerprintSha256: "fingerprint-sha-1",
externalRef,
providerVersionRef: "aws-version-1",
});
vi.spyOn(awsSecretsManagerProvider, "deleteOrArchive").mockResolvedValue();
vi.spyOn(db, "transaction").mockRejectedValueOnce(new Error("db activate failed"));
vi.spyOn(db, "delete").mockImplementationOnce(() => {
throw new Error("reserved row delete failed");
});
await expect(
svc.createCurrentUserSecretValue(companyId, "user-1", {
definitionId: definition.id,
value: "runtime-secret",
}),
).rejects.toMatchObject({
status: 500,
message: "Secret create failed and Paperclip could not roll back the local secret reservation.",
details: {
code: "secret_create_rollback_failed",
provider: "aws_secrets_manager",
operation: "user_secret_value.create_rollback",
providerConfigId: awsVault.id,
},
});
const persisted = await db
.select()
.from(companySecrets)
.where(eq(companySecrets.companyId, companyId));
expect(persisted).toHaveLength(1);
expect(JSON.stringify(persisted)).not.toContain("runtime-secret");
});
it("returns conflict when concurrent user secret definition creation races the unique index", async () => {
const companyId = await seedCompany();
const svc = secretService(db);
const results = await Promise.allSettled([
svc.createUserSecretDefinition(companyId, {
key: "github_token",
name: "GitHub token",
provider: "local_encrypted",
}),
svc.createUserSecretDefinition(companyId, {
key: "github_token",
name: "GitHub token duplicate",
provider: "local_encrypted",
}),
]);
expect(results.filter((result) => result.status === "fulfilled")).toHaveLength(1);
const rejected = results.find((result) => result.status === "rejected");
expect(rejected).toBeTruthy();
if (rejected?.status === "rejected") {
expect(rejected.reason).toMatchObject({
status: 409,
message: "User secret definition already exists: github_token",
});
}
const rows = await db
.select()
.from(userSecretDefinitions)
.where(eq(userSecretDefinitions.companyId, companyId));
expect(rows.filter((row) => row.key === "github_token" && row.deletedAt === null)).toHaveLength(1);
});
it("removes user secret values and provider material when deleting a definition", async () => {
const companyId = await seedCompany();
await seedCompanyMember(companyId, "user-1", "owner");
await seedCompanyMember(companyId, "user-2", "member");
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 definition = await svc.createUserSecretDefinition(companyId, {
key: "github_token",
name: "GitHub token",
provider: "aws_secrets_manager",
providerConfigId: awsVault.id,
});
let nextVersion = 0;
vi.spyOn(awsSecretsManagerProvider, "createSecret").mockImplementation(async (input) => {
nextVersion += 1;
const externalRef =
`arn:aws:secretsmanager:us-east-1:123456789012:secret:paperclip/prod-use1/${input.context.secretKey}`;
return {
material: {
scheme: "aws_secrets_manager_v1",
secretId: externalRef,
versionId: `aws-version-${nextVersion}`,
source: "managed",
},
valueSha256: `value-sha-${nextVersion}`,
fingerprintSha256: `fingerprint-sha-${nextVersion}`,
externalRef,
providerVersionRef: `aws-version-${nextVersion}`,
};
});
const deleteSpy = vi.spyOn(awsSecretsManagerProvider, "deleteOrArchive").mockResolvedValue();
const userOneSecret = await svc.createCurrentUserSecretValue(companyId, "user-1", {
definitionId: definition.id,
value: "user-one-secret",
});
const userTwoSecret = await svc.createCurrentUserSecretValue(companyId, "user-2", {
definitionId: definition.id,
value: "user-two-secret",
});
const removed = await svc.removeUserSecretDefinition(companyId, definition.id, { userId: "admin-user" });
const remainingValues = await db
.select()
.from(companySecrets)
.where(eq(companySecrets.userSecretDefinitionId, definition.id));
expect(removed).toMatchObject({
id: definition.id,
key: `github_token__deleted__${definition.id}`,
status: "deleted",
updatedByUserId: "admin-user",
});
expect(remainingValues).toHaveLength(0);
expect(deleteSpy).toHaveBeenCalledTimes(2);
expect(deleteSpy).toHaveBeenCalledWith(expect.objectContaining({
externalRef: userOneSecret.externalRef,
providerConfig: expect.objectContaining({ id: awsVault.id }),
context: {
companyId,
secretKey: userOneSecret.key,
secretName: userOneSecret.name,
version: 1,
},
mode: "delete",
}));
expect(deleteSpy).toHaveBeenCalledWith(expect.objectContaining({
externalRef: userTwoSecret.externalRef,
providerConfig: expect.objectContaining({ id: awsVault.id }),
context: {
companyId,
secretKey: userTwoSecret.key,
secretName: userTwoSecret.name,
version: 1,
},
mode: "delete",
}));
});
it("removes user secret values and provider material when update deletes a definition", async () => {
const companyId = await seedCompany();
await seedCompanyMember(companyId, "user-1", "owner");
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 definition = await svc.createUserSecretDefinition(companyId, {
key: "github_token",
name: "GitHub token",
provider: "aws_secrets_manager",
providerConfigId: awsVault.id,
});
vi.spyOn(awsSecretsManagerProvider, "createSecret").mockResolvedValue({
material: {
scheme: "aws_secrets_manager_v1",
secretId: "arn:aws:secretsmanager:us-east-1:123456789012:secret:paperclip/prod-use1/user-secret",
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/user-secret",
providerVersionRef: "aws-version-1",
});
const deleteSpy = vi.spyOn(awsSecretsManagerProvider, "deleteOrArchive").mockResolvedValue();
const userSecret = await svc.createCurrentUserSecretValue(companyId, "user-1", {
definitionId: definition.id,
value: "user-one-secret",
});
const removed = await svc.updateUserSecretDefinition(
companyId,
definition.id,
{ status: "deleted" },
{ userId: "admin-user" },
);
const remainingValues = await db
.select()
.from(companySecrets)
.where(eq(companySecrets.userSecretDefinitionId, definition.id));
expect(removed).toMatchObject({
id: definition.id,
key: `github_token__deleted__${definition.id}`,
status: "deleted",
updatedByUserId: "admin-user",
});
expect(remainingValues).toHaveLength(0);
expect(deleteSpy).toHaveBeenCalledWith(expect.objectContaining({
externalRef: userSecret.externalRef,
providerConfig: expect.objectContaining({ id: awsVault.id }),
context: {
companyId,
secretKey: userSecret.key,
secretName: userSecret.name,
version: 1,
},
mode: "delete",
}));
});
it("treats nullable user-secret value patches as non-rotation updates", async () => {
const companyId = await seedCompany();
await seedCompanyMember(companyId, "user-1", "owner");
const svc = secretService(db);
await svc.createUserSecretDefinition(companyId, {
key: "github_token",
name: "GitHub token",
provider: "local_encrypted",
});
const secret = await svc.createCurrentUserSecretValue(companyId, "user-1", {
definitionKey: "github_token",
value: "user-one-secret",
});
const updated = await svc.updateCurrentUserSecretValue(companyId, "user-1", secret.id, {
value: null,
externalRef: null,
providerVersionRef: null,
providerConfigId: null,
});
expect(updated.latestVersion).toBe(secret.latestVersion);
expect(updated.status).toBe(secret.status);
const versions = await db
.select()
.from(companySecretVersions)
.where(eq(companySecretVersions.secretId, secret.id));
expect(versions).toHaveLength(1);
expect(versions[0]).toMatchObject({ version: secret.latestVersion, status: "current" });
expect(versions[0]?.material).toBeTruthy();
});
it("reports missing adapter-config user secret refs before runtime resolution", async () => {
const companyId = await seedCompany();
await seedCompanyMember(companyId, "user-1", "owner");
const svc = secretService(db);
const definition = await svc.createUserSecretDefinition(companyId, {
key: "hermes_api_key",
name: "Hermes API key",
provider: "local_encrypted",
});
const adapterConfig = {
apiBaseUrl: "http://127.0.0.1:9119/api",
apiKey: { type: "user_secret_ref" as const, key: "hermes_api_key", version: "latest" as const },
};
await svc.syncUserSecretDeclarationsForTarget(companyId, {
targetType: "agent",
targetId: "agent-1",
}, [
{
definitionKey: "hermes_api_key",
configPath: "apiKey",
envKey: "apiKey",
},
]);
await expect(
svc.collectMissingAdapterConfigRuntimeBindings(
companyId,
adapterConfig,
"hermes_gateway",
{
consumerType: "agent",
consumerId: "agent-1",
responsibleUserId: "user-1",
},
),
).resolves.toEqual([
expect.objectContaining({
bindingType: "user_secret_ref",
configPath: "apiKey",
envKey: "apiKey",
userSecretDefinitionId: definition.id,
userSecretDefinitionKey: "hermes_api_key",
responsibleUserId: "user-1",
errorCode: "user_secret_missing",
}),
]);
await expect(
svc.collectMissingAdapterConfigRuntimeBindings(
companyId,
{
...adapterConfig,
apiKey: {
type: "user_secret_ref" as const,
key: "hermes_api_key",
version: "latest" as const,
required: false,
},
},
"hermes_gateway",
{
consumerType: "agent",
consumerId: "agent-1",
responsibleUserId: "user-1",
},
),
).resolves.toEqual([]);
await db
.update(userSecretDefinitions)
.set({ status: "archived" })
.where(eq(userSecretDefinitions.id, definition.id));
await expect(
svc.collectMissingAdapterConfigRuntimeBindings(
companyId,
adapterConfig,
"hermes_gateway",
{
consumerType: "agent",
consumerId: "agent-1",
responsibleUserId: "user-1",
},
),
).resolves.toEqual([
expect.objectContaining({
bindingType: "user_secret_ref",
configPath: "apiKey",
envKey: "apiKey",
userSecretDefinitionId: definition.id,
userSecretDefinitionKey: "hermes_api_key",
userSecretDefinitionName: "Hermes API key",
responsibleUserId: "user-1",
errorCode: "user_secret_definition_inactive",
}),
]);
});
it("skips optional user secret refs when the declaration is missing at runtime", async () => {
const companyId = await seedCompany();
await seedCompanyMember(companyId, "user-1", "owner");
const svc = secretService(db);
const definition = await svc.createUserSecretDefinition(companyId, {
key: "github_api_token",
name: "GitHub API token",
provider: "local_encrypted",
});
await svc.createCurrentUserSecretValue(companyId, "user-1", {
definitionId: definition.id,
value: "ghp_secret",
});
await expect(
svc.resolveUserSecretValue(
companyId,
{
definitionKey: "github_api_token",
responsibleUserId: "user-1",
required: false,
},
{
consumerType: "agent",
consumerId: "agent-with-stale-config",
configPath: "env.GITHUB_TOKEN",
},
),
).resolves.toBeNull();
await expect(
svc.resolveUserSecretValue(
companyId,
{
definitionKey: "github_api_token",
responsibleUserId: "user-1",
},
{
consumerType: "agent",
consumerId: "agent-with-stale-config",
configPath: "env.GITHUB_TOKEN",
},
),
).rejects.toMatchObject({
details: { code: "binding_missing" },
});
});
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,
},
}));
const persisted = await svc.getByName(companyId, "Create Rollback");
expect(persisted).toBeNull();
const versions = await db.select().from(companySecretVersions);
expect(versions).toHaveLength(0);
});
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.toMatchObject({
status: 500,
message: "Secret create failed and Paperclip could not clean up the remote provider secret.",
details: {
code: "secret_create_provider_cleanup_failed",
provider: "aws_secrets_manager",
operation: "create.rollback",
providerConfigId: awsVault.id,
localCleanupHandle: true,
},
});
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("reports managed secret persistence rollback failures when local cleanup cannot remove the reserved row", 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-local-cleanup",
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-local-cleanup",
providerVersionRef: "aws-version-1",
};
vi.spyOn(awsSecretsManagerProvider, "createSecret").mockResolvedValue(prepared);
vi.spyOn(awsSecretsManagerProvider, "deleteOrArchive").mockResolvedValue();
vi.spyOn(db, "transaction").mockRejectedValueOnce(new Error("db activate failed"));
vi.spyOn(db, "delete").mockImplementationOnce(() => {
throw new Error("reserved row delete failed");
});
await expect(
svc.create(companyId, {
name: "Create Local Cleanup",
key: "create-local-cleanup",
provider: "aws_secrets_manager",
providerConfigId: awsVault.id,
value: "runtime-secret",
}),
).rejects.toMatchObject({
status: 500,
message: "Secret create failed and Paperclip could not roll back the local secret reservation.",
details: {
code: "secret_create_rollback_failed",
provider: "aws_secrets_manager",
operation: "create.rollback",
providerConfigId: awsVault.id,
},
});
const persisted = await db
.select()
.from(companySecrets)
.where(eq(companySecrets.companyId, companyId));
expect(persisted).toHaveLength(1);
expect(JSON.stringify(persisted)).not.toContain("runtime-secret");
});
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("sanitizes AWS managed secret create failures and removes the reserved row", 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:CreateSecret on arn:aws:secretsmanager:us-east-1:123456789012:secret:paperclip/prod-use1";
vi.spyOn(awsSecretsManagerProvider, "createSecret").mockRejectedValueOnce(
new SecretProviderClientError({
code: "access_denied",
provider: "aws_secrets_manager",
operation: "createSecret",
message: "AWS Secrets Manager denied the request. Check IAM permissions for this provider vault.",
rawMessage: rawProviderMessage,
}),
);
let thrown: unknown;
try {
await svc.create(companyId, {
name: "Vercel token",
key: "vercel_token",
provider: "aws_secrets_manager",
providerConfigId: awsVault.id,
managedMode: "paperclip_managed",
value: "vcp_test",
});
} 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",
provider: "aws_secrets_manager",
operation: "secret.create",
providerConfigId: awsVault.id,
region: "us-east-1",
requiredCapability: "secretsmanager:CreateSecret",
},
});
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");
const persisted = await db
.select()
.from(companySecrets)
.where(eq(companySecrets.companyId, companyId));
expect(persisted).toHaveLength(0);
});
it("reports rollback failures when AWS managed secret create cleanup cannot remove the reserved row", 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").mockRejectedValueOnce(
new SecretProviderClientError({
code: "access_denied",
provider: "aws_secrets_manager",
operation: "createSecret",
message: "AWS Secrets Manager denied the request. Check IAM permissions for this provider vault.",
rawMessage:
"AccessDeniedException: arn:aws:sts::123456789012:assumed-role/prod/Paperclip cannot create secret",
}),
);
vi.spyOn(db, "delete").mockImplementationOnce(() => {
throw new Error("reserved row delete failed");
});
await expect(
svc.create(companyId, {
name: "Vercel token",
key: "vercel_token",
provider: "aws_secrets_manager",
providerConfigId: awsVault.id,
managedMode: "paperclip_managed",
value: "vcp_test",
}),
).rejects.toMatchObject({
status: 500,
message: "Secret create failed and Paperclip could not roll back the local secret reservation.",
details: {
code: "secret_create_rollback_failed",
provider: "aws_secrets_manager",
operation: "secret.create",
providerConfigId: awsVault.id,
providerError: {
status: 403,
message: "AWS Secrets Manager denied the request. Check IAM permissions for this provider vault.",
details: {
code: "access_denied",
requiredCapability: "secretsmanager:CreateSecret",
},
},
},
});
const persisted = await db
.select()
.from(companySecrets)
.where(eq(companySecrets.companyId, companyId));
expect(persisted).toHaveLength(1);
expect(JSON.stringify(persisted)).not.toContain("vcp_test");
});
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",
provider: "aws_secrets_manager",
operation: "secret_provider_config.discovery.preview",
providerConfigId: "discovery-preview",
providerVaultContext: "draft_config",
region: "us-east-1",
credentialPath: "Paperclip server runtime/provider credential path",
requiredCapability: "secretsmanager:ListSecrets",
actionableMessage:
"AWS discovery preview needs secretsmanager:ListSecrets in the selected region for the Paperclip server runtime/provider credential path.",
safeAlternative:
"If the operator already knows the exact AWS Secrets Manager ARN, paste/link that ARN instead of using discovery. Exact-resource DescribeSecret and runtime read permissions are still required.",
},
});
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("writes external reference rotations through the provider when a value is given", 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:shared/neon-admin";
const secret = await svc.create(companyId, {
name: `external-${randomUUID()}`,
provider: "aws_secrets_manager",
providerConfigId: awsVault.id,
managedMode: "external_reference",
externalRef,
});
const writeSpy = vi
.spyOn(awsSecretsManagerProvider, "updateExternalSecretValue")
.mockResolvedValueOnce({
material: {
scheme: "aws_secrets_manager_v1",
secretId: externalRef,
versionId: null,
source: "external_reference",
lastWrittenVersionId: "aws-version-2",
},
valueSha256: "a".repeat(64),
fingerprintSha256: "a".repeat(64),
externalRef,
providerVersionRef: null,
});
const linkSpy = vi.spyOn(awsSecretsManagerProvider, "linkExternalSecret");
const rotated = await svc.rotate(secret.id, { value: "new-admin-key" }, { userId: "user-1" });
expect(writeSpy).toHaveBeenCalledTimes(1);
expect(writeSpy.mock.calls[0]?.[0]).toMatchObject({
externalRef,
value: "new-admin-key",
});
expect(linkSpy).not.toHaveBeenCalled();
expect(rotated.latestVersion).toBe(2);
expect(rotated.externalRef).toBe(externalRef);
const versions = await db
.select()
.from(companySecretVersions)
.where(eq(companySecretVersions.secretId, secret.id));
const current = versions.find((row) => row.status === "current");
expect(current?.version).toBe(2);
expect(current?.providerVersionRef).toBeNull();
expect(JSON.stringify(current)).not.toContain("new-admin-key");
});
it("restores the provider current version when external value rotation 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 externalRef = "arn:aws:secretsmanager:us-east-1:123456789012:secret:shared/rollback";
const secret = await svc.create(companyId, {
name: "External rollback",
key: "external-rollback",
provider: "aws_secrets_manager",
providerConfigId: awsVault.id,
managedMode: "external_reference",
externalRef,
});
const prepared = {
material: {
scheme: "aws_secrets_manager_v1",
secretId: externalRef,
versionId: null,
source: "external_reference",
lastWrittenVersionId: "aws-version-2",
previousCurrentVersionId: "aws-version-1",
},
valueSha256: "a".repeat(64),
fingerprintSha256: "a".repeat(64),
externalRef,
providerVersionRef: null,
};
vi.spyOn(awsSecretsManagerProvider, "updateExternalSecretValue").mockResolvedValueOnce(prepared);
const rollbackSpy = vi.spyOn(awsSecretsManagerProvider, "deleteOrArchive").mockResolvedValue();
vi.spyOn(db, "transaction").mockRejectedValueOnce(new Error("db rotate failed"));
await expect(svc.rotate(secret.id, { value: "new-value" })).rejects.toThrow(
"db rotate failed",
);
expect(rollbackSpy).toHaveBeenCalledWith(expect.objectContaining({
material: prepared.material,
externalRef,
mode: "archive",
providerConfig: expect.objectContaining({ id: awsVault.id }),
context: {
companyId,
secretKey: "external-rollback",
secretName: "External rollback",
version: 2,
},
}));
});
it("rejects external value rotations that also retarget or pin versions", 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/neon-admin",
});
await expect(
svc.rotate(secret.id, {
value: "new-admin-key",
externalRef: "arn:aws:secretsmanager:us-east-1:123456789012:secret:shared/other",
}),
).rejects.toThrow(/not both/);
await expect(
svc.rotate(secret.id, { value: "new-admin-key", providerVersionRef: "pinned-1" }),
).rejects.toThrow(/cannot pin/i);
});
it("rejects external value rotations when the provider cannot write values", async () => {
const companyId = await seedCompany();
const svc = secretService(db);
const secret = await svc.create(companyId, {
name: `external-${randomUUID()}`,
provider: "vault",
managedMode: "external_reference",
externalRef: "kv/data/shared/neon-admin",
});
await expect(svc.rotate(secret.id, { value: "new-admin-key" })).rejects.toThrow(
/does not support writing values/,
);
});
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,
);
});
it("records audited ephemeral secret access without requiring a persisted binding", async () => {
const companyId = await seedCompany();
const svc = secretService(db);
const secret = await svc.create(companyId, {
name: `ephemeral-${randomUUID()}`,
provider: "local_encrypted",
value: "runtime-secret",
});
await seedCompanyMember(companyId, "user-1");
const resolved = await svc.resolveSecretValueForEphemeralAccess(companyId, secret.id, "latest", {
consumerType: "system",
consumerId: "environment-probe-config",
configPath: "apiKey",
actorType: "user",
actorId: "user-1",
});
expect(resolved).toBe("runtime-secret");
const events = await svc.listAccessEvents(companyId, secret.id);
expect(events).toHaveLength(1);
expect(events[0]).toMatchObject({
companyId,
secretId: secret.id,
consumerType: "system",
consumerId: "environment-probe-config",
configPath: "apiKey",
actorType: "user",
actorId: "user-1",
outcome: "success",
});
expect(JSON.stringify(events)).not.toContain("runtime-secret");
});
it("preserves local implicit board authorization for ephemeral secret access", async () => {
const companyId = await seedCompany();
const svc = secretService(db);
const secret = await svc.create(companyId, {
name: `ephemeral-local-board-${randomUUID()}`,
provider: "local_encrypted",
value: "runtime-secret",
});
const resolved = await svc.resolveSecretValueForEphemeralAccess(companyId, secret.id, "latest", {
consumerType: "system",
consumerId: "environment-probe-config",
configPath: "apiKey",
actorType: "user",
actorId: "local-board",
actorSource: "local_implicit",
});
expect(resolved).toBe("runtime-secret");
});
it("preserves agent jwt source for ephemeral secret authorization", async () => {
const companyId = await seedCompany();
const svc = secretService(db);
const secret = await svc.create(companyId, {
name: `ephemeral-agent-jwt-${randomUUID()}`,
provider: "local_encrypted",
value: "runtime-secret",
});
const agentId = randomUUID();
await db.insert(agents).values({
id: agentId,
companyId,
name: "JWT Agent",
role: "engineer",
adapterType: "codex_local",
adapterConfig: {},
status: "idle",
createdAt: new Date(),
updatedAt: new Date(),
});
const resolved = await svc.resolveSecretValueForEphemeralAccess(companyId, secret.id, "latest", {
consumerType: "system",
consumerId: "environment-probe-config",
configPath: "apiKey",
actorType: "agent",
actorId: agentId,
actorSource: "agent_jwt",
});
expect(resolved).toBe("runtime-secret");
});
it("rejects ephemeral secret access for actors without secret-read authorization", async () => {
const companyId = await seedCompany();
const svc = secretService(db);
const secret = await svc.create(companyId, {
name: `ephemeral-denied-${randomUUID()}`,
provider: "local_encrypted",
value: "runtime-secret",
});
await expect(
svc.resolveSecretValueForEphemeralAccess(companyId, secret.id, "latest", {
consumerType: "system",
consumerId: "environment-probe-config",
configPath: "apiKey",
actorType: "user",
actorId: "user-without-membership",
}),
).rejects.toThrow(/active member|secrets:read|forbidden/i);
});
});