mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-01 06:42:06 +01:00
feat(apps): resolve credentials through grants
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
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;
|
||||
+39749
File diff suppressed because it is too large.
Load diff
+39760
File diff suppressed because it is too large.
Load diff
+15
-1
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -130,6 +130,7 @@ export {
|
||||
toolApplications,
|
||||
toolConnections,
|
||||
connectionGrants,
|
||||
connectionGrantMembers,
|
||||
toolConnectionInstalls,
|
||||
toolOauthStates,
|
||||
toolCatalogEntries,
|
||||
|
||||
@@ -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),
|
||||
],
|
||||
);
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -489,12 +489,15 @@ export type {
|
||||
ToolConnection,
|
||||
ToolConnectionHealthStatus,
|
||||
ToolConnectionAuthKind,
|
||||
ToolConnectionCredentialPolicy,
|
||||
ToolConnectionOwnership,
|
||||
ToolConnectionTransport,
|
||||
ToolConnectionStatus,
|
||||
ToolConnectionKind,
|
||||
ConnectionGrant,
|
||||
ConnectionGrantKind,
|
||||
ConnectionGrantMember,
|
||||
ConnectionGrantMemberSubjectType,
|
||||
ConnectionGrantStatus,
|
||||
ToolCredentialSecretRef,
|
||||
ToolInvocation,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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") {
|
||||
|
||||
@@ -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: {
|
||||
|
||||
Reference in new issue
Block a user