mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-27 19:35:13 +01:00
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work > - The server controls secrets, host files, outbound requests, and workspace commands > - A red-team review found cases where restricted callers could cross these trust boundaries > - These cases could expose credentials or let untrusted input reach privileged resources > - This pull request applies least-privilege checks at each affected server boundary > - The benefit is safer agent execution without changing the private-instance bootstrap contract ## Linked Issues or Issue Description **What happened?** Several server paths used authorization, redaction, or content-delivery rules that were too broad. Restricted agent keys could obtain company-level operational data. Some adapter and instruction paths could reach server-owned network or file resources without the required owner approval. **Expected behavior** Paperclip must redact credential values, enforce restricted-key scopes, guard outbound network access, prevent same-origin script execution, and reserve host-level file and command controls for authorized operators. **Steps to reproduce** 1. Configure an authenticated development instance at the parent commit. 2. Exercise the affected APIs with a restricted agent key or a non-instance-admin company user. 3. Observe that the parent commit returns privileged data or accepts a privileged operation. 4. Repeat on this branch and observe a redacted response, a safe download, or an HTTP 403 response. **Paperclip version or commit** The findings reproduce from commit `39898ab22` and are fixed by this pull request. **Deployment mode** Authenticated self-hosted server and local development modes. **Installation method** Built from source with pnpm. ## What Changed - Redact generic secret `value` and `token` fields recursively in structured logs. - Classify exact and separator-suffixed `KEY` environment names as secrets in company exports. - Limit restricted self-identity responses and protect company run, log, and secret catalog APIs. - Route HTTP adapter requests through DNS-pinned SSRF protection with exact private-origin allowlisting. - Download HTML, SVG, and other script-capable assets with `nosniff` and a sandbox CSP. - Require instance-admin access for external instruction roots and exports that read them. - Block agent-authenticated host command persistence across supported workspace runtime shapes. - Apply the central runtime-management decision before workspace command controls. - Keep the documented first-user instance-admin claim contract unchanged. - Add regression tests and server-owner configuration documentation. ## Verification - `pnpm -r typecheck` passes. - The Node 24 remediation suite passes with 365 tests. It skips 25 environment-gated tests. - `pnpm build` passes under Node 24. - `git diff --check` passes. - The full local runner reaches known macOS-only general-server harness failures before the serialized route lane. The Linux PR matrix is the authoritative full-suite gate. ## Risks - Restricted agent keys now receive HTTP 403 responses from company-wide run, log, and secret catalog endpoints. - Script-capable assets now download instead of rendering inline. - External instruction roots now require instance-admin access. - Private HTTP adapter endpoints now require an exact origin in `PAPERCLIP_HTTP_ADAPTER_PRIVATE_ENDPOINT_ALLOWLIST`. - Public HTTP adapter endpoints remain enabled. Redirects and metadata or link-local targets remain blocked. - No database migration is required. > For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and discuss it in `#dev` before opening the PR. Feature PRs that overlap with planned core work may need to be redirected — check the roadmap first. See `CONTRIBUTING.md`. ## Model Used OpenAI Codex, GPT-5. The exact serving snapshot and context-window size are not exposed. The model used tool-enabled reasoning, repository access, code execution, and test execution. ## 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 - [x] 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>
1141 lines
39 KiB
TypeScript
1141 lines
39 KiB
TypeScript
import express from "express";
|
|
import request from "supertest";
|
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
import { secretRoutes } from "../routes/secrets.js";
|
|
import { errorHandler } from "../middleware/error-handler.js";
|
|
import { HttpError, unprocessable } from "../errors.js";
|
|
|
|
const mockSecretService = vi.hoisted(() => ({
|
|
listProviders: vi.fn(),
|
|
checkProviders: vi.fn(),
|
|
listProviderConfigs: vi.fn(),
|
|
previewProviderConfigDiscovery: vi.fn(),
|
|
getProviderConfigById: vi.fn(),
|
|
createProviderConfig: vi.fn(),
|
|
updateProviderConfig: vi.fn(),
|
|
disableProviderConfig: vi.fn(),
|
|
removeProviderConfig: vi.fn(),
|
|
setDefaultProviderConfig: vi.fn(),
|
|
checkProviderConfigHealth: vi.fn(),
|
|
getById: vi.fn(),
|
|
list: vi.fn(),
|
|
getByKey: vi.fn(),
|
|
create: vi.fn(),
|
|
rotate: vi.fn(),
|
|
update: vi.fn(),
|
|
remove: vi.fn(),
|
|
listUserSecretDefinitions: vi.fn(),
|
|
createUserSecretDefinition: vi.fn(),
|
|
updateUserSecretDefinition: vi.fn(),
|
|
removeUserSecretDefinition: vi.fn(),
|
|
getUserSecretDefinitionCoverage: vi.fn(),
|
|
listCurrentUserSecretValues: vi.fn(),
|
|
createCurrentUserSecretValue: vi.fn(),
|
|
updateCurrentUserSecretValue: vi.fn(),
|
|
rotateCurrentUserSecretValue: vi.fn(),
|
|
removeCurrentUserSecretValue: vi.fn(),
|
|
previewRemoteImport: vi.fn(),
|
|
importRemoteSecrets: vi.fn(),
|
|
listBindingReferences: vi.fn(),
|
|
listAccessEvents: vi.fn(),
|
|
listAgentSecretAccess: vi.fn(),
|
|
resolveSecretValueForAgentAccess: vi.fn(),
|
|
}));
|
|
const mockLogActivity = vi.hoisted(() => vi.fn());
|
|
const mockAccessService = vi.hoisted(() => ({
|
|
decide: vi.fn(),
|
|
}));
|
|
|
|
vi.mock("../services/index.js", () => ({
|
|
accessService: () => mockAccessService,
|
|
secretService: () => mockSecretService,
|
|
logActivity: mockLogActivity,
|
|
}));
|
|
|
|
vi.mock("../services/access.js", () => ({
|
|
accessService: () => mockAccessService,
|
|
}));
|
|
|
|
function createApp(actor: Record<string, unknown> = {
|
|
type: "board",
|
|
userId: "user-1",
|
|
source: "session",
|
|
companyIds: ["company-1"],
|
|
memberships: [{ companyId: "company-1", status: "active", membershipRole: "admin" }],
|
|
}) {
|
|
const app = express();
|
|
app.use(express.json());
|
|
app.use((req, _res, next) => {
|
|
(req as any).actor = actor;
|
|
next();
|
|
});
|
|
app.use("/api", secretRoutes({} as any));
|
|
app.use(errorHandler);
|
|
return app;
|
|
}
|
|
|
|
describe("secret routes", () => {
|
|
beforeEach(() => {
|
|
for (const mock of Object.values(mockSecretService)) {
|
|
mock.mockReset();
|
|
}
|
|
mockLogActivity.mockReset();
|
|
mockAccessService.decide.mockReset();
|
|
mockAccessService.decide.mockResolvedValue({
|
|
allowed: true,
|
|
reason: "allow_standard_agent",
|
|
explanation: "Allowed by test policy",
|
|
});
|
|
});
|
|
|
|
it("returns an opaque secretRef in agent secret metadata without internal binding details", async () => {
|
|
const secretId = "11111111-1111-4111-8111-111111111111";
|
|
mockSecretService.listAgentSecretAccess.mockResolvedValue([{
|
|
secretId,
|
|
bindingId: "22222222-2222-4222-8222-222222222222",
|
|
configPath: "env.OPENAI_API_KEY",
|
|
key: "openai_api_key",
|
|
name: "OpenAI API key",
|
|
description: "Used for model access",
|
|
delivery: "env",
|
|
projectionClass: "unclassified",
|
|
latestVersion: 3,
|
|
versionSelector: "latest",
|
|
resolvedVersion: 3,
|
|
}]);
|
|
|
|
const res = await request(createApp({
|
|
type: "agent",
|
|
agentId: "33333333-3333-4333-8333-333333333333",
|
|
companyId: "44444444-4444-4444-8444-444444444444",
|
|
runId: "55555555-5555-4555-8555-555555555555",
|
|
source: "agent_jwt",
|
|
keyScope: { kind: "standard" },
|
|
})).get("/api/agents/me/secrets");
|
|
|
|
expect(res.status).toBe(200);
|
|
expect(res.body.secrets).toEqual([{
|
|
secretRef: secretId,
|
|
key: "openai_api_key",
|
|
name: "OpenAI API key",
|
|
description: "Used for model access",
|
|
delivery: "env",
|
|
projectionClass: "unclassified",
|
|
latestVersion: 3,
|
|
versionSelector: "latest",
|
|
resolvedVersion: 3,
|
|
}]);
|
|
expect(res.body.secrets[0]).not.toHaveProperty("secretId");
|
|
expect(res.body.secrets[0]).not.toHaveProperty("bindingId");
|
|
expect(res.body.secrets[0]).not.toHaveProperty("configPath");
|
|
});
|
|
|
|
it("returns provider health checks for board callers with company access", async () => {
|
|
mockSecretService.checkProviders.mockResolvedValue([
|
|
{
|
|
provider: "local_encrypted",
|
|
status: "ok",
|
|
message: "Local encrypted provider configured",
|
|
backupGuidance: ["Back up the key file together with database backups."],
|
|
},
|
|
]);
|
|
|
|
const res = await request(createApp()).get("/api/companies/company-1/secret-providers/health");
|
|
|
|
expect(res.status).toBe(200);
|
|
expect(res.body).toEqual({
|
|
providers: [
|
|
{
|
|
provider: "local_encrypted",
|
|
status: "ok",
|
|
message: "Local encrypted provider configured",
|
|
backupGuidance: ["Back up the key file together with database backups."],
|
|
},
|
|
],
|
|
});
|
|
});
|
|
|
|
it("rejects managed secret creation when externalRef is supplied", async () => {
|
|
const res = await request(createApp()).post("/api/companies/company-1/secrets").send({
|
|
name: "OpenAI API Key",
|
|
managedMode: "paperclip_managed",
|
|
value: "secret-value",
|
|
externalRef: "arn:aws:secretsmanager:us-east-1:123456789012:secret:shared/other",
|
|
});
|
|
|
|
expect(res.status).toBe(400);
|
|
expect(JSON.stringify(res.body)).toMatch(/Managed secrets cannot set externalRef/);
|
|
expect(mockSecretService.create).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("returns sanitized AWS provider errors when managed secret creation fails", async () => {
|
|
mockSecretService.create.mockRejectedValue(
|
|
new HttpError(
|
|
403,
|
|
"AWS Secrets Manager denied the request. Check IAM permissions for this provider vault.",
|
|
{
|
|
code: "access_denied",
|
|
provider: "aws_secrets_manager",
|
|
operation: "secret.create",
|
|
providerConfigId: "11111111-1111-4111-8111-111111111111",
|
|
region: "us-east-1",
|
|
credentialPath: "Paperclip server runtime/provider credential path",
|
|
requiredCapability: "secretsmanager:CreateSecret",
|
|
actionableMessage:
|
|
"AWS managed secret creation needs secretsmanager:CreateSecret in the selected region for this provider vault.",
|
|
safeAlternative:
|
|
"If the secret already exists in AWS, link it as an external reference instead of creating a Paperclip-managed value.",
|
|
},
|
|
),
|
|
);
|
|
|
|
const res = await request(createApp()).post("/api/companies/company-1/secrets").send({
|
|
name: "Vercel token",
|
|
key: "vercel_token",
|
|
provider: "aws_secrets_manager",
|
|
providerConfigId: "11111111-1111-4111-8111-111111111111",
|
|
managedMode: "paperclip_managed",
|
|
value: "vcp_test",
|
|
});
|
|
|
|
expect(res.status).toBe(403);
|
|
expect(res.body).toMatchObject({
|
|
code: "access_denied",
|
|
error: "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: "11111111-1111-4111-8111-111111111111",
|
|
region: "us-east-1",
|
|
requiredCapability: "secretsmanager:CreateSecret",
|
|
},
|
|
});
|
|
expect(JSON.stringify(res.body)).not.toContain("arn:aws");
|
|
expect(JSON.stringify(res.body)).not.toContain("123456789012");
|
|
expect(mockLogActivity).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("restricts user secret definition management to company admins", async () => {
|
|
const res = await request(createApp({
|
|
type: "board",
|
|
userId: "user-1",
|
|
source: "session",
|
|
companyIds: ["company-1"],
|
|
memberships: [{ companyId: "company-1", status: "active", membershipRole: "member" }],
|
|
})).post("/api/companies/company-1/user-secret-definitions").send({
|
|
key: "github_token",
|
|
name: "GitHub token",
|
|
provider: "local_encrypted",
|
|
});
|
|
|
|
expect(res.status).toBe(403);
|
|
expect(mockSecretService.createUserSecretDefinition).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("records implicit user-secret definition admins as system actors instead of board pseudo-users", async () => {
|
|
mockSecretService.createUserSecretDefinition.mockResolvedValue({
|
|
id: "definition-1",
|
|
companyId: "company-1",
|
|
key: "github_token",
|
|
name: "GitHub token",
|
|
provider: "local_encrypted",
|
|
status: "active",
|
|
});
|
|
|
|
const res = await request(createApp({
|
|
type: "board",
|
|
source: "local_implicit",
|
|
isInstanceAdmin: true,
|
|
companyIds: ["company-1"],
|
|
memberships: [],
|
|
})).post("/api/companies/company-1/user-secret-definitions").send({
|
|
key: "github_token",
|
|
name: "GitHub token",
|
|
provider: "local_encrypted",
|
|
});
|
|
|
|
expect(res.status).toBe(201);
|
|
expect(mockSecretService.createUserSecretDefinition).toHaveBeenCalledWith(
|
|
"company-1",
|
|
expect.objectContaining({ key: "github_token" }),
|
|
{ userId: null, agentId: null },
|
|
);
|
|
expect(mockLogActivity).toHaveBeenCalledWith(
|
|
expect.anything(),
|
|
expect.objectContaining({
|
|
actorType: "system",
|
|
actorId: "local_implicit",
|
|
action: "user_secret_definition.created",
|
|
}),
|
|
);
|
|
expect(JSON.stringify(mockLogActivity.mock.calls)).not.toContain("\"board\"");
|
|
});
|
|
|
|
it("logs patched user-secret definition deletion as deletion activity", async () => {
|
|
mockSecretService.updateUserSecretDefinition.mockResolvedValue({
|
|
id: "definition-1",
|
|
companyId: "company-1",
|
|
key: "github_token__deleted__definition-1",
|
|
name: "GitHub token",
|
|
provider: "local_encrypted",
|
|
status: "deleted",
|
|
});
|
|
|
|
const res = await request(createApp())
|
|
.patch("/api/companies/company-1/user-secret-definitions/definition-1")
|
|
.send({ status: "deleted" });
|
|
|
|
expect(res.status).toBe(200);
|
|
expect(mockSecretService.updateUserSecretDefinition).toHaveBeenCalledWith(
|
|
"company-1",
|
|
"definition-1",
|
|
expect.objectContaining({ status: "deleted" }),
|
|
{ userId: "user-1", agentId: null },
|
|
);
|
|
expect(mockLogActivity).toHaveBeenCalledWith(
|
|
expect.anything(),
|
|
expect.objectContaining({
|
|
action: "user_secret_definition.deleted",
|
|
entityType: "user_secret_definition",
|
|
entityId: "definition-1",
|
|
}),
|
|
);
|
|
});
|
|
|
|
it("creates current-user secret values for the authenticated user only", async () => {
|
|
mockSecretService.createCurrentUserSecretValue.mockResolvedValue({
|
|
id: "secret-1",
|
|
companyId: "company-1",
|
|
scope: "user",
|
|
ownerUserId: "user-1",
|
|
userSecretDefinitionId: "definition-1",
|
|
provider: "local_encrypted",
|
|
latestVersion: 1,
|
|
});
|
|
|
|
const res = await request(createApp()).post("/api/companies/company-1/me/user-secrets").send({
|
|
definitionKey: "github_token",
|
|
value: "secret-value",
|
|
});
|
|
|
|
expect(res.status).toBe(201);
|
|
expect(mockSecretService.createCurrentUserSecretValue).toHaveBeenCalledWith(
|
|
"company-1",
|
|
"user-1",
|
|
{
|
|
definitionKey: "github_token",
|
|
definitionId: undefined,
|
|
value: "secret-value",
|
|
externalRef: undefined,
|
|
providerVersionRef: undefined,
|
|
providerConfigId: undefined,
|
|
},
|
|
{ userId: "user-1", agentId: null },
|
|
);
|
|
expect(JSON.stringify(mockLogActivity.mock.calls)).not.toContain("secret-value");
|
|
});
|
|
|
|
it("rejects current-user secret values without a concrete user identity", async () => {
|
|
const res = await request(createApp({
|
|
type: "board",
|
|
source: "local_implicit",
|
|
companyIds: ["company-1"],
|
|
memberships: [{ companyId: "company-1", status: "active", membershipRole: "admin" }],
|
|
})).post("/api/companies/company-1/me/user-secrets").send({
|
|
definitionKey: "github_token",
|
|
value: "secret-value",
|
|
});
|
|
|
|
expect(res.status).toBe(401);
|
|
expect(res.body).toMatchObject({ error: "User identity required for user-specific secrets" });
|
|
expect(mockSecretService.createCurrentUserSecretValue).not.toHaveBeenCalled();
|
|
expect(mockLogActivity).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("rejects empty current-user secret rotation payloads", async () => {
|
|
const res = await request(createApp())
|
|
.post("/api/companies/company-1/me/user-secrets/secret-1/rotate")
|
|
.send({});
|
|
|
|
expect(res.status).toBe(400);
|
|
expect(JSON.stringify(res.body)).toMatch(/requires value, externalRef/);
|
|
expect(mockSecretService.rotateCurrentUserSecretValue).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("hides user-scoped secrets from legacy company secret mutation routes", async () => {
|
|
mockSecretService.getById.mockResolvedValue({
|
|
id: "secret-1",
|
|
companyId: "company-1",
|
|
scope: "user",
|
|
ownerUserId: "user-2",
|
|
status: "active",
|
|
});
|
|
|
|
const res = await request(createApp()).post("/api/secrets/secret-1/rotate").send({
|
|
value: "new-secret-value",
|
|
});
|
|
|
|
expect(res.status).toBe(404);
|
|
expect(mockSecretService.rotate).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("rejects provider vault routes for non-board actors", async () => {
|
|
const res = await request(createApp({
|
|
type: "agent",
|
|
agentId: "agent-1",
|
|
companyId: "company-1",
|
|
})).get("/api/companies/company-1/secret-provider-configs");
|
|
|
|
expect(res.status).toBe(403);
|
|
expect(mockSecretService.listProviderConfigs).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("rejects provider vault cross-company access before calling the service", async () => {
|
|
const res = await request(createApp({
|
|
type: "board",
|
|
userId: "user-1",
|
|
source: "session",
|
|
companyIds: ["company-2"],
|
|
memberships: [{ companyId: "company-2", status: "active", membershipRole: "admin" }],
|
|
})).get("/api/companies/company-1/secret-provider-configs");
|
|
|
|
expect(res.status).toBe(403);
|
|
expect(mockSecretService.listProviderConfigs).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("rejects provider vault discovery preview for non-board actors", async () => {
|
|
const res = await request(createApp({
|
|
type: "agent",
|
|
agentId: "agent-1",
|
|
companyId: "company-1",
|
|
}))
|
|
.post("/api/companies/company-1/secret-provider-configs/discovery/preview")
|
|
.send({
|
|
provider: "aws_secrets_manager",
|
|
config: { region: "us-east-1" },
|
|
});
|
|
|
|
expect(res.status).toBe(403);
|
|
expect(mockSecretService.previewProviderConfigDiscovery).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("rejects sensitive provider vault config fields", async () => {
|
|
const res = await request(createApp()).post("/api/companies/company-1/secret-provider-configs").send({
|
|
provider: "aws_secrets_manager",
|
|
displayName: "AWS prod",
|
|
config: {
|
|
region: "us-east-1",
|
|
accessKeyId: "AKIA...",
|
|
},
|
|
});
|
|
|
|
expect(res.status).toBe(400);
|
|
expect(JSON.stringify(res.body)).toMatch(/sensitive field/i);
|
|
expect(mockSecretService.createProviderConfig).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("rejects sensitive provider vault discovery draft config fields", async () => {
|
|
const res = await request(createApp())
|
|
.post("/api/companies/company-1/secret-provider-configs/discovery/preview")
|
|
.send({
|
|
provider: "aws_secrets_manager",
|
|
config: {
|
|
region: "us-east-1",
|
|
secretAccessKey: "secret",
|
|
},
|
|
});
|
|
|
|
expect(res.status).toBe(400);
|
|
expect(JSON.stringify(res.body)).toMatch(/sensitive field/i);
|
|
expect(mockSecretService.previewProviderConfigDiscovery).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("previews provider vault discovery and logs only aggregate metadata", async () => {
|
|
mockSecretService.previewProviderConfigDiscovery.mockResolvedValue({
|
|
provider: "aws_secrets_manager",
|
|
nextToken: null,
|
|
sampledSecretCount: 2,
|
|
skippedForeignPaperclipSampleCount: 0,
|
|
candidates: [
|
|
{
|
|
provider: "aws_secrets_manager",
|
|
displayName: "AWS production",
|
|
config: {
|
|
region: "us-east-1",
|
|
namespace: "prod-use1",
|
|
secretNamePrefix: "paperclip",
|
|
environmentTag: "production",
|
|
ownerTag: "platform",
|
|
kmsKeyId: null,
|
|
},
|
|
sampleCount: 2,
|
|
samples: [
|
|
{ name: "paperclip/prod-use1/company-1/openai", hasKmsKey: false, tagKeys: ["environment"] },
|
|
],
|
|
signals: {
|
|
namespace: "prod-use1",
|
|
secretNamePrefix: "paperclip",
|
|
environmentTag: "production",
|
|
ownerTag: "platform",
|
|
kmsKeyId: null,
|
|
hasKmsKey: false,
|
|
sampleCount: 2,
|
|
paperclipManagedSampleCount: 0,
|
|
skippedForeignPaperclipSampleCount: 0,
|
|
},
|
|
warnings: [],
|
|
},
|
|
],
|
|
warnings: [],
|
|
});
|
|
|
|
const res = await request(createApp())
|
|
.post("/api/companies/company-1/secret-provider-configs/discovery/preview")
|
|
.send({
|
|
provider: "aws_secrets_manager",
|
|
config: { region: "us-east-1" },
|
|
query: "paperclip",
|
|
pageSize: 25,
|
|
});
|
|
|
|
expect(res.status).toBe(200);
|
|
expect(mockSecretService.previewProviderConfigDiscovery).toHaveBeenCalledWith("company-1", {
|
|
provider: "aws_secrets_manager",
|
|
config: { region: "us-east-1" },
|
|
query: "paperclip",
|
|
nextToken: undefined,
|
|
pageSize: 25,
|
|
});
|
|
expect(mockLogActivity).toHaveBeenCalledWith(expect.anything(), expect.objectContaining({
|
|
action: "secret_provider_config.discovery_previewed",
|
|
entityType: "secret_provider_config_discovery",
|
|
entityId: "company-1",
|
|
details: {
|
|
provider: "aws_secrets_manager",
|
|
candidateCount: 1,
|
|
sampledSecretCount: 2,
|
|
warningCount: 0,
|
|
},
|
|
}));
|
|
expect(JSON.stringify(mockLogActivity.mock.calls)).not.toContain("paperclip/prod-use1/company-1/openai");
|
|
});
|
|
|
|
it("returns actionable sanitized provider vault discovery errors", async () => {
|
|
mockSecretService.previewProviderConfigDiscovery.mockRejectedValue(
|
|
new HttpError(
|
|
403,
|
|
"AWS Secrets Manager denied the request. Check IAM permissions for this provider vault.",
|
|
{
|
|
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.",
|
|
},
|
|
),
|
|
);
|
|
|
|
const res = await request(createApp())
|
|
.post("/api/companies/company-1/secret-provider-configs/discovery/preview")
|
|
.send({
|
|
provider: "aws_secrets_manager",
|
|
config: { region: "us-east-1" },
|
|
pageSize: 25,
|
|
});
|
|
|
|
expect(res.status).toBe(403);
|
|
expect(res.body).toMatchObject({
|
|
error: "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",
|
|
providerVaultContext: "draft_config",
|
|
region: "us-east-1",
|
|
requiredCapability: "secretsmanager:ListSecrets",
|
|
},
|
|
});
|
|
expect(res.body.details.actionableMessage).toContain("Paperclip server runtime/provider credential path");
|
|
expect(res.body.details.safeAlternative).toContain("paste/link that ARN");
|
|
expect(JSON.stringify(res.body)).not.toContain("arn:aws");
|
|
expect(JSON.stringify(res.body)).not.toContain("123456789012");
|
|
expect(mockLogActivity).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("rejects ready status for coming-soon provider vaults", async () => {
|
|
const res = await request(createApp()).post("/api/companies/company-1/secret-provider-configs").send({
|
|
provider: "vault",
|
|
displayName: "Vault draft",
|
|
status: "ready",
|
|
config: {
|
|
address: "https://vault.example.com",
|
|
},
|
|
});
|
|
|
|
expect(res.status).toBe(400);
|
|
expect(JSON.stringify(res.body)).toMatch(/locked while coming soon/i);
|
|
expect(mockSecretService.createProviderConfig).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("rejects credential-bearing Vault provider vault addresses before persistence", async () => {
|
|
const res = await request(createApp()).post("/api/companies/company-1/secret-provider-configs").send({
|
|
provider: "vault",
|
|
displayName: "Vault draft",
|
|
config: {
|
|
address: "https://user:pass@vault.example.com",
|
|
},
|
|
});
|
|
|
|
expect(res.status).toBe(400);
|
|
expect(JSON.stringify(res.body)).toMatch(/origin-only HTTP\(S\) URL/i);
|
|
expect(mockSecretService.createProviderConfig).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it.each([
|
|
"https://vault.example.com?token=hvs.x",
|
|
"https://vault.example.com#token=hvs.x",
|
|
])("rejects token-bearing Vault provider vault address %s before persistence", async (address) => {
|
|
const res = await request(createApp()).post("/api/companies/company-1/secret-provider-configs").send({
|
|
provider: "vault",
|
|
displayName: "Vault draft",
|
|
config: { address },
|
|
});
|
|
|
|
expect(res.status).toBe(400);
|
|
expect(JSON.stringify(res.body)).toMatch(/origin-only HTTP\(S\) URL/i);
|
|
expect(mockSecretService.createProviderConfig).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("rejects unsafe Vault provider vault address patches before persistence", async () => {
|
|
const res = await request(createApp()).patch("/api/secret-provider-configs/vault-1").send({
|
|
config: {
|
|
address: "https://vault.example.com#token=hvs.x",
|
|
},
|
|
});
|
|
|
|
expect(res.status).toBe(400);
|
|
expect(JSON.stringify(res.body)).toMatch(/origin-only HTTP\(S\) URL/i);
|
|
expect(mockSecretService.getProviderConfigById).not.toHaveBeenCalled();
|
|
expect(mockSecretService.updateProviderConfig).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("creates provider vaults and logs safe activity details", async () => {
|
|
const createdAt = new Date("2026-05-06T00:00:00.000Z");
|
|
mockSecretService.createProviderConfig.mockResolvedValue({
|
|
id: "11111111-1111-4111-8111-111111111111",
|
|
companyId: "company-1",
|
|
provider: "aws_secrets_manager",
|
|
displayName: "AWS prod",
|
|
status: "ready",
|
|
isDefault: true,
|
|
config: { region: "us-east-1" },
|
|
healthStatus: null,
|
|
healthCheckedAt: null,
|
|
healthMessage: null,
|
|
healthDetails: null,
|
|
disabledAt: null,
|
|
createdByAgentId: null,
|
|
createdByUserId: "user-1",
|
|
createdAt,
|
|
updatedAt: createdAt,
|
|
});
|
|
|
|
const res = await request(createApp()).post("/api/companies/company-1/secret-provider-configs").send({
|
|
provider: "aws_secrets_manager",
|
|
displayName: "AWS prod",
|
|
isDefault: true,
|
|
config: { region: "us-east-1" },
|
|
});
|
|
|
|
expect(res.status).toBe(201);
|
|
expect(mockSecretService.createProviderConfig).toHaveBeenCalledWith(
|
|
"company-1",
|
|
{
|
|
provider: "aws_secrets_manager",
|
|
displayName: "AWS prod",
|
|
status: undefined,
|
|
isDefault: true,
|
|
config: { region: "us-east-1" },
|
|
},
|
|
{ userId: "user-1", agentId: null },
|
|
);
|
|
expect(mockLogActivity).toHaveBeenCalledWith(expect.anything(), expect.objectContaining({
|
|
action: "secret_provider_config.created",
|
|
details: {
|
|
provider: "aws_secrets_manager",
|
|
displayName: "AWS prod",
|
|
status: "ready",
|
|
isDefault: true,
|
|
},
|
|
}));
|
|
expect(JSON.stringify(mockLogActivity.mock.calls)).not.toContain("accessKey");
|
|
});
|
|
|
|
it("removes provider vault config locally without deleting remote provider data", async () => {
|
|
const createdAt = new Date("2026-05-06T00:00:00.000Z");
|
|
const providerConfig = {
|
|
id: "11111111-1111-4111-8111-111111111111",
|
|
companyId: "company-1",
|
|
provider: "aws_secrets_manager",
|
|
displayName: "AWS prod",
|
|
status: "ready",
|
|
isDefault: false,
|
|
config: { region: "us-east-1" },
|
|
healthStatus: null,
|
|
healthCheckedAt: null,
|
|
healthMessage: null,
|
|
healthDetails: null,
|
|
disabledAt: null,
|
|
createdByAgentId: null,
|
|
createdByUserId: "user-1",
|
|
createdAt,
|
|
updatedAt: createdAt,
|
|
};
|
|
mockSecretService.getProviderConfigById.mockResolvedValue(providerConfig);
|
|
mockSecretService.removeProviderConfig.mockResolvedValue(providerConfig);
|
|
|
|
const res = await request(createApp()).delete(
|
|
"/api/secret-provider-configs/11111111-1111-4111-8111-111111111111",
|
|
);
|
|
|
|
expect(res.status).toBe(200);
|
|
expect(mockSecretService.removeProviderConfig).toHaveBeenCalledWith(
|
|
"11111111-1111-4111-8111-111111111111",
|
|
);
|
|
expect(mockSecretService.disableProviderConfig).not.toHaveBeenCalled();
|
|
expect(mockLogActivity).toHaveBeenCalledWith(expect.anything(), expect.objectContaining({
|
|
action: "secret_provider_config.removed",
|
|
details: {
|
|
provider: "aws_secrets_manager",
|
|
displayName: "AWS prod",
|
|
remoteDeleted: false,
|
|
},
|
|
}));
|
|
});
|
|
|
|
it("rejects remote import preview for non-board actors", async () => {
|
|
const res = await request(createApp({
|
|
type: "agent",
|
|
agentId: "agent-1",
|
|
companyId: "company-1",
|
|
})).post("/api/companies/company-1/secrets/remote-import/preview").send({
|
|
providerConfigId: "11111111-1111-4111-8111-111111111111",
|
|
});
|
|
|
|
expect(res.status).toBe(403);
|
|
expect(mockSecretService.previewRemoteImport).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("previews remote imports and logs only aggregate metadata", async () => {
|
|
mockSecretService.previewRemoteImport.mockResolvedValue({
|
|
providerConfigId: "11111111-1111-4111-8111-111111111111",
|
|
provider: "aws_secrets_manager",
|
|
nextToken: null,
|
|
candidates: [
|
|
{
|
|
externalRef: "arn:aws:secretsmanager:us-east-1:123456789012:secret:prod/openai",
|
|
remoteName: "prod/openai",
|
|
name: "openai",
|
|
key: "openai",
|
|
providerVersionRef: null,
|
|
providerMetadata: { description: "OpenAI API key" },
|
|
status: "ready",
|
|
importable: true,
|
|
conflicts: [],
|
|
},
|
|
],
|
|
});
|
|
|
|
const res = await request(createApp())
|
|
.post("/api/companies/company-1/secrets/remote-import/preview")
|
|
.send({
|
|
providerConfigId: "11111111-1111-4111-8111-111111111111",
|
|
query: "openai",
|
|
pageSize: 25,
|
|
});
|
|
|
|
expect(res.status).toBe(200);
|
|
expect(mockSecretService.previewRemoteImport).toHaveBeenCalledWith("company-1", {
|
|
providerConfigId: "11111111-1111-4111-8111-111111111111",
|
|
query: "openai",
|
|
nextToken: undefined,
|
|
pageSize: 25,
|
|
});
|
|
expect(mockLogActivity).toHaveBeenCalledWith(expect.anything(), expect.objectContaining({
|
|
action: "secret.remote_import.previewed",
|
|
details: {
|
|
provider: "aws_secrets_manager",
|
|
candidateCount: 1,
|
|
readyCount: 1,
|
|
duplicateCount: 0,
|
|
conflictCount: 0,
|
|
},
|
|
}));
|
|
expect(JSON.stringify(mockLogActivity.mock.calls)).not.toContain("prod/openai");
|
|
});
|
|
|
|
it("returns sanitized remote import preview provider errors", async () => {
|
|
mockSecretService.previewRemoteImport.mockRejectedValue(
|
|
new HttpError(
|
|
403,
|
|
"AWS Secrets Manager denied the request. Check IAM permissions for this provider vault.",
|
|
{ code: "access_denied" },
|
|
),
|
|
);
|
|
|
|
const res = await request(createApp())
|
|
.post("/api/companies/company-1/secrets/remote-import/preview")
|
|
.send({
|
|
providerConfigId: "11111111-1111-4111-8111-111111111111",
|
|
});
|
|
|
|
expect(res.status).toBe(403);
|
|
expect(res.body).toEqual({
|
|
code: "access_denied",
|
|
error: "AWS Secrets Manager denied the request. Check IAM permissions for this provider vault.",
|
|
details: { code: "access_denied" },
|
|
});
|
|
expect(JSON.stringify(res.body)).not.toContain("arn:aws");
|
|
expect(JSON.stringify(res.body)).not.toContain("123456789012");
|
|
expect(mockLogActivity).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("imports remote references and logs aggregate row counts", async () => {
|
|
mockSecretService.importRemoteSecrets.mockResolvedValue({
|
|
providerConfigId: "11111111-1111-4111-8111-111111111111",
|
|
provider: "aws_secrets_manager",
|
|
importedCount: 1,
|
|
skippedCount: 0,
|
|
errorCount: 0,
|
|
results: [
|
|
{
|
|
externalRef: "arn:aws:secretsmanager:us-east-1:123456789012:secret:prod/openai",
|
|
name: "OpenAI API key",
|
|
key: "openai-api-key",
|
|
status: "imported",
|
|
reason: null,
|
|
secretId: "22222222-2222-4222-8222-222222222222",
|
|
conflicts: [],
|
|
},
|
|
],
|
|
});
|
|
|
|
const res = await request(createApp())
|
|
.post("/api/companies/company-1/secrets/remote-import")
|
|
.send({
|
|
providerConfigId: "11111111-1111-4111-8111-111111111111",
|
|
secrets: [
|
|
{
|
|
externalRef: "arn:aws:secretsmanager:us-east-1:123456789012:secret:prod/openai",
|
|
name: "OpenAI API key",
|
|
key: "openai-api-key",
|
|
description: "Operator-entered Paperclip description",
|
|
},
|
|
],
|
|
});
|
|
|
|
expect(res.status).toBe(200);
|
|
expect(mockSecretService.importRemoteSecrets).toHaveBeenCalledWith(
|
|
"company-1",
|
|
{
|
|
providerConfigId: "11111111-1111-4111-8111-111111111111",
|
|
secrets: [
|
|
{
|
|
externalRef: "arn:aws:secretsmanager:us-east-1:123456789012:secret:prod/openai",
|
|
name: "OpenAI API key",
|
|
key: "openai-api-key",
|
|
description: "Operator-entered Paperclip description",
|
|
},
|
|
],
|
|
},
|
|
{ userId: "user-1", agentId: null },
|
|
);
|
|
expect(mockLogActivity).toHaveBeenCalledWith(expect.anything(), expect.objectContaining({
|
|
action: "secret.remote_import.completed",
|
|
details: {
|
|
provider: "aws_secrets_manager",
|
|
importedCount: 1,
|
|
skippedCount: 0,
|
|
errorCount: 0,
|
|
},
|
|
}));
|
|
expect(JSON.stringify(mockLogActivity.mock.calls)).not.toContain("prod/openai");
|
|
});
|
|
|
|
it("surfaces update-route externalRef retarget rejection without logging raw refs", async () => {
|
|
mockSecretService.getById.mockResolvedValue({
|
|
id: "22222222-2222-4222-8222-222222222222",
|
|
companyId: "company-1",
|
|
name: "OpenAI API key",
|
|
key: "openai-api-key",
|
|
provider: "aws_secrets_manager",
|
|
managedMode: "external_reference",
|
|
externalRef: "arn:aws:secretsmanager:us-east-1:123456789012:secret:shared/original",
|
|
});
|
|
mockSecretService.update.mockRejectedValue(
|
|
unprocessable("External reference secrets cannot be retargeted through generic update"),
|
|
);
|
|
|
|
const res = await request(createApp())
|
|
.patch("/api/secrets/22222222-2222-4222-8222-222222222222")
|
|
.send({
|
|
externalRef: "arn:aws:secretsmanager:us-east-1:123456789012:secret:shared/repointed",
|
|
});
|
|
|
|
expect(res.status).toBe(422);
|
|
expect(mockSecretService.update).toHaveBeenCalledWith(
|
|
"22222222-2222-4222-8222-222222222222",
|
|
expect.objectContaining({
|
|
externalRef: "arn:aws:secretsmanager:us-east-1:123456789012:secret:shared/repointed",
|
|
}),
|
|
);
|
|
expect(mockLogActivity).not.toHaveBeenCalled();
|
|
expect(JSON.stringify(mockLogActivity.mock.calls)).not.toContain("shared/repointed");
|
|
});
|
|
|
|
it("returns 404 for cross-tenant GET /secrets/:id/usage without leaking existence", async () => {
|
|
mockSecretService.getById.mockResolvedValue({
|
|
id: "44444444-4444-4444-8444-444444444444",
|
|
companyId: "company-2",
|
|
name: "Other tenant secret",
|
|
key: "other-secret",
|
|
provider: "aws_secrets_manager",
|
|
managedMode: "paperclip_managed",
|
|
});
|
|
|
|
const crossTenantApp = createApp({
|
|
type: "board",
|
|
userId: "mallory",
|
|
source: "session",
|
|
companyIds: ["company-1"],
|
|
memberships: [{ companyId: "company-1", status: "active", membershipRole: "admin" }],
|
|
isInstanceAdmin: false,
|
|
});
|
|
|
|
const res = await request(crossTenantApp).get(
|
|
"/api/secrets/44444444-4444-4444-8444-444444444444/usage",
|
|
);
|
|
|
|
expect(res.status).toBe(404);
|
|
expect(res.body).toEqual({ error: "Secret not found" });
|
|
expect(mockSecretService.listBindingReferences).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("returns 404 for missing GET /secrets/:id/usage with identical response shape", async () => {
|
|
mockSecretService.getById.mockResolvedValue(null);
|
|
|
|
const res = await request(createApp()).get(
|
|
"/api/secrets/55555555-5555-4555-8555-555555555555/usage",
|
|
);
|
|
|
|
expect(res.status).toBe(404);
|
|
expect(res.body).toEqual({ error: "Secret not found" });
|
|
expect(mockSecretService.listBindingReferences).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("returns 404 for cross-tenant GET /secrets/:id/access-events without leaking existence", async () => {
|
|
mockSecretService.getById.mockResolvedValue({
|
|
id: "66666666-6666-4666-8666-666666666666",
|
|
companyId: "company-2",
|
|
name: "Other tenant secret",
|
|
key: "other-secret",
|
|
provider: "aws_secrets_manager",
|
|
managedMode: "paperclip_managed",
|
|
});
|
|
|
|
const crossTenantApp = createApp({
|
|
type: "board",
|
|
userId: "mallory",
|
|
source: "session",
|
|
companyIds: ["company-1"],
|
|
memberships: [{ companyId: "company-1", status: "active", membershipRole: "admin" }],
|
|
isInstanceAdmin: false,
|
|
});
|
|
|
|
const res = await request(crossTenantApp).get(
|
|
"/api/secrets/66666666-6666-4666-8666-666666666666/access-events",
|
|
);
|
|
|
|
expect(res.status).toBe(404);
|
|
expect(res.body).toEqual({ error: "Secret not found" });
|
|
expect(mockSecretService.listAccessEvents).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("returns 404 for missing GET /secrets/:id/access-events with identical response shape", async () => {
|
|
mockSecretService.getById.mockResolvedValue(null);
|
|
|
|
const res = await request(createApp()).get(
|
|
"/api/secrets/77777777-7777-4777-8777-777777777777/access-events",
|
|
);
|
|
|
|
expect(res.status).toBe(404);
|
|
expect(res.body).toEqual({ error: "Secret not found" });
|
|
expect(mockSecretService.listAccessEvents).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("returns usage bindings for in-tenant GET /secrets/:id/usage", async () => {
|
|
mockSecretService.getById.mockResolvedValue({
|
|
id: "88888888-8888-4888-8888-888888888888",
|
|
companyId: "company-1",
|
|
name: "OpenAI",
|
|
key: "openai",
|
|
provider: "aws_secrets_manager",
|
|
managedMode: "paperclip_managed",
|
|
});
|
|
mockSecretService.listBindingReferences.mockResolvedValue([]);
|
|
|
|
const res = await request(createApp()).get(
|
|
"/api/secrets/88888888-8888-4888-8888-888888888888/usage",
|
|
);
|
|
|
|
expect(res.status).toBe(200);
|
|
expect(res.body).toEqual({
|
|
secretId: "88888888-8888-4888-8888-888888888888",
|
|
bindings: [],
|
|
});
|
|
expect(mockSecretService.listBindingReferences).toHaveBeenCalledWith(
|
|
"company-1",
|
|
"88888888-8888-4888-8888-888888888888",
|
|
);
|
|
});
|
|
|
|
it("allows DELETE to retry cleanup for already soft-deleted secrets", async () => {
|
|
const secret = {
|
|
id: "33333333-3333-4333-8333-333333333333",
|
|
companyId: "company-1",
|
|
name: "OpenAI API Key__deleted__33333333-3333-4333-8333-333333333333",
|
|
key: "openai-api-key__deleted__33333333-3333-4333-8333-333333333333",
|
|
provider: "aws_secrets_manager",
|
|
managedMode: "paperclip_managed",
|
|
status: "deleted",
|
|
};
|
|
mockSecretService.getById.mockResolvedValue(secret);
|
|
mockSecretService.remove.mockResolvedValue(secret);
|
|
|
|
const res = await request(createApp()).delete(
|
|
"/api/secrets/33333333-3333-4333-8333-333333333333",
|
|
);
|
|
|
|
expect(res.status).toBe(200);
|
|
expect(res.body).toEqual({ ok: true });
|
|
expect(mockSecretService.remove).toHaveBeenCalledWith(
|
|
"33333333-3333-4333-8333-333333333333",
|
|
);
|
|
expect(mockLogActivity).toHaveBeenCalledWith(
|
|
expect.anything(),
|
|
expect.objectContaining({
|
|
action: "secret.deleted",
|
|
companyId: "company-1",
|
|
entityId: secret.id,
|
|
}),
|
|
);
|
|
});
|
|
|
|
describe("GET /companies/:companyId/secrets/catalog", () => {
|
|
const fullSecrets = [
|
|
{
|
|
id: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
|
|
name: "MY_API_KEY",
|
|
key: "my_api_key",
|
|
status: "active",
|
|
companyId: "company-1",
|
|
provider: "local_encrypted",
|
|
providerMetadata: null,
|
|
referenceCount: 2,
|
|
},
|
|
{
|
|
id: "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb",
|
|
name: "DB_PASSWORD",
|
|
key: "db_password",
|
|
status: "active",
|
|
companyId: "company-1",
|
|
provider: "local_encrypted",
|
|
providerMetadata: null,
|
|
referenceCount: 0,
|
|
},
|
|
];
|
|
|
|
it("returns id/name/key/status only for board callers", async () => {
|
|
mockSecretService.list.mockResolvedValue(fullSecrets);
|
|
|
|
const res = await request(createApp()).get("/api/companies/company-1/secrets/catalog");
|
|
|
|
expect(res.status).toBe(200);
|
|
expect(res.body).toEqual([
|
|
{ id: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", name: "MY_API_KEY", key: "my_api_key", status: "active" },
|
|
{ id: "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb", name: "DB_PASSWORD", key: "db_password", status: "active" },
|
|
]);
|
|
expect(res.body[0]).not.toHaveProperty("provider");
|
|
expect(res.body[0]).not.toHaveProperty("referenceCount");
|
|
});
|
|
|
|
it("returns id/name/key/status only for agent callers in the same company", async () => {
|
|
mockSecretService.list.mockResolvedValue(fullSecrets);
|
|
|
|
const agentApp = createApp({
|
|
type: "agent",
|
|
agentId: "agent-1",
|
|
companyId: "company-1",
|
|
});
|
|
const res = await request(agentApp).get("/api/companies/company-1/secrets/catalog");
|
|
|
|
expect(res.status).toBe(200);
|
|
expect(res.body).toHaveLength(2);
|
|
expect(res.body[0]).toMatchObject({ id: expect.any(String), name: "MY_API_KEY", key: "my_api_key", status: "active" });
|
|
});
|
|
|
|
it.each(["skill_test", "task_bridge"])(
|
|
"rejects %s keys from the company secret catalog",
|
|
async (kind) => {
|
|
mockAccessService.decide.mockResolvedValue({
|
|
allowed: false,
|
|
reason: "deny_key_scope",
|
|
explanation: "Restricted keys cannot read the company secret catalog.",
|
|
});
|
|
|
|
const res = await request(createApp({
|
|
type: "agent",
|
|
agentId: "agent-1",
|
|
companyId: "company-1",
|
|
source: "agent_key",
|
|
keyScope: { kind },
|
|
})).get("/api/companies/company-1/secrets/catalog");
|
|
|
|
expect(res.status).toBe(403);
|
|
expect(mockSecretService.list).not.toHaveBeenCalled();
|
|
expect(mockAccessService.decide).toHaveBeenCalledWith(expect.objectContaining({
|
|
action: "secrets:read",
|
|
resource: { type: "company", companyId: "company-1" },
|
|
}));
|
|
},
|
|
);
|
|
|
|
it("rejects unauthenticated requests", async () => {
|
|
const res = await request(createApp({ type: "none" }))
|
|
.get("/api/companies/company-1/secrets/catalog");
|
|
|
|
// assertBoardOrAgent throws forbidden (403) for all non-agent/non-board actors;
|
|
// it does not call assertAuthenticated first, so type:"none" gets 403, not 401.
|
|
expect(res.status).toBe(403);
|
|
});
|
|
|
|
it("rejects agents from a different company", async () => {
|
|
const res = await request(createApp({
|
|
type: "agent",
|
|
agentId: "agent-1",
|
|
companyId: "company-2",
|
|
})).get("/api/companies/company-1/secrets/catalog");
|
|
|
|
expect(res.status).toBe(403);
|
|
});
|
|
});
|
|
});
|