feat: enforce personal connection delegations

Add named-agent standing delegation, user-scoped OAuth secrets, and membership-removal cleanup for personal connection grants.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-08-21 01:55:23 +00:00
1 parent 7db053bef2
commit 05ba2bdae7
19 files changed
+40813 -18

No files matched your search

@@ -0,0 +1,31 @@
import fs from "node:fs";
import path from "node:path";
import { describe, expect, it } from "vitest";
const sql = fs.readFileSync(
path.join(import.meta.dirname, "migrations/0229_sticky_nightcrawler.sql"),
"utf8",
);
describe("connection grants phase 4 migration", () => {
it("adds named-agent standing delegations", () => {
expect(sql).toContain(`CREATE TABLE IF NOT EXISTS "connection_grant_delegations"`);
expect(sql).toContain(`"connection_grant_delegations_grant_agent_uq"`);
expect(sql).toContain(`FOREIGN KEY ("company_id","grant_id")`);
});
it("can replay after schema state moves ahead of the migration journal", () => {
expect(sql).toContain(`DROP CONSTRAINT IF EXISTS "connection_grant_delegations_company_grant_fk"`);
expect(sql).toContain(`CREATE INDEX IF NOT EXISTS "connection_grant_delegations_company_agent_idx"`);
expect(sql).toContain(`CREATE UNIQUE INDEX IF NOT EXISTS "connection_grant_delegations_grant_agent_uq"`);
expect(sql).toContain(`ON CONFLICT DO NOTHING`);
});
it("moves personal grant secrets to their owner's user scope", () => {
expect(sql).toContain(`g."kind" = 'user'`);
expect(sql).toContain(`"scope" = 'user'`);
expect(sql).toContain(`"owner_user_id" = owner_map."owner_user_id"`);
expect(sql).toContain(`"user_secret_definition_id" = d."id"`);
expect(sql).toContain(`HAVING count(DISTINCT g."subject_user_id") = 1`);
});
});
@@ -0,0 +1,60 @@
CREATE TABLE IF NOT EXISTS "connection_grant_delegations" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"company_id" uuid NOT NULL,
"grant_id" uuid NOT NULL,
"agent_id" uuid NOT NULL,
"created_by_user_id" text NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
ALTER TABLE "connection_grant_delegations" DROP CONSTRAINT IF EXISTS "connection_grant_delegations_company_id_companies_id_fk";
--> statement-breakpoint
ALTER TABLE "connection_grant_delegations" ADD CONSTRAINT "connection_grant_delegations_company_id_companies_id_fk" FOREIGN KEY ("company_id") REFERENCES "public"."companies"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "connection_grant_delegations" DROP CONSTRAINT IF EXISTS "connection_grant_delegations_agent_id_agents_id_fk";
--> statement-breakpoint
ALTER TABLE "connection_grant_delegations" ADD CONSTRAINT "connection_grant_delegations_agent_id_agents_id_fk" FOREIGN KEY ("agent_id") REFERENCES "public"."agents"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "connection_grant_delegations" DROP CONSTRAINT IF EXISTS "connection_grant_delegations_company_grant_fk";
--> statement-breakpoint
ALTER TABLE "connection_grant_delegations" ADD CONSTRAINT "connection_grant_delegations_company_grant_fk" FOREIGN KEY ("company_id","grant_id") REFERENCES "public"."connection_grants"("company_id","id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
CREATE INDEX IF NOT EXISTS "connection_grant_delegations_company_agent_idx" ON "connection_grant_delegations" USING btree ("company_id","agent_id");--> statement-breakpoint
CREATE UNIQUE INDEX IF NOT EXISTS "connection_grant_delegations_grant_agent_uq" ON "connection_grant_delegations" USING btree ("grant_id","agent_id");
--> statement-breakpoint
INSERT INTO "user_secret_definitions" (
"company_id", "key", "name", "description", "provider", "managed_mode",
"provider_config_id", "provider_metadata", "created_by_agent_id", "created_by_user_id"
)
SELECT DISTINCT
s."company_id",
'tool_oauth.' || s."id"::text,
s."name",
'Personal connection credential migrated to user scope.',
s."provider",
s."managed_mode",
s."provider_config_id",
s."provider_metadata",
s."created_by_agent_id",
s."created_by_user_id"
FROM "company_secrets" s
JOIN "connection_grants" g ON g."company_id" = s."company_id" AND g."kind" = 'user'
CROSS JOIN LATERAL jsonb_array_elements(g."credential_secret_refs") ref
WHERE s."id"::text = ref ->> 'secretId'
AND s."scope" = 'company'
ON CONFLICT DO NOTHING;
--> statement-breakpoint
UPDATE "company_secrets" s
SET
"scope" = 'user',
"owner_user_id" = owner_map."owner_user_id",
"user_secret_definition_id" = d."id",
"updated_at" = now()
FROM (
SELECT s2."id" AS "secret_id", min(g."subject_user_id") AS "owner_user_id"
FROM "company_secrets" s2
JOIN "connection_grants" g ON g."company_id" = s2."company_id" AND g."kind" = 'user'
CROSS JOIN LATERAL jsonb_array_elements(g."credential_secret_refs") ref
WHERE s2."id"::text = ref ->> 'secretId' AND s2."scope" = 'company'
GROUP BY s2."id"
HAVING count(DISTINCT g."subject_user_id") = 1
) owner_map
JOIN "user_secret_definitions" d ON d."key" = 'tool_oauth.' || owner_map."secret_id"::text AND d."deleted_at" IS NULL
WHERE s."id" = owner_map."secret_id" AND d."company_id" = s."company_id";
File diff suppressed because it is too large. Load diff
+7
View File
@@ -1590,6 +1590,13 @@
"when": 1787274518405,
"tag": "0228_last_anthem",
"breakpoints": true
},
{
"idx": 229,
"version": "7",
"when": 1787276029881,
"tag": "0229_sticky_nightcrawler",
"breakpoints": true
}
]
}
+1
View File
@@ -131,6 +131,7 @@ export {
toolConnections,
connectionGrants,
connectionGrantMembers,
connectionGrantDelegations,
toolConnectionInstalls,
toolOauthStates,
toolCatalogEntries,
+21
View File
@@ -211,6 +211,27 @@ export const connectionGrantMembers = pgTable(
],
);
export const connectionGrantDelegations = pgTable(
"connection_grant_delegations",
{
id: uuid("id").primaryKey().defaultRandom(),
companyId: uuid("company_id").notNull().references(() => companies.id, { onDelete: "cascade" }),
grantId: uuid("grant_id").notNull(),
agentId: uuid("agent_id").notNull().references(() => agents.id, { onDelete: "cascade" }),
createdByUserId: text("created_by_user_id").notNull(),
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
},
(table) => [
foreignKey({
columns: [table.companyId, table.grantId],
foreignColumns: [connectionGrants.companyId, connectionGrants.id],
name: "connection_grant_delegations_company_grant_fk",
}).onDelete("cascade"),
index("connection_grant_delegations_company_agent_idx").on(table.companyId, table.agentId),
uniqueIndex("connection_grant_delegations_grant_agent_uq").on(table.grantId, table.agentId),
],
);
export const toolConnectionInstalls = pgTable(
"tool_connection_installs",
{
+3
View File
@@ -1356,6 +1356,7 @@ export type {
ToolConnectionRemovalResult,
ToolConnectionRemovalSummary,
ConnectionGrant,
ConnectionGrantDelegation,
ConnectionGrantKind,
ConnectionGrantMember,
ConnectionGrantMemberSubjectType,
@@ -2051,6 +2052,7 @@ export {
connectionTokenRequestSchema,
connectionTokenSubjectSchema,
startConnectionAuthorizationSchema,
createConnectionGrantDelegationSchema,
toolConnectionTestCallSchema,
toolPolicyTestRequestSchema,
importMcpJsonSchema,
@@ -2137,6 +2139,7 @@ export {
type PutToolConnectionInstalls,
type UpdateToolMcpGateway,
type ConnectionTokenRequestInput,
type CreateConnectionGrantDelegation,
type ImportMcpJson,
type ToolPolicyTestRequestInput,
type CreateToolInvocation,
+1
View File
@@ -495,6 +495,7 @@ export type {
ToolConnectionStatus,
ToolConnectionKind,
ConnectionGrant,
ConnectionGrantDelegation,
ConnectionGrantKind,
ConnectionGrantMember,
ConnectionGrantMemberSubjectType,
+11
View File
@@ -176,6 +176,16 @@ export interface ConnectionGrant {
createdAt: Date;
updatedAt: Date;
members?: ConnectionGrantMember[];
delegations?: ConnectionGrantDelegation[];
}
export interface ConnectionGrantDelegation {
id: string;
companyId: string;
grantId: string;
agentId: string;
createdByUserId: string;
createdAt: Date;
}
export interface ConnectionGrantMember {
@@ -260,6 +270,7 @@ export const CONNECTION_RECOVERABLE_ERROR_CODES = [
"installation_required",
"connection_not_installed",
"subject_not_permitted",
"standing_delegation_required",
] as const;
export type ConnectionRecoverableErrorCode = typeof CONNECTION_RECOVERABLE_ERROR_CODES[number];
+2
View File
@@ -858,6 +858,7 @@ export {
connectionTokenScopeSchema,
connectionTokenSubjectSchema,
startConnectionAuthorizationSchema,
createConnectionGrantDelegationSchema,
createToolTrustRuleFromActionRequestSchema,
revokeToolTrustRuleSchema,
toolPolicyTestRequestSchema,
@@ -935,6 +936,7 @@ export {
type UnbindToolProfileBinding,
type UpsertToolCatalogEntry,
type ConnectionTokenRequestInput,
type CreateConnectionGrantDelegation,
type ToolPolicyTestRequestInput,
type CreateToolTrustRuleFromActionRequest,
type RevokeToolTrustRule,
@@ -45,6 +45,10 @@ export const toolConnectionAuthKindSchema = z.enum(["oauth", "api_key", "none"])
export const toolConnectionOwnershipSchema = z.enum(["platform_shared", "platform_provisioned", "customer", "dcr"]);
export const connectionGrantKindSchema = z.enum(["organization", "user"]);
export const connectionGrantStatusSchema = z.enum(["active", "revoked", "expired", "needs_reauthorization"]);
export const createConnectionGrantDelegationSchema = z.object({
agentId: z.string().uuid(),
});
export type CreateConnectionGrantDelegation = z.infer<typeof createConnectionGrantDelegationSchema>;
export const toolConnectionCredentialPolicySchema = z.enum(["shared", "per_user", "per_user_with_fallback"]);
export const toolConnectionStatusSchema = z.enum(["draft", "active", "disabled", "archived"]);
export const toolConnectionInstallTargetTypeSchema = z.enum(["company", "agent"]);
+102
View File
@@ -3,12 +3,20 @@ import { and, eq, sql } from "drizzle-orm";
import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest";
import {
agents,
companySecrets,
companies,
companyMemberships,
createDb,
connectionGrantDelegations,
connectionGrantMembers,
connectionGrants,
instanceUserRoles,
issues,
principalPermissionGrants,
toolAccessAuditEvents,
toolApplications,
toolConnections,
userSecretDefinitions,
} from "@paperclipai/db";
import {
getEmbeddedPostgresTestSupport,
@@ -56,6 +64,14 @@ describeEmbeddedPostgres("access service", () => {
}, 20_000);
afterEach(async () => {
await db.delete(toolAccessAuditEvents);
await db.delete(connectionGrantDelegations);
await db.delete(connectionGrantMembers);
await db.delete(connectionGrants);
await db.delete(toolConnections);
await db.delete(toolApplications);
await db.delete(companySecrets);
await db.delete(userSecretDefinitions);
await db.delete(issues);
await db.delete(principalPermissionGrants);
await db.delete(instanceUserRoles);
@@ -226,6 +242,92 @@ describeEmbeddedPostgres("access service", () => {
).rejects.toThrow("Instance admins cannot be removed from company access");
});
it("revokes personal connection access and destroys user-scoped secrets when membership is archived", async () => {
const { company, owner } = await createCompanyWithOwner(db);
const member = await db.insert(companyMemberships).values({
companyId: company.id,
principalType: "user",
principalId: `member-${randomUUID()}`,
status: "active",
membershipRole: "member",
}).returning().then((rows) => rows[0]!);
const agent = await db.insert(agents).values({
companyId: company.id,
name: "Delegated agent",
role: "worker",
adapterType: "process",
adapterConfig: {},
}).returning().then((rows) => rows[0]!);
const application = await db.insert(toolApplications).values({
companyId: company.id,
applicationKey: `app-${randomUUID()}`,
name: "Personal mail",
type: "mcp",
status: "active",
}).returning().then((rows) => rows[0]!);
const connection = await db.insert(toolConnections).values({
companyId: company.id,
applicationId: application.id,
name: "Mail",
uid: `mail-${randomUUID()}`,
connectionKind: "managed",
ownership: "customer",
transport: "mcp_remote",
authKind: "oauth",
credentialPolicy: "per_user",
}).returning().then((rows) => rows[0]!);
const definition = await db.insert(userSecretDefinitions).values({
companyId: company.id,
key: `oauth-${randomUUID()}`,
name: "Personal OAuth token",
}).returning().then((rows) => rows[0]!);
const secret = await db.insert(companySecrets).values({
companyId: company.id,
scope: "user",
ownerUserId: member.principalId,
userSecretDefinitionId: definition.id,
key: `oauth-${randomUUID()}`,
name: `OAuth ${randomUUID()}`,
}).returning().then((rows) => rows[0]!);
const grant = await db.insert(connectionGrants).values({
companyId: company.id,
connectionId: connection.id,
kind: "user",
subjectUserId: member.principalId,
credentialSecretRefs: [{ secretId: secret.id, configPath: "oauth.access_token" }],
}).returning().then((rows) => rows[0]!);
await db.insert(connectionGrantDelegations).values({
companyId: company.id,
grantId: grant.id,
agentId: agent.id,
createdByUserId: member.principalId,
});
const organizationGrant = await db.insert(connectionGrants).values({
companyId: company.id,
connectionId: connection.id,
kind: "organization",
isDefault: true,
}).returning().then((rows) => rows[0]!);
await db.insert(connectionGrantMembers).values({
companyId: company.id,
grantId: organizationGrant.id,
subjectType: "user",
subjectId: member.principalId,
});
await accessService(db).archiveMember(company.id, member.id, {
reassignment: { assigneeUserId: owner.principalId },
});
expect(await db.select().from(connectionGrantDelegations)).toHaveLength(0);
expect(await db.select().from(connectionGrantMembers)).toHaveLength(0);
expect(await db.select().from(companySecrets).where(eq(companySecrets.id, secret.id))).toHaveLength(0);
expect(await db.select().from(connectionGrants).where(eq(connectionGrants.id, grant.id)))
.toEqual([expect.objectContaining({ status: "revoked" })]);
expect(await db.select().from(toolAccessAuditEvents).where(eq(toolAccessAuditEvents.reasonCode, "membership_removed")))
.toHaveLength(1);
});
it("allows owner and admin role-default grants to manage environments", async () => {
const { company, owner } = await createCompanyWithOwner(db);
const access = accessService(db);
@@ -10,6 +10,7 @@ import {
companyMemberships,
companySecretBindings,
connectionGrantMembers,
connectionGrantDelegations,
connectionGrants,
connectionTokenIssuances,
companySecrets,
@@ -37,7 +38,7 @@ import {
toolRuntimeSlots,
toolStdioCommandTemplates,
} from "@paperclipai/db";
import { and, eq } from "drizzle-orm";
import { and, eq, inArray } from "drizzle-orm";
import { getConnectableAppDefinition } from "@paperclipai/shared";
import {
getEmbeddedPostgresTestSupport,
@@ -868,6 +869,75 @@ describeEmbeddedPostgres("tool access service", () => {
expect(revoked.body).toMatchObject({ code: "grant_revoked", grantId: grant.id });
});
it("allows only the personal grant owner to create named-agent delegations and audits revocation", async () => {
const company = await createCompany(db);
const agent = await createAgent(db, company.id);
const { connection } = await createBrokerConnection(db, company.id);
const grant = await db.insert(connectionGrants).values({
companyId: company.id,
connectionId: connection.id,
kind: "user",
subjectUserId: "alice",
status: "active",
isDefault: false,
}).returning().then((rows) => rows[0]!);
const service = createTestToolAccessService(db);
await expect(service.createConnectionGrantDelegation(connection.id, grant.id, agent.id, "mallory"))
.rejects.toThrow("Only the active personal grant owner can create a delegation");
const delegation = await service.createConnectionGrantDelegation(connection.id, grant.id, agent.id, "alice");
expect(delegation).toMatchObject({ grantId: grant.id, agentId: agent.id, createdByUserId: "alice" });
await service.revokeConnectionGrantDelegation(
connection.id,
grant.id,
delegation.id,
{ actorType: "user", actorId: "manager" },
);
expect(await db.select().from(connectionGrantDelegations).where(eq(connectionGrantDelegations.id, delegation.id)))
.toHaveLength(0);
expect(await db.select().from(toolAccessAuditEvents).where(eq(toolAccessAuditEvents.connectionId, connection.id)))
.toEqual(expect.arrayContaining([
expect.objectContaining({ action: "connection_grant.delegated", actorId: "alice" }),
expect.objectContaining({ action: "connection_grant.delegation_revoked", actorId: "manager" }),
]));
});
it("fails autonomous token minting closed until the named agent has a standing delegation", async () => {
const company = await createCompany(db);
const agent = await createAgent(db, company.id);
const { issue, run } = await createIssueAndRun(db, company.id, agent.id);
await db.update(heartbeatRuns).set({ invocationSource: "automation" }).where(eq(heartbeatRuns.id, run.id));
const { connection } = await createBrokerConnection(db, company.id);
await allowConnectionForAgent(db, company.id, agent.id, connection.id);
await db.update(toolConnections).set({ credentialPolicy: "per_user" }).where(eq(toolConnections.id, connection.id));
const grant = await db.insert(connectionGrants).values({
companyId: company.id,
connectionId: connection.id,
kind: "user",
subjectUserId: "user-for-run",
status: "active",
isDefault: false,
}).returning().then((rows) => rows[0]!);
const app = createRouteApp(db, agentJwtActor(company.id, agent.id, run.id));
const denied = await request(app)
.post(`/api/agents/me/connections/${connection.id}/token`)
.send({});
expect(denied.status).toBe(409);
expect(denied.body).toMatchObject({
code: "standing_delegation_required",
grantId: grant.id,
remediation: { action: "delegate_personal_grant", grantId: grant.id, agentId: agent.id },
});
expect(await db.select().from(issueThreadInteractions).where(eq(issueThreadInteractions.issueId, issue.id)))
.toEqual([expect.objectContaining({
status: "pending",
addresseeUserId: "user-for-run",
idempotencyKey: `connection-delegation:${connection.id}:user-for-run:${agent.id}`,
})]);
});
it("enforces organization grant audiences at token mint time", async () => {
const company = await createCompany(db);
const agent = await createAgent(db, company.id);
@@ -3558,6 +3628,14 @@ describeEmbeddedPostgres("tool access service", () => {
expect(grant).toMatchObject({ kind: "user", status: "active" });
expect(grant.credentialSecretRefs.map((ref) => ref.configPath).sort()).toEqual(["oauth.access_token", "oauth.refresh_token"]);
expect(grant.credentialSecretRefs.map((ref) => ref.secretId).sort()).not.toEqual(workspaceSecretIds);
const personalSecrets = await db.select().from(companySecrets).where(
inArray(companySecrets.id, grant.credentialSecretRefs.map((ref) => ref.secretId)),
);
expect(personalSecrets).toHaveLength(2);
expect(personalSecrets).toEqual(expect.arrayContaining([
expect.objectContaining({ scope: "user", ownerUserId: "user-for-run" }),
]));
expect(personalSecrets.every((secret) => secret.userSecretDefinitionId !== null)).toBe(true);
const [unchangedConnection] = await db.select().from(toolConnections).where(eq(toolConnections.id, connected.connectionId));
expect(unchangedConnection.credentialSecretRefs.map((ref) => ref.secretId).sort()).toEqual(workspaceSecretIds);
const [resolved] = await db.select().from(issueThreadInteractions).where(eq(issueThreadInteractions.id, interaction.id));
+61
View File
@@ -13,6 +13,7 @@ import {
companyMemberships,
companies,
connectionGrantMembers,
connectionGrantDelegations,
connectionGrants,
createDb,
heartbeatRuns,
@@ -1897,6 +1898,66 @@ rl.on("line", (line) => {
}
});
it("requires an explicit named-agent delegation for autonomous personal-identity runs", async () => {
const company = await createCompany(db);
const agent = await createAgent(db, company.id);
const { issue, run } = await createIssueAndRun(db, company.id, agent.id);
await db.update(heartbeatRuns).set({
responsibleUserId: "alice",
invocationSource: "automation",
}).where(eq(heartbeatRuns.id, run.id));
const fake = await startFakeRemoteMcpServer((fakeRequest) => ({
body: {
jsonrpc: "2.0",
id: fakeRequest.body?.id,
result: { content: [{ type: "text", text: "delegated" }] },
},
}));
try {
const { connection } = await createRemoteMcpTool(db, company.id, {
url: fake.url,
toolName: "whoami",
riskLevel: "read",
});
await db.update(toolConnections).set({ credentialPolicy: "per_user" }).where(eq(toolConnections.id, connection.id));
const grant = await db.insert(connectionGrants).values({
companyId: company.id,
connectionId: connection.id,
kind: "user",
subjectUserId: "alice",
credentialSecretRefs: [],
status: "active",
isDefault: false,
}).returning().then((rows) => rows[0]!);
await allowAllToolsForAgent(db, company.id, agent.id);
const gateway = createTestToolGatewayService(db);
const session = await gateway.createSession({ companyId: company.id, agentId: agent.id, runId: run.id });
const tool = (await gateway.listToolsForSession(session.token)).find((item) => item.providerType === "mcp_remote_http")!;
await expect(gateway.executeTool({ sessionToken: session.token, tool: tool.name, parameters: {} }))
.rejects.toMatchObject({ status: 409, reasonCode: "standing_delegation_required" });
expect(fake.requests).toHaveLength(0);
expect(await db.select().from(issueThreadInteractions).where(eq(issueThreadInteractions.issueId, issue.id)))
.toEqual([expect.objectContaining({
status: "pending",
addresseeUserId: "alice",
idempotencyKey: `connection-delegation:${connection.id}:alice:${agent.id}`,
})]);
await db.insert(connectionGrantDelegations).values({
companyId: company.id,
grantId: grant.id,
agentId: agent.id,
createdByUserId: "alice",
});
await expect(gateway.executeTool({ sessionToken: session.token, tool: tool.name, parameters: {} }))
.resolves.toMatchObject({ status: "completed", result: { content: "delegated" } });
expect(fake.requests).toHaveLength(1);
} finally {
await fake.close();
}
});
it("keeps managed credentials authoritative even when legacy override flags are set", async () => {
const company = await createCompany(db);
const agent = await createAgent(db, company.id);
+1
View File
@@ -100,6 +100,7 @@ export function errorHandler(
"installation_required",
"connection_not_installed",
"subject_not_permitted",
"standing_delegation_required",
]).has(typeof details?.code === "string" ? details.code : "");
recordResponsibleUserDenialFromHttpError(req, details);
if (err.status >= 500) {
+55
View File
@@ -10,6 +10,7 @@ import {
type DeploymentMode,
type PermissionKey,
connectToolAppSchema,
createConnectionGrantDelegationSchema,
createToolStdioCommandTemplateSchema,
createToolApplicationSchema,
createToolConnectionSchema,
@@ -783,6 +784,60 @@ export function toolAccessRoutes(
res.json(grant);
});
router.post("/tool-connections/:connectionId/grants/:grantId/delegations", validate(createConnectionGrantDelegationSchema), async (req, res) => {
assertBoard(req);
const connection = await getAccessibleResource(req, res, svc.getConnection(req.params.connectionId as string), "Tool connection not found");
if (!connection) return;
const ownerUserId = req.actor.userId;
if (!ownerUserId) throw forbidden("A named user is required to delegate a personal grant");
const agentId = req.body.agentId;
const delegation = await svc.createConnectionGrantDelegation(
connection.id,
req.params.grantId as string,
agentId,
ownerUserId,
);
await logActivity(db, {
companyId: connection.companyId,
actorType: "user",
actorId: ownerUserId,
action: "tool_connection.grant_delegated",
entityType: "connection_grant",
entityId: req.params.grantId as string,
details: { connectionId: connection.id, delegationId: delegation.id, agentId },
});
res.status(201).json(delegation);
});
router.delete("/tool-connections/:connectionId/grants/:grantId/delegations/:delegationId", async (req, res) => {
assertBoard(req);
const connection = await getAccessibleResource(req, res, svc.getConnection(req.params.connectionId as string), "Tool connection not found");
if (!connection) return;
const { grants } = await svc.listConnectionGrants(connection.id, connection.companyId);
const grant = grants.find((candidate) => candidate.id === req.params.grantId);
if (!grant) throw notFound("Connection grant not found");
const canRevokeOwnDelegation = Boolean(req.actor.userId && grant.subjectUserId === req.actor.userId);
if (!canRevokeOwnDelegation && !await isToolConnectionManager(req, connection.companyId)) {
throw forbidden("Only the personal grant owner or a connection manager can revoke a delegation");
}
const delegation = await svc.revokeConnectionGrantDelegation(
connection.id,
grant.id,
req.params.delegationId as string,
getActorInfo(req),
);
await logActivity(db, {
companyId: connection.companyId,
actorType: "user",
actorId: req.actor.userId ?? "board",
action: "tool_connection.grant_delegation_revoked",
entityType: "connection_grant",
entityId: grant.id,
details: { connectionId: connection.id, delegationId: delegation.id, agentId: delegation.agentId },
});
res.json(delegation);
});
router.get("/tool-connections/:connectionId/usage", async (req, res) => {
assertBoard(req);
const connection = await getAccessibleResource(req, res, svc.getConnection(req.params.connectionId as string), "Tool connection not found");
+86 -1
View File
@@ -2,9 +2,14 @@ import { and, eq, inArray, ne, sql } from "drizzle-orm";
import type { Db } from "@paperclipai/db";
import {
companyMemberships,
companySecrets,
connectionGrantDelegations,
connectionGrantMembers,
connectionGrants,
instanceUserRoles,
issues,
principalPermissionGrants,
toolAccessAuditEvents,
} from "@paperclipai/db";
import type { PermissionKey, PrincipalType } from "@paperclipai/shared";
import { conflict } from "../errors.js";
@@ -28,6 +33,78 @@ type MemberArchiveInput = {
export function accessService(db: Db) {
const authorization = authorizationService(db);
async function sweepMemberConnectionAccess(
tx: Parameters<Parameters<Db["transaction"]>[0]>[0],
companyId: string,
userId: string,
now: Date,
) {
const ownedGrants = await tx.select({
id: connectionGrants.id,
connectionId: connectionGrants.connectionId,
credentialSecretRefs: connectionGrants.credentialSecretRefs,
}).from(connectionGrants).where(and(
eq(connectionGrants.companyId, companyId),
eq(connectionGrants.kind, "user"),
eq(connectionGrants.subjectUserId, userId),
));
const grantIds = ownedGrants.map((grant) => grant.id);
const secretIds = [...new Set(ownedGrants.flatMap((grant) =>
grant.credentialSecretRefs.map((ref) => ref.secretId),
))];
const removedDelegations = grantIds.length === 0 ? [] : await tx
.delete(connectionGrantDelegations)
.where(and(
eq(connectionGrantDelegations.companyId, companyId),
inArray(connectionGrantDelegations.grantId, grantIds),
))
.returning();
if (grantIds.length > 0) {
await tx.update(connectionGrants).set({
status: "revoked",
isDefault: false,
revokedAt: now,
revokedByUserId: null,
revokedByAgentId: null,
updatedAt: now,
}).where(and(
eq(connectionGrants.companyId, companyId),
inArray(connectionGrants.id, grantIds),
));
if (secretIds.length > 0) {
await tx.delete(companySecrets).where(and(
eq(companySecrets.companyId, companyId),
eq(companySecrets.scope, "user"),
eq(companySecrets.ownerUserId, userId),
inArray(companySecrets.id, secretIds),
));
}
}
await tx.delete(connectionGrantMembers).where(and(
eq(connectionGrantMembers.companyId, companyId),
eq(connectionGrantMembers.subjectType, "user"),
eq(connectionGrantMembers.subjectId, userId),
));
if (removedDelegations.length > 0) {
const connectionByGrant = new Map(ownedGrants.map((grant) => [grant.id, grant.connectionId]));
await tx.insert(toolAccessAuditEvents).values(removedDelegations.map((delegation) => ({
companyId,
connectionId: connectionByGrant.get(delegation.grantId) ?? null,
actorType: "system",
actorId: null,
action: "connection_grant.delegation_revoked",
outcome: "success",
reasonCode: "membership_removed",
details: {
grantId: delegation.grantId,
delegationId: delegation.id,
agentId: delegation.agentId,
ownerUserId: userId,
},
})));
}
}
async function isInstanceAdmin(userId: string | null | undefined): Promise<boolean> {
if (!userId) return false;
const row = await db
@@ -216,6 +293,9 @@ export function accessService(db: Db) {
}
const now = new Date();
if (existing.status === "active" && nextStatus !== "active" && existing.principalType === "user") {
await sweepMemberConnectionAccess(tx, companyId, existing.principalId, now);
}
const updated = await tx
.update(companyMemberships)
.set({
@@ -356,6 +436,7 @@ export function accessService(db: Db) {
await assertAssignableArchiveTarget(companyId, input.reassignment, tx);
const now = new Date();
await sweepMemberConnectionAccess(tx, companyId, existing.principalId, now);
const assignmentPatch = {
assigneeAgentId: input.reassignment?.assigneeAgentId ?? null,
assigneeUserId: input.reassignment?.assigneeUserId ?? null,
@@ -489,9 +570,13 @@ export function accessService(db: Db) {
}
}
if (toArchive.length > 0) {
const now = new Date();
for (const membership of toArchive) {
await sweepMemberConnectionAccess(tx, membership.companyId, membership.principalId, now);
}
await tx
.update(companyMemberships)
.set({ status: "archived", updatedAt: new Date() })
.set({ status: "archived", updatedAt: now })
.where(inArray(companyMemberships.id, toArchive.map((row) => row.id)));
await tx
.delete(principalPermissionGrants)
+251 -14
View File
@@ -5,11 +5,14 @@ import type { Db } from "@paperclipai/db";
import {
agents,
connectionGrantMembers,
connectionGrantDelegations,
connectionGrants,
connectionTokenIssuances,
authUsers,
companies,
companySecretBindings,
companySecrets,
userSecretDefinitions,
heartbeatRuns,
issues,
issueThreadInteractions,
@@ -2249,7 +2252,8 @@ export function toolAccessService(db: Db, options: ToolAccessServiceOptions = {}
projectId: runSnapshotString(snapshot, "projectId") ?? runSnapshotString(paperclipIssue, "projectId"),
routineId: runSnapshotString(snapshot, "routineId"),
responsibleUserId: runSnapshotString(snapshot, "responsibleUserId", "responsible_user_id")
?? runSnapshotString(paperclipIssue, "responsibleUserId", "responsible_user_id"),
?? runSnapshotString(paperclipIssue, "responsibleUserId", "responsible_user_id")
?? run.responsibleUserId,
};
}
@@ -2326,6 +2330,72 @@ export function toolAccessService(db: Db, options: ToolAccessServiceOptions = {}
});
}
async function createStandingDelegationAsk(input: {
connection: typeof toolConnections.$inferSelect;
issueId: string | null;
runId: string;
agentId: string;
ownerUserId: string;
}) {
if (!input.issueId) return;
const [company] = await db.select({ issuePrefix: companies.issuePrefix }).from(companies)
.where(eq(companies.id, input.connection.companyId)).limit(1);
const idempotencyKey = `connection-delegation:${input.connection.id}:${input.ownerUserId}:${input.agentId}`;
const payload = {
version: 1 as const,
prompt: `Allow this agent to use your ${input.connection.name} account for autonomous runs`,
acceptLabel: "Review delegation",
rejectLabel: "Not now",
detailsMarkdown: "This autonomous run is paused. Paperclip will not use your personal identity until you explicitly delegate it to this named agent.",
target: {
type: "custom" as const,
key: `connection:${input.connection.uid}:delegation:${input.ownerUserId}:${input.agentId}`,
revisionId: input.connection.updatedAt.toISOString(),
label: `Delegate ${input.connection.name}`,
href: `/${company?.issuePrefix ?? ""}/apps/${input.connection.id}/setup`,
},
};
const [existing] = await db.select({ id: issueThreadInteractions.id }).from(issueThreadInteractions).where(and(
eq(issueThreadInteractions.companyId, input.connection.companyId),
eq(issueThreadInteractions.issueId, input.issueId),
eq(issueThreadInteractions.idempotencyKey, idempotencyKey),
)).limit(1);
if (existing) {
await db.update(issueThreadInteractions).set({
status: "pending",
continuationPolicy: "wake_assignee",
requestedResolverPolicy: "human_only",
effectiveResolverPolicy: "human_only",
resolverPolicyProvenance: "explicit",
effectiveResolverPolicySource: "requested",
addresseeUserId: input.ownerUserId,
payload,
result: null,
resolvedAt: null,
updatedAt: new Date(),
}).where(eq(issueThreadInteractions.id, existing.id));
return;
}
await db.insert(issueThreadInteractions).values({
companyId: input.connection.companyId,
issueId: input.issueId,
kind: "request_confirmation",
status: "pending",
continuationPolicy: "wake_assignee",
requestedResolverPolicy: "human_only",
effectiveResolverPolicy: "human_only",
resolverPolicyProvenance: "explicit",
effectiveResolverPolicySource: "requested",
idempotencyKey,
sourceRunId: input.runId,
title: `Delegate your ${input.connection.name}`,
summary: "An explicit standing delegation is required for this autonomous run.",
createdByAgentId: input.agentId,
addresseeUserId: input.ownerUserId,
payload,
});
}
async function enforceDefaultConnectionTokenRateLimit(input: {
connection: typeof toolConnections.$inferSelect;
agentId: string;
@@ -5374,6 +5444,7 @@ export function toolAccessService(db: Db, options: ToolAccessServiceOptions = {}
value: string;
actor?: ActorInfo;
existingRefs?: typeof connectionGrants.$inferSelect.credentialSecretRefs;
ownerUserId?: string;
}) {
const existing = input.existingRefs === undefined
? oauthSecretRef(input.connection, input.configPath)
@@ -5382,6 +5453,45 @@ export function toolAccessService(db: Db, options: ToolAccessServiceOptions = {}
await secrets.rotate(existing.secretId, { value: input.value }, actorForSecret(input.actor));
return existing;
}
if (input.ownerUserId) {
const definitionKey = `tool_oauth.${input.connection.id}.${input.configPath.replace(/[^a-z0-9_:-]+/gi, "_")}`;
let [definition] = await db.select().from(userSecretDefinitions).where(and(
eq(userSecretDefinitions.companyId, input.companyId),
eq(userSecretDefinitions.key, definitionKey),
isNull(userSecretDefinitions.deletedAt),
)).limit(1);
if (!definition) {
[definition] = await db.insert(userSecretDefinitions).values({
companyId: input.companyId,
key: definitionKey,
name: `${input.connection.name} ${input.label}`,
description: `Personal OAuth ${input.label.toLowerCase()} for ${input.connection.name}.`,
provider: "local_encrypted",
managedMode: "paperclip_managed",
createdByAgentId: input.actor?.actorType === "agent" ? input.actor.actorId : null,
createdByUserId: input.actor?.actorType === "user" ? input.actor.actorId : null,
}).onConflictDoNothing().returning();
if (!definition) {
[definition] = await db.select().from(userSecretDefinitions).where(and(
eq(userSecretDefinitions.companyId, input.companyId),
eq(userSecretDefinitions.key, definitionKey),
isNull(userSecretDefinitions.deletedAt),
)).limit(1);
}
}
if (!definition) throw new Error("Failed to create personal OAuth secret definition");
const secret = await secrets.createCurrentUserSecretValue(input.companyId, input.ownerUserId, {
definitionId: definition.id,
value: input.value,
}, actorForSecret(input.actor));
return {
secretId: secret.id,
versionSelector: "latest" as const,
configPath: input.configPath,
required: input.configPath === "oauth.access_token",
label: input.label,
};
}
const secret = await secrets.create(input.companyId, {
name: `${input.connection.name} ${input.label} ${randomUUID().slice(0, 8)}`,
key: `tool_app.${randomUUID()}.${input.configPath.replace(/[^a-z0-9_:-]+/gi, "_")}`,
@@ -7459,6 +7569,7 @@ export function toolAccessService(db: Db, options: ToolAccessServiceOptions = {}
value: token.accessToken,
actor: input.actor,
existingRefs: stateRow.subjectUserId ? subjectCredentialSecretRefs : undefined,
ownerUserId: stateRow.subjectUserId ?? undefined,
});
const nextCredentialSecretRefs = [
...subjectCredentialSecretRefs.filter((ref) => ref.configPath !== "oauth.access_token" && ref.configPath !== "oauth.refresh_token"),
@@ -7473,6 +7584,7 @@ export function toolAccessService(db: Db, options: ToolAccessServiceOptions = {}
value: token.refreshToken,
actor: input.actor,
existingRefs: stateRow.subjectUserId ? subjectCredentialSecretRefs : undefined,
ownerUserId: stateRow.subjectUserId ?? undefined,
}));
} else {
const existingRefreshRef = subjectCredentialSecretRefs.find((ref) => ref.configPath === "oauth.refresh_token");
@@ -8047,7 +8159,85 @@ export function toolAccessService(db: Db, options: ToolAccessServiceOptions = {}
eq(connectionGrants.companyId, connection.companyId),
eq(connectionGrants.connectionId, connection.id),
)).orderBy(desc(connectionGrants.isDefault), desc(connectionGrants.updatedAt));
return { connection: { id: connection.id, uid: connection.uid }, grants };
const grantIds = grants.map((grant) => grant.id);
const delegations = grantIds.length === 0 ? [] : await db.select().from(connectionGrantDelegations).where(and(
eq(connectionGrantDelegations.companyId, connection.companyId),
inArray(connectionGrantDelegations.grantId, grantIds),
));
return {
connection: { id: connection.id, uid: connection.uid },
grants: grants.map((grant) => ({
...grant,
delegations: delegations.filter((delegation) => delegation.grantId === grant.id),
})),
};
},
createConnectionGrantDelegation: async (
idOrUid: string,
grantId: string,
agentId: string,
ownerUserId: string,
) => {
const connection = await getConnectionRow(idOrUid);
const [grant] = await db.select().from(connectionGrants).where(and(
eq(connectionGrants.id, grantId),
eq(connectionGrants.companyId, connection.companyId),
eq(connectionGrants.connectionId, connection.id),
eq(connectionGrants.kind, "user"),
eq(connectionGrants.subjectUserId, ownerUserId),
eq(connectionGrants.status, "active"),
)).limit(1);
if (!grant) throw forbidden("Only the active personal grant owner can create a delegation");
const [targetAgent] = await db.select({ id: agents.id }).from(agents).where(and(
eq(agents.id, agentId),
eq(agents.companyId, connection.companyId),
)).limit(1);
if (!targetAgent) throw notFound("Agent not found");
const [existing] = await db.select().from(connectionGrantDelegations).where(and(
eq(connectionGrantDelegations.grantId, grant.id),
eq(connectionGrantDelegations.agentId, agentId),
)).limit(1);
if (existing) return existing;
const [delegation] = await db.insert(connectionGrantDelegations).values({
companyId: connection.companyId,
grantId: grant.id,
agentId,
createdByUserId: ownerUserId,
}).returning();
await db.insert(toolAccessAuditEvents).values({
companyId: connection.companyId,
connectionId: connection.id,
actorType: "user",
actorId: ownerUserId,
action: "connection_grant.delegated",
outcome: "success",
reasonCode: "delegation_created",
details: { grantId: grant.id, delegationId: delegation!.id, agentId },
});
return delegation!;
},
revokeConnectionGrantDelegation: async (idOrUid: string, grantId: string, delegationId: string, actor?: ActorInfo) => {
const connection = await getConnectionRow(idOrUid);
const [delegation] = await db.delete(connectionGrantDelegations).where(and(
eq(connectionGrantDelegations.id, delegationId),
eq(connectionGrantDelegations.companyId, connection.companyId),
eq(connectionGrantDelegations.grantId, grantId),
)).returning();
if (!delegation) throw notFound("Connection grant delegation not found");
const binding = actorBinding(actor);
await db.insert(toolAccessAuditEvents).values({
companyId: connection.companyId,
connectionId: connection.id,
actorType: binding.actorType ?? "system",
actorId: binding.actorId,
action: "connection_grant.delegation_revoked",
outcome: "success",
reasonCode: "delegation_revoked",
details: { grantId, delegationId, agentId: delegation.agentId },
});
return delegation;
},
addConnectionInstallation: async (idOrUid: string, input: {
@@ -8092,18 +8282,38 @@ export function toolAccessService(db: Db, options: ToolAccessServiceOptions = {}
revokeConnectionGrant: async (idOrUid: string, grantId: string, actor?: ActorInfo) => {
const connection = await getConnectionRow(idOrUid);
const binding = actorBinding(actor);
const [grant] = await db.update(connectionGrants).set({
status: "revoked",
isDefault: false,
revokedAt: new Date(),
revokedByAgentId: binding.actorType === "agent" ? binding.actorId : null,
revokedByUserId: binding.actorType === "user" ? binding.actorId : null,
updatedAt: new Date(),
}).where(and(
eq(connectionGrants.id, grantId),
eq(connectionGrants.companyId, connection.companyId),
eq(connectionGrants.connectionId, connection.id),
)).returning();
const grant = await db.transaction(async (tx) => {
const removedDelegations = await tx.delete(connectionGrantDelegations).where(and(
eq(connectionGrantDelegations.companyId, connection.companyId),
eq(connectionGrantDelegations.grantId, grantId),
)).returning();
const [updated] = await tx.update(connectionGrants).set({
status: "revoked",
isDefault: false,
revokedAt: new Date(),
revokedByAgentId: binding.actorType === "agent" ? binding.actorId : null,
revokedByUserId: binding.actorType === "user" ? binding.actorId : null,
updatedAt: new Date(),
}).where(and(
eq(connectionGrants.id, grantId),
eq(connectionGrants.companyId, connection.companyId),
eq(connectionGrants.connectionId, connection.id),
)).returning();
if (!updated) throw notFound("Connection grant not found");
if (removedDelegations.length > 0) {
await tx.insert(toolAccessAuditEvents).values(removedDelegations.map((delegation) => ({
companyId: connection.companyId,
connectionId: connection.id,
actorType: binding.actorType ?? "system",
actorId: binding.actorId,
action: "connection_grant.delegation_revoked",
outcome: "success",
reasonCode: "grant_revoked",
details: { grantId, delegationId: delegation.id, agentId: delegation.agentId },
})));
}
return updated;
});
if (!grant) throw notFound("Connection grant not found");
await db.insert(toolAccessAuditEvents).values({
companyId: connection.companyId,
@@ -9090,6 +9300,7 @@ export function toolAccessService(db: Db, options: ToolAccessServiceOptions = {}
}
const actingUserId = runContext.responsibleUserId;
const autonomous = runContext.run.invocationSource === "automation" || runContext.run.invocationSource === "timer";
const subject = connection.credentialPolicy === "shared" || !actingUserId
? { type: "app" as const }
: { type: "user" as const, userId: actingUserId };
@@ -9113,6 +9324,32 @@ export function toolAccessService(db: Db, options: ToolAccessServiceOptions = {}
grant = await ensureDefaultOrganizationGrant(connection);
}
if (grant.kind === "user" && autonomous) {
const [delegation] = await db.select({ id: connectionGrantDelegations.id }).from(connectionGrantDelegations).where(and(
eq(connectionGrantDelegations.companyId, connection.companyId),
eq(connectionGrantDelegations.grantId, grant.id),
eq(connectionGrantDelegations.agentId, input.agentId),
)).limit(1);
if (!delegation) {
if (actingUserId) {
await createStandingDelegationAsk({
connection,
issueId: runContext.issueId,
runId: input.runId,
agentId: input.agentId,
ownerUserId: actingUserId,
});
}
await fail(409, "Standing delegation is required for this autonomous run", "denied", "standing_delegation_required", {
connection: { id: connection.id, uid: connection.uid, name: connection.name },
grantId: grant.id,
subject: actingUserId ? { type: "user", userId: actingUserId } : { type: "app" },
agentId: input.agentId,
remediation: { action: "delegate_personal_grant", grantId: grant.id, agentId: input.agentId },
});
}
}
if (grant.kind === "organization") {
const audience = await db.select({ subjectId: connectionGrantMembers.subjectId }).from(connectionGrantMembers).where(and(
eq(connectionGrantMembers.companyId, connection.companyId),
+98 -2
View File
@@ -7,6 +7,7 @@ import {
approvals,
companies,
connectionGrantMembers,
connectionGrantDelegations,
connectionGrants,
documents,
heartbeatRuns,
@@ -2638,17 +2639,86 @@ export function createToolGatewayService(
});
}
async function createStandingDelegationInteraction(
session: ToolGatewaySession,
connection: typeof toolConnections.$inferSelect,
userId: string,
) {
if (!session.issueId || !session.agentId || !session.runId) return;
const [company] = await db.select({ issuePrefix: companies.issuePrefix }).from(companies)
.where(eq(companies.id, session.companyId)).limit(1);
const href = `/${company?.issuePrefix ?? ""}/apps/${connection.id}/setup`;
const idempotencyKey = `connection-delegation:${connection.id}:${userId}:${session.agentId}`;
const payload = {
version: 1 as const,
prompt: `Allow this agent to use your ${connection.name} account for autonomous runs`,
acceptLabel: "Review delegation",
rejectLabel: "Not now",
detailsMarkdown: "This autonomous run is paused. Paperclip will not use your personal identity until you explicitly delegate it to this named agent.",
target: {
type: "custom" as const,
key: `connection:${connection.uid}:delegation:${userId}:${session.agentId}`,
revisionId: connection.updatedAt.toISOString(),
label: `Delegate ${connection.name}`,
href,
},
};
const [existing] = await db.select({ id: issueThreadInteractions.id }).from(issueThreadInteractions).where(and(
eq(issueThreadInteractions.companyId, session.companyId),
eq(issueThreadInteractions.issueId, session.issueId),
eq(issueThreadInteractions.idempotencyKey, idempotencyKey),
)).limit(1);
if (existing) {
await db.update(issueThreadInteractions).set({
status: "pending",
continuationPolicy: "wake_assignee",
requestedResolverPolicy: "human_only",
effectiveResolverPolicy: "human_only",
resolverPolicyProvenance: "explicit",
effectiveResolverPolicySource: "requested",
addresseeUserId: userId,
payload,
result: null,
resolvedAt: null,
updatedAt: new Date(),
}).where(eq(issueThreadInteractions.id, existing.id));
return;
}
await db.insert(issueThreadInteractions).values({
companyId: session.companyId,
issueId: session.issueId,
kind: "request_confirmation",
status: "pending",
continuationPolicy: "wake_assignee",
requestedResolverPolicy: "human_only",
effectiveResolverPolicy: "human_only",
resolverPolicyProvenance: "explicit",
effectiveResolverPolicySource: "requested",
idempotencyKey,
sourceRunId: session.runId,
title: `Delegate your ${connection.name}`,
summary: "An explicit standing delegation is required for this autonomous run.",
createdByAgentId: session.agentId,
addresseeUserId: userId,
payload,
});
}
async function resolveConnectionGrant(
session: ToolGatewaySession,
connection: typeof toolConnections.$inferSelect,
): Promise<typeof connectionGrants.$inferSelect> {
const [run] = session.runId
? await db.select({ responsibleUserId: heartbeatRuns.responsibleUserId }).from(heartbeatRuns).where(and(
? await db.select({
responsibleUserId: heartbeatRuns.responsibleUserId,
invocationSource: heartbeatRuns.invocationSource,
}).from(heartbeatRuns).where(and(
eq(heartbeatRuns.id, session.runId),
eq(heartbeatRuns.companyId, session.companyId),
)).limit(1)
: [];
const actingUserId = run?.responsibleUserId ?? null;
const autonomous = run?.invocationSource === "automation" || run?.invocationSource === "timer";
const findUserGrant = async () => {
if (!actingUserId) return undefined;
const [grant] = await db.select().from(connectionGrants).where(and(
@@ -2690,7 +2760,33 @@ export function createToolGatewayService(
const userGrant = connection.credentialPolicy === "shared" ? undefined : await findUserGrant();
const resolution = resolveCredentialGrantKind(connection.credentialPolicy, actingUserId, Boolean(userGrant));
if (resolution === "user" && userGrant) return userGrant;
if (resolution === "user" && userGrant) {
if (autonomous) {
if (!session.agentId) {
throw new ToolGatewayHttpError(409, "Standing delegation requires a named agent", "standing_delegation_required", {
connectionId: connection.id,
grantId: userGrant.id,
actingUserId,
});
}
const [delegation] = await db.select({ id: connectionGrantDelegations.id }).from(connectionGrantDelegations).where(and(
eq(connectionGrantDelegations.companyId, connection.companyId),
eq(connectionGrantDelegations.grantId, userGrant.id),
eq(connectionGrantDelegations.agentId, session.agentId),
)).limit(1);
if (!delegation) {
await createStandingDelegationInteraction(session, connection, actingUserId!);
throw new ToolGatewayHttpError(409, "Standing delegation is required for this autonomous run", "standing_delegation_required", {
connectionId: connection.id,
grantId: userGrant.id,
actingUserId,
agentId: session.agentId,
remediation: { action: "delegate_personal_grant", grantId: userGrant.id, agentId: session.agentId },
});
}
}
return userGrant;
}
if (resolution === "user_authorization_required") {
if (actingUserId) await createUserAuthorizationInteraction(session, connection, actingUserId);
throw new ToolGatewayHttpError(409, "User authorization is required", "user_authorization_required", {