Files
PaperClipAI/server/src/__tests__/authz-secret-context.test.ts
T
81f47e70a6 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>
2026-07-23 13:30:26 -07:00

98 lines
2.9 KiB
TypeScript

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