diff --git a/server/src/__tests__/agents-adapter-config-user-secret.test.ts b/server/src/__tests__/agents-adapter-config-user-secret.test.ts new file mode 100644 index 0000000000..abb26ffa1f --- /dev/null +++ b/server/src/__tests__/agents-adapter-config-user-secret.test.ts @@ -0,0 +1,409 @@ +import { randomUUID } from "node:crypto"; +import { mkdirSync, rmSync } from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import express from "express"; +import request from "supertest"; +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; +import { + activityLog, + companies, + companyMemberships, + companySecretBindings, + companySecretVersions, + companySecrets, + createDb, + secretAccessEvents, + userSecretDeclarations, + userSecretDefinitions, +} from "@paperclipai/db"; +import type { ServerAdapterModule } from "../adapters/index.js"; +import { getEmbeddedPostgresTestSupport, startEmbeddedPostgresTestDatabase } from "./helpers/embedded-postgres.js"; + +const mockAgentService = vi.hoisted(() => ({ + getById: vi.fn(), + getChainOfCommand: vi.fn(async () => []), +})); + +const mockAccessService = vi.hoisted(() => ({ + canUser: vi.fn(), + decide: vi.fn(async () => ({ allowed: true, reason: "allow_explicit_grant", explanation: "allowed" })), + hasPermission: vi.fn(), + getMembership: vi.fn(async () => null), + listPrincipalGrants: vi.fn(async () => []), +})); + +const mockEnvironmentService = vi.hoisted(() => ({ + getById: vi.fn(), + releaseLease: vi.fn(), +})); + +const mockEnvironmentRuntime = vi.hoisted(() => ({ + acquireRunLease: vi.fn(), + realizeWorkspace: vi.fn(), + getDriver: vi.fn(() => ({ releaseRunLease: vi.fn(async () => undefined) })), +})); + +const mockResolveEnvironmentExecutionTarget = vi.hoisted(() => vi.fn(async () => null)); +const mockInstanceSettingsService = vi.hoisted(() => ({ + getGeneral: vi.fn(async () => ({ censorUsernameInLogs: false })), +})); +const mockRunClaudeLogin = vi.hoisted(() => vi.fn(async () => ({ ok: true }))); + +vi.mock("../services/index.js", () => ({ + agentService: () => mockAgentService, + agentInstructionsService: () => ({}), + accessService: () => mockAccessService, + approvalService: () => ({}), + builtInAgentService: () => ({ ensureCompanyDefaultAgentGrants: vi.fn() }), + companySkillService: () => ({ + listRuntimeSkillEntries: vi.fn(async () => []), + resolveRequestedSkillKeys: vi.fn(async () => []), + }), + budgetService: () => ({}), + heartbeatService: () => ({ wakeup: vi.fn(), cancelActiveForAgent: vi.fn() }), + ISSUE_LIST_DEFAULT_LIMIT: 50, + issueApprovalService: () => ({}), + issueRecoveryActionService: () => ({}), + issueService: () => ({}), + logActivity: vi.fn(), + syncInstructionsBundleConfigFromFilePath: vi.fn((_agent, config) => config), + workspaceOperationService: () => ({}), +})); + +vi.mock("../services/environments.js", () => ({ + environmentService: () => mockEnvironmentService, +})); + +vi.mock("../services/environment-runtime.js", () => ({ + environmentRuntimeService: () => mockEnvironmentRuntime, +})); + +vi.mock("../services/environment-execution-target.js", () => ({ + resolveEnvironmentExecutionTarget: mockResolveEnvironmentExecutionTarget, +})); + +vi.mock("../services/instance-settings.js", () => ({ + instanceSettingsService: () => mockInstanceSettingsService, +})); + +vi.mock("@paperclipai/adapter-claude-local/server", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + runClaudeLogin: mockRunClaudeLogin, + }; +}); + +// NOTE: ../services/secrets.js is intentionally NOT mocked — the routes resolve +// against the real embedded-postgres-backed secret service. +import { secretService } from "../services/secrets.js"; + +const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport(); +const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip; + +if (!embeddedPostgresSupport.supported) { + console.warn( + `Skipping adapter-config user-secret route tests on this host: ${embeddedPostgresSupport.reason ?? "unsupported environment"}`, + ); +} + +const COMPANY_ID = "11111111-1111-4111-8111-111111111111"; +const ENVIRONMENT_ID = "22222222-2222-4222-8222-222222222222"; + +type TestActor = Express.Request["actor"]; +let currentActor: TestActor | undefined; + +const testEnvironmentSpy = vi.fn(); + +const externalAdapter: ServerAdapterModule = { + type: "external_test", + execute: async () => ({ exitCode: 0, signal: null, timedOut: false }), + testEnvironment: testEnvironmentSpy, +}; + +describeEmbeddedPostgres("agents adapter-config user-secret resolution routes", () => { + let stopDb: (() => Promise) | null = null; + let db!: ReturnType; + const previousKeyFile = process.env.PAPERCLIP_SECRETS_MASTER_KEY_FILE; + const secretsTmpDir = path.join(os.tmpdir(), `paperclip-adapter-user-secret-${randomUUID()}`); + + beforeAll(async () => { + mkdirSync(secretsTmpDir, { recursive: true }); + process.env.PAPERCLIP_SECRETS_MASTER_KEY_FILE = path.join(secretsTmpDir, "master.key"); + const started = await startEmbeddedPostgresTestDatabase("adapter-user-secret-routes"); + stopDb = started.cleanup; + db = createDb(started.connectionString); + await db.insert(companies).values({ + id: COMPANY_ID, + name: "Acme", + issuePrefix: "ACME", + status: "active", + createdAt: new Date(), + updatedAt: new Date(), + }); + await db.insert(companyMemberships).values({ + companyId: COMPANY_ID, + principalType: "user", + principalId: "user-1", + status: "active", + membershipRole: "owner", + createdAt: new Date(), + updatedAt: new Date(), + }); + const { registerServerAdapter } = await import("../adapters/index.js"); + registerServerAdapter(externalAdapter); + }); + + beforeEach(() => { + // Reset the request actor so each test starts from an explicit, empty + // fixture state — a test that forgets to set an actor fails loudly rather + // than inheriting one leaked from a prior test. + currentActor = undefined; + vi.clearAllMocks(); + mockAccessService.decide.mockResolvedValue({ + allowed: true, + reason: "allow_explicit_grant", + explanation: "allowed", + }); + mockResolveEnvironmentExecutionTarget.mockResolvedValue(null); + testEnvironmentSpy.mockResolvedValue({ + adapterType: "external_test", + status: "pass", + checks: [], + testedAt: new Date(0).toISOString(), + }); + }); + + afterEach(async () => { + 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); + }); + + afterAll(async () => { + const { unregisterServerAdapter } = await import("../adapters/index.js"); + unregisterServerAdapter("external_test"); + if (stopDb) 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 createApp() { + const { agentRoutes } = await vi.importActual("../routes/agents.js"); + const { errorHandler } = await vi.importActual("../middleware/index.js"); + const app = express(); + app.use(express.json()); + app.use((req, _res, next) => { + (req as any).actor = currentActor; + next(); + }); + app.use("/api", agentRoutes(db)); + app.use(errorHandler); + return app; + } + + const boardUserActor: TestActor = { + type: "board", + userId: "user-1", + companyIds: [COMPANY_ID], + source: "session", + isInstanceAdmin: false, + }; + + const boardNoUserActor: TestActor = { + type: "board", + companyIds: [COMPANY_ID], + source: "local_implicit", + isInstanceAdmin: false, + }; + + async function seedUserSecretDefinitionWithValue(key: string, value: string) { + const svc = secretService(db); + const definition = await svc.createUserSecretDefinition(COMPANY_ID, { + key, + name: key, + provider: "local_encrypted", + }); + await svc.createCurrentUserSecretValue(COMPANY_ID, "user-1", { + definitionId: definition.id, + value, + }); + return definition; + } + + // ── test-environment ────────────────────────────────────────────── + + it("test-environment resolves a required user_secret_ref for the acting user (owner-scoped, no declaration)", async () => { + beforeEachActor(boardUserActor); + await seedUserSecretDefinitionWithValue("github_token", "ghp_owner"); + const app = await createApp(); + + const res = await request(app) + .post(`/api/companies/${COMPANY_ID}/adapters/external_test/test-environment`) + .send({ + adapterConfig: { + env: { + GH_TOKEN: { type: "user_secret_ref", key: "github_token", version: "latest", required: true }, + }, + }, + }); + + expect(res.status, JSON.stringify(res.body)).toBe(200); + expect(res.body).toMatchObject({ adapterType: "external_test", status: "pass" }); + // The resolved (secret) value reached the adapter probe. + expect(testEnvironmentSpy).toHaveBeenCalledTimes(1); + expect(testEnvironmentSpy.mock.calls[0][0].config.env.GH_TOKEN).toBe("ghp_owner"); + }); + + it("test-environment throws responsible_user_missing when no responsible user", async () => { + beforeEachActor(boardNoUserActor); + await seedUserSecretDefinitionWithValue("github_token", "ghp_owner"); + const app = await createApp(); + + const res = await request(app) + .post(`/api/companies/${COMPANY_ID}/adapters/external_test/test-environment`) + .send({ + adapterConfig: { + env: { + GH_TOKEN: { type: "user_secret_ref", key: "github_token", version: "latest", required: true }, + }, + }, + }); + + expect(res.status, JSON.stringify(res.body)).toBe(422); + expect(res.body).toMatchObject({ code: "responsible_user_missing" }); + expect(testEnvironmentSpy).not.toHaveBeenCalled(); + }); + + it("test-environment company secret_ref still resolves (no binding_missing regression)", async () => { + beforeEachActor(boardUserActor); + const svc = secretService(db); + const companySecret = await svc.create(COMPANY_ID, { + name: `company-token-${randomUUID()}`, + provider: "local_encrypted", + value: "company-value", + }); + const app = await createApp(); + + const res = await request(app) + .post(`/api/companies/${COMPANY_ID}/adapters/external_test/test-environment`) + .send({ + adapterConfig: { + env: { + COMPANY_TOKEN: { type: "secret_ref", secretId: companySecret.id, version: "latest" }, + }, + }, + }); + + expect(res.status, JSON.stringify(res.body)).toBe(200); + expect(testEnvironmentSpy.mock.calls[0][0].config.env.COMPANY_TOKEN).toBe("company-value"); + }); + + it("test-environment records an honest audit consumer (environment: when selected, else system:adapter_test — never agent) with the real actor/responsible-user", async () => { + // (a) No environment selected → system:adapter_test. + beforeEachActor(boardUserActor); + await seedUserSecretDefinitionWithValue("github_token", "ghp_owner"); + let app = await createApp(); + await request(app) + .post(`/api/companies/${COMPANY_ID}/adapters/external_test/test-environment`) + .send({ + adapterConfig: { + env: { GH_TOKEN: { type: "user_secret_ref", key: "github_token", version: "latest", required: true } }, + }, + }) + .expect(200); + + let events = await db.select().from(secretAccessEvents); + expect(events.length).toBeGreaterThan(0); + for (const ev of events) { + expect(ev.consumerType).toBe("system"); + expect(ev.consumerId).toBe("adapter_test"); + expect(ev.consumerType).not.toBe("agent"); + expect(ev.actorType).toBe("user"); + expect(ev.actorId).toBe("user-1"); + expect(ev.responsibleUserId).toBe("user-1"); + } + + // (b) Environment selected → environment:. + await db.delete(secretAccessEvents); + mockEnvironmentService.getById.mockResolvedValue({ + id: ENVIRONMENT_ID, + companyId: COMPANY_ID, + name: "Sandbox", + driver: "local", + config: {}, + }); + app = await createApp(); + await request(app) + .post(`/api/companies/${COMPANY_ID}/adapters/external_test/test-environment`) + .send({ + environmentId: ENVIRONMENT_ID, + adapterConfig: { + env: { GH_TOKEN: { type: "user_secret_ref", key: "github_token", version: "latest", required: true } }, + }, + }) + .expect(200); + + events = await db.select().from(secretAccessEvents); + expect(events.length).toBeGreaterThan(0); + for (const ev of events) { + expect(ev.consumerType).toBe("environment"); + expect(ev.consumerId).toBe(ENVIRONMENT_ID); + expect(ev.actorType).toBe("user"); + expect(ev.responsibleUserId).toBe("user-1"); + } + }); + + // ── claude-login ────────────────────────────────────────────────── + + it("claude-login resolves a declared required user_secret_ref; undeclared → binding_missing", async () => { + const definition = await seedUserSecretDefinitionWithValue("anthropic_key", "sk-owner"); + const agentId = randomUUID(); + mockAgentService.getById.mockResolvedValue({ + id: agentId, + companyId: COMPANY_ID, + name: "Claude agent", + adapterType: "claude_local", + adapterConfig: { + env: { ANTHROPIC_API_KEY: { type: "user_secret_ref", key: "anthropic_key", version: "latest", required: true } }, + }, + }); + beforeEachActor(boardUserActor); + + // Undeclared → binding_missing (declared mode declaration guard active). + let app = await createApp(); + let res = await request(app).post(`/api/agents/${agentId}/claude-login`).send({}); + expect(res.status, JSON.stringify(res.body)).toBe(422); + expect(res.body).toMatchObject({ code: "binding_missing" }); + expect(mockRunClaudeLogin).not.toHaveBeenCalled(); + + // Declare it at the resolver-injected configPath (env.) for consumer agent:. + await db.insert(userSecretDeclarations).values({ + companyId: COMPANY_ID, + userSecretDefinitionId: definition.id, + targetType: "agent", + targetId: agentId, + configPath: "env.ANTHROPIC_API_KEY", + envKey: "ANTHROPIC_API_KEY", + versionSelector: "latest", + required: true, + allowMissingOverride: false, + }); + + app = await createApp(); + res = await request(app).post(`/api/agents/${agentId}/claude-login`).send({}); + expect(res.status, JSON.stringify(res.body)).toBe(200); + expect(mockRunClaudeLogin).toHaveBeenCalledTimes(1); + expect(mockRunClaudeLogin.mock.calls[0][0].config.env.ANTHROPIC_API_KEY).toBe("sk-owner"); + }); +}); + +function beforeEachActor(actor: TestActor) { + currentActor = actor; +} diff --git a/server/src/__tests__/authz-secret-context.test.ts b/server/src/__tests__/authz-secret-context.test.ts new file mode 100644 index 0000000000..ef38129112 --- /dev/null +++ b/server/src/__tests__/authz-secret-context.test.ts @@ -0,0 +1,97 @@ +import { describe, expect, it } from "vitest"; +import { buildActorSecretContext } from "../routes/authz.js"; + +function makeReq(actor: Express.Request["actor"]) { + return { method: "POST", actor } as Express.Request; +} + +describe("buildActorSecretContext", () => { + it("responsibleUserId resolves to req.actor.userId for a user actor", () => { + const req = makeReq({ + type: "board", + userId: "user-1", + source: "session", + }); + + const context = buildActorSecretContext(req, { + consumerType: "agent", + consumerId: "agent-1", + }); + + expect(context.responsibleUserId).toBe("user-1"); + expect(context.actorType).toBe("user"); + expect(context.actorId).toBe("user-1"); + expect(context.actorSource).toBe("session"); + }); + + it("responsibleUserId falls back to onBehalfOfUserId for an agent actor", () => { + const req = makeReq({ + type: "agent", + agentId: "agent-7", + onBehalfOfUserId: "user-42", + source: "agent_key", + }); + + const context = buildActorSecretContext(req, { + consumerType: "agent", + consumerId: "agent-7", + }); + + expect(context.responsibleUserId).toBe("user-42"); + expect(context.actorType).toBe("agent"); + expect(context.actorId).toBe("agent-7"); + expect(context.actorSource).toBe("agent_key"); + }); + + it("prefers userId over onBehalfOfUserId when both are present", () => { + const req = makeReq({ + type: "board", + userId: "user-1", + onBehalfOfUserId: "user-99", + source: "board_key", + }); + + const context = buildActorSecretContext(req, { + consumerType: "agent", + consumerId: "agent-1", + }); + + expect(context.responsibleUserId).toBe("user-1"); + }); + + it("responsibleUserId is null when neither userId nor onBehalfOfUserId is present", () => { + const req = makeReq({ + type: "agent", + agentId: "agent-3", + source: "agent_key", + }); + + const context = buildActorSecretContext(req, { + consumerType: "system", + consumerId: "adapter_test", + }); + + expect(context.responsibleUserId).toBeNull(); + }); + + it("carries the passed consumerType/consumerId params (agent, environment, and system all accepted) and never sets configPath or allowedBindingIds", () => { + const req = makeReq({ + type: "board", + userId: "user-1", + source: "session", + }); + + for (const params of [ + { consumerType: "agent" as const, consumerId: "agent-1" }, + { consumerType: "environment" as const, consumerId: "env-9" }, + { consumerType: "system" as const, consumerId: "adapter_test" }, + ]) { + const context = buildActorSecretContext(req, params); + expect(context.consumerType).toBe(params.consumerType); + expect(context.consumerId).toBe(params.consumerId); + // Never carries a config path (the resolver injects it) or a binding allowlist. + expect(context).not.toHaveProperty("configPath"); + expect(context).not.toHaveProperty("allowedBindingIds"); + } + }); +}); diff --git a/server/src/__tests__/secrets-service-user-secret-owner-scoped.test.ts b/server/src/__tests__/secrets-service-user-secret-owner-scoped.test.ts new file mode 100644 index 0000000000..a922cce4a0 --- /dev/null +++ b/server/src/__tests__/secrets-service-user-secret-owner-scoped.test.ts @@ -0,0 +1,352 @@ +import { randomUUID } from "node:crypto"; +import { mkdirSync, rmSync } from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest"; +import { + activityLog, + companies, + companyMemberships, + companySecretBindings, + companySecretVersions, + companySecrets, + createDb, + secretAccessEvents, + userSecretDeclarations, + userSecretDefinitions, +} from "@paperclipai/db"; +import { getEmbeddedPostgresTestSupport, startEmbeddedPostgresTestDatabase } from "./helpers/embedded-postgres.js"; +import { secretService } from "../services/secrets.js"; + +const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport(); +const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip; + +if (!embeddedPostgresSupport.supported) { + console.warn( + `Skipping owner-scoped secrets service tests on this host: ${embeddedPostgresSupport.reason ?? "unsupported environment"}`, + ); +} + +describeEmbeddedPostgres("secretService resolveAdapterConfigForRuntime — userSecretMediation", () => { + let stopDb: (() => Promise) | null = null; + let db!: ReturnType; + const previousKeyFile = process.env.PAPERCLIP_SECRETS_MASTER_KEY_FILE; + const secretsTmpDir = path.join(os.tmpdir(), `paperclip-owner-scoped-${randomUUID()}`); + + beforeAll(async () => { + mkdirSync(secretsTmpDir, { recursive: true }); + process.env.PAPERCLIP_SECRETS_MASTER_KEY_FILE = path.join(secretsTmpDir, "master.key"); + const started = await startEmbeddedPostgresTestDatabase("owner-scoped-secrets"); + 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(companyMemberships); + await db.delete(companies); + }); + + afterAll(async () => { + if (stopDb) 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; + } + + async function seedCompanyMember( + companyId: string, + userId: string, + membershipRole: "owner" | "member" | "viewer" = "owner", + ) { + await db.insert(companyMemberships).values({ + companyId, + principalType: "user", + principalId: userId, + status: "active", + membershipRole, + createdAt: new Date(), + updatedAt: new Date(), + }); + } + + // The honest audit consumer test-environment uses when no environment is selected. + const ownerScopedConsumer = { + consumerType: "system" as const, + consumerId: "adapter_test", + actorType: "user" as const, + actorId: "user-1", + actorSource: "session" as const, + }; + + it("owner_scoped resolves a required user_secret_ref by owner without a declaration row", 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", + }); + await svc.createCurrentUserSecretValue(companyId, "user-1", { + definitionId: definition.id, + value: "ghp_owner_value", + }); + + const adapterConfig = { + env: { + GH_TOKEN: { + type: "user_secret_ref" as const, + key: "github_token", + version: "latest" as const, + required: true, + }, + }, + }; + + // No userSecretDeclarations row exists — owner_scoped must still resolve. + const resolved = await svc.resolveAdapterConfigForRuntime( + companyId, + adapterConfig, + { ...ownerScopedConsumer, responsibleUserId: "user-1" }, + { adapterType: "hermes_gateway", userSecretMediation: "owner_scoped" }, + ); + + expect(resolved.config.env).toEqual({ GH_TOKEN: "ghp_owner_value" }); + expect(resolved.secretKeys).toEqual(new Set(["GH_TOKEN"])); + }); + + it("owner_scoped still throws responsible_user_missing when no responsible user", 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 adapterConfig = { + env: { + GH_TOKEN: { + type: "user_secret_ref" as const, + key: "github_token", + version: "latest" as const, + required: true, + }, + }, + }; + + await expect( + svc.resolveAdapterConfigForRuntime( + companyId, + adapterConfig, + { ...ownerScopedConsumer, actorId: null, responsibleUserId: null }, + { adapterType: "hermes_gateway", userSecretMediation: "owner_scoped" }, + ), + ).rejects.toMatchObject({ + status: 422, + details: { code: "responsible_user_missing" }, + }); + }); + + it("owner_scoped resolves a company secret_ref with no binding row (no regression)", async () => { + const companyId = await seedCompany(); + await seedCompanyMember(companyId, "user-1", "owner"); + const svc = secretService(db); + const companySecret = await svc.create(companyId, { + name: `company-token-${randomUUID()}`, + provider: "local_encrypted", + value: "company-secret-value", + }); + + const adapterConfig = { + env: { + COMPANY_TOKEN: { + type: "secret_ref" as const, + secretId: companySecret.id, + version: "latest" as const, + }, + }, + }; + + // No companySecretBindings row exists for this prospective config. + const resolved = await svc.resolveAdapterConfigForRuntime( + companyId, + adapterConfig, + { ...ownerScopedConsumer, responsibleUserId: "user-1" }, + { adapterType: "hermes_gateway", userSecretMediation: "owner_scoped" }, + ); + + expect(resolved.config.env).toEqual({ COMPANY_TOKEN: "company-secret-value" }); + expect(resolved.secretKeys).toEqual(new Set(["COMPANY_TOKEN"])); + }); + + it("owner_scoped with allowedBindingIds present throws the explicit owner-scoped configuration error (fail-closed, not silently stripped)", 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", + }); + await svc.createCurrentUserSecretValue(companyId, "user-1", { + definitionId: definition.id, + value: "ghp_owner_value", + }); + + const adapterConfig = { + env: { + GH_TOKEN: { + type: "user_secret_ref" as const, + key: "github_token", + version: "latest" as const, + required: true, + }, + }, + }; + + await expect( + svc.resolveAdapterConfigForRuntime( + companyId, + adapterConfig, + { ...ownerScopedConsumer, responsibleUserId: "user-1", allowedBindingIds: ["some-binding-id"] }, + { adapterType: "hermes_gateway", userSecretMediation: "owner_scoped" }, + ), + ).rejects.toMatchObject({ + status: 422, + details: { code: "owner_scoped_allowed_bindings_unsupported" }, + }); + }); + + it("owner_scoped with an empty allowedBindingIds array is rejected too (an empty allowlist requests 'allow nothing', which owner_scoped cannot honor)", 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", + }); + await svc.createCurrentUserSecretValue(companyId, "user-1", { + definitionId: definition.id, + value: "ghp_owner_value", + }); + + const adapterConfig = { + env: { + GH_TOKEN: { + type: "user_secret_ref" as const, + key: "github_token", + version: "latest" as const, + required: true, + }, + }, + }; + + await expect( + svc.resolveAdapterConfigForRuntime( + companyId, + adapterConfig, + { ...ownerScopedConsumer, responsibleUserId: "user-1", allowedBindingIds: [] }, + { adapterType: "hermes_gateway", userSecretMediation: "owner_scoped" }, + ), + ).rejects.toMatchObject({ + status: 422, + details: { code: "owner_scoped_allowed_bindings_unsupported" }, + }); + }); + + it("declared mode is unchanged (declared ref resolves; undeclared required ref → binding_missing)", 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", + }); + await svc.createCurrentUserSecretValue(companyId, "user-1", { + definitionId: definition.id, + value: "ghp_owner_value", + }); + + const declaredConsumer = { + consumerType: "agent" as const, + consumerId: "agent-1", + actorType: "user" as const, + actorId: "user-1", + actorSource: "session" as const, + responsibleUserId: "user-1", + }; + + const adapterConfig = { + env: { + GH_TOKEN: { + type: "user_secret_ref" as const, + key: "github_token", + version: "latest" as const, + required: true, + }, + }, + }; + + // Undeclared required ref → binding_missing (declaration guard active in declared mode). + await expect( + svc.resolveAdapterConfigForRuntime( + companyId, + adapterConfig, + declaredConsumer, + { adapterType: "hermes_gateway" }, + ), + ).rejects.toMatchObject({ + status: 422, + details: { code: "binding_missing" }, + }); + + // Add the matching declaration row (configPath the resolver injects: env.). + await db.insert(userSecretDeclarations).values({ + companyId, + userSecretDefinitionId: definition.id, + targetType: "agent", + targetId: "agent-1", + configPath: "env.GH_TOKEN", + envKey: "GH_TOKEN", + versionSelector: "latest", + required: true, + allowMissingOverride: false, + }); + + const resolved = await svc.resolveAdapterConfigForRuntime( + companyId, + adapterConfig, + declaredConsumer, + { adapterType: "hermes_gateway" }, + ); + expect(resolved.config.env).toEqual({ GH_TOKEN: "ghp_owner_value" }); + }); +}); diff --git a/server/src/routes/agents.ts b/server/src/routes/agents.ts index cff27b96c0..f034ce5324 100644 --- a/server/src/routes/agents.ts +++ b/server/src/routes/agents.ts @@ -54,7 +54,7 @@ import { workspaceOperationService, } from "../services/index.js"; import { conflict, forbidden, HttpError, notFound, unprocessable } from "../errors.js"; -import { assertBoard, assertCompanyAccess, assertInstanceAdmin, getAccessibleResource, getActorInfo, hasCompanyAccess } from "./authz.js"; +import { assertBoard, assertCompanyAccess, assertInstanceAdmin, buildActorSecretContext, getAccessibleResource, getActorInfo, hasCompanyAccess } from "./authz.js"; import { assertNoAgentHostWorkspaceCommandMutation, collectAgentAdapterWorkspaceCommandPaths, @@ -1769,11 +1769,20 @@ export function agentRoutes( inputAdapterConfig, { strictMode: strictSecretsMode, adapterType: type }, ); + // Prospective, non-persisted config: resolve the acting user's own user + // secrets in owner_scoped mode (no declaration rows exist for this config). + // Record an honest audit consumer — environment: when the caller selected + // one, otherwise system:adapter_test — never a fake agent consumer. const { config: runtimeAdapterConfig } = await secretsSvc.resolveAdapterConfigForRuntime( companyId, normalizedAdapterConfig, - undefined, - { adapterType: type }, + buildActorSecretContext( + req, + requestedEnvironmentId + ? { consumerType: "environment", consumerId: requestedEnvironmentId } + : { consumerType: "system", consumerId: "adapter_test" }, + ), + { adapterType: type, userSecretMediation: "owner_scoped" }, ); const { executionTarget, environmentName, fallbackChecks, sandboxIdentityCheck, release } = @@ -3567,7 +3576,14 @@ export function agentRoutes( } const config = asRecord(agent.adapterConfig) ?? {}; - const { config: runtimeConfig } = await secretsSvc.resolveAdapterConfigForRuntime(agent.companyId, config); + // Persisted agent: default declared mode; consumerId = agent.id matches the + // declaration rows written at env. by syncAgentAdapterEnvBindings. + const { config: runtimeConfig } = await secretsSvc.resolveAdapterConfigForRuntime( + agent.companyId, + config, + buildActorSecretContext(req, { consumerType: "agent", consumerId: agent.id }), + { adapterType: agent.adapterType }, + ); const result = await runClaudeLogin({ runId: `claude-login-${randomUUID()}`, agent: { diff --git a/server/src/routes/authz.ts b/server/src/routes/authz.ts index f8ed72d7ee..a0e0bd25c0 100644 --- a/server/src/routes/authz.ts +++ b/server/src/routes/authz.ts @@ -1,4 +1,5 @@ import type { Request, Response } from "express"; +import type { SecretBindingTargetType } from "@paperclipai/shared"; import { forbidden, HttpError, unauthorized } from "../errors.js"; import { logger } from "../middleware/logger.js"; import { responsibleUserAuthzShadowMode } from "../services/authorization.js"; @@ -242,3 +243,47 @@ export function getActorInfo(req: Request): ( actorSource, }; } + +/** + * The actor-scoped fields of a secret-binding context, keyed to a caller-supplied + * consumer identity. Structurally matches `SecretConsumerContext` in + * `services/secrets.ts` (whose types are not exported), so the return value slots + * into `resolveAdapterConfigForRuntime`'s 3rd argument + * (`Omit`) unchanged. + */ +export type ActorSecretContext = { + consumerType: SecretBindingTargetType; + consumerId: string; + actorType: "agent" | "user"; + actorId: string | null; + actorSource: "local_implicit" | "session" | "board_key" | "agent_key" | "agent_jwt" | "cloud_tenant"; + responsibleUserId: string | null; +}; + +/** + * Build the actor-scoped portion of a secret-binding context from `req.actor`, + * taking the consumer identity as parameters. The responsible user is derived + * server-side (`req.actor.userId ?? req.actor.onBehalfOfUserId ?? null`) and is + * never request-body-controllable; a `null` result surfaces downstream as the + * intended `responsible_user_missing` loud failure for a required user secret. + * + * `consumerType` is a parameter (not hardcoded `"agent"`) so callers can record an + * honest consumer — `agent` for a persisted agent, `environment`/`system` for a + * prospective config with no persisted consumer. + * + * Never sets `configPath` (the resolver injects it) or `allowedBindingIds`. + */ +export function buildActorSecretContext( + req: Request, + params: { consumerType: SecretBindingTargetType; consumerId: string }, +): ActorSecretContext { + const info = getActorInfo(req); + return { + consumerType: params.consumerType, + consumerId: params.consumerId, + actorType: info.actorType, + actorId: info.actorId, + actorSource: info.actorSource, + responsibleUserId: req.actor.userId ?? req.actor.onBehalfOfUserId ?? null, + }; +} diff --git a/server/src/services/secrets.ts b/server/src/services/secrets.ts index 83362222d8..3c2c821005 100644 --- a/server/src/services/secrets.ts +++ b/server/src/services/secrets.ts @@ -453,6 +453,20 @@ export type AgentSecretAccessEntry = { type ResolveAdapterConfigForRuntimeOptions = { adapterType?: string | null; skipUserSecrets?: boolean; + /** + * Selects how user-scoped secrets are mediated for this resolution. + * + * - `"declared"` (default): the resolver injects a `configPath`, activating + * `resolveUserSecretValue`'s declaration guard. A persisted consumer's real + * declaration rows satisfy it; an undeclared required ref → `binding_missing`. + * - `"owner_scoped"`: for a prospective, non-persisted config (e.g. adapter + * test-environment). The user-secret call omits `configPath` so the + * declaration lookup is skipped and the value resolves by definition + owner + * boundary; the company `secret_ref` call routes through `bindingContext: + * undefined` (audit-only `accessContext`) to preserve today's zero-enforcement + * company-secret behavior while gaining actor attribution. Opt-in per call. + */ + userSecretMediation?: "declared" | "owner_scoped"; }; export type RuntimeSecretManifestEntry = { @@ -4538,6 +4552,21 @@ export function secretService(db: Db) { context?: Omit, opts?: ResolveAdapterConfigForRuntimeOptions, ): Promise<{ config: Record; secretKeys: Set; manifest: RuntimeSecretManifestEntry[] }> => { + const ownerScoped = opts?.userSecretMediation === "owner_scoped"; + // Fail closed: owner_scoped skips declaration mediation, so an + // allowedBindingIds allowlist has no declaration to enforce against. + // Rejecting (rather than silently stripping) prevents a future low-trust + // owner_scoped caller from bypassing an allowlist by choosing this mode. + // Any supplied array — including an empty one, which requests "allow + // nothing" — is rejected: owner_scoped cannot honor either intent, and + // letting `[]` slip through would resolve every owner secret, the exact + // opposite of what an empty allowlist asks for. + if (ownerScoped && Array.isArray(context?.allowedBindingIds)) { + throw unprocessable( + "allowedBindingIds is not supported with owner_scoped user-secret mediation", + { code: "owner_scoped_allowed_bindings_unsupported" }, + ); + } const resolved = { ...adapterConfig }; const secretKeys = new Set(); const manifest: RuntimeSecretManifestEntry[] = []; @@ -4564,10 +4593,18 @@ export function secretService(db: Db) { binding.secretId, binding.version, context - ? { - bindingContext: { ...context, configPath: `env.${key}` }, - accessContext: { ...context, configPath: `env.${key}` }, - } + ? ownerScoped + ? { + // owner_scoped: omit bindingContext so assertBindingContext + // returns null (no binding enforcement) — preserves today's + // undefined-context behavior for a prospective config — + // while still carrying the actor via accessContext for audit. + accessContext: { ...context, configPath: `env.${key}` }, + } + : { + bindingContext: { ...context, configPath: `env.${key}` }, + accessContext: { ...context, configPath: `env.${key}` }, + } : undefined, ); env[key] = secretResolution.value; @@ -4584,11 +4621,20 @@ export function secretService(db: Db) { allowMissingOverride: binding.allowMissingOverride, }, context - ? { - ...context, - configPath: `env.${key}`, - responsibleUserId: context.responsibleUserId ?? null, - } + ? ownerScoped + ? { + // owner_scoped: omit configPath so resolveUserSecretValue's + // `if (context?.configPath)` declaration guard stays false — + // resolution proceeds by definition + owner boundary, with no + // declaration row required for a prospective config. + ...context, + responsibleUserId: context.responsibleUserId ?? null, + } + : { + ...context, + configPath: `env.${key}`, + responsibleUserId: context.responsibleUserId ?? null, + } : undefined, ); if (secretResolution) { @@ -4621,11 +4667,18 @@ export function secretService(db: Db) { allowMissingOverride: binding.allowMissingOverride, }, context - ? { - ...context, - configPath: key, - responsibleUserId: context.responsibleUserId ?? null, - } + ? ownerScoped + ? { + // owner_scoped: omit configPath so the declaration guard stays + // false — resolve by definition + owner boundary. + ...context, + responsibleUserId: context.responsibleUserId ?? null, + } + : { + ...context, + configPath: key, + responsibleUserId: context.responsibleUserId ?? null, + } : undefined, ); if (secretResolution) { @@ -4640,10 +4693,16 @@ export function secretService(db: Db) { binding.secretId, binding.version, context - ? { - bindingContext: { ...context, configPath: key }, - accessContext: { ...context, configPath: key }, - } + ? ownerScoped + ? { + // owner_scoped: omit bindingContext (no binding enforcement), + // carry the actor via accessContext for audit only. + accessContext: { ...context, configPath: key }, + } + : { + bindingContext: { ...context, configPath: key }, + accessContext: { ...context, configPath: key }, + } : undefined, ); resolved[key] = secretResolution.value;