mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-27 19:35:13 +01:00
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:
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";
|
||||
+39939
File diff suppressed because it is too large.
Load diff
+7
@@ -1590,6 +1590,13 @@
|
||||
"when": 1787274518405,
|
||||
"tag": "0228_last_anthem",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 229,
|
||||
"version": "7",
|
||||
"when": 1787276029881,
|
||||
"tag": "0229_sticky_nightcrawler",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -131,6 +131,7 @@ export {
|
||||
toolConnections,
|
||||
connectionGrants,
|
||||
connectionGrantMembers,
|
||||
connectionGrantDelegations,
|
||||
toolConnectionInstalls,
|
||||
toolOauthStates,
|
||||
toolCatalogEntries,
|
||||
|
||||
@@ -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",
|
||||
{
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -495,6 +495,7 @@ export type {
|
||||
ToolConnectionStatus,
|
||||
ToolConnectionKind,
|
||||
ConnectionGrant,
|
||||
ConnectionGrantDelegation,
|
||||
ConnectionGrantKind,
|
||||
ConnectionGrantMember,
|
||||
ConnectionGrantMemberSubjectType,
|
||||
|
||||
@@ -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];
|
||||
|
||||
@@ -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"]);
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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", {
|
||||
|
||||
Reference in new issue
Block a user