perf(server): batch connection install capability checks (#17945)

## Thinking Path

> - Paperclip manages AI agents and their access to external
connections.
> - The connection grants screen describes which agents can receive an
installation.
> - Each candidate currently repeats authorization for every retained
installation.
> - A company installation makes this work grow with the square of the
roster size.
> - This pull request checks retained targets once inside the existing
service owner.
> - The benefit is fewer authorization calls with the same scoped
capability results.

## Linked Issues or Issue Description

**What happened?**

The grants GET repeats connection preparation and retained-target checks
for each candidate agent. A local 100-agent company-install fixture made
50,300 SQL calls and 10,000 target lookups in one capability-description
operation. Open PR searches found no matching capability fan-out fix.

**Expected behavior**

The description must preserve retained-installation obligations and each
candidate's permission. It should evaluate retained targets once per
description. Writes must still authorize fresh.

**Steps to reproduce**

Run the committed benchmark with
`--baseline-ref=f6bc653f7f745adaafa26393852fb2f5a99256a4`. It creates
disposable synthetic PostgreSQL fixtures and loads the actual pre-fix
service from Git. It compares every positional capability, reason, and
operation.

**Paperclip version or commit**

Baseline `f6bc653f7f745adaafa26393852fb2f5a99256a4`.

**Deployment mode**

Built from source. Local real PostgreSQL proof. No hosted rollout.

## What Changed

- Add `describeAgentInstallCapabilities` to connection management.
Ordinary authorization and the batch share preparation, target checks,
and fallback completion.
- Use the batch only for direct saved non-AI connections and board
actors. Complex resources, delegated actors, and unusual saved target
IDs keep the ordinary evaluator. Fallback consumes the first preparation
once.
- Replace only the grants route's editable-agent loop. Keep
configure/use checks, identity filtering, audience disclosure, and write
authority on their existing paths.
- Add scoped-grant, retained-target, error-order, freshness, fallback,
full HTTP response, and real mutation regression coverage. Add a
reproducible benchmark and evidence notes.

## Verification

Real PostgreSQL 18.6 Linux Docker, Node 26.4.0. Two warmups and five
measured samples per arm, in alternating order. These are
service-component measurements with the same SQL-count instrumentation
in both arms. They do not measure the whole hosted endpoint.

| Fixture | Old median | Batch median | SQL calls old → batch | Target
lookups old → batch |
| --- | ---: | ---: | ---: | ---: |
| 100 agents, company install | 24.753 s | 246.39 ms | 50,300 → 503 |
10,000 → 100 |
| 100 agents, partial install | 6.339 s | 236.37 ms | 13,075 → 502 |
2,575 → 100 |
| 1 agent, company install | 5.13 ms | 4.82 ms | 8 → 8 | 1 → 1 |

All eight fixture shapes and every measured iteration had complete
positional equality. One-agent timing ranges overlap. Full ranges,
database settings, and limits are in
`doc/plans/2026-10-27-connection-grants-batch-verification.md`.

- **Passed:** `pnpm -r typecheck` and `pnpm build`, using pinned pnpm
9.15.4.
- **Passed:** 116 focused tests: all 83 connection-management
integration cases use real PostgreSQL and HTTP; 33 batch regressions
execute real policy over synthetic query transport. The integration
cases keep existing database creation, migrations, clients, services,
and assembled HTTP server, with disposable Docker PostgreSQL.
- **Passed:** complete grants GET parity across six human-role fixtures;
real install PUT succeeds before revocation, then fresh GET and actual
PUT deny after revocation without changing saved installs.
- **Passed:** a real PostgreSQL red/green regression for empty saved
target selectors, including candidate-specific decisions and later
reached infrastructure faults.
- **Passed:** independent old/new evaluator comparisons, including
recursive and delegated fallback, run-context reuse, continuation,
failure order, and principal/company isolation.
- **Local full-suite limit:** `pnpm test:run` was attempted and stopped
after native PostgreSQL fixtures could not initialize. An isolated
failed native case reproduced that startup error. The host cannot
allocate PostgreSQL SysV semaphores, including outside the sandbox. The
focused suites use an existing constructor seam to start disposable
Docker PostgreSQL. This is not a complete local full-suite pass.
- **Passed:** all 16 native-runtime cases that failed to start
PostgreSQL on the host, rerun with the disposable Docker provider. The
full local workflow remains incomplete.
- `git diff --check` passes. Greptile is 5/5 with no actionable findings
on final head `41dcf3ae029b6c60999ca6208136399b0af663d0`. All final-head
CI gates passed, including the full PR workflow. The two Storybook
checks are expected skips because this change has no UI changes.

Normal reproduction:

```sh
node --import ./server/node_modules/tsx/dist/esm/index.mjs scripts/benchmark-connection-install-capabilities.ts --baseline-ref=f6bc653f7f745adaafa26393852fb2f5a99256a4
cd server
pnpm exec vitest run src/__tests__/connection-install-capabilities.test.ts src/__tests__/connection-management.integration.test.ts
```

## Risks

Retained facts are observed once during one descriptive call. A mid-call
revoke or deletion can leave a stale displayed choice. The next
description and every mutation or human continuation read fresh
authority. The batch returns no reusable authorization context.

The bound applies to target evaluations in the eligible shape. Scoped
grant payloads and hierarchy reads can still grow inside each policy
decision. Complex resources and agent callers keep existing costs.
Hosted latency improvement remains unverified. There is no migration,
provider protocol change, new worker, persistent cache, or UI contract
change.

## Model Used

OpenAI Codex with code editing, execution, and test tools. Design, test,
and independent review agents used exact model IDs `gpt-6.1-sol` and
`gpt-6-astra` with xhigh reasoning. The host did not expose the primary
session's exact model ID or context-window size.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [x] All Paperclip CI gates are green
- [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge

---------

Co-authored-by: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip authored and GitHub committed 2026-10-27 20:59:01 -05:00
1 parent 38d08847b8
commit 2cd747d1b6
8 files changed
+1523 -37

No files matched your search

@@ -0,0 +1,417 @@
import { describe, expect, it } from "vitest";
import { PgDialect } from "drizzle-orm/pg-core";
import type { SQL } from "drizzle-orm";
import {
agents,
aiConnectionPools,
companyMemberships,
connectionGrants,
principalPermissionGrants,
routines,
toolConnections,
toolConnectionInstalls,
toolMcpGateways,
toolProfiles,
type Db,
} from "@paperclipai/db";
import { connectionManagementService } from "../services/connection-management.js";
import type { AuthorizationActor } from "../services/authorization.js";
import type { ConnectionManagementResource } from "../services/connection-management-policy.js";
const companyId = "00000000-0000-4000-8000-000000000001";
const connectionId = "00000000-0000-4000-8000-000000000002";
const userId = "capability-test-user";
const agentId = (index: number) => `00000000-0000-4000-8000-${String(index + 10).padStart(12, "0")}`;
const actor: AuthorizationActor = { type: "board", source: "cloud_tenant", userId };
const localActor: AuthorizationActor = { type: "board", source: "local_implicit" };
const resource: ConnectionManagementResource = {
type: "connection_management", companyId, connectionId, operation: "configure", readOnly: false,
};
const dialect = new PgDialect();
/** Only the query transport is synthetic. Both paths use the real services and policy evaluator. */
function fixture(count = 3) {
const agentRows = Array.from({ length: count }, (_, index) => ({
id: agentId(index), companyId, status: "idle", role: "engineer", permissions: {}, reportsTo: null,
}));
const state = {
activeMembership: true,
membershipRole: "member",
creatorId: userId,
connectionPurpose: "tool",
provider: null as string | null,
connectionExists: true,
missingConnections: new Set<string>(),
agentStatus: "idle",
installs: [] as Array<{ targetType: string; targetId: string }>,
missingAgents: new Set<string>(),
targetErrors: new Map<string, Error>(),
grants: new Map<string, Record<string, unknown> | null>(),
poolMembers: [] as Array<{ binding: { connectionId: string } }>,
afterTargetRead: undefined as ((targetId: string) => void) | undefined,
};
const reads = { total: 0, targetIds: [] as string[], connections: 0, pools: 0, profiles: 0, gateways: 0 };
const db = {
select() {
return {
from(table: unknown) {
return {
async where(condition: SQL) {
reads.total++;
const query = dialect.sqlToQuery(condition);
if (table === toolConnections) {
reads.connections++;
const selectedId = String(query.params[0]);
return state.connectionExists && query.params[1] === companyId && !state.missingConnections.has(selectedId) ? [{
id: selectedId, companyId, createdByUserId: state.creatorId,
connectionPurpose: selectedId === connectionId ? state.connectionPurpose : "tool",
credentialPolicy: "shared", config: state.provider ? { provider: state.provider } : {}, transportConfig: {},
}] : [];
}
if (table === toolConnectionInstalls) return state.installs;
if (table === companyMemberships) return state.activeMembership && query.params[0] === companyId
&& query.params[2] === userId ? [{
companyId, principalType: "user", principalId: userId,
status: "active", membershipRole: state.membershipRole,
}] : [];
if (table === principalPermissionGrants) {
const permissionKey = String(query.params[3]);
return state.grants.has(permissionKey) ? [{
companyId, principalType: "user", principalId: userId,
permissionKey, scope: state.grants.get(permissionKey),
}] : [];
}
if (table === connectionGrants) return [];
if (table === aiConnectionPools) {
reads.pools++;
return [{ id: connectionId, config: { members: state.poolMembers } }];
}
if (table === toolProfiles) { reads.profiles++; return []; }
if (table === toolMcpGateways) { reads.gateways++; return []; }
if (table === routines) return [];
if (table === agents) {
if (!query.sql.includes('"agents"."id" =')) return agentRows;
const targetId = String(query.params[0]);
reads.targetIds.push(targetId);
const error = state.targetErrors.get(targetId);
if (error) throw error;
const result = state.missingAgents.has(targetId) ? [] : agentRows
.filter(row => row.id === targetId)
.map(row => ({ ...row, status: state.agentStatus }));
state.afterTargetRead?.(targetId);
return result;
}
throw new Error(`Unexpected capability fixture query: ${query.sql}`);
},
};
},
};
},
} as unknown as Db;
const management = connectionManagementService(db);
const candidates = agentRows.map(row => row.id);
const baseline = async (
ids = candidates,
probeResource = resource,
probeActor = actor,
) => {
const results = [];
for (const targetAgentId of ids) {
results.push(await management.capability(probeActor, "connections:profiles", {
...probeResource, readOnly: false, targetAgentId,
}));
}
return results;
};
return { state, reads, management, candidates, baseline };
}
describe("connection agent-install capability batches", () => {
it("does no database work for an empty candidate list", async () => {
const f = fixture();
expect(await f.management.describeAgentInstallCapabilities(actor, resource, [])).toEqual([]);
expect(f.reads.total).toBe(0);
});
it("preserves candidate order, duplicate results and target-scoped grants without retained installs", async () => {
const f = fixture();
f.state.creatorId = "another-user";
f.state.grants.set("tools:manage_profiles", { targetAgentIds: [agentId(1)] });
const candidates = [agentId(1), agentId(0), agentId(1), agentId(2)];
const expected = await f.baseline(candidates);
expect(expected.map(result => result.allowed)).toEqual([true, false, true, false]);
expect(await f.management.describeAgentInstallCapabilities(actor, resource, candidates)).toEqual(expected);
});
it("preserves a mixture of existing and missing candidates", async () => {
const f = fixture();
const candidates = [agentId(1), agentId(99), agentId(0)];
const expected = await f.baseline(candidates);
expect(expected.map(result => result.allowed)).toEqual([true, false, true]);
expect(await f.management.describeAgentInstallCapabilities(actor, resource, candidates)).toEqual(expected);
});
it("denies every candidate when a retained installed target is unavailable", async () => {
const f = fixture();
f.state.installs = [{ targetType: "agent", targetId: agentId(99) }];
const expected = await f.baseline();
expect(expected.every(result => !result.allowed)).toBe(true);
expect(await f.management.describeAgentInstallCapabilities(actor, resource, f.candidates)).toEqual(expected);
});
it("does not hide a retained-target policy denial behind candidate-specific permission", async () => {
const f = fixture();
f.state.creatorId = "another-user";
f.state.grants.set("tools:manage_profiles", { targetAgentIds: [agentId(0)] });
f.state.installs = [{ targetType: "agent", targetId: agentId(1) }];
const expected = await f.baseline([agentId(0)]);
expect(expected[0]).toMatchObject({ allowed: false });
expect(await f.management.describeAgentInstallCapabilities(actor, resource, [agentId(0)])).toEqual(expected);
});
it("preserves candidate-dependent policy checks for an empty retained agent selector", async () => {
const f = fixture();
f.state.creatorId = "another-user";
f.state.grants.set("tools:manage_profiles", { targetAgentIds: [agentId(1)] });
f.state.installs = [{ targetType: "agent", targetId: "" }];
const expected = await f.baseline();
expect(expected.map(result => result.allowed)).toEqual([false, true, false]);
expect(await f.management.describeAgentInstallCapabilities(actor, resource, f.candidates)).toEqual(expected);
const laterFault = new Error("later allowed candidate database fault");
f.state.targetErrors.set(agentId(1), laterFault);
await expect(f.baseline()).rejects.toBe(laterFault);
await expect(f.management.describeAgentInstallCapabilities(actor, resource, f.candidates)).rejects.toBe(laterFault);
});
it("preserves output for retained candidates, new candidates and duplicates in the same batch", async () => {
const f = fixture();
f.state.installs = [{ targetType: "agent", targetId: agentId(0) }];
const candidates = [agentId(0), agentId(2), agentId(0), agentId(99), agentId(1)];
const expected = await f.baseline(candidates);
expect(expected.map(result => result.allowed)).toEqual([true, true, true, false, true]);
expect(await f.management.describeAgentInstallCapabilities(actor, resource, candidates)).toEqual(expected);
});
it("stops at an earlier retained denial without visiting a later infrastructure fault", async () => {
const f = fixture();
f.state.installs = [
{ targetType: "agent", targetId: agentId(99) },
{ targetType: "agent", targetId: agentId(1) },
];
f.state.targetErrors.set(agentId(1), new Error("later database fault"));
const expected = await f.baseline();
f.reads.targetIds.length = 0;
expect(await f.management.describeAgentInstallCapabilities(actor, resource, f.candidates)).toEqual(expected);
expect(f.reads.targetIds).not.toContain(agentId(1));
});
it("propagates an earlier retained infrastructure fault before any later denial", async () => {
const f = fixture();
f.state.installs = [
{ targetType: "agent", targetId: agentId(0) },
{ targetType: "agent", targetId: agentId(99) },
];
const fault = new Error("first database fault");
f.state.targetErrors.set(agentId(0), fault);
await expect(f.baseline()).rejects.toBe(fault);
f.reads.targetIds.length = 0;
await expect(f.management.describeAgentInstallCapabilities(actor, resource, f.candidates)).rejects.toBe(fault);
expect(f.reads.targetIds).toEqual([agentId(0)]);
});
it("converts only 403 faults to denied descriptions and continues in candidate order", async () => {
const f = fixture();
f.state.targetErrors.set(agentId(1), Object.assign(new Error("synthetic forbidden target"), { status: 403 }));
const expected = await f.baseline();
expect(expected.map(result => result.allowed)).toEqual([true, false, true]);
expect(await f.management.describeAgentInstallCapabilities(actor, resource, f.candidates)).toEqual(expected);
});
it.each([409, 500])("propagates a %s fault without evaluating later candidates", async status => {
const f = fixture();
const fault = Object.assign(new Error("synthetic infrastructure failure"), { status });
f.state.targetErrors.set(agentId(1), fault);
await expect(f.management.describeAgentInstallCapabilities(actor, resource, f.candidates)).rejects.toBe(fault);
expect(f.reads.targetIds).not.toContain(agentId(2));
});
it("always probes mutation authority even when a caller supplies readOnly or mutation:false", async () => {
const f = fixture();
f.state.installs = [{ targetType: "agent", targetId: agentId(99) }];
expect(await f.management.capability(localActor, "connections:profiles", {
...resource, targetAgentId: agentId(0),
}, { mutation: false })).toMatchObject({ allowed: true });
const extraCallerOptions = { mutation: false, readContext: undefined };
expect(await f.management.describeAgentInstallCapabilities(localActor, { ...resource, readOnly: true },
[agentId(0)], extraCallerOptions)).toEqual(await f.baseline([agentId(0)], resource, localActor));
});
it("ignores a caller's targetAgentId when evaluating each supplied candidate", async () => {
const f = fixture();
f.state.creatorId = "another-user";
f.state.grants.set("tools:manage_profiles", { targetAgentIds: [agentId(0)] });
expect(await f.management.describeAgentInstallCapabilities(actor, { ...resource, targetAgentId: agentId(0) },
[agentId(1)])).toEqual(await f.baseline([agentId(1)]));
});
it("keeps later batches and actual authorization fresh after membership revocation", async () => {
const f = fixture();
const description = await f.management.describeAgentInstallCapabilities(actor, resource, [agentId(0)]);
expect(description[0]).toMatchObject({ allowed: true });
expect(description[0]).not.toHaveProperty("reauthorize");
const context = await f.management.authorize(actor, "connections:profiles", { ...resource, targetAgentId: agentId(0) });
f.state.activeMembership = false;
expect(await f.management.describeAgentInstallCapabilities(actor, resource, [agentId(0)]))
.toEqual(await f.baseline([agentId(0)]));
expect((await f.baseline([agentId(0)]))[0]).toMatchObject({ allowed: false });
await expect(f.management.authorize(actor, "connections:profiles", { ...resource, targetAgentId: agentId(0) }))
.rejects.toMatchObject({ status: 403 });
await expect(context.reauthorize()).rejects.toMatchObject({ status: 403 });
});
it("reloads target-scoped grants on the next batch", async () => {
const f = fixture();
f.state.creatorId = "another-user";
f.state.grants.set("tools:manage_profiles", { targetAgentIds: [agentId(0)] });
expect((await f.management.describeAgentInstallCapabilities(actor, resource, [agentId(0)]))[0].allowed).toBe(true);
f.state.grants.delete("tools:manage_profiles");
expect((await f.management.describeAgentInstallCapabilities(actor, resource, [agentId(0)]))[0].allowed).toBe(false);
});
it("reloads a deleted connection on the next batch", async () => {
const f = fixture();
expect((await f.management.describeAgentInstallCapabilities(actor, resource, [agentId(0)]))[0].allowed).toBe(true);
f.state.connectionExists = false;
const result = await f.management.describeAgentInstallCapabilities(actor, resource, [agentId(0)]);
expect(result).toEqual(await f.baseline([agentId(0)]));
expect(result[0]).toMatchObject({ allowed: false, reason: "Connection is unavailable in this company" });
});
it("does not reuse a prior actor's or company's descriptions", async () => {
const f = fixture();
expect((await f.management.describeAgentInstallCapabilities(actor, resource, [agentId(0)]))[0].allowed).toBe(true);
const otherActor: AuthorizationActor = { ...actor, userId: "another-user" };
const otherResource = { ...resource, companyId: agentId(98) };
const actorResult = await f.management.describeAgentInstallCapabilities(otherActor, resource, [agentId(0)]);
const companyResult = await f.management.describeAgentInstallCapabilities(actor, otherResource, [agentId(0)]);
expect(actorResult).toEqual(await f.baseline([agentId(0)], resource, otherActor));
expect(companyResult).toEqual(await f.baseline([agentId(0)], otherResource));
expect(actorResult[0].allowed).toBe(false);
expect(companyResult[0].allowed).toBe(false);
});
it("allows descriptive staleness after a retained check while fresh authorization observes deletion", async () => {
const f = fixture(2);
f.state.installs = [{ targetType: "agent", targetId: agentId(0) }];
const targetResource = { ...resource, targetAgentId: agentId(1) };
const context = await f.management.authorize(actor, "connections:profiles", targetResource);
f.state.afterTargetRead = targetId => {
if (targetId === agentId(0)) f.state.missingAgents.add(targetId);
};
const described = await f.management.describeAgentInstallCapabilities(actor, resource, f.candidates);
expect(described.map(result => result.allowed)).toEqual([true, true]);
await expect(f.management.authorize(actor, "connections:profiles", targetResource))
.rejects.toMatchObject({ status: 403 });
await expect(context.reauthorize()).rejects.toMatchObject({ status: 403 });
const freshDescriptions = await f.management.describeAgentInstallCapabilities(actor, resource, f.candidates);
expect(freshDescriptions.every(result => !result.allowed)).toBe(true);
});
it.each([4, 8])("checks company-installed targets once instead of N squared for N=%s", async count => {
const f = fixture(count);
f.state.installs = [{ targetType: "company", targetId: companyId }];
const expected = await f.baseline();
expect(f.reads.targetIds).toHaveLength(count * count);
f.reads.targetIds.length = 0;
expect(await f.management.describeAgentInstallCapabilities(actor, resource, f.candidates)).toEqual(expected);
expect(f.reads.targetIds).toHaveLength(count);
expect(new Set(f.reads.targetIds)).toEqual(new Set(f.candidates));
});
it.each([
"AAAAAAAA-0000-4000-8000-000000000001",
"",
"not-a-uuid",
])("uses ordinary evaluation without repeating preparation for candidate %j", async candidate => {
const f = fixture(2);
f.state.installs = [{ targetType: "company", targetId: companyId }];
const candidates = [agentId(0), candidate, agentId(1)];
const expected = await f.baseline(candidates);
// The ordinary evaluator treats an empty selector as omitted.
expect(expected.map(result => result.allowed)).toEqual([true, candidate === "", true]);
f.reads.connections = 0;
expect(await f.management.describeAgentInstallCapabilities(actor, resource, candidates)).toEqual(expected);
expect(f.reads.connections).toBe(candidates.length);
});
it.each([
"AAAAAAAA-0000-4000-8000-000000000001",
"not-a-uuid",
])("uses ordinary evaluation without repeating preparation for retained selector %j", async targetId => {
const f = fixture();
f.state.installs = [{ targetType: "agent", targetId }];
const expected = await f.baseline();
expect(expected.every(result => !result.allowed)).toBe(true);
f.reads.connections = 0;
expect(await f.management.describeAgentInstallCapabilities(actor, resource, f.candidates)).toEqual(expected);
expect(f.reads.connections).toBe(f.candidates.length);
});
it.each(["profileId", "gatewayId", "routineId"] as const)("retains ordinary denial behavior for a missing %s selector", async selector => {
const f = fixture();
const selected = { ...resource, [selector]: agentId(99) };
const expected = await f.baseline(f.candidates, selected);
expect(expected.every(result => !result.allowed)).toBe(true);
expect(await f.management.describeAgentInstallCapabilities(actor, selected, f.candidates)).toEqual(expected);
});
it("retains recursive AI-pool checks through the ordinary path", async () => {
const f = fixture(2);
f.state.connectionPurpose = "ai";
f.state.poolMembers = [{ binding: { connectionId: agentId(99) } }];
const expected = await f.baseline();
expect(await f.management.describeAgentInstallCapabilities(actor, resource, f.candidates)).toEqual(expected);
expect(f.reads.pools).toBeGreaterThan(0);
f.state.missingConnections.add(agentId(99));
const denied = await f.baseline();
expect(denied.every(result => !result.allowed)).toBe(true);
expect(await f.management.describeAgentInstallCapabilities(actor, resource, f.candidates)).toEqual(denied);
});
it("retains proposed-provider scope checks through the ordinary path", async () => {
const f = fixture();
f.state.creatorId = "another-user";
f.state.provider = "current-provider";
f.state.grants.set("tools:manage_profiles", { providers: ["current-provider"] });
const selected = { ...resource, requestedProvider: "replacement-provider" };
const expected = await f.baseline(f.candidates, selected);
expect(expected.every(result => !result.allowed)).toBe(true);
expect(await f.management.describeAgentInstallCapabilities(actor, selected, f.candidates)).toEqual(expected);
f.state.grants.set("tools:manage_profiles", { providers: ["current-provider", "replacement-provider"] });
const allowed = await f.baseline(f.candidates, selected);
expect(allowed.every(result => result.allowed)).toBe(true);
expect(await f.management.describeAgentInstallCapabilities(actor, selected, f.candidates)).toEqual(allowed);
});
it("retains configure escalation for a proposed company installation", async () => {
const f = fixture();
f.state.creatorId = "another-user";
f.state.grants.set("tools:manage_profiles", null);
expect((await f.baseline())[0].allowed).toBe(true);
const selected = { ...resource, requestedInstallCompanyIds: [companyId] };
const expected = await f.baseline(f.candidates, selected);
expect(expected.every(result => !result.allowed)).toBe(true);
expect(await f.management.describeAgentInstallCapabilities(actor, selected, f.candidates)).toEqual(expected);
});
it("retains inactive-agent rejection through the ordinary path", async () => {
const f = fixture();
f.state.agentStatus = "paused";
const agentActor: AuthorizationActor = {
type: "agent", source: "agent_key", companyId, agentId: agentId(0), onBehalfOfUserId: userId,
};
const expected = await f.baseline(f.candidates, resource, agentActor);
expect(expected.every(result => !result.allowed)).toBe(true);
expect(await f.management.describeAgentInstallCapabilities(agentActor, resource, f.candidates)).toEqual(expected);
});
});
@@ -6,7 +6,7 @@ import { join } from "node:path";
import { createServer } from "node:http";
import { and, eq, sql } from "drizzle-orm";
import { beforeAll, afterAll, describe, expect, it, vi } from "vitest";
import { createDb, activityLog, agentApiKeys, companies, companySecrets, companySecretVersions, chatConversations, chatDeliveries, chatEndpoints, chatExternalPrincipals, chatIdentityLinks, toolPolicies, toolRateLimitCounters, connectionGrants, connectionGrantDelegations, toolAccessAuditEvents, toolOauthStates, issueThreadInteractions, connectionIntentDeliveries, agents, companyMemberships, heartbeatRuns, issues, principalPermissionGrants, projects, toolConnections, toolMcpGateways, toolMcpGatewayTokens, toolActionRequests, toolCatalogEntries, toolInvocations, toolCallEvents, toolProfileBindings, plugins, adapterAuthSessions, environments, routines, chatGitHubReviews } from "@paperclipai/db";
import { createDb, authUsers, activityLog, agentApiKeys, companies, companySecrets, companySecretVersions, chatConversations, chatDeliveries, chatEndpoints, chatExternalPrincipals, chatIdentityLinks, toolPolicies, toolRateLimitCounters, connectionGrants, connectionGrantMembers, connectionGrantDelegations, toolAccessAuditEvents, toolOauthStates, issueThreadInteractions, connectionIntentDeliveries, agents, companyMemberships, heartbeatRuns, issues, principalPermissionGrants, projects, toolApplications, toolConnections, toolConnectionInstalls, toolMcpGateways, toolMcpGatewayTokens, toolActionRequests, toolCatalogEntries, toolInvocations, toolCallEvents, toolProfiles, toolProfileBindings, plugins, adapterAuthSessions, environments, routines, chatGitHubReviews } from "@paperclipai/db";
import { findSatisfiedToolConnection, satisfiedConnectionIntentService } from "../services/satisfied-connection-intents.js";
import { connectionManagementService, continuingConnectionManagement } from "../services/connection-management.js";
import { redactRunnerApiPayloads } from "../services/native-runtime/runner-api-log-redaction.js";
@@ -25,6 +25,8 @@ import { secretService } from "../services/secrets.js";
import { localEncryptedProvider } from "../secrets/local-encrypted-provider.js";
import { instanceSettingsService } from "../services/instance-settings.js";
import { PaperclipRunnerToolAuthority } from "../services/native-runtime/paperclip-runner-tool-authority.js";
import type { AuthorizationActor } from "../services/authorization.js";
import type { ConnectionManagementResource } from "../services/connection-management-policy.js";
vi.mock("../services/agent-lifecycle-driver.js", () => ({
createLifecycleDriver: () => ({ requiredPluginIds: async () => [], runPlugin: async () => "complete", runHost: async () => "complete" }),
@@ -85,6 +87,172 @@ describe("connection management with production default agents", () => {
authority: new PaperclipRunnerToolAuthority(server.db, { companyId: base.companyId, agentId: agent.id, issueId: base.issueId, runId: base.runId, apiUrl: server.apiUrl, storage: server.storage }) };
}
it("matches ordinary install capabilities on real PostgreSQL for scoped, retained and fallback resources", async () => {
const f = await server.fixture({ connectionScenario: "fresh" });
const candidateIds = [f.agentId, randomUUID(), randomUUID()];
await server.db.insert(agents).values(candidateIds.slice(1).map((id, index) => ({ id, companyId: f.companyId, name: `Capability target ${index}`, adapterType: "process" })));
const [application] = await server.db.insert(toolApplications).values({ companyId: f.companyId, name: "Capability differential", type: "mcp_http" }).returning();
const [connection] = await server.db.insert(toolConnections).values({ companyId: f.companyId, applicationId: application.id,
uid: randomUUID(), name: "Capability differential", transport: "mcp_remote", authKind: "none", createdByUserId: "different-creator" }).returning();
await server.db.insert(principalPermissionGrants).values({ companyId: f.companyId, principalType: "user", principalId: f.responsibleUserId!,
permissionKey: "tools:manage_profiles", scope: { targetAgentIds: candidateIds.slice(0, 2) } });
const service = connectionManagementService(server.db);
const actor: AuthorizationActor = { type: "board", userId: f.responsibleUserId, source: "cloud_tenant" };
const resource = { type: "connection_management" as const, companyId: f.companyId, connectionId: connection.id, operation: "configure", readOnly: false };
const compare = async (base: ConnectionManagementResource = resource, candidates: readonly string[] = candidateIds, principal = actor) => {
const baseline = [];
for (const targetAgentId of candidates) baseline.push(await service.capability(principal, "connections:profiles", { ...base, targetAgentId }));
const batch = await service.describeAgentInstallCapabilities(principal, base, candidates);
expect(batch).toEqual(baseline);
return batch;
};
expect((await compare()).map(result => result.allowed)).toEqual([true, true, false]);
expect(await service.capability(actor, "connections:profiles", resource)).toMatchObject({ allowed: false });
expect(await compare(resource, [])).toEqual([]);
expect((await compare(resource, [candidateIds[1], candidateIds[0], candidateIds[1]])).map(result => result.allowed)).toEqual([true, true, true]);
// A saved empty text target leaves the candidate ID in the ordinary
// evaluator's resolved policy input, so it is not a shared target prefix.
await server.db.insert(toolConnectionInstalls).values({ companyId: f.companyId, connectionId: connection.id, targetType: "agent", targetId: "" });
const emptyTargetCandidates = [candidateIds[2], candidateIds[0]];
const emptyTargetBaseline = [];
for (const targetAgentId of emptyTargetCandidates) {
emptyTargetBaseline.push(await service.capability(actor, "connections:profiles", { ...resource, targetAgentId }));
}
expect(emptyTargetBaseline.map(capability => capability.allowed)).toEqual([false, true]);
expect(await service.describeAgentInstallCapabilities(actor, resource, emptyTargetCandidates)).toEqual(emptyTargetBaseline);
await server.db.delete(toolConnectionInstalls).where(eq(toolConnectionInstalls.connectionId, connection.id));
await server.db.insert(toolConnectionInstalls).values({ companyId: f.companyId, connectionId: connection.id, targetType: "agent", targetId: candidateIds[0] });
expect((await compare()).map(result => result.allowed)).toEqual([true, true, false]);
await server.db.insert(toolConnectionInstalls).values({ companyId: f.companyId, connectionId: connection.id, targetType: "agent", targetId: candidateIds[2] });
expect((await compare()).every(result => !result.allowed)).toBe(true);
await server.db.delete(toolConnectionInstalls).where(eq(toolConnectionInstalls.connectionId, connection.id));
await server.db.insert(toolConnectionInstalls).values({ companyId: f.companyId, connectionId: connection.id, targetType: "company", targetId: f.companyId });
expect((await compare()).every(result => !result.allowed)).toBe(true);
// JavaScript target sets distinguish UUID spelling; PostgreSQL's uuid
// lookup does not. Noncanonical candidates must retain ordinary evaluation.
for (const spellings of [candidateIds.map(id => id.toUpperCase()),
[candidateIds[0].toUpperCase(), candidateIds[1], candidateIds[0], candidateIds[1].toUpperCase(), candidateIds[0].toUpperCase()]]) {
expect((await compare(resource, spellings, { type: "board", source: "local_implicit" })).map(result => result.allowed))
.toEqual(spellings.map(() => true));
}
await server.db.delete(toolConnectionInstalls).where(eq(toolConnectionInstalls.connectionId, connection.id));
await server.db.insert(toolConnectionInstalls).values({ companyId: f.companyId, connectionId: connection.id, targetType: "agent", targetId: randomUUID() });
expect((await compare(resource, candidateIds, { type: "board", source: "local_implicit" })).map(result => result.reason))
.toEqual(candidateIds.map(() => "Target agent is outside this company"));
await server.db.delete(toolConnectionInstalls).where(eq(toolConnectionInstalls.connectionId, connection.id));
const [foreign] = await server.db.insert(agents).values({ companyId: f.foreignCompanyId, name: "Foreign capability target", adapterType: "process" }).returning();
expect((await compare(resource, [candidateIds[0], foreign.id], { type: "board", source: "local_implicit" })).map(result => result.allowed))
.toEqual([true, false]);
const [profile] = await server.db.insert(toolProfiles).values({ companyId: f.companyId, name: "Fallback profile", profileKey: randomUUID(), defaultAction: "deny" }).returning();
await compare({ ...resource, profileId: profile.id });
// Proposed changes also stay on the full evaluator, including own-property presence.
await compare({ ...resource, requestedProvider: null });
const faultingDb = new Proxy(server.db, { get(target, key, receiver) {
if (key !== "select") return Reflect.get(target, key, receiver);
return (...fields: Parameters<typeof target.select>) => {
const builder = target.select(...fields), from = builder.from.bind(builder);
builder.from = ((...args: Parameters<typeof from>) => {
const query = from(...args), execute = query.execute.bind(query);
query.execute = (async (...executeArgs: Parameters<typeof execute>) => {
const compiled = query.toSQL();
if (compiled.sql.includes('"agents"."id" =') && compiled.params[0] === candidateIds[1]) {
// A real database failure at the visited target boundary, rather
// than a fake permission denial or a mocked query result.
await server.db.$client.unsafe("SELECT 1 / 0");
}
return execute(...executeArgs);
}) as typeof execute;
return query;
}) as typeof builder.from;
return builder;
};
} });
const faulting = connectionManagementService(faultingDb);
const localActor: AuthorizationActor = { type: "board", source: "local_implicit" };
await expect(faulting.capability(localActor, "connections:profiles", { ...resource, targetAgentId: candidateIds[1] })).rejects.toThrow(/division by zero/);
await expect(faulting.describeAgentInstallCapabilities(localActor, resource, [candidateIds[1]])).rejects.toThrow(/division by zero/);
await server.db.insert(toolConnectionInstalls).values({ companyId: f.companyId, connectionId: connection.id, targetType: "agent", targetId: randomUUID() });
const denied = await faulting.capability(localActor, "connections:profiles", { ...resource, targetAgentId: candidateIds[1] });
expect(denied).toMatchObject({ allowed: false, reason: "Target agent is outside this company" });
expect(await faulting.describeAgentInstallCapabilities(localActor, resource, [candidateIds[1]])).toEqual([denied]);
});
it("preserves the complete grants GET across human roles and denies a real install mutation after revocation", async () => {
const f = await server.fixture({ connectionScenario: "fresh" });
const candidateIds = [f.agentId, randomUUID(), randomUUID()];
await server.db.insert(agents).values(candidateIds.slice(1).map((id, index) => ({ id, companyId: f.companyId, name: `Grant target ${index}`, adapterType: "process" })));
const users = { creator: f.responsibleUserId!, manager: randomUUID(), member: randomUUID(), viewer: randomUUID(), inactive: randomUUID(),
admin: randomUUID(), owner: randomUUID(), operator: randomUUID() };
for (const [name, userId] of Object.entries(users)) {
if (name !== "creator") {
await server.db.insert(authUsers).values({ id: userId, name, email: `${userId}@fixture.invalid`, emailVerified: true, createdAt: new Date(), updatedAt: new Date() });
await server.db.insert(companyMemberships).values({ companyId: f.companyId, principalType: "user", principalId: userId,
membershipRole: ["viewer", "admin", "owner", "operator"].includes(name) ? name : "member", status: name === "inactive" ? "inactive" : "active" });
}
}
for (const permissionKey of ["tools:manage_connections", "tools:manage_profiles", "tools:use", "agents:configure"]) {
await server.db.insert(principalPermissionGrants).values([users.manager, users.admin].map(principalId => ({ companyId: f.companyId, principalType: "user", principalId, permissionKey })));
}
await server.db.insert(principalPermissionGrants).values(["tools:manage_profiles", "agents:configure"].map(permissionKey => ({ companyId: f.companyId,
principalType: "user", principalId: users.member, permissionKey,
scope: permissionKey === "tools:manage_profiles" ? { targetAgentIds: candidateIds.slice(0, 2) } : null })));
const [application] = await server.db.insert(toolApplications).values({ companyId: f.companyId, name: "Grants response parity", type: "mcp_http" }).returning();
const [connection] = await server.db.insert(toolConnections).values({ companyId: f.companyId, applicationId: application.id,
uid: randomUUID(), name: "Grants response parity", transport: "mcp_remote", authKind: "none", createdByUserId: users.creator }).returning();
const grants = await server.db.insert(connectionGrants).values([
{ companyId: f.companyId, connectionId: connection.id, kind: "organization", createdByUserId: users.creator, isDefault: true },
...[users.creator, users.manager, users.member].map((subjectUserId, index) => ({ companyId: f.companyId, connectionId: connection.id, kind: "user" as const,
subjectUserId, createdByUserId: subjectUserId, updatedAt: new Date(`2026-10-2${index + 1}T12:00:00.000Z`) })),
]).returning();
await server.db.insert(connectionGrantMembers).values({ companyId: f.companyId, grantId: grants[0].id, subjectType: "user", subjectId: users.member });
await server.db.insert(toolConnectionInstalls).values({ companyId: f.companyId, connectionId: connection.id, targetType: "agent", targetId: candidateIds[0] });
const cookies = Object.fromEntries(await Promise.all(Object.entries(users).map(async ([name, userId]) => [name, await server.humanSession(userId)])));
const request = async (name: keyof typeof users, method = "GET", body?: unknown) => {
const response = await fetch(`${server.apiUrl}/api/tool-connections/${connection.id}/${method === "GET" ? "grants" : "installs"}`, {
method, headers: { cookie: cookies[name], origin: server.apiUrl, "Content-Type": "application/json" },
...(body === undefined ? {} : { body: JSON.stringify(body) }),
});
return { status: response.status, body: await response.json() };
};
const original = connectionManagementModule.connectionManagementService;
const legacy = vi.spyOn(connectionManagementModule, "connectionManagementService").mockImplementation(db => {
const service = original(db);
return { ...service, describeAgentInstallCapabilities: async (actor, base, candidates, options) => {
const result = [];
for (const targetAgentId of candidates) result.push(await service.capability(actor, "connections:profiles", { ...base, targetAgentId, readOnly: false }, options));
return result;
} };
});
const baseline = new Map<string, Awaited<ReturnType<typeof request>>>();
try {
for (const name of Object.keys(users) as Array<keyof typeof users>) baseline.set(name, await request(name));
} finally { legacy.mockRestore(); }
const actual = new Map<string, Awaited<ReturnType<typeof request>>>();
for (const name of Object.keys(users) as Array<keyof typeof users>) {
actual.set(name, await request(name));
expect(actual.get(name), name).toEqual(baseline.get(name));
}
expect(actual.get("manager")).toMatchObject({ status: 200, body: { capabilities: { canConfigure: true, canViewOtherPersonalIdentities: true, editableAgentIds: expect.arrayContaining(candidateIds) } } });
expect(actual.get("manager")!.body.grants).toHaveLength(4);
expect(actual.get("creator")!.body.capabilities).toMatchObject({ canConfigure: true, canViewOtherPersonalIdentities: false });
expect(actual.get("creator")!.body.members.length).toBeGreaterThan(0);
expect(actual.get("member")!.body.capabilities).toMatchObject({ canConfigure: false, canViewOtherPersonalIdentities: false, editableAgentIds: expect.arrayContaining(candidateIds.slice(0, 2)) });
expect(actual.get("member")!.body.capabilities.editableAgentIds).toHaveLength(2);
expect(actual.get("member")!.body.grants.map((grant: { id: string }) => grant.id)).toEqual([grants[0].id, grants[3].id]);
expect(actual.get("member")!.body.members).toEqual([]);
expect(actual.get("viewer")!.body.capabilities).toMatchObject({ canConfigure: false, canManageAgentInstalls: false, editableAgentIds: [] });
expect(actual.get("inactive")!.status).toBe(404);
const installs = [{ targetType: "agent", targetId: candidateIds[0] }, { targetType: "agent", targetId: candidateIds[1] }];
const beforeRevoke = await request("member", "PUT", { installs });
expect(beforeRevoke, JSON.stringify(beforeRevoke)).toMatchObject({ status: 200 });
await server.db.delete(principalPermissionGrants).where(and(eq(principalPermissionGrants.companyId, f.companyId), eq(principalPermissionGrants.principalId, users.member)));
expect(await request("member")).toMatchObject({ status: 200, body: { capabilities: { editableAgentIds: [], canManageAgentInstalls: false } } });
expect(await request("member", "PUT", { installs: [{ targetType: "agent", targetId: candidateIds[0] }] })).toMatchObject({ status: 403 });
const savedInstalls = await server.db.select().from(toolConnectionInstalls).where(eq(toolConnectionInstalls.connectionId, connection.id));
expect(savedInstalls.map(install => install.targetId)).toEqual(expect.arrayContaining(candidateIds.slice(0, 2)));
expect(savedInstalls).toHaveLength(2);
});
it("creates and configures tool connections and profiles with fresh grants and actual key/JWT authentication", async () => {
const f = await fixture();
const grants = await server.db.select().from(principalPermissionGrants).where(eq(principalPermissionGrants.principalId, f.agentId));
+3 -3
View File
@@ -687,9 +687,9 @@ function connectorEnrollmentPrincipal(req: Request): string {
const editableAgentIds: string[] = [];
if (mutationCapable) {
const companyAgents = await db.select({ id: agents.id }).from(agents).where(eq(agents.companyId, connection.companyId));
for (const agent of companyAgents) {
const capability = await management.capability(req.actor, "connections:profiles", { ...resource, targetAgentId: agent.id }, { readContext: connectionRequestContext(req) });
if (capability.allowed) editableAgentIds.push(agent.id);
const capabilities = await management.describeAgentInstallCapabilities(req.actor, resource, companyAgents.map(agent => agent.id), { readContext: connectionRequestContext(req) });
for (let i = 0; i < companyAgents.length; i++) {
if (capabilities[i].allowed) editableAgentIds.push(companyAgents[i].id);
}
}
return {
+152 -33
View File
@@ -20,6 +20,22 @@ export type ConnectionManagementContext = {
type CapturedConnectionRun = RunIdentityCapture<"task_authorization">;
const connectionReadRuns = new WeakMap<ConnectionManagementContext, CapturedConnectionRun>();
type AuthorizationOptions = { targetAgentIds?: string[]; mutation?: boolean; afterHumanAction?: boolean; expandedProfile?: boolean; gatewayContextChecked?: boolean; readContext?: ConnectionManagementContext; capabilityProbe?: boolean };
type PreparedAuthorization = {
actor: AuthorizationActor;
action: ConnectionManagementAction;
resource: ConnectionManagementResource;
options: AuthorizationOptions;
responsibleUserId: string | null;
workContext: ConnectionManagementContext["workContext"];
capturedRun: CapturedConnectionRun | undefined;
expandedTargets: Set<string>;
referencedConnections: string[];
currentGatewayResource: ConnectionManagementResource | undefined;
savedNonAiConnection: boolean;
};
type ConnectionCapability = { allowed: boolean; reason: string; operation: string };
export function responsibleConnectionUser(actor: AuthorizationActor): string | null {
return (actor.type === "agent" ? actor.onBehalfOfUserId : actor.userId) ?? null;
}
@@ -28,12 +44,12 @@ export function responsibleConnectionUser(actor: AuthorizationActor): string | n
export function connectionManagementService(db: Db) {
const authorization = authorizationService(db);
async function authorize(
async function prepareAuthorization(
actor: AuthorizationActor,
action: ConnectionManagementAction,
resource: ConnectionManagementResource,
options: { targetAgentIds?: string[]; mutation?: boolean; afterHumanAction?: boolean; expandedProfile?: boolean; gatewayContextChecked?: boolean; readContext?: ConnectionManagementContext; capabilityProbe?: boolean } = {},
): Promise<ConnectionManagementContext> {
options: AuthorizationOptions,
): Promise<PreparedAuthorization> {
const responsibleUserId = responsibleConnectionUser(actor);
let workContext: ConnectionManagementContext["workContext"] = { runId: null, issueId: null, projectId: null };
let capturedRun: CapturedConnectionRun | undefined;
@@ -72,6 +88,7 @@ export function connectionManagementService(db: Db) {
const expandedTargets = new Set(options.targetAgentIds ?? []);
const referencedConnections: string[] = [];
let currentGatewayResource: ConnectionManagementResource | undefined;
let savedNonAiConnection = false;
if (resource.gatewayId && !options.gatewayContextChecked) {
const [gateway] = await db.select().from(toolMcpGateways).where(and(eq(toolMcpGateways.id, resource.gatewayId), eq(toolMcpGateways.companyId, resource.companyId)));
if (!gateway) throw forbidden("Gateway is unavailable in this company");
@@ -142,6 +159,7 @@ export function connectionManagementService(db: Db) {
if (resource.connectionId) {
const [connection] = await db.select().from(toolConnections).where(and(eq(toolConnections.id, resource.connectionId), eq(toolConnections.companyId, resource.companyId)));
if (!connection) throw forbidden("Connection is unavailable in this company");
savedNonAiConnection = connection.connectionPurpose !== "ai";
resource = { ...resource, selfServiceUserId: connection.createdByUserId,
purpose: connection.connectionPurpose === "ai" ? "ai" : connection.connectionPurpose === "channel" ? "chat" : "tool",
provider: typeof connection.config.sourceTemplateKey === "string" ? connection.config.sourceTemplateKey
@@ -222,34 +240,47 @@ export function connectionManagementService(db: Db) {
if (issue.workMode === "skill_test" || ((options.mutation ?? action !== "connections:read") && issue.workMode !== "standard")) throw forbidden("Connection changes require a standard task");
}
}
return { actor, action, resource, options, responsibleUserId, workContext, capturedRun,
expandedTargets, referencedConnections, currentGatewayResource, savedNonAiConnection };
}
async function evaluateTarget(prepared: PreparedAuthorization, targetAgentId: string | undefined): Promise<AuthorizationDecision> {
const { actor, action, resource, responsibleUserId } = prepared;
const resolved = { ...resource, ...(targetAgentId ? { targetAgentId } : {}) };
const decision = await authorization.decide({ actor, action, resource: resolved, scope: { ...resolved } });
if (!decision.allowed) throw forbidden(decision.explanation, { reason: decision.reason, code: decision.code, operation: resource.operation });
if (Object.hasOwn(resource, "requestedProvider") && resource.requestedProvider !== resource.provider) {
const proposed = { ...resolved, provider: resource.requestedProvider };
const proposedDecision = await authorization.decide({ actor, action, resource: proposed, scope: { ...proposed } });
if (!proposedDecision.allowed) throw forbidden(proposedDecision.explanation, { reason: proposedDecision.reason, code: proposedDecision.code, operation: resource.operation });
}
if (actor.type === "agent" && actor.runId && (resolved.projectId || resolved.issueId)) {
const resourceTrust = await authorization.decide({ actor: { ...actor, runId: undefined }, action, resource: resolved, scope: { ...resolved } });
if (!resourceTrust.allowed) throw forbidden(resourceTrust.explanation);
}
if (targetAgentId) {
const [target] = await db.select({ id: agents.id }).from(agents).where(and(eq(agents.id, targetAgentId), eq(agents.companyId, resource.companyId)));
if (!target) throw forbidden("Target agent is outside this company");
// Explicitly enforce both principals even when unrelated authorization is in shadow mode.
// Existing human routes retain their domain-specific target authority.
// Delegation additionally checks both the acting agent and its human.
const principals: AuthorizationActor[] = actor.type === "agent" || resource.selfServiceAgentInstalls ? [actor] : [];
if (actor.type === "agent") principals.push({ type: "board", userId: responsibleUserId, source: "session", ignoreInstanceAdmin: true });
for (const principal of principals) {
const targetDecision = await authorization.decide({ actor: principal, action: "agent_config:update", resource: { type: "agent", companyId: resource.companyId, agentId: targetAgentId }, scope: { targetAgentId } });
if (!targetDecision.allowed) throw forbidden(targetDecision.explanation);
}
}
return decision;
}
async function finishAuthorization(prepared: PreparedAuthorization): Promise<ConnectionManagementContext> {
const { actor, action, resource, options, responsibleUserId, workContext, capturedRun,
expandedTargets, referencedConnections, currentGatewayResource } = prepared;
const targets = [...new Set([...expandedTargets, ...(resource.targetAgentId ? [resource.targetAgentId] : [])])];
let decision: AuthorizationDecision | undefined;
for (const targetAgentId of targets.length ? targets : [undefined]) {
const resolved = { ...resource, ...(targetAgentId ? { targetAgentId } : {}) };
decision = await authorization.decide({ actor, action, resource: resolved, scope: { ...resolved } });
if (!decision.allowed) throw forbidden(decision.explanation, { reason: decision.reason, code: decision.code, operation: resource.operation });
if (Object.hasOwn(resource, "requestedProvider") && resource.requestedProvider !== resource.provider) {
const proposed = { ...resolved, provider: resource.requestedProvider };
const proposedDecision = await authorization.decide({ actor, action, resource: proposed, scope: { ...proposed } });
if (!proposedDecision.allowed) throw forbidden(proposedDecision.explanation, { reason: proposedDecision.reason, code: proposedDecision.code, operation: resource.operation });
}
if (actor.type === "agent" && actor.runId && (resolved.projectId || resolved.issueId)) {
const resourceTrust = await authorization.decide({ actor: { ...actor, runId: undefined }, action, resource: resolved, scope: { ...resolved } });
if (!resourceTrust.allowed) throw forbidden(resourceTrust.explanation);
}
if (targetAgentId) {
const [target] = await db.select({ id: agents.id }).from(agents).where(and(eq(agents.id, targetAgentId), eq(agents.companyId, resource.companyId)));
if (!target) throw forbidden("Target agent is outside this company");
// Explicitly enforce both principals even when unrelated authorization is in shadow mode.
// Existing human routes retain their domain-specific target authority.
// Delegation additionally checks both the acting agent and its human.
const principals: AuthorizationActor[] = actor.type === "agent" || resource.selfServiceAgentInstalls ? [actor] : [];
if (actor.type === "agent") principals.push({ type: "board", userId: responsibleUserId, source: "session", ignoreInstanceAdmin: true });
for (const principal of principals) {
const targetDecision = await authorization.decide({ actor: principal, action: "agent_config:update", resource: { type: "agent", companyId: resource.companyId, agentId: targetAgentId }, scope: { targetAgentId } });
if (!targetDecision.allowed) throw forbidden(targetDecision.explanation);
}
}
decision = await evaluateTarget(prepared, targetAgentId);
}
if (action === "connections:profiles" && resource.requestedInstallCompanyIds && !resource.selfServiceAgentInstalls) {
await authorize(actor, "connections:configure", resource, options);
@@ -275,6 +306,15 @@ export function connectionManagementService(db: Db) {
return context;
}
async function authorize(
actor: AuthorizationActor,
action: ConnectionManagementAction,
resource: ConnectionManagementResource,
options: AuthorizationOptions = {},
): Promise<ConnectionManagementContext> {
return finishAuthorization(await prepareAuthorization(actor, action, resource, options));
}
async function authorizeBindingTarget(actor: AuthorizationActor, action: ConnectionManagementAction,
resource: ConnectionManagementResource, binding: { targetType: string; targetId: string },
options: NonNullable<Parameters<typeof authorize>[3]> = {}): Promise<ConnectionManagementContext> {
@@ -303,17 +343,96 @@ export function connectionManagementService(db: Db) {
return authorize(actor, action, resolved, checkOptions);
}
async function capability(...args: Parameters<typeof authorize>) {
function deniedCapability(error: unknown, operation: string): ConnectionCapability {
// Database/provider faults are not authorization denials.
if (!(error instanceof Error && "status" in error && error.status === 403)) throw error;
return { allowed: false, reason: error.message, operation };
}
async function capability(...args: Parameters<typeof authorize>): Promise<ConnectionCapability> {
try {
const context = await authorize(args[0], args[1], args[2], { ...args[3], capabilityProbe: true });
return { allowed: true, reason: context.decision.reason, operation: args[2].operation };
} catch (error) {
// Database/provider faults are not authorization denials.
if (!(error instanceof Error && "status" in error && error.status === 403)) throw error;
return { allowed: false, reason: error.message, operation: args[2].operation };
return deniedCapability(error, args[2].operation);
}
}
return { authorize, authorizeBindingTarget, capability };
/** Descriptions only: retained installation authority is checked once per
* call. Mutations and human continuations always start a fresh authorization.
* Complex resources and delegated actors retain the ordinary evaluator. */
async function describeAgentInstallCapabilities(
actor: AuthorizationActor,
resource: ConnectionManagementResource,
candidateAgentIds: readonly string[],
options: { readContext?: ConnectionManagementContext } = {},
): Promise<ConnectionCapability[]> {
if (!candidateAgentIds.length) return [];
const candidates = [...candidateAgentIds];
const base = { ...resource, readOnly: false };
const probeOptions = { readContext: options.readContext, capabilityProbe: true, mutation: true };
const results: ConnectionCapability[] = [];
const describeRemaining = async (start: number) => {
for (let i = start; i < candidates.length; i++) {
results.push(await capability(actor, "connections:profiles", { ...base, targetAgentId: candidates[i] }, probeOptions));
}
return results;
};
let prepared: PreparedAuthorization;
try {
prepared = await prepareAuthorization(actor, "connections:profiles", { ...base, targetAgentId: candidates[0] }, probeOptions);
} catch (error) {
results.push(deniedCapability(error, base.operation));
return describeRemaining(1);
}
const resolved = prepared.resource;
const isCanonicalAgentId = (id: string) => isUuidLike(id) && id === id.toLowerCase();
const eligible = actor.type === "board" && prepared.savedNonAiConnection
&& [resolved.profileId, resolved.gatewayId, resolved.routineId, resolved.projectId, resolved.issueId].every(id => id == null)
&& !Object.hasOwn(resolved, "requestedProvider") && !Object.hasOwn(resolved, "requestedInstallCompanyIds")
&& !resolved.selfServiceAgentInstalls
&& !prepared.referencedConnections.length && !prepared.currentGatewayResource
&& candidates.every(isCanonicalAgentId)
// A saved empty selector resolves against the current candidate in the
// ordinary evaluator. Such retained checks cannot be shared.
&& [...prepared.expandedTargets].every(isCanonicalAgentId);
if (!eligible) {
// Consume the already prepared first evaluation. Restarting it can repeat
// recursive checks or run-identity reconciliation and its locking effects.
try {
const context = await finishAuthorization(prepared);
results.push({ allowed: true, reason: context.decision.reason, operation: base.operation });
} catch (error) {
results.push(deniedCapability(error, base.operation));
}
return describeRemaining(1);
}
let retainedDecision: AuthorizationDecision | undefined;
try {
for (const targetAgentId of prepared.expandedTargets) {
retainedDecision = await evaluateTarget(prepared, targetAgentId);
}
} catch (error) {
const denied = deniedCapability(error, base.operation);
return candidates.map(() => ({ ...denied }));
}
for (const targetAgentId of candidates) {
try {
// With no retained targets, the named candidate is the first policy
// target: a targetless preflight can incorrectly deny scoped grants.
const decision = prepared.expandedTargets.has(targetAgentId)
? retainedDecision!
: await evaluateTarget(prepared, targetAgentId);
results.push({ allowed: true, reason: decision.reason, operation: base.operation });
} catch (error) {
results.push(deniedCapability(error, base.operation));
}
}
return results;
}
return { authorize, authorizeBindingTarget, capability, describeAgentInstallCapabilities };
}
/** A pending provider login can finish after a successful yielding run. This