feat(apps): resolve credentials through grants

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-08-20 21:23:02 +00:00
1 parent c7cc9b51c0
commit 89468893b9
22 files changed
+80032 -56

No files matched your search

@@ -0,0 +1,27 @@
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/0227_parallel_venom.sql"), "utf8");
describe("connection grants phase 2 migration", () => {
it("renames workspace grants before restoring organization-only constraints", () => {
const rename = sql.indexOf(`UPDATE "connection_grants" SET "kind" = 'organization' WHERE "kind" = 'workspace'`);
const constraint = sql.indexOf(`"connection_grants"."kind" in ('organization', 'user')`);
expect(rename).toBeGreaterThan(-1);
expect(constraint).toBeGreaterThan(rename);
expect(sql).not.toContain(`CHECK ("connection_grants"."kind" in ('workspace', 'user'))`);
});
it("backfills a default organization grant only when a connection is missing one", () => {
expect(sql).toContain(`c."id", 'organization', c."credential_secret_refs", 'active', true`);
expect(sql).toContain(`WHERE NOT EXISTS`);
expect(sql).toContain(`g."connection_id" = c."id" AND g."is_default" = true`);
});
it("adds credential policy and the grant audience table", () => {
expect(sql).toContain(`CREATE TABLE "connection_grant_members"`);
expect(sql).toContain(`ADD COLUMN "credential_policy" text DEFAULT 'shared' NOT NULL`);
expect(sql).toContain(`'shared', 'per_user', 'per_user_with_fallback'`);
});
});
@@ -0,0 +1,37 @@
CREATE TABLE "connection_grant_members" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"company_id" uuid NOT NULL,
"grant_id" uuid NOT NULL,
"subject_type" text NOT NULL,
"subject_id" text NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT "connection_grant_members_subject_type_check" CHECK ("connection_grant_members"."subject_type" in ('user'))
);
--> statement-breakpoint
ALTER TABLE "connection_grants" DROP CONSTRAINT "connection_grants_kind_check";--> statement-breakpoint
ALTER TABLE "connection_grants" DROP CONSTRAINT "connection_grants_subject_check";--> statement-breakpoint
ALTER TABLE "connection_grants" DROP CONSTRAINT "connection_grants_default_check";--> statement-breakpoint
DROP INDEX "connection_grants_default_uq";--> statement-breakpoint
UPDATE "connection_grants" SET "kind" = 'organization' WHERE "kind" = 'workspace';--> statement-breakpoint
INSERT INTO "connection_grants" (
"company_id", "connection_id", "kind", "credential_secret_refs", "status", "is_default",
"created_by_agent_id", "created_by_user_id", "created_at", "updated_at"
)
SELECT
c."company_id", c."id", 'organization', c."credential_secret_refs", 'active', true,
c."created_by_agent_id", c."created_by_user_id", c."created_at", c."updated_at"
FROM "tool_connections" c
WHERE NOT EXISTS (
SELECT 1 FROM "connection_grants" g
WHERE g."connection_id" = c."id" AND g."is_default" = true
);--> statement-breakpoint
ALTER TABLE "tool_connections" ADD COLUMN "credential_policy" text DEFAULT 'shared' NOT NULL;--> statement-breakpoint
ALTER TABLE "connection_grant_members" ADD CONSTRAINT "connection_grant_members_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_members" ADD CONSTRAINT "connection_grant_members_grant_id_connection_grants_id_fk" FOREIGN KEY ("grant_id") REFERENCES "public"."connection_grants"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
CREATE INDEX "connection_grant_members_company_subject_idx" ON "connection_grant_members" USING btree ("company_id","subject_type","subject_id");--> statement-breakpoint
CREATE UNIQUE INDEX "connection_grant_members_grant_subject_uq" ON "connection_grant_members" USING btree ("grant_id","subject_type","subject_id");--> statement-breakpoint
CREATE UNIQUE INDEX "connection_grants_default_uq" ON "connection_grants" USING btree ("connection_id") WHERE "connection_grants"."is_default" = true and "connection_grants"."kind" = 'organization';--> statement-breakpoint
ALTER TABLE "connection_grants" ADD CONSTRAINT "connection_grants_kind_check" CHECK ("connection_grants"."kind" in ('organization', 'user'));--> statement-breakpoint
ALTER TABLE "connection_grants" ADD CONSTRAINT "connection_grants_subject_check" CHECK (("connection_grants"."kind" = 'user' and "connection_grants"."subject_user_id" is not null) or ("connection_grants"."kind" = 'organization' and "connection_grants"."subject_user_id" is null));--> statement-breakpoint
ALTER TABLE "connection_grants" ADD CONSTRAINT "connection_grants_default_check" CHECK ("connection_grants"."is_default" = false or "connection_grants"."kind" = 'organization');--> statement-breakpoint
ALTER TABLE "tool_connections" ADD CONSTRAINT "tool_connections_credential_policy_check" CHECK ("tool_connections"."credential_policy" in ('shared', 'per_user', 'per_user_with_fallback'));
@@ -0,0 +1,4 @@
ALTER TABLE "connection_grant_members" DROP CONSTRAINT "connection_grant_members_grant_id_connection_grants_id_fk";
--> statement-breakpoint
ALTER TABLE "connection_grants" ADD CONSTRAINT "connection_grants_company_id_uq" UNIQUE("company_id","id");--> statement-breakpoint
ALTER TABLE "connection_grant_members" ADD CONSTRAINT "connection_grant_members_company_grant_fk" FOREIGN KEY ("company_id","grant_id") REFERENCES "public"."connection_grants"("company_id","id") ON DELETE cascade ON UPDATE no action;
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+15 -1
View File
@@ -1576,6 +1576,20 @@
"when": 1787101145413,
"tag": "0226_remove_app_connection_wide_includes",
"breakpoints": true
},
{
"idx": 227,
"version": "7",
"when": 1787260299823,
"tag": "0227_parallel_venom",
"breakpoints": true
},
{
"idx": 228,
"version": "7",
"when": 1787260338592,
"tag": "0228_absurd_hedge_knight",
"breakpoints": true
}
]
}
}
+1
View File
@@ -130,6 +130,7 @@ export {
toolApplications,
toolConnections,
connectionGrants,
connectionGrantMembers,
toolConnectionInstalls,
toolOauthStates,
toolCatalogEntries,
+31 -4
View File
@@ -29,11 +29,13 @@ import type {
ToolConnectionHealthStatus,
ToolConnectionAuthKind,
ToolConnectionKind,
ToolConnectionCredentialPolicy,
ToolConnectionOwnership,
ToolConnectionInstallTargetType,
ToolConnectionStatus,
ToolConnectionTransport,
ConnectionGrantKind,
ConnectionGrantMemberSubjectType,
ConnectionGrantStatus,
ToolCredentialSecretRef,
ToolInvocationApprovalState,
@@ -117,6 +119,7 @@ export const toolConnections = pgTable(
ownership: text("ownership").$type<ToolConnectionOwnership>().notNull().default("customer"),
transport: text("transport").$type<ToolConnectionTransport>().notNull(),
authKind: text("auth_kind").$type<ToolConnectionAuthKind>().notNull().default("none"),
credentialPolicy: text("credential_policy").$type<ToolConnectionCredentialPolicy>().notNull().default("shared"),
status: text("status").$type<ToolConnectionStatus>().notNull().default("draft"),
enabled: boolean("enabled").notNull().default(false),
config: jsonb("config").$type<Record<string, unknown>>().notNull().default({}),
@@ -138,6 +141,7 @@ export const toolConnections = pgTable(
check("tool_connections_ownership_check", sql`${table.ownership} in ('platform_shared', 'platform_provisioned', 'customer', 'dcr')`),
check("tool_connections_transport_check", sql`${table.transport} in ('mcp_remote', 'rest_api', 'local_stdio')`),
check("tool_connections_auth_kind_check", sql`${table.authKind} in ('oauth', 'api_key', 'none')`),
check("tool_connections_credential_policy_check", sql`${table.credentialPolicy} in ('shared', 'per_user', 'per_user_with_fallback')`),
index("tool_connections_company_idx").on(table.companyId),
index("tool_connections_application_idx").on(table.applicationId),
index("tool_connections_company_enabled_idx").on(table.companyId, table.enabled),
@@ -168,10 +172,10 @@ export const connectionGrants = pgTable(
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
},
(table) => [
check("connection_grants_kind_check", sql`${table.kind} in ('workspace', 'user')`),
check("connection_grants_kind_check", sql`${table.kind} in ('organization', 'user')`),
check("connection_grants_status_check", sql`${table.status} in ('active', 'revoked', 'expired', 'needs_reauthorization')`),
check("connection_grants_subject_check", sql`(${table.kind} = 'user' and ${table.subjectUserId} is not null) or (${table.kind} = 'workspace' and ${table.subjectUserId} is null)`),
check("connection_grants_default_check", sql`${table.isDefault} = false or ${table.kind} = 'workspace'`),
check("connection_grants_subject_check", sql`(${table.kind} = 'user' and ${table.subjectUserId} is not null) or (${table.kind} = 'organization' and ${table.subjectUserId} is null)`),
check("connection_grants_default_check", sql`${table.isDefault} = false or ${table.kind} = 'organization'`),
foreignKey({
columns: [table.companyId, table.connectionId],
foreignColumns: [toolConnections.companyId, toolConnections.id],
@@ -179,8 +183,31 @@ export const connectionGrants = pgTable(
}).onDelete("cascade"),
index("connection_grants_company_connection_idx").on(table.companyId, table.connectionId),
index("connection_grants_subject_user_idx").on(table.companyId, table.subjectUserId),
unique("connection_grants_company_id_uq").on(table.companyId, table.id),
uniqueIndex("connection_grants_user_uq").on(table.connectionId, table.subjectUserId),
uniqueIndex("connection_grants_default_uq").on(table.connectionId).where(sql`${table.isDefault} = true and ${table.kind} = 'workspace'`),
uniqueIndex("connection_grants_default_uq").on(table.connectionId).where(sql`${table.isDefault} = true and ${table.kind} = 'organization'`),
],
);
export const connectionGrantMembers = pgTable(
"connection_grant_members",
{
id: uuid("id").primaryKey().defaultRandom(),
companyId: uuid("company_id").notNull().references(() => companies.id, { onDelete: "cascade" }),
grantId: uuid("grant_id").notNull(),
subjectType: text("subject_type").$type<ConnectionGrantMemberSubjectType>().notNull(),
subjectId: text("subject_id").notNull(),
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
},
(table) => [
check("connection_grant_members_subject_type_check", sql`${table.subjectType} in ('user')`),
foreignKey({
columns: [table.companyId, table.grantId],
foreignColumns: [connectionGrants.companyId, connectionGrants.id],
name: "connection_grant_members_company_grant_fk",
}).onDelete("cascade"),
index("connection_grant_members_company_subject_idx").on(table.companyId, table.subjectType, table.subjectId),
uniqueIndex("connection_grant_members_grant_subject_uq").on(table.grantId, table.subjectType, table.subjectId),
],
);
+3
View File
@@ -1314,6 +1314,7 @@ export type {
ToolCatalogEntryKind,
ToolConnectionHealthStatus,
ToolConnectionAuthKind,
ToolConnectionCredentialPolicy,
ToolConnectionOwnership,
ToolConnectionTransport,
ToolConnectionStatus,
@@ -1342,6 +1343,8 @@ export type {
ToolConnectionRemovalSummary,
ConnectionGrant,
ConnectionGrantKind,
ConnectionGrantMember,
ConnectionGrantMemberSubjectType,
ConnectionGrantStatus,
ConnectionTokenScope,
ConnectionTokenRequest,
+3
View File
@@ -489,12 +489,15 @@ export type {
ToolConnection,
ToolConnectionHealthStatus,
ToolConnectionAuthKind,
ToolConnectionCredentialPolicy,
ToolConnectionOwnership,
ToolConnectionTransport,
ToolConnectionStatus,
ToolConnectionKind,
ConnectionGrant,
ConnectionGrantKind,
ConnectionGrantMember,
ConnectionGrantMemberSubjectType,
ConnectionGrantStatus,
ToolCredentialSecretRef,
ToolInvocation,
+15 -1
View File
@@ -72,8 +72,10 @@ export type ToolConnectionAuthKind = "oauth" | "api_key" | "none";
export type ToolConnectionOwnership = "platform_shared" | "platform_provisioned" | "customer" | "dcr";
export type ToolConnectionStatus = "draft" | "active" | "disabled" | "archived";
export type ToolConnectionInstallTargetType = "company" | "agent";
export type ConnectionGrantKind = "workspace" | "user";
export type ConnectionGrantKind = "organization" | "user";
export type ConnectionGrantStatus = "active" | "revoked" | "expired" | "needs_reauthorization";
export type ToolConnectionCredentialPolicy = "shared" | "per_user" | "per_user_with_fallback";
export type ConnectionGrantMemberSubjectType = "user";
export type ToolCredentialPlacement = "header" | "env";
export interface McpConnectionCredentialRef {
@@ -132,6 +134,7 @@ export interface ToolConnection {
ownership: ToolConnectionOwnership;
transport: ToolConnectionTransport;
authKind: ToolConnectionAuthKind;
credentialPolicy: ToolConnectionCredentialPolicy;
status?: ToolConnectionStatus;
transportConfig: Record<string, unknown>;
config?: Record<string, unknown>;
@@ -172,6 +175,16 @@ export interface ConnectionGrant {
lastUsedAt: Date | null;
createdAt: Date;
updatedAt: Date;
members?: ConnectionGrantMember[];
}
export interface ConnectionGrantMember {
id: string;
companyId: string;
grantId: string;
subjectType: ConnectionGrantMemberSubjectType;
subjectId: string;
createdAt: Date;
}
export interface ToolConnectionInstall {
@@ -240,6 +253,7 @@ export type ConnectionTokenSubject = { type: "app" } | { type: "user"; userId: s
export const CONNECTION_RECOVERABLE_ERROR_CODES = [
"user_authorization_required",
"grant_audience_denied",
"grant_revoked",
"needs_reauthorization",
"installation_required",
@@ -43,8 +43,9 @@ export const toolApplicationStatusSchema = z.enum(TOOL_APPLICATION_STATUSES);
export const toolConnectionTransportSchema = z.enum(["mcp_remote", "rest_api", "local_stdio"]);
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(["workspace", "user"]);
export const connectionGrantKindSchema = z.enum(["organization", "user"]);
export const connectionGrantStatusSchema = z.enum(["active", "revoked", "expired", "needs_reauthorization"]);
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"]);
export const toolCredentialPlacementSchema = z.enum(["header", "env"]);
@@ -154,6 +155,7 @@ export const createToolConnectionSchema = z.object({
name: z.string().trim().min(1).max(160),
transport: toolConnectionTransportSchema.optional(),
authKind: toolConnectionAuthKindSchema.default("none"),
credentialPolicy: toolConnectionCredentialPolicySchema.optional(),
ownership: toolConnectionOwnershipSchema.default("customer"),
status: toolConnectionStatusSchema.optional(),
connectionKind: toolConnectionKindSchema.default("managed"),
@@ -196,7 +198,7 @@ export const connectionGrantSchema = z.object({
updatedAt: z.coerce.date(),
}).superRefine((grant, ctx) => {
if ((grant.kind === "user") !== Boolean(grant.subjectUserId)) {
ctx.addIssue({ code: z.ZodIssueCode.custom, path: ["subjectUserId"], message: "User grants require a subject user; workspace grants must not have one" });
ctx.addIssue({ code: z.ZodIssueCode.custom, path: ["subjectUserId"], message: "User grants require a subject user; organization grants must not have one" });
}
});
@@ -0,0 +1,24 @@
import { describe, expect, it } from "vitest";
import {
isConnectionGrantAudienceAllowed,
resolveCredentialGrantKind,
} from "../services/tool-gateway.js";
describe("connection grant resolution", () => {
it("resolves every credential policy deterministically", () => {
expect(resolveCredentialGrantKind("shared", "alice", true)).toBe("organization");
expect(resolveCredentialGrantKind("per_user", "alice", true)).toBe("user");
expect(resolveCredentialGrantKind("per_user", "alice", false)).toBe("user_authorization_required");
expect(resolveCredentialGrantKind("per_user", null, false)).toBe("user_authorization_required");
expect(resolveCredentialGrantKind("per_user_with_fallback", "alice", true)).toBe("user");
expect(resolveCredentialGrantKind("per_user_with_fallback", "alice", false)).toBe("organization");
expect(resolveCredentialGrantKind("per_user_with_fallback", null, false)).toBe("organization");
});
it("allows an empty audience and rejects users outside a restricted audience", () => {
expect(isConnectionGrantAudienceAllowed([], "alice")).toBe(true);
expect(isConnectionGrantAudienceAllowed(["alice", "bob"], "alice")).toBe(true);
expect(isConnectionGrantAudienceAllowed(["alice", "bob"], "carol")).toBe(false);
expect(isConnectionGrantAudienceAllowed(["alice"], null)).toBe(false);
});
});
@@ -9,6 +9,7 @@ import {
companies,
companyMemberships,
companySecretBindings,
connectionGrantMembers,
connectionGrants,
connectionTokenIssuances,
companySecrets,
@@ -800,22 +801,72 @@ describeEmbeddedPostgres("tool access service", () => {
subject: { type: "user", userId: "someone-else" },
});
await db.update(toolConnections).set({ credentialPolicy: "per_user" }).where(eq(toolConnections.id, connection.id));
const missing = await request(app)
.post(`/api/agents/me/connections/${encodeURIComponent(connection.uid)}/token`)
.send({ subject: { type: "user", userId: "user-for-run" } });
expect(missing.status).toBe(409);
expect(missing.body).toMatchObject({ code: "user_authorization_required", remediation: { action: "start_authorization" } });
const service = toolAccessService(db);
const grant = await service.addConnectionInstallation(connection.id, { isDefault: false });
await service.revokeConnectionGrant(connection.id, grant.id);
const [grant] = await db.insert(connectionGrants).values({
companyId: company.id,
connectionId: connection.id,
kind: "user",
subjectUserId: "user-for-run",
status: "revoked",
isDefault: false,
}).returning();
const revoked = await request(app)
.post(`/api/agents/me/connections/${connection.id}/token`)
.send({ grantId: grant.id });
.send({});
expect(revoked.status).toBe(409);
expect(revoked.body).toMatchObject({ code: "grant_revoked", grantId: grant.id });
});
it("enforces organization grant audiences at token mint time", async () => {
const company = await createCompany(db);
const agent = await createAgent(db, company.id);
const { run } = await createIssueAndRun(db, company.id, agent.id);
const { connection } = await createBrokerConnection(db, company.id);
await allowConnectionForAgent(db, company.id, agent.id, connection.id);
const [grant] = await db.insert(connectionGrants).values({
companyId: company.id,
connectionId: connection.id,
kind: "organization",
credentialSecretRefs: connection.credentialSecretRefs,
status: "active",
isDefault: true,
}).returning();
await db.insert(connectionGrantMembers).values({
companyId: company.id,
grantId: grant!.id,
subjectType: "user",
subjectId: "user-for-run",
});
const app = createRouteApp(db, agentJwtActor(company.id, agent.id, run.id));
const fetchMock = vi.spyOn(globalThis, "fetch").mockResolvedValue({
ok: true,
status: 201,
json: async () => ({ token: "audience-token", expires_in: 600 }),
} as Response);
const allowed = await request(app).post(`/api/agents/me/connections/${connection.id}/token`).send({});
expect(allowed.status).toBe(200);
expect(fetchMock).toHaveBeenCalledTimes(1);
await db.delete(connectionGrantMembers).where(eq(connectionGrantMembers.grantId, grant!.id));
await db.insert(connectionGrantMembers).values({
companyId: company.id,
grantId: grant!.id,
subjectType: "user",
subjectId: "sales-user",
});
const denied = await request(app).post(`/api/agents/me/connections/${connection.id}/token`).send({});
expect(denied.status).toBe(403);
expect(denied.body).toMatchObject({ code: "grant_audience_denied", grantId: grant!.id });
expect(fetchMock).toHaveBeenCalledTimes(1);
});
it("returns daily connection usage buckets", async () => {
const company = await createCompany(db);
const { connection } = await createBrokerConnection(db, company.id);
+64
View File
@@ -12,6 +12,7 @@ import {
companySecretVersions,
companyMemberships,
companies,
connectionGrants,
createDb,
heartbeatRuns,
issueThreadInteractions,
@@ -214,6 +215,14 @@ async function createRemoteMcpTool(
credentialRefs: input.credentialRefs ?? [],
credentialSecretRefs: input.credentialSecretRefs ?? [],
}).returning();
await db.insert(connectionGrants).values({
companyId,
connectionId: connection.id,
kind: "organization",
credentialSecretRefs: connection.credentialSecretRefs,
status: "active",
isDefault: true,
});
if (input.credentialRefs?.length || input.credentialSecretRefs?.length) {
await db.insert(companySecretBindings).values([
...(input.credentialRefs ?? []).map((ref) => ({
@@ -1629,6 +1638,61 @@ rl.on("line", (line) => {
}
});
it("creates a personal authorization card and resumes after the user grant exists", 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: "carol" }).where(eq(heartbeatRuns.id, run.id));
const fake = await startFakeRemoteMcpServer((fakeRequest) => ({
body: {
jsonrpc: "2.0",
id: fakeRequest.body?.id,
result: { content: [{ type: "text", text: "connected" }] },
},
}));
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));
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: "user_authorization_required" });
const [interaction] = await db.select().from(issueThreadInteractions).where(eq(issueThreadInteractions.issueId, issue.id));
expect(interaction).toMatchObject({
kind: "request_confirmation",
status: "pending",
requestedResolverPolicy: "human_only",
title: `Connect your ${connection.name}`,
});
expect(interaction!.payload).toMatchObject({
prompt: `Connect your ${connection.name} account to continue`,
target: { key: `connection:${connection.uid}:user:carol` },
});
await db.insert(connectionGrants).values({
companyId: company.id,
connectionId: connection.id,
kind: "user",
subjectUserId: "carol",
credentialSecretRefs: [],
status: "active",
isDefault: false,
});
const result = await gateway.executeTool({ sessionToken: session.token, tool: tool.name, parameters: {} });
expect(result).toMatchObject({ status: "completed", result: { content: "connected" } });
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
@@ -93,6 +93,7 @@ export function errorHandler(
const workspaceRepairPreconditionFailure = details?.code === "workspace_repair_precondition_failed";
const structuredConnectionError = new Set([
"user_authorization_required",
"grant_audience_denied",
"grant_revoked",
"needs_reauthorization",
"installation_required",
+2 -2
View File
@@ -224,10 +224,10 @@ export function toolAccessRoutes(
eq(connectionGrants.connectionId, connection.id),
eq(connectionGrants.status, "active"),
or(
eq(connectionGrants.kind, "workspace"),
eq(connectionGrants.kind, "organization"),
req.actor.userId
? and(eq(connectionGrants.kind, "user"), eq(connectionGrants.subjectUserId, req.actor.userId))
: eq(connectionGrants.kind, "workspace"),
: eq(connectionGrants.kind, "organization"),
),
))
.limit(1);
+42 -34
View File
@@ -4,6 +4,7 @@ import { and, asc, desc, eq, gte, inArray, isNull, lt, max, ne, sql } from "driz
import type { Db } from "@paperclipai/db";
import {
agents,
connectionGrantMembers,
connectionGrants,
connectionTokenIssuances,
authUsers,
@@ -1070,6 +1071,7 @@ function toConnection(row: typeof toolConnections.$inferSelect): ToolConnection
ownership: row.ownership,
transport: row.transport,
authKind: row.authKind,
credentialPolicy: row.credentialPolicy,
status: row.status,
enabled: row.enabled,
config: row.config ?? {},
@@ -3186,14 +3188,14 @@ export function toolAccessService(db: Db, options: ToolAccessServiceOptions = {}
return row;
}
async function ensureDefaultWorkspaceGrant(connection: typeof toolConnections.$inferSelect) {
async function ensureDefaultOrganizationGrant(connection: typeof toolConnections.$inferSelect) {
const [existing] = await db
.select()
.from(connectionGrants)
.where(and(
eq(connectionGrants.companyId, connection.companyId),
eq(connectionGrants.connectionId, connection.id),
eq(connectionGrants.kind, "workspace"),
eq(connectionGrants.kind, "organization"),
eq(connectionGrants.isDefault, true),
))
.limit(1);
@@ -3203,7 +3205,7 @@ export function toolAccessService(db: Db, options: ToolAccessServiceOptions = {}
.values({
companyId: connection.companyId,
connectionId: connection.id,
kind: "workspace",
kind: "organization",
credentialSecretRefs: connection.credentialSecretRefs,
status: "active",
isDefault: true,
@@ -7953,6 +7955,7 @@ export function toolAccessService(db: Db, options: ToolAccessServiceOptions = {}
ownership: input.ownership ?? "customer",
transport,
authKind: input.authKind ?? "none",
credentialPolicy: input.credentialPolicy ?? (input.authKind === "oauth" ? "per_user" : "shared"),
status: input.status ?? "draft",
enabled: input.enabled ?? false,
config,
@@ -7962,7 +7965,7 @@ export function toolAccessService(db: Db, options: ToolAccessServiceOptions = {}
createdByAgentId: binding.actorType === "agent" ? binding.actorId : null,
createdByUserId: binding.actorType === "user" ? binding.actorId : null,
}).returning();
await ensureDefaultWorkspaceGrant(row);
await ensureDefaultOrganizationGrant(row);
await syncCredentialBindings(row);
await ensureRuntimeSlot(row);
return toConnection(row);
@@ -7993,14 +7996,14 @@ export function toolAccessService(db: Db, options: ToolAccessServiceOptions = {}
if (input.isDefault) {
await db.update(connectionGrants).set({ isDefault: false, updatedAt: new Date() }).where(and(
eq(connectionGrants.connectionId, connection.id),
eq(connectionGrants.kind, "workspace"),
eq(connectionGrants.kind, "organization"),
));
}
const binding = actorBinding(actor);
const [grant] = await db.insert(connectionGrants).values({
companyId: connection.companyId,
connectionId: connection.id,
kind: "workspace",
kind: "organization",
providerTenant: input.providerTenant,
credentialSecretRefs: input.credentialSecretRefs ?? [],
status: "active",
@@ -8211,6 +8214,7 @@ export function toolAccessService(db: Db, options: ToolAccessServiceOptions = {}
transportConfig: isGoogleSheetsConnectionConfig(config) ? config : input.transportConfig ?? config,
credentialRefs: input.credentialRefs ?? existing.credentialRefs,
credentialSecretRefs: input.credentialSecretRefs ?? existing.credentialSecretRefs,
credentialPolicy: input.credentialPolicy ?? existing.credentialPolicy,
updatedAt: new Date(),
})
.where(eq(toolConnections.id, connectionId))
@@ -9013,47 +9017,51 @@ export function toolAccessService(db: Db, options: ToolAccessServiceOptions = {}
);
}
const subject = input.body.subject ?? { type: "app" as const };
if (subject.type === "user" && subject.userId !== runContext.responsibleUserId) {
const requestedSubject = input.body.subject;
if (requestedSubject?.type === "user" && requestedSubject.userId !== runContext.responsibleUserId) {
await fail(403, "The agent run cannot act as the requested user", "denied", "subject_not_permitted", {
connection: { uid: connection.uid },
subject,
subject: requestedSubject,
});
}
let grant: typeof connectionGrants.$inferSelect;
if (subject.type === "user") {
const conditions = [
const actingUserId = runContext.responsibleUserId;
const subject = connection.credentialPolicy === "shared" || !actingUserId
? { type: "app" as const }
: { type: "user" as const, userId: actingUserId };
let grant: typeof connectionGrants.$inferSelect | undefined;
if (connection.credentialPolicy !== "shared" && actingUserId) {
[grant] = await db.select().from(connectionGrants).where(and(
eq(connectionGrants.companyId, connection.companyId),
eq(connectionGrants.connectionId, connection.id),
eq(connectionGrants.kind, "user"),
eq(connectionGrants.subjectUserId, subject.userId),
];
if (input.body.grantId) conditions.push(eq(connectionGrants.id, input.body.grantId));
[grant] = await db.select().from(connectionGrants).where(and(...conditions)).limit(1);
if (!grant) {
await fail(409, "User authorization is required", "denied", "user_authorization_required", {
connection: { uid: connection.uid },
subject,
remediation: { action: "start_authorization" },
});
}
} else if (input.body.grantId) {
[grant] = await db.select().from(connectionGrants).where(and(
eq(connectionGrants.id, input.body.grantId),
eq(connectionGrants.companyId, connection.companyId),
eq(connectionGrants.connectionId, connection.id),
eq(connectionGrants.kind, "workspace"),
eq(connectionGrants.subjectUserId, actingUserId),
)).limit(1);
if (!grant) {
await fail(409, "The requested installation is not available", "denied", "installation_required", {
}
if (!grant && connection.credentialPolicy === "per_user") {
await fail(409, "User authorization is required", "denied", "user_authorization_required", {
connection: { uid: connection.uid },
subject: actingUserId ? { type: "user", userId: actingUserId } : { type: "app" },
remediation: { action: "start_authorization" },
});
}
if (!grant) {
grant = await ensureDefaultOrganizationGrant(connection);
}
if (grant.kind === "organization") {
const audience = await db.select({ subjectId: connectionGrantMembers.subjectId }).from(connectionGrantMembers).where(and(
eq(connectionGrantMembers.companyId, connection.companyId),
eq(connectionGrantMembers.grantId, grant.id),
eq(connectionGrantMembers.subjectType, "user"),
));
if (audience.length > 0 && (!actingUserId || !audience.some((member) => member.subjectId === actingUserId))) {
await fail(403, "The acting user is not in this grant's audience", "denied", "grant_audience_denied", {
connection: { uid: connection.uid },
subject,
remediation: { action: "add_installation" },
grantId: grant.id,
});
}
} else {
grant = await ensureDefaultWorkspaceGrant(connection);
}
if (grant.status !== "active") {
+191 -8
View File
@@ -5,6 +5,9 @@ import type { Db } from "@paperclipai/db";
import {
agents,
approvals,
companies,
connectionGrantMembers,
connectionGrants,
documents,
heartbeatRuns,
issueApprovals,
@@ -80,6 +83,20 @@ import {
const DEFAULT_SESSION_TTL_MS = 15 * 60 * 1000;
const MAX_SESSION_TTL_MS = 60 * 60 * 1000;
const DEFAULT_TOOL_TIMEOUT_MS = 10_000;
export function resolveCredentialGrantKind(
policy: "shared" | "per_user" | "per_user_with_fallback",
actingUserId: string | null,
hasUserGrant: boolean,
): "organization" | "user" | "user_authorization_required" {
if (policy === "shared") return "organization";
if (actingUserId && hasUserGrant) return "user";
return policy === "per_user" ? "user_authorization_required" : "organization";
}
export function isConnectionGrantAudienceAllowed(memberUserIds: string[], actingUserId: string | null): boolean {
return memberUserIds.length === 0 || (actingUserId !== null && memberUserIds.includes(actingUserId));
}
// When a human approves a parked write, the server carries it out on their
// behalf with no interactive caller left to raise `timeoutMs`. Remote write
// providers (e.g. Zapier Google Sheets `add_row`) routinely take longer than
@@ -2387,12 +2404,26 @@ export function createToolGatewayService(
.where(eq(toolConnections.id, connection.id));
}
async function resolveCredentialHeaders(connection: typeof toolConnections.$inferSelect): Promise<Record<string, string>> {
function grantRefForHeader(
grant: typeof connectionGrants.$inferSelect,
ref: McpConnectionCredentialRef,
): ToolCredentialSecretRef | undefined {
return grant.credentialSecretRefs.find((candidate) =>
candidate.configPath === ref.name || candidate.configPath === `credentials.${ref.name}`,
);
}
async function resolveCredentialHeaders(
connection: typeof toolConnections.$inferSelect,
grant: typeof connectionGrants.$inferSelect,
): Promise<Record<string, string>> {
const headers: Record<string, string> = {};
for (const ref of connection.credentialRefs ?? []) {
if (ref.placement !== "header") continue;
const grantRef = grantRefForHeader(grant, ref);
if (!grantRef) continue;
try {
const value = await secrets.resolveSecretValue(connection.companyId, ref.secretId, ref.version ?? "latest", {
const value = await secrets.resolveSecretValue(connection.companyId, grantRef.secretId, grantRef.versionSelector ?? "latest", {
consumerType: "tool_connection",
consumerId: connection.id,
configPath: `credentials.${ref.name}`,
@@ -2409,6 +2440,29 @@ export function createToolGatewayService(
);
}
}
const oauthAccessRef = grant.credentialSecretRefs.find((ref) => ref.configPath === "oauth.access_token");
if (oauthAccessRef && headers.Authorization === undefined) {
try {
const value = await secrets.resolveSecretValue(
connection.companyId,
oauthAccessRef.secretId,
oauthAccessRef.versionSelector ?? "latest",
{
consumerType: "tool_connection",
consumerId: connection.id,
configPath: oauthAccessRef.configPath,
actorType: "system",
},
);
headers.Authorization = `Bearer ${value}`;
} catch {
await markRemoteConnectionHealth(connection, "missing_secret", "A configured credential secret could not be resolved.");
throw new ToolGatewayHttpError(422, "A configured credential secret could not be resolved.", "mcp_remote_missing_secret", {
connectionId: connection.id,
credential: oauthAccessRef.configPath,
});
}
}
return headers;
}
@@ -2459,6 +2513,7 @@ export function createToolGatewayService(
async function connectedCredentialVersionSnapshots(
connection: typeof toolConnections.$inferSelect,
grant: typeof connectionGrants.$inferSelect,
options: { requireResolved: boolean },
): Promise<{
headerCredentialVersions: ConnectedCredentialVersionSnapshot[];
@@ -2470,15 +2525,17 @@ export function createToolGatewayService(
for (const ref of connection.credentialRefs ?? []) {
if (ref.placement !== "header") continue;
const typedRef = ref as McpConnectionCredentialRef;
const grantRef = grantRefForHeader(grant, typedRef);
if (!grantRef) continue;
const configPath = `credentials.${typedRef.name}`;
headerCredentialVersions.push(await resolveConnectedCredentialVersion(connection, {
secretId: typedRef.secretId,
versionSelector: typedRef.version,
secretId: grantRef.secretId,
versionSelector: grantRef.versionSelector,
configPath,
refHash: credentialVersionRefHash({
kind: "header",
name: typedRef.name,
secretId: typedRef.secretId,
secretId: grantRef.secretId,
placement: typedRef.placement,
key: typedRef.key,
prefix: typedRef.prefix ?? null,
@@ -2488,7 +2545,7 @@ export function createToolGatewayService(
}));
}
for (const ref of connection.credentialSecretRefs ?? []) {
for (const ref of grant.credentialSecretRefs ?? []) {
const typedRef = ref as ToolCredentialSecretRef;
credentialSecretVersions.push(await resolveConnectedCredentialVersion(connection, {
secretId: typedRef.secretId,
@@ -2508,6 +2565,127 @@ export function createToolGatewayService(
return { headerCredentialVersions, credentialSecretVersions };
}
async function createUserAuthorizationInteraction(
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-authorization:${connection.id}:${userId}`;
const payload = {
version: 1 as const,
prompt: `Connect your ${connection.name} account to continue`,
acceptLabel: "Connect account",
rejectLabel: "Not now",
detailsMarkdown: "This run needs your personal authorization. Paperclip will not use another user's identity.",
target: {
type: "custom" as const,
key: `connection:${connection.uid}:user:${userId}`,
revisionId: connection.updatedAt.toISOString(),
label: `Connect ${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",
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: `Connect your ${connection.name}`,
summary: "Personal authorization is required before this run can continue.",
createdByAgentId: session.agentId,
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(
eq(heartbeatRuns.id, session.runId),
eq(heartbeatRuns.companyId, session.companyId),
)).limit(1)
: [];
const actingUserId = run?.responsibleUserId ?? null;
const findUserGrant = async () => {
if (!actingUserId) return undefined;
const [grant] = await db.select().from(connectionGrants).where(and(
eq(connectionGrants.companyId, connection.companyId),
eq(connectionGrants.connectionId, connection.id),
eq(connectionGrants.kind, "user"),
eq(connectionGrants.subjectUserId, actingUserId),
eq(connectionGrants.status, "active"),
)).limit(1);
return grant;
};
const findOrganizationGrant = async () => {
const [grant] = await db.select().from(connectionGrants).where(and(
eq(connectionGrants.companyId, connection.companyId),
eq(connectionGrants.connectionId, connection.id),
eq(connectionGrants.kind, "organization"),
eq(connectionGrants.isDefault, true),
eq(connectionGrants.status, "active"),
)).limit(1);
if (!grant) {
throw new ToolGatewayHttpError(409, "Organization authorization is required", "user_authorization_required", {
connectionId: connection.id,
});
}
const members = await db.select({ subjectId: connectionGrantMembers.subjectId }).from(connectionGrantMembers).where(and(
eq(connectionGrantMembers.companyId, connection.companyId),
eq(connectionGrantMembers.grantId, grant.id),
eq(connectionGrantMembers.subjectType, "user"),
));
if (!isConnectionGrantAudienceAllowed(members.map((member) => member.subjectId), actingUserId)) {
throw new ToolGatewayHttpError(403, "The acting user is not in this grant's audience", "grant_audience_denied", {
connectionId: connection.id,
grantId: grant.id,
actingUserId,
});
}
return grant;
};
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_authorization_required") {
if (actingUserId) await createUserAuthorizationInteraction(session, connection, actingUserId);
throw new ToolGatewayHttpError(409, "User authorization is required", "user_authorization_required", {
connectionId: connection.id,
actingUserId,
});
}
return findOrganizationGrant();
}
async function resolveConnectedRemoteTool(session: ToolGatewaySession, tool: ToolGatewayDescriptor) {
if (tool.providerType !== "mcp_remote_http" || !tool.connectionId || !tool.catalogEntryId) {
throw new ToolGatewayHttpError(404, `Tool "${tool.name}" not found`, "tool_not_found");
@@ -2799,7 +2977,8 @@ export function createToolGatewayService(
))
.limit(1);
if (!row) return null;
const credentialVersions = await connectedCredentialVersionSnapshots(row.connection, {
const grant = await resolveConnectionGrant(session, row.connection);
const credentialVersions = await connectedCredentialVersionSnapshots(row.connection, grant, {
requireResolved: options.requireResolvedCredentials === true,
});
return {
@@ -2815,6 +2994,8 @@ export function createToolGatewayService(
connectionTransportConfigHash: stableHash(row.connection.transportConfig ?? {}),
credentialRefsHash: stableHash(row.connection.credentialRefs ?? []),
credentialSecretRefsHash: stableHash(row.connection.credentialSecretRefs ?? []),
credentialGrantId: grant.id,
credentialGrantRefsHash: stableHash(grant.credentialSecretRefs ?? []),
headerCredentialVersions: credentialVersions.headerCredentialVersions,
credentialSecretVersions: credentialVersions.credentialSecretVersions,
catalogEntryId: row.entry.id,
@@ -3056,13 +3237,14 @@ export function createToolGatewayService(
callerHeaders?: ExecuteGatewayToolInput["callerHeaders"],
): Promise<RemoteHttpExecutionResult> {
const { entry, connection } = await resolveConnectedRemoteTool(session, tool);
const grant = await resolveConnectionGrant(session, connection);
const endpoint = remoteEndpoint(connection.config ?? {});
// Method-defined headers are trusted catalog configuration. Treat them as
// managed headers so callers cannot override the scope that was reviewed
// during tools/list. Credentials remain authoritative on collisions.
const credentialHeaders = {
...projectedConnectionHeaders(connection),
...await resolveCredentialHeaders(connection),
...await resolveCredentialHeaders(connection, grant),
};
const { headers, summary: headerSummary } = buildRemoteHeaders({
session,
@@ -3203,6 +3385,7 @@ export function createToolGatewayService(
ms: number,
): Promise<RemoteHttpExecutionResult> {
const { entry, connection } = await resolveConnectedLocalStdioTool(session, tool);
await resolveConnectionGrant(session, connection);
const template = await resolveLocalStdioRuntimeTemplate(connection);
const result = await runtimeSupervisor.useConnectionSlot(
{
@@ -90,6 +90,7 @@ function connectResult(overrides: Partial<ConnectToolAppResult> = {}): ConnectTo
ownership: "customer",
transport: "mcp_remote",
authKind: "none",
credentialPolicy: "shared",
status: "draft",
enabled: false,
config: { url: "http://127.0.0.1:8848/mcp" },
@@ -275,6 +275,7 @@ const CONNECTIONS: ToolConnection[] = [
ownership: "customer",
transport: "mcp_remote",
authKind: "oauth",
credentialPolicy: "per_user",
status: "active",
transportConfig: {},
credentialSecretRefs: [],
@@ -309,6 +310,7 @@ const CONNECTIONS: ToolConnection[] = [
ownership: "customer",
transport: "mcp_remote",
authKind: "oauth",
credentialPolicy: "per_user",
status: "active",
transportConfig: {},
credentialSecretRefs: [],
@@ -67,6 +67,7 @@ function notionConnection(overrides: Partial<ToolConnection> = {}): ToolConnecti
ownership: "dcr",
transport: "mcp_remote",
authKind: "oauth",
credentialPolicy: "per_user",
status: "active",
transportConfig: { url: "https://mcp.notion.com/mcp" },
config: {