feat(secrets): thread the acting user into user-scoped secret resolution (#10115)

## Thinking Path

> - Paperclip is the control plane for autonomous AI companies
> - Its agents and adapters need to resolve secrets through the same
governed runtime path that checks ownership and company boundaries
> - This change fixes a gap where user-scoped secret resolution could
lose the acting-user context before adapter runtime startup
> - Without that context, a required user secret could fail closed with
responsible_user_missing even though an authenticated user was in scope
> - This PR threads the acting user into the user-scoped secret
resolution path and keeps the owner boundary explicit
> - The benefit is adapter runtime setup can resolve the right
credential without broadening access

## Linked Issues or Issue Description

Refs #8309 (related: agent secret_ref env drift and binding context)

No exact public GitHub issue for this specific behavior.

### Bug report

- Problem: two agent-management routes resolved user-scoped secrets
without an acting-user binding, so a required `user_secret_ref` could
not be resolved before runtime.
- Expected behavior: the authenticated acting user should be threaded
into user-scoped secret resolution so the owning user secret can be
selected safely.
- Actual behavior: adapter startup paths failed closed with
`responsible_user_missing` even though a user was already in scope.
- Steps to reproduce: configure an adapter test-environment or login
flow that depends on a user-scoped secret, then invoke it with an
authenticated user context that does not carry the acting-user binding
into runtime secret resolution.
- Impact: the adapter test-environment probe and login path cannot
start, so the runtime never reaches the work it was supposed to do.

## What Changed

- Added an actor secret-context helper so the server can derive
responsible-user context without inventing config-path or binding
allowlists.
- Added an explicit user-secret mediation mode for runtime config
resolution, with an owner-scoped path that resolves by definition plus
owner boundary and fails closed when an allowlist is present.
- Wired the adapter test-environment route to owner-scoped mediation
with an audit-only consumer and kept claude-login on the declared path
with its persisted agent identity.
- Added and updated tests for the factory, owner-scoped resolver mode,
and adapter route coverage.

## Verification

- `tsc --noEmit` clean
- Factory tests: `authz-secret-context` 5/5
- Service tests: `secrets-service-user-secret-owner-scoped` 5/5,
including fail-closed allowlist coverage and company-secret
non-regression
- Route tests: `agents-adapter-config-user-secret` 5/5, including
`responsible_user_missing` and `binding_missing` coverage
- Regression suites: `agents` + `secrets` 194/194

## Risks

- A regression in the owner-scoped mediation path could accidentally
loosen secret access if the audit consumer or allowlist guard changes.
- The change depends on the server-derived responsible user; if auth
context regresses, the system should fail closed with
responsible_user_missing.
- The new mediation mode adds a branch in runtime config resolution, so
future changes need to keep declared-mode behavior intact.

## Model Used

- OpenAI GPT-5 (Codex tool-use session)

## 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 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: Harold Kim <harold@paperclip.ing>
Co-authored-by: Paperclip <noreply@paperclip.ing>
This commit is contained in:
Nicky Leach 2026-07-23 13:30:26 -07:00 committed by GitHub
parent f2f168f6a1
commit 81f47e70a6
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 1000 additions and 22 deletions

View File

@ -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<typeof import("@paperclipai/adapter-claude-local/server")>();
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<void>) | null = null;
let db!: ReturnType<typeof createDb>;
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<typeof import("../routes/agents.js")>("../routes/agents.js");
const { errorHandler } = await vi.importActual<typeof import("../middleware/index.js")>("../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:<id> 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:<id>.
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.<KEY>) for consumer agent:<agentId>.
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;
}

View File

@ -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");
}
});
});

View File

@ -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<void>) | null = null;
let db!: ReturnType<typeof createDb>;
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.<KEY>).
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" });
});
});

View File

@ -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:<id> 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.<KEY> 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: {

View File

@ -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<SecretBindingContext, "configPath">`) 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,
};
}

View File

@ -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<SecretBindingContext, "configPath">,
opts?: ResolveAdapterConfigForRuntimeOptions,
): Promise<{ config: Record<string, unknown>; secretKeys: Set<string>; 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<string>();
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;