mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-28 20:21:50 +01:00
## 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>
452 lines
31 KiB
TypeScript
452 lines
31 KiB
TypeScript
import { and, eq, inArray, or } from "drizzle-orm";
|
|
import { isUuidLike } from "@paperclipai/shared";
|
|
import { agents, agentApiKeys, heartbeatRuns, runIdentityContexts, issues, projects, toolApplications, toolCatalogEntries, toolConnections, toolConnectionInstalls, toolProfiles, toolProfileEntries, toolProfileBindings, toolMcpGateways, aiConnectionPools, connectionGrants, routines, type Db } from "@paperclipai/db";
|
|
import { forbidden } from "../errors.js";
|
|
import { authorizationService, canActorReadIssuePrivacy, canActorReadProjectPrivacy, type AuthorizationActor, type AuthorizationDecision } from "./authorization.js";
|
|
import { captureRunIdentity, runIdentityTaskFields, type RunIdentityCapture } from "./run-identity.js";
|
|
import type { LogActivityInput } from "./activity-log.js";
|
|
import type { ConnectionManagementAction, ConnectionManagementResource } from "./connection-management-policy.js";
|
|
|
|
export type ConnectionManagementContext = {
|
|
actor: AuthorizationActor;
|
|
responsibleUserId: string | null;
|
|
resource: ConnectionManagementResource;
|
|
workContext: { runId: string | null; issueId: string | null; projectId: string | null };
|
|
decision: AuthorizationDecision;
|
|
audit: Pick<LogActivityInput, "actorType" | "actorId" | "agentId" | "runId" | "agentApiKeyId" | "responsibleUserIdOverride" | "authorization">;
|
|
reauthorize: (continuation?: { afterHumanAction: true }) => Promise<void>;
|
|
};
|
|
|
|
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;
|
|
}
|
|
|
|
/** Used at HTTP and service boundaries; capability probes call the same evaluator. */
|
|
export function connectionManagementService(db: Db) {
|
|
const authorization = authorizationService(db);
|
|
|
|
async function prepareAuthorization(
|
|
actor: AuthorizationActor,
|
|
action: ConnectionManagementAction,
|
|
resource: ConnectionManagementResource,
|
|
options: AuthorizationOptions,
|
|
): Promise<PreparedAuthorization> {
|
|
const responsibleUserId = responsibleConnectionUser(actor);
|
|
let workContext: ConnectionManagementContext["workContext"] = { runId: null, issueId: null, projectId: null };
|
|
let capturedRun: CapturedConnectionRun | undefined;
|
|
if (actor.type === "agent") {
|
|
if (!responsibleUserId || !actor.agentId || actor.companyId !== resource.companyId) {
|
|
throw forbidden("Connection management requires a responsible user in this company");
|
|
}
|
|
const [agent] = await db.select().from(agents).where(and(eq(agents.id, actor.agentId), eq(agents.companyId, resource.companyId)));
|
|
if (!agent || ["terminated", "pending_approval", "paused"].includes(agent.status)) throw forbidden("Agent is not active");
|
|
if (actor.keyId) {
|
|
const [key] = await db.select().from(agentApiKeys).where(and(eq(agentApiKeys.id, actor.keyId), eq(agentApiKeys.agentId, actor.agentId), eq(agentApiKeys.companyId, resource.companyId)));
|
|
if (!key || key.revokedAt || key.responsibleUserId !== responsibleUserId || (key.scopeConfig && key.scopeConfig.kind !== "standard")) throw forbidden("Agent key authority is no longer valid");
|
|
}
|
|
if (actor.runId) {
|
|
const reusable = options.capabilityProbe && !options.afterHumanAction && options.readContext?.actor === actor && options.readContext.resource.companyId === resource.companyId
|
|
? connectionReadRuns.get(options.readContext) : undefined;
|
|
const captured = reusable ?? (options.afterHumanAction ? await (async () => {
|
|
const [run] = await db.select(runIdentityTaskFields).from(heartbeatRuns).where(and(eq(heartbeatRuns.id, actor.runId!), eq(heartbeatRuns.agentId, actor.agentId!), eq(heartbeatRuns.companyId, resource.companyId)));
|
|
if (!run || !["running", "succeeded"].includes(run.status)) throw forbidden("The originating run cannot continue this authentication");
|
|
const [pending] = await db.select().from(runIdentityContexts).where(and(eq(runIdentityContexts.runId, run.id), eq(runIdentityContexts.status, "pending")));
|
|
if (pending) throw forbidden("Run identity is being reconciled");
|
|
return { run, context: null };
|
|
})() : await captureRunIdentity(db, { companyId: resource.companyId, agentId: actor.agentId, runId: actor.runId }, { readScope: "task_authorization" }));
|
|
capturedRun = captured;
|
|
const { run, context } = captured;
|
|
if ((context?.responsibleUserId ?? run.responsibleUserId) !== responsibleUserId) throw forbidden("Run responsible-user authority has changed");
|
|
const snapshot = run.contextSnapshot ?? {};
|
|
const issueId = run.issueId ?? (typeof snapshot.issueId === "string" ? snapshot.issueId : typeof snapshot.taskId === "string" ? snapshot.taskId : null);
|
|
const [issue] = issueId ? await db.select().from(issues).where(and(eq(issues.id, issueId), eq(issues.companyId, resource.companyId))) : [];
|
|
workContext = { runId: run.id, issueId, projectId: issue?.projectId ?? null };
|
|
if (issueId && (!issue || issue.assigneeAgentId !== actor.agentId || ["cancelled", "done"].includes(issue.status))) throw forbidden("Run no longer owns an eligible task");
|
|
if (snapshot.paperclipSkillTest || issue?.workMode === "skill_test") throw forbidden("Skill tests cannot administer connections");
|
|
if ((options.mutation ?? action !== "connections:read") && issue && issue.workMode !== "standard") throw forbidden("Connection changes are unavailable in Ask and Plan modes");
|
|
}
|
|
}
|
|
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");
|
|
// Explicit null is the proposed cleared binding, not an omitted selector.
|
|
resource = { ...resource,
|
|
profileId: Object.hasOwn(resource, "profileId") ? resource.profileId : gateway.profileId,
|
|
projectId: Object.hasOwn(resource, "projectId") ? resource.projectId : gateway.projectId,
|
|
issueId: Object.hasOwn(resource, "issueId") ? resource.issueId : gateway.issueId,
|
|
};
|
|
if (gateway.agentId) expandedTargets.add(gateway.agentId);
|
|
// Authorize both sides of a binding change. The saved side is read afresh
|
|
// on reauthorization, even when the request context holds an older binding.
|
|
if (resource.profileId !== gateway.profileId || resource.projectId !== gateway.projectId || resource.issueId !== gateway.issueId) {
|
|
currentGatewayResource = { ...resource, profileId: gateway.profileId, projectId: gateway.projectId, issueId: gateway.issueId };
|
|
}
|
|
}
|
|
if (resource.profileId && !options.expandedProfile) {
|
|
const [profile] = await db.select().from(toolProfiles).where(and(eq(toolProfiles.id, resource.profileId), eq(toolProfiles.companyId, resource.companyId)));
|
|
if (!profile) throw forbidden("Profile is unavailable in this company");
|
|
// Managed installation profiles can be empty while their labels still
|
|
// identify the connection they administer.
|
|
if (profile.profileKey.startsWith("app:") && isUuidLike(profile.profileKey.slice(4))) referencedConnections.push(profile.profileKey.slice(4));
|
|
const bindings = await db.select().from(toolProfileBindings).where(eq(toolProfileBindings.profileId, profile.id));
|
|
for (const binding of bindings) {
|
|
if (binding.targetType === "agent") expandedTargets.add(binding.targetId);
|
|
if (binding.targetType === "company") {
|
|
const companyAgents = await db.select({ id: agents.id }).from(agents).where(eq(agents.companyId, resource.companyId));
|
|
companyAgents.forEach(agent => expandedTargets.add(agent.id));
|
|
}
|
|
if (["project", "issue", "routine", "gateway"].includes(binding.targetType)) {
|
|
await authorizeBindingTarget(actor, action, resource, binding, options);
|
|
}
|
|
|
|
}
|
|
const linkedGateways = await db.select({ id: toolMcpGateways.id }).from(toolMcpGateways).where(and(eq(toolMcpGateways.companyId, resource.companyId), eq(toolMcpGateways.profileId, profile.id)));
|
|
for (const gateway of linkedGateways) await authorizeBindingTarget(actor, action, resource, { targetType: "gateway", targetId: gateway.id }, options);
|
|
const entries = await db.select().from(toolProfileEntries).where(eq(toolProfileEntries.profileId, profile.id));
|
|
for (const entry of entries) if (entry.connectionId) referencedConnections.push(entry.connectionId);
|
|
const applicationIds = new Set(entries.flatMap(entry => entry.selectorType === "application" && entry.applicationId ? [entry.applicationId] : []));
|
|
const catalogIds = new Set(entries.flatMap(entry => entry.catalogEntryId ? [entry.catalogEntryId] : []));
|
|
if (catalogIds.size) {
|
|
const selected = await db.select({ id: toolCatalogEntries.id, connectionId: toolCatalogEntries.connectionId, applicationId: toolCatalogEntries.applicationId }).from(toolCatalogEntries)
|
|
.where(and(eq(toolCatalogEntries.companyId, resource.companyId), inArray(toolCatalogEntries.id, [...catalogIds])));
|
|
if (selected.length !== catalogIds.size) throw forbidden("Profile catalog selection is unavailable in this company");
|
|
for (const entry of selected) {
|
|
referencedConnections.push(entry.connectionId);
|
|
// New-tool review also covers other connections in the selected
|
|
// catalog entry's application, not just its original connection.
|
|
if (entry.applicationId) applicationIds.add(entry.applicationId);
|
|
}
|
|
}
|
|
if (applicationIds.size) {
|
|
const selected = await db.select({ id: toolApplications.id }).from(toolApplications).where(and(eq(toolApplications.companyId, resource.companyId), inArray(toolApplications.id, [...applicationIds])));
|
|
if (selected.length !== applicationIds.size) throw forbidden("Profile application selection is unavailable in this company");
|
|
const connections = await db.select({ id: toolConnections.id }).from(toolConnections).where(and(eq(toolConnections.companyId, resource.companyId), inArray(toolConnections.applicationId, [...applicationIds])));
|
|
connections.forEach(connection => referencedConnections.push(connection.id));
|
|
}
|
|
const toolNames = entries.flatMap(entry => entry.selectorType === "tool_name" && entry.toolName ? [entry.toolName] : []);
|
|
const riskLevels = entries.flatMap(entry => entry.selectorType === "risk_level" && entry.riskLevel ? [entry.riskLevel] : []);
|
|
if (profile.defaultAction === "allow" || toolNames.length || riskLevels.length) {
|
|
const selected = await db.selectDistinct({ connectionId: toolCatalogEntries.connectionId }).from(toolCatalogEntries).where(and(
|
|
eq(toolCatalogEntries.companyId, resource.companyId), eq(toolCatalogEntries.status, "active"),
|
|
profile.defaultAction === "allow" ? undefined : or(toolNames.length ? inArray(toolCatalogEntries.toolName, toolNames) : undefined, riskLevels.length ? inArray(toolCatalogEntries.riskLevel, riskLevels) : undefined),
|
|
));
|
|
selected.forEach(entry => referencedConnections.push(entry.connectionId));
|
|
}
|
|
}
|
|
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
|
|
: typeof connection.config.provider === "string" ? connection.config.provider
|
|
: typeof (connection.config.ai as Record<string, unknown> | undefined)?.provider === "string" ? String((connection.config.ai as Record<string, unknown>).provider)
|
|
: typeof connection.transportConfig.sourceTemplateKey === "string" ? connection.transportConfig.sourceTemplateKey : null,
|
|
};
|
|
if (options.mutation ?? action !== "connections:read") {
|
|
const installs = await db.select().from(toolConnectionInstalls).where(eq(toolConnectionInstalls.connectionId, connection.id));
|
|
for (const install of installs) {
|
|
if (install.targetType === "agent") expandedTargets.add(install.targetId);
|
|
if (install.targetType === "company") {
|
|
const rows = await db.select({ id: agents.id }).from(agents).where(eq(agents.companyId, resource.companyId));
|
|
rows.forEach(agent => expandedTargets.add(agent.id));
|
|
}
|
|
}
|
|
if (resource.requestedInstallCompanyIds) {
|
|
const current = new Set(installs.filter(install => install.targetType === "company").map(install => install.targetId));
|
|
resource = { ...resource, selfServiceAgentInstalls: current.size === resource.requestedInstallCompanyIds.length && resource.requestedInstallCompanyIds.every(id => current.has(id)) };
|
|
}
|
|
}
|
|
if (connection.connectionPurpose === "ai") {
|
|
const [pool] = await db.select().from(aiConnectionPools).where(eq(aiConnectionPools.id, connection.id));
|
|
for (const member of pool?.config.members ?? []) referencedConnections.push(member.binding.connectionId);
|
|
}
|
|
if (responsibleUserId && (connection.credentialPolicy === "per_user" || resource.selfServiceAgentInstalls || resource.operation.includes("/grants/"))) {
|
|
const [ownGrant] = await db.select({ id: connectionGrants.id }).from(connectionGrants).where(and(
|
|
eq(connectionGrants.connectionId, connection.id), eq(connectionGrants.status, "active"),
|
|
eq(connectionGrants.kind, "user"), eq(connectionGrants.subjectUserId, responsibleUserId),
|
|
));
|
|
if (ownGrant) resource.selfServiceUserId = responsibleUserId;
|
|
}
|
|
if (resource.selfServiceAgentInstalls) {
|
|
const [access] = await db.select({ id: connectionGrants.id }).from(connectionGrants).where(and(
|
|
eq(connectionGrants.connectionId, connection.id), eq(connectionGrants.status, "active"),
|
|
or(eq(connectionGrants.kind, "organization"), and(eq(connectionGrants.kind, "user"), eq(connectionGrants.subjectUserId, responsibleUserId ?? ""))),
|
|
));
|
|
if (!access && resource.selfServiceUserId !== responsibleUserId) throw forbidden("You need access to this connection before you can install it on an agent");
|
|
}
|
|
if (actor.type === "agent" && connection.credentialPolicy === "per_user" && connection.createdByUserId !== responsibleUserId) {
|
|
const [grant] = await db.select({ id: connectionGrants.id }).from(connectionGrants).where(and(
|
|
eq(connectionGrants.connectionId, connection.id), eq(connectionGrants.status, "active"),
|
|
eq(connectionGrants.kind, "user"), eq(connectionGrants.subjectUserId, responsibleUserId!),
|
|
));
|
|
if (!grant) throw forbidden("This personal connection is unavailable to the responsible user");
|
|
}
|
|
}
|
|
if (resource.routineId) {
|
|
const [routine] = await db.select().from(routines).where(and(eq(routines.id, resource.routineId), eq(routines.companyId, resource.companyId)));
|
|
if (!routine) throw forbidden("Routine is unavailable in this company");
|
|
resource = { ...resource, projectId: routine.projectId, issueId: routine.parentIssueId };
|
|
if (routine.assigneeAgentId) expandedTargets.add(routine.assigneeAgentId);
|
|
else if (options.mutation ?? action !== "connections:read") {
|
|
const rows = await db.select({ id: agents.id }).from(agents).where(eq(agents.companyId, resource.companyId));
|
|
rows.forEach(row => expandedTargets.add(row.id));
|
|
}
|
|
}
|
|
if (actor.type === "agent") {
|
|
if (resource.projectId) {
|
|
const [project] = await db.select().from(projects).where(and(eq(projects.id, resource.projectId), eq(projects.companyId, resource.companyId)));
|
|
if (!project || !(await canActorReadProjectPrivacy(db, actor, project))) throw forbidden("Project is unavailable to this delegated principal");
|
|
}
|
|
if (resource.issueId) {
|
|
const [issue] = await db.select().from(issues).where(and(eq(issues.id, resource.issueId), eq(issues.companyId, resource.companyId)));
|
|
if (!issue || !(await canActorReadIssuePrivacy(db, actor, issue))) throw forbidden("Task is unavailable to this delegated principal");
|
|
if (options.mutation ?? action !== "connections:read") {
|
|
if (issue.assigneeAgentId) expandedTargets.add(issue.assigneeAgentId);
|
|
else {
|
|
const rows = await db.select({ id: agents.id }).from(agents).where(eq(agents.companyId, resource.companyId));
|
|
rows.forEach(row => expandedTargets.add(row.id));
|
|
}
|
|
}
|
|
if (issue.projectId && issue.projectId !== resource.projectId) {
|
|
const [project] = await db.select().from(projects).where(and(eq(projects.id, issue.projectId), eq(projects.companyId, resource.companyId)));
|
|
if (!project || !(await canActorReadProjectPrivacy(db, actor, project))) throw forbidden("Task project is unavailable to this delegated principal");
|
|
}
|
|
resource = { ...resource, projectId: resource.projectId ?? issue.projectId };
|
|
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]) {
|
|
decision = await evaluateTarget(prepared, targetAgentId);
|
|
}
|
|
if (action === "connections:profiles" && resource.requestedInstallCompanyIds && !resource.selfServiceAgentInstalls) {
|
|
await authorize(actor, "connections:configure", resource, options);
|
|
}
|
|
for (const connectionId of new Set(referencedConnections)) {
|
|
if (connectionId !== resource.connectionId) await authorize(actor, action, { ...resource, connectionId }, { ...options, targetAgentIds: [...expandedTargets], expandedProfile: true, gatewayContextChecked: options.gatewayContextChecked || Boolean(resource.gatewayId) });
|
|
}
|
|
if (currentGatewayResource) {
|
|
await authorize(actor, action, currentGatewayResource, { ...options, targetAgentIds: [...expandedTargets], expandedProfile: false, gatewayContextChecked: true });
|
|
}
|
|
const context: ConnectionManagementContext = {
|
|
actor, responsibleUserId, resource, workContext, decision: decision!,
|
|
audit: { authorization: { action, operation: resource.operation, reason: decision!.reason }, actorType: actor.type === "agent" ? "agent" : "user", actorId: actor.type === "agent" ? actor.agentId! : actor.userId ?? "board",
|
|
agentId: actor.agentId ?? null, runId: actor.runId ?? null, agentApiKeyId: actor.keyId ?? null, responsibleUserIdOverride: responsibleUserId },
|
|
reauthorize: async (continuation) => {
|
|
const current = await authorize(actor, action, resource, { ...options, afterHumanAction: continuation?.afterHumanAction ?? options.afterHumanAction });
|
|
context.workContext = current.workContext;
|
|
const snapshot = connectionReadRuns.get(current);
|
|
if (snapshot) connectionReadRuns.set(context, snapshot);
|
|
},
|
|
};
|
|
if (capturedRun) connectionReadRuns.set(context, capturedRun);
|
|
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> {
|
|
let resolved = { ...resource };
|
|
const checkOptions = { ...options, expandedProfile: true };
|
|
if (binding.targetType === "project") {
|
|
resolved.projectId = binding.targetId;
|
|
const rows = await db.select({ id: agents.id }).from(agents).where(eq(agents.companyId, resource.companyId));
|
|
checkOptions.targetAgentIds = [...new Set([...(options.targetAgentIds ?? []), ...rows.map(row => row.id)])];
|
|
}
|
|
else if (binding.targetType === "issue") resolved.issueId = binding.targetId;
|
|
else if (binding.targetType === "routine") resolved.routineId = binding.targetId;
|
|
else if (binding.targetType === "gateway") {
|
|
const [gateway] = await db.select().from(toolMcpGateways).where(and(eq(toolMcpGateways.id, binding.targetId), eq(toolMcpGateways.companyId, resource.companyId)));
|
|
if (!gateway) throw forbidden("Binding gateway is unavailable in this company");
|
|
resolved = { ...resolved, gatewayId: gateway.id, projectId: gateway.projectId, issueId: gateway.issueId };
|
|
checkOptions.gatewayContextChecked = true;
|
|
const targets = new Set(options.targetAgentIds ?? []);
|
|
if (gateway.agentId) targets.add(gateway.agentId);
|
|
else {
|
|
const rows = await db.select({ id: agents.id }).from(agents).where(eq(agents.companyId, resource.companyId));
|
|
rows.forEach(row => targets.add(row.id));
|
|
}
|
|
checkOptions.targetAgentIds = [...targets];
|
|
} else throw forbidden("Unsupported profile binding target");
|
|
return authorize(actor, action, resolved, checkOptions);
|
|
}
|
|
|
|
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) {
|
|
return deniedCapability(error, args[2].operation);
|
|
}
|
|
}
|
|
|
|
/** 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
|
|
* keeps its original actor, user, task, trust and grant checks; it is never an
|
|
* option accepted from an HTTP caller. Cancelled tasks/runs remain ineligible. */
|
|
export function continuingConnectionManagement(context?: ConnectionManagementContext): ConnectionManagementContext | undefined {
|
|
if (!context) return undefined;
|
|
const continuation: ConnectionManagementContext = { ...context, reauthorize: async () => {
|
|
await context.reauthorize({ afterHumanAction: true });
|
|
continuation.workContext = context.workContext;
|
|
continuation.resource = context.resource;
|
|
continuation.decision = context.decision;
|
|
continuation.audit = context.audit;
|
|
} };
|
|
return continuation;
|
|
}
|