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:
parent
f2f168f6a1
commit
81f47e70a6
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
@ -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" });
|
||||
});
|
||||
});
|
||||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Reference in New Issue