feat(connections): add runtime subjects and grants (#9982)
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work > - Connections is the subsystem that lets operators connect external apps and govern which subjects may use those credentials > - #9958 established the v3 schema foundation and #9981 adds the AppDefinition catalog layer > - The runtime still needs subject-aware authorization state, scoped key handling, and API/OpenAPI routes so connected apps can actually be granted and used safely > - This pull request adds the runtime grants/authorization behavior on top of the catalog branch, while keeping unrelated dependency and workflow sync commits out of the stack > - The benefit is a reviewable runtime layer that can land after the catalog PR, then unblock the wizard and orchestrator cutover work ## Linked Issues or Issue Description Refs #9958 and #9981. Refs #9981. No public GitHub issue exists for this branch. This is the runtime layer for the Connections v3 stack and is rebased onto `master` after #9981 landed. ## What Changed - Adds the connection user authorization state migration and schema wiring. - Adds shared runtime subject/grant types and validators. - Adds runtime grant and scoped key behavior in the tool-access service. - Adds runtime route coverage and registers the routes in OpenAPI. - Replays only the Connections runtime commits on top of the catalog branch, dropping unrelated sync/dependency history from the prior closed runtime PR. ## Verification - `pnpm run preflight:workspace-links` - `pnpm exec vitest run packages/shared/src/validators/tool-access.test.ts server/src/__tests__/tool-access-service.test.ts` ## Risks - Medium: runtime grant enforcement is security-sensitive and must fail closed for unknown key scopes. - Migration ordering depends on the schema and catalog layers already merged through #9958 and #9981. - This PR is rebased and retargeted to `master` with runtime-only commits. > For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and discuss it in `#dev` before opening the PR. Feature PRs that overlap with planned core work may need to be redirected — check the roadmap first. See `CONTRIBUTING.md`. ## Model Used OpenAI GPT-5 Codex coding agent with repository tool use. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] All Paperclip CI gates are green - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip <noreply@paperclip.ing>
This commit is contained in:
parent
d23fbf8ae4
commit
cac3c0fa1a
|
|
@ -0,0 +1,6 @@
|
|||
ALTER TABLE "tool_oauth_states" ADD COLUMN "subject_user_id" text;--> statement-breakpoint
|
||||
ALTER TABLE "tool_oauth_states" ADD COLUMN "requested_scopes" jsonb;--> statement-breakpoint
|
||||
ALTER TABLE "tool_oauth_states" ADD COLUMN "return_to" text;--> statement-breakpoint
|
||||
ALTER TABLE "tool_oauth_states" ADD COLUMN "issue_id" uuid;--> statement-breakpoint
|
||||
ALTER TABLE "tool_oauth_states" ADD COLUMN "interaction_id" uuid;--> statement-breakpoint
|
||||
CREATE INDEX "tool_oauth_states_subject_user_idx" ON "tool_oauth_states" USING btree ("company_id", "subject_user_id");
|
||||
|
|
@ -1268,6 +1268,13 @@
|
|||
"when": 1784592000000,
|
||||
"tag": "0182_connections_v3_schema_core",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 183,
|
||||
"version": "7",
|
||||
"when": 1784653200000,
|
||||
"tag": "0183_connection_user_authorization_state",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -220,6 +220,11 @@ export const toolOauthStates = pgTable(
|
|||
createdByActorType: text("created_by_actor_type"),
|
||||
createdByActorId: text("created_by_actor_id"),
|
||||
createdBySessionId: text("created_by_session_id"),
|
||||
subjectUserId: text("subject_user_id"),
|
||||
requestedScopes: jsonb("requested_scopes").$type<string[]>(),
|
||||
returnTo: text("return_to"),
|
||||
issueId: uuid("issue_id"),
|
||||
interactionId: uuid("interaction_id"),
|
||||
expiresAt: timestamp("expires_at", { withTimezone: true }).notNull(),
|
||||
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
||||
},
|
||||
|
|
|
|||
|
|
@ -1777,6 +1777,8 @@ export {
|
|||
toolTrustRuleBatchApprovalSchema,
|
||||
toolTrustRuleScopeSchema,
|
||||
connectionTokenRequestSchema,
|
||||
connectionTokenSubjectSchema,
|
||||
startConnectionAuthorizationSchema,
|
||||
toolConnectionTestCallSchema,
|
||||
toolPolicyTestRequestSchema,
|
||||
importMcpJsonSchema,
|
||||
|
|
|
|||
|
|
@ -400,12 +400,19 @@ export type {
|
|||
ToolConnectionInstallSnapshot,
|
||||
ToolConnectionInstallTargetType,
|
||||
ConnectionTokenAttribution,
|
||||
ConnectionRecoverableErrorCode,
|
||||
ConnectionRecoverableErrorPayload,
|
||||
ConnectionTokenIssuance,
|
||||
ConnectionTokenMintedResponse,
|
||||
ConnectionTokenRequest,
|
||||
ConnectionTokenResponse,
|
||||
ConnectionTokenScope,
|
||||
ConnectionTokenSubject,
|
||||
ConnectionTokenUseEnvLeaseResponse,
|
||||
ConnectionUsageDailyBucket,
|
||||
ConnectionUsageResponse,
|
||||
StartConnectionAuthorizationRequest,
|
||||
StartConnectionAuthorizationResponse,
|
||||
ToolConnection,
|
||||
ToolConnectionHealthStatus,
|
||||
ToolConnectionAuthKind,
|
||||
|
|
@ -498,6 +505,7 @@ export type {
|
|||
ToolConnectionTestCallStatus,
|
||||
ToolConnectionTestCallStatusPhase,
|
||||
} from "./tool-access.js";
|
||||
export { CONNECTION_RECOVERABLE_ERROR_CODES } from "./tool-access.js";
|
||||
export type {
|
||||
IssueWorkProduct,
|
||||
IssueWorkProductType,
|
||||
|
|
|
|||
|
|
@ -191,10 +191,31 @@ export interface ToolConnectionInstallSnapshot {
|
|||
}
|
||||
|
||||
export type ConnectionTokenScope = string | string[];
|
||||
export type ConnectionTokenSubject = { type: "app" } | { type: "user"; userId: string };
|
||||
|
||||
export const CONNECTION_RECOVERABLE_ERROR_CODES = [
|
||||
"user_authorization_required",
|
||||
"grant_revoked",
|
||||
"needs_reauthorization",
|
||||
"installation_required",
|
||||
"connection_not_installed",
|
||||
"subject_not_permitted",
|
||||
] as const;
|
||||
|
||||
export type ConnectionRecoverableErrorCode = typeof CONNECTION_RECOVERABLE_ERROR_CODES[number];
|
||||
|
||||
export interface ConnectionRecoverableErrorPayload {
|
||||
code: ConnectionRecoverableErrorCode;
|
||||
connection: { uid: string };
|
||||
subject?: ConnectionTokenSubject;
|
||||
remediation?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface ConnectionTokenRequest {
|
||||
subject?: ConnectionTokenSubject;
|
||||
scope?: ConnectionTokenScope;
|
||||
requestedTtlSeconds?: number;
|
||||
grantId?: string;
|
||||
}
|
||||
|
||||
export interface ConnectionTokenAttribution {
|
||||
|
|
@ -208,6 +229,11 @@ export interface ConnectionTokenAttribution {
|
|||
export interface ConnectionTokenMintedResponse {
|
||||
status: "minted";
|
||||
connectionId: string;
|
||||
connection: { id: string; uid: string };
|
||||
grantId: string;
|
||||
providerTenantId?: string;
|
||||
externalSubject?: string;
|
||||
metadata?: Record<string, unknown>;
|
||||
path: "exchange";
|
||||
token: string;
|
||||
tokenType: "Bearer" | string;
|
||||
|
|
@ -221,6 +247,8 @@ export interface ConnectionTokenUseEnvLeaseResponse {
|
|||
status: "use_env_lease";
|
||||
code: "use_env_lease";
|
||||
connectionId: string;
|
||||
connection: { id: string; uid: string };
|
||||
grantId: string;
|
||||
path: "static";
|
||||
message: string;
|
||||
scope: string[];
|
||||
|
|
@ -229,6 +257,29 @@ export interface ConnectionTokenUseEnvLeaseResponse {
|
|||
|
||||
export type ConnectionTokenResponse = ConnectionTokenMintedResponse | ConnectionTokenUseEnvLeaseResponse;
|
||||
|
||||
export interface StartConnectionAuthorizationRequest {
|
||||
subjectUserId: string;
|
||||
scopes?: string[];
|
||||
returnTo?: string;
|
||||
}
|
||||
|
||||
export interface StartConnectionAuthorizationResponse {
|
||||
url: string;
|
||||
}
|
||||
|
||||
export interface ConnectionUsageDailyBucket {
|
||||
date: string;
|
||||
issuances: { total: number; byOutcome: Record<string, number>; byPath: Record<string, number> };
|
||||
invocations: { total: number; byRiskLevel: Record<string, number> };
|
||||
deliveries: { received: number; forwarded: number };
|
||||
}
|
||||
|
||||
export interface ConnectionUsageResponse {
|
||||
connection: { id: string; uid: string };
|
||||
range: "7d" | "30d";
|
||||
buckets: ConnectionUsageDailyBucket[];
|
||||
}
|
||||
|
||||
export interface ConnectionTokenIssuance {
|
||||
id: string;
|
||||
companyId: string;
|
||||
|
|
|
|||
|
|
@ -793,6 +793,8 @@ export {
|
|||
connectionTokenIssuancePathSchema,
|
||||
connectionTokenRequestSchema,
|
||||
connectionTokenScopeSchema,
|
||||
connectionTokenSubjectSchema,
|
||||
startConnectionAuthorizationSchema,
|
||||
createToolTrustRuleFromActionRequestSchema,
|
||||
revokeToolTrustRuleSchema,
|
||||
toolPolicyTestRequestSchema,
|
||||
|
|
|
|||
|
|
@ -1,12 +1,30 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
connectionTokenRequestSchema,
|
||||
createToolConnectionSchema,
|
||||
startConnectionAuthorizationSchema,
|
||||
toolCredentialSecretRefSchema,
|
||||
toolRedactedValueSummarySchema,
|
||||
toolTransportConfigSchema,
|
||||
} from "./tool-access.js";
|
||||
|
||||
describe("tool access validators", () => {
|
||||
it("defaults connection token subjects to app", () => {
|
||||
expect(connectionTokenRequestSchema.parse({})).toEqual({ subject: { type: "app" } });
|
||||
});
|
||||
|
||||
it("accepts user subjects, grant selection, and authorization input", () => {
|
||||
const request = connectionTokenRequestSchema.parse({
|
||||
subject: { type: "user", userId: "user-123" },
|
||||
grantId: "11111111-1111-4111-8111-111111111111",
|
||||
});
|
||||
expect(request.subject).toEqual({ type: "user", userId: "user-123" });
|
||||
expect(startConnectionAuthorizationSchema.parse({ subjectUserId: "user-123", scopes: ["read"] })).toEqual({
|
||||
subjectUserId: "user-123",
|
||||
scopes: ["read"],
|
||||
});
|
||||
});
|
||||
|
||||
it("accepts multi-key credential annotations", () => {
|
||||
const parsed = toolCredentialSecretRefSchema.parse({
|
||||
secretId: "11111111-1111-4111-8111-111111111111",
|
||||
|
|
|
|||
|
|
@ -210,9 +210,22 @@ export const connectionTokenScopeSchema = z.union([
|
|||
z.array(z.string().trim().min(1).max(240)).max(100),
|
||||
]);
|
||||
|
||||
export const connectionTokenSubjectSchema = z.discriminatedUnion("type", [
|
||||
z.object({ type: z.literal("app") }).strict(),
|
||||
z.object({ type: z.literal("user"), userId: z.string().trim().min(1).max(500) }).strict(),
|
||||
]);
|
||||
|
||||
export const connectionTokenRequestSchema = z.object({
|
||||
subject: connectionTokenSubjectSchema.optional().default({ type: "app" }),
|
||||
scope: connectionTokenScopeSchema.optional(),
|
||||
requestedTtlSeconds: z.number().int().positive().max(86_400).optional(),
|
||||
grantId: z.string().uuid().optional(),
|
||||
}).strict();
|
||||
|
||||
export const startConnectionAuthorizationSchema = z.object({
|
||||
subjectUserId: z.string().trim().min(1).max(500),
|
||||
scopes: z.array(z.string().trim().min(1).max(240)).max(100).optional(),
|
||||
returnTo: z.string().trim().max(2000).optional(),
|
||||
}).strict();
|
||||
|
||||
export type ConnectionTokenRequestInput = z.infer<typeof connectionTokenRequestSchema>;
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import {
|
|||
companies,
|
||||
companyMemberships,
|
||||
companySecretBindings,
|
||||
connectionGrants,
|
||||
connectionTokenIssuances,
|
||||
companySecrets,
|
||||
companySecretVersions,
|
||||
|
|
@ -495,7 +496,7 @@ describeEmbeddedPostgres("tool access service", () => {
|
|||
});
|
||||
|
||||
const res = await request(app)
|
||||
.post(`/api/agents/me/connections/${connection.id}/token`)
|
||||
.post(`/api/agents/me/connections/${encodeURIComponent(connection.uid)}/token`)
|
||||
.set("X-Paperclip-Run-Id", run.id)
|
||||
.send({ scope: "pages:publish:ns/dotta", requestedTtlSeconds: 5000 });
|
||||
|
||||
|
|
@ -503,6 +504,8 @@ describeEmbeddedPostgres("tool access service", () => {
|
|||
expect(res.body).toMatchObject({
|
||||
status: "minted",
|
||||
connectionId: connection.id,
|
||||
connection: { id: connection.id, uid: connection.uid },
|
||||
grantId: expect.any(String),
|
||||
path: "exchange",
|
||||
token: "child-pages-token",
|
||||
tokenType: "Bearer",
|
||||
|
|
@ -539,6 +542,157 @@ describeEmbeddedPostgres("tool access service", () => {
|
|||
]));
|
||||
});
|
||||
|
||||
it("selects scoped credentials for array scopes and fails closed for unknown selectors", 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, {
|
||||
parentScopes: ["staging", "production"],
|
||||
});
|
||||
const productionSecret = await secretService(db).create(company.id, {
|
||||
provider: "local_encrypted",
|
||||
name: `Production broker parent ${randomUUID()}`,
|
||||
key: `broker.production.${randomUUID()}`,
|
||||
value: "production-deploy-token",
|
||||
});
|
||||
await db.update(toolConnections).set({
|
||||
config: {
|
||||
...connection.config,
|
||||
tokenBroker: {
|
||||
...(connection.config.tokenBroker as Record<string, unknown>),
|
||||
parentCredentialConfigPath: "credentials.production_token",
|
||||
},
|
||||
},
|
||||
credentialSecretRefs: [
|
||||
...connection.credentialSecretRefs,
|
||||
{
|
||||
secretId: productionSecret.id,
|
||||
versionSelector: "latest",
|
||||
configPath: "credentials.production_token",
|
||||
required: true,
|
||||
label: "Production deploy token",
|
||||
keyScope: "production",
|
||||
},
|
||||
],
|
||||
updatedAt: new Date(),
|
||||
}).where(eq(toolConnections.id, connection.id));
|
||||
await db.insert(companySecretBindings).values({
|
||||
companyId: company.id,
|
||||
secretId: productionSecret.id,
|
||||
targetType: "tool_connection",
|
||||
targetId: connection.id,
|
||||
configPath: "credentials.production_token",
|
||||
});
|
||||
await allowConnectionForAgent(db, company.id, agent.id, connection.id);
|
||||
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: "unexpected-production-token",
|
||||
expiresAt: new Date(Date.now() + 900_000).toISOString(),
|
||||
scope: "staging",
|
||||
}),
|
||||
} as Response);
|
||||
|
||||
const res = await request(app)
|
||||
.post(`/api/agents/me/connections/${connection.id}/token`)
|
||||
.send({ scope: "staging" });
|
||||
|
||||
expect(res.status).toBe(422);
|
||||
expect(res.body).toMatchObject({ code: "parent_credential_missing" });
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
const productionSecretEvents = await db.select().from(secretAccessEvents).where(and(
|
||||
eq(secretAccessEvents.consumerId, connection.id),
|
||||
eq(secretAccessEvents.configPath, "credentials.production_token"),
|
||||
));
|
||||
expect(productionSecretEvents).toHaveLength(0);
|
||||
|
||||
fetchMock.mockClear();
|
||||
fetchMock.mockImplementation(async (_url, init) => {
|
||||
expect(init?.headers).toEqual(expect.objectContaining({ authorization: "Bearer production-deploy-token" }));
|
||||
return {
|
||||
ok: true,
|
||||
status: 201,
|
||||
json: async () => ({
|
||||
token: "production-child-token",
|
||||
expiresAt: new Date(Date.now() + 900_000).toISOString(),
|
||||
scope: "production",
|
||||
}),
|
||||
} as Response;
|
||||
});
|
||||
|
||||
const productionRes = await request(app)
|
||||
.post(`/api/agents/me/connections/${connection.id}/token`)
|
||||
.send({ scope: ["production"] });
|
||||
|
||||
expect(productionRes.status).toBe(200);
|
||||
expect(productionRes.body).toMatchObject({ token: "production-child-token", scope: ["production"] });
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("returns typed subject errors and rejects revoked grants immediately", 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 app = createRouteApp(db, agentJwtActor(company.id, agent.id, run.id));
|
||||
|
||||
const denied = await request(app)
|
||||
.post(`/api/agents/me/connections/${encodeURIComponent(connection.uid)}/token`)
|
||||
.send({ subject: { type: "user", userId: "someone-else" } });
|
||||
expect(denied.status).toBe(403);
|
||||
expect(denied.body).toMatchObject({
|
||||
code: "subject_not_permitted",
|
||||
connection: { uid: connection.uid },
|
||||
subject: { type: "user", userId: "someone-else" },
|
||||
});
|
||||
|
||||
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 revoked = await request(app)
|
||||
.post(`/api/agents/me/connections/${connection.id}/token`)
|
||||
.send({ grantId: grant.id });
|
||||
expect(revoked.status).toBe(409);
|
||||
expect(revoked.body).toMatchObject({ code: "grant_revoked", grantId: grant.id });
|
||||
});
|
||||
|
||||
it("returns daily connection usage buckets", async () => {
|
||||
const company = await createCompany(db);
|
||||
const { connection } = await createBrokerConnection(db, company.id);
|
||||
const service = toolAccessService(db);
|
||||
await db.insert(connectionTokenIssuances).values({
|
||||
companyId: company.id,
|
||||
applicationId: connection.applicationId,
|
||||
connectionId: connection.id,
|
||||
agentId: (await createAgent(db, company.id)).id,
|
||||
path: "exchange",
|
||||
requestedScope: [],
|
||||
issuedScope: [],
|
||||
outcome: "success",
|
||||
});
|
||||
await db.insert(toolInvocations).values({
|
||||
companyId: company.id,
|
||||
connectionId: connection.id,
|
||||
toolName: "fixture",
|
||||
riskLevel: "write",
|
||||
});
|
||||
const usage = await service.getConnectionUsage(connection.uid, "7d", company.id);
|
||||
expect(usage.connection).toEqual({ id: connection.id, uid: connection.uid });
|
||||
expect(usage.buckets.at(-1)).toMatchObject({
|
||||
issuances: { total: 1, byOutcome: { success: 1 }, byPath: { exchange: 1 } },
|
||||
invocations: { total: 1, byRiskLevel: { write: 1 } },
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects connection token minting after the heartbeat run completes", async () => {
|
||||
const company = await createCompany(db);
|
||||
const agent = await createAgent(db, company.id);
|
||||
|
|
@ -2722,6 +2876,92 @@ describeEmbeddedPostgres("tool access service", () => {
|
|||
expect(updated.transportConfig).toEqual(updated.config);
|
||||
});
|
||||
|
||||
it("creates and resolves an agent-initiated user authorization grant card", async () => {
|
||||
vi.stubEnv("PAPERCLIP_TOOL_OAUTH_SLACK_CLIENT_ID", "slack-client-id");
|
||||
vi.stubEnv("PAPERCLIP_TOOL_OAUTH_SLACK_CLIENT_SECRET", "slack-client-secret");
|
||||
const company = await createCompany(db);
|
||||
const agent = await createAgent(db, company.id);
|
||||
const { issue, run } = await createIssueAndRun(db, company.id, agent.id);
|
||||
const service = toolAccessService(db);
|
||||
const connected = await service.connectGalleryApp(company.id, { galleryKey: "slack", name: "Slack user auth" });
|
||||
|
||||
const workspaceStarted = await service.startOAuth(company.id, connected.connectionId, {
|
||||
redirectUri: "https://paperclip.example/api/tools/oauth/callback",
|
||||
actor: { actorType: "user", actorId: "workspace-owner" },
|
||||
});
|
||||
const workspaceState = new URL(workspaceStarted.authorizationUrl).searchParams.get("state")!;
|
||||
vi.spyOn(globalThis, "fetch").mockImplementation(async (url, init) => {
|
||||
const href = String(url);
|
||||
if (href === "https://slack.com/api/oauth.v2.access") {
|
||||
const body = init?.body as URLSearchParams;
|
||||
const userAuthorization = body.get("code") === "user-authorization-code";
|
||||
return {
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => ({
|
||||
access_token: userAuthorization ? "user-access-token" : "workspace-access-token",
|
||||
refresh_token: userAuthorization ? "user-refresh-token" : "workspace-refresh-token",
|
||||
expires_in: 3600,
|
||||
}),
|
||||
} as Response;
|
||||
}
|
||||
if (href === "https://mcp.slack.com/mcp") {
|
||||
return mcpHttpResponse({ jsonrpc: "2.0", id: "paperclip-catalog-refresh", result: { tools: [] } });
|
||||
}
|
||||
throw new Error(`unexpected fetch ${href}`);
|
||||
});
|
||||
await service.completeOAuthCallback({
|
||||
state: workspaceState,
|
||||
code: "workspace-authorization-code",
|
||||
redirectUri: "https://paperclip.example/api/tools/oauth/callback",
|
||||
actor: { actorType: "user", actorId: "workspace-owner" },
|
||||
});
|
||||
const [workspaceConnection] = await db.select().from(toolConnections).where(eq(toolConnections.id, connected.connectionId));
|
||||
const workspaceSecretIds = workspaceConnection.credentialSecretRefs.map((ref) => ref.secretId).sort();
|
||||
|
||||
const started = await service.startAuthorizationForAgent({
|
||||
companyId: company.id,
|
||||
connectionId: connected.connectionId,
|
||||
agentId: agent.id,
|
||||
runId: run.id,
|
||||
subjectUserId: "user-for-run",
|
||||
scopes: ["users:read"],
|
||||
redirectUri: "https://paperclip.example/api/tools/oauth/callback",
|
||||
});
|
||||
const authorizationUrl = new URL(started.authorizationUrl);
|
||||
expect(authorizationUrl.searchParams.get("scope")).toBe("users:read");
|
||||
|
||||
const [state] = await db.select().from(toolOauthStates);
|
||||
expect(state).toMatchObject({ subjectUserId: "user-for-run", issueId: issue.id, requestedScopes: ["users:read"] });
|
||||
const [interaction] = await db.select().from(issueThreadInteractions);
|
||||
expect(interaction).toMatchObject({
|
||||
issueId: issue.id,
|
||||
kind: "request_confirmation",
|
||||
status: "pending",
|
||||
title: "Connect your account",
|
||||
});
|
||||
expect(interaction.payload).toMatchObject({ target: { href: started.authorizationUrl } });
|
||||
|
||||
await service.completeOAuthCallback({
|
||||
state: state.state,
|
||||
code: "user-authorization-code",
|
||||
redirectUri: "https://paperclip.example/api/tools/oauth/callback",
|
||||
actor: { actorType: "user", actorId: "user-for-run" },
|
||||
});
|
||||
|
||||
const [grant] = await db.select().from(connectionGrants).where(and(
|
||||
eq(connectionGrants.connectionId, connected.connectionId),
|
||||
eq(connectionGrants.subjectUserId, "user-for-run"),
|
||||
));
|
||||
expect(grant).toMatchObject({ kind: "user", status: "active" });
|
||||
expect(grant.credentialSecretRefs.map((ref) => ref.configPath).sort()).toEqual(["oauth.access_token", "oauth.refresh_token"]);
|
||||
expect(grant.credentialSecretRefs.map((ref) => ref.secretId).sort()).not.toEqual(workspaceSecretIds);
|
||||
const [unchangedConnection] = await db.select().from(toolConnections).where(eq(toolConnections.id, connected.connectionId));
|
||||
expect(unchangedConnection.credentialSecretRefs.map((ref) => ref.secretId).sort()).toEqual(workspaceSecretIds);
|
||||
const [resolved] = await db.select().from(issueThreadInteractions).where(eq(issueThreadInteractions.id, interaction.id));
|
||||
expect(resolved).toMatchObject({ status: "accepted", result: { version: 1, outcome: "accepted" } });
|
||||
});
|
||||
|
||||
it("starts and completes OAuth app sign-in with PKCE state and secret-backed tokens", async () => {
|
||||
vi.stubEnv("PAPERCLIP_TOOL_OAUTH_SLACK_CLIENT_ID", "slack-client-id");
|
||||
vi.stubEnv("PAPERCLIP_TOOL_OAUTH_SLACK_CLIENT_SECRET", "slack-client-secret");
|
||||
|
|
|
|||
|
|
@ -83,6 +83,14 @@ export function errorHandler(
|
|||
? err.details as Record<string, unknown>
|
||||
: null;
|
||||
const redactedSkillPolicyDenial = isRedactedSkillPolicyDenial(details);
|
||||
const structuredConnectionError = new Set([
|
||||
"user_authorization_required",
|
||||
"grant_revoked",
|
||||
"needs_reauthorization",
|
||||
"installation_required",
|
||||
"connection_not_installed",
|
||||
"subject_not_permitted",
|
||||
]).has(typeof details?.code === "string" ? details.code : "");
|
||||
recordResponsibleUserDenialFromHttpError(req, details);
|
||||
if (err.status >= 500) {
|
||||
attachErrorContext(
|
||||
|
|
@ -98,7 +106,12 @@ export function errorHandler(
|
|||
error: err.message,
|
||||
...(typeof details?.code === "string" ? { code: details.code } : {}),
|
||||
...(redactedSkillPolicyDenial && typeof details?.reason === "string" ? { reason: details.reason } : {}),
|
||||
...(typeof details?.remediation === "string" ? { remediation: details.remediation } : {}),
|
||||
...(typeof details?.remediation === "string" || (structuredConnectionError && details?.remediation && typeof details.remediation === "object")
|
||||
? { remediation: details.remediation }
|
||||
: {}),
|
||||
...(structuredConnectionError && details?.connection ? { connection: details.connection } : {}),
|
||||
...(structuredConnectionError && details?.subject ? { subject: details.subject } : {}),
|
||||
...(structuredConnectionError && typeof details?.grantId === "string" ? { grantId: details.grantId } : {}),
|
||||
...(!redactedSkillPolicyDenial && err.details ? { details: err.details } : {}),
|
||||
});
|
||||
return;
|
||||
|
|
|
|||
|
|
@ -173,6 +173,7 @@ import {
|
|||
updateToolApplicationSchema,
|
||||
createToolConnectionSchema,
|
||||
connectionTokenRequestSchema,
|
||||
startConnectionAuthorizationSchema,
|
||||
createToolStdioCommandTemplateSchema,
|
||||
disableToolStdioCommandTemplateSchema,
|
||||
finishToolAppSchema,
|
||||
|
|
@ -772,7 +773,12 @@ const BOARD_ONLY_OPERATIONS = new Set([
|
|||
"DELETE /api/tool-applications/{applicationId}",
|
||||
"GET /api/companies/{companyId}/tools/connections",
|
||||
"POST /api/companies/{companyId}/tools/connections",
|
||||
"POST /api/companies/{companyId}/tools/connections/{connectionId}/start-authorization",
|
||||
"GET /api/tool-connections/{connectionId}",
|
||||
"GET /api/tool-connections/{connectionId}/grants",
|
||||
"POST /api/tool-connections/{connectionId}/grants/installations",
|
||||
"DELETE /api/tool-connections/{connectionId}/grants/{grantId}",
|
||||
"GET /api/tool-connections/{connectionId}/usage",
|
||||
"PATCH /api/tool-connections/{connectionId}",
|
||||
"DELETE /api/tool-connections/{connectionId}",
|
||||
"POST /api/tool-connections/{connectionId}/health-check",
|
||||
|
|
@ -783,6 +789,7 @@ const BOARD_ONLY_OPERATIONS = new Set([
|
|||
"GET /api/tool-connections/{connectionId}/test-agents",
|
||||
"POST /api/tool-connections/{connectionId}/test-calls",
|
||||
"GET /api/tool-connections/{connectionId}/test-calls/{actionRequestId}",
|
||||
"POST /api/agents/me/connections/{connectionId}/start-authorization",
|
||||
"POST /api/agents/me/connections/{connectionId}/token",
|
||||
"POST /api/tools/oauth/{connectionId}/start",
|
||||
"GET /api/tools/oauth/callback",
|
||||
|
|
@ -5975,6 +5982,51 @@ registerCurrentRoute({
|
|||
summary: "Get a tool connection",
|
||||
});
|
||||
|
||||
registerCurrentRoute({
|
||||
method: "post",
|
||||
path: "/api/companies/{companyId}/tools/connections/{connectionId}/start-authorization",
|
||||
tags: ["tool-access"],
|
||||
summary: "Start user authorization for a tool connection",
|
||||
body: startConnectionAuthorizationSchema,
|
||||
});
|
||||
|
||||
registerCurrentRoute({
|
||||
method: "post",
|
||||
path: "/api/agents/me/connections/{connectionId}/start-authorization",
|
||||
tags: ["tool-access"],
|
||||
summary: "Start user authorization for an agent tool connection",
|
||||
body: startConnectionAuthorizationSchema,
|
||||
});
|
||||
|
||||
registerCurrentRoute({
|
||||
method: "get",
|
||||
path: "/api/tool-connections/{connectionId}/grants",
|
||||
tags: ["tool-access"],
|
||||
summary: "List tool connection grants",
|
||||
});
|
||||
|
||||
registerCurrentRoute({
|
||||
method: "post",
|
||||
path: "/api/tool-connections/{connectionId}/grants/installations",
|
||||
tags: ["tool-access"],
|
||||
summary: "Add an installation grant to a tool connection",
|
||||
responses: { 201: r.ok(), 400: r.badRequest, 401: r.unauthorized, 403: r.forbidden, 404: r.notFound },
|
||||
});
|
||||
|
||||
registerCurrentRoute({
|
||||
method: "delete",
|
||||
path: "/api/tool-connections/{connectionId}/grants/{grantId}",
|
||||
tags: ["tool-access"],
|
||||
summary: "Revoke a tool connection grant",
|
||||
});
|
||||
|
||||
registerCurrentRoute({
|
||||
method: "get",
|
||||
path: "/api/tool-connections/{connectionId}/usage",
|
||||
tags: ["tool-access"],
|
||||
summary: "Get tool connection usage",
|
||||
});
|
||||
|
||||
registerCurrentRoute({
|
||||
method: "get",
|
||||
path: "/api/tool-connections/{connectionId}/installs",
|
||||
|
|
|
|||
|
|
@ -28,6 +28,7 @@ import {
|
|||
importMcpJsonSchema,
|
||||
putToolConnectionInstallsSchema,
|
||||
connectionTokenRequestSchema,
|
||||
startConnectionAuthorizationSchema,
|
||||
revokeToolTrustRuleSchema,
|
||||
reorderToolPoliciesSchema,
|
||||
toolPolicyTestRequestSchema,
|
||||
|
|
@ -174,6 +175,24 @@ export function toolAccessRoutes(
|
|||
await assertBoardToolPermission(req, companyId, "tools:manage_runtime");
|
||||
}
|
||||
|
||||
router.post("/agents/me/connections/:connectionId/start-authorization", validate(startConnectionAuthorizationSchema), async (req, res) => {
|
||||
if (req.actor.type !== "agent" || !req.actor.agentId || !req.actor.companyId || !req.actor.runId) {
|
||||
res.status(401).json({ error: "Active agent run authentication required" });
|
||||
return;
|
||||
}
|
||||
const result = await svc.startAuthorizationForAgent({
|
||||
companyId: req.actor.companyId,
|
||||
connectionId: req.params.connectionId as string,
|
||||
agentId: req.actor.agentId,
|
||||
runId: req.actor.runId,
|
||||
subjectUserId: req.body.subjectUserId,
|
||||
scopes: req.body.scopes,
|
||||
returnTo: req.body.returnTo,
|
||||
redirectUri: oauthRedirectUri(),
|
||||
});
|
||||
res.json({ url: result.authorizationUrl });
|
||||
});
|
||||
|
||||
router.post("/agents/me/connections/:connectionId/token", validate(connectionTokenRequestSchema), async (req, res) => {
|
||||
if (req.actor.type !== "agent" || !req.actor.agentId || !req.actor.companyId) {
|
||||
res.status(401).json({ error: "Agent authentication required" });
|
||||
|
|
@ -267,6 +286,27 @@ export function toolAccessRoutes(
|
|||
}
|
||||
});
|
||||
|
||||
router.post(
|
||||
"/companies/:companyId/tools/connections/:connectionId/start-authorization",
|
||||
validate(startConnectionAuthorizationSchema),
|
||||
async (req, res) => {
|
||||
const companyId = req.params.companyId as string;
|
||||
assertToolAppMutationAccess(req, companyId);
|
||||
if (!req.actor.userId || req.actor.userId !== req.body.subjectUserId) {
|
||||
throw forbidden("Board users may only authorize their own connection subject");
|
||||
}
|
||||
const existing = await svc.getConnection(req.params.connectionId as string, companyId);
|
||||
const result = await svc.startOAuth(companyId, existing.id, {
|
||||
redirectUri: oauthRedirectUri(),
|
||||
actor: getActorInfo(req),
|
||||
subjectUserId: req.body.subjectUserId,
|
||||
scopes: req.body.scopes,
|
||||
returnTo: req.body.returnTo,
|
||||
});
|
||||
res.json({ url: result.authorizationUrl });
|
||||
},
|
||||
);
|
||||
|
||||
router.post("/tools/oauth/:connectionId/start", async (req, res) => {
|
||||
const existing = await svc.getConnection(req.params.connectionId as string);
|
||||
assertToolAppMutationAccess(req, existing.companyId);
|
||||
|
|
@ -523,6 +563,67 @@ export function toolAccessRoutes(
|
|||
res.json(connection);
|
||||
});
|
||||
|
||||
router.get("/tool-connections/:connectionId/grants", async (req, res) => {
|
||||
assertBoard(req);
|
||||
const connection = await svc.getConnection(req.params.connectionId as string);
|
||||
if (!hasCompanyAccess(req, connection.companyId)) throw notFound("Tool connection not found");
|
||||
assertCompanyAccess(req, connection.companyId);
|
||||
res.json(await svc.listConnectionGrants(connection.id, connection.companyId));
|
||||
});
|
||||
|
||||
router.post("/tool-connections/:connectionId/grants/installations", async (req, res) => {
|
||||
assertBoard(req);
|
||||
const connection = await svc.getConnection(req.params.connectionId as string);
|
||||
await assertBoardToolPermission(req, connection.companyId, "tools:manage_connections");
|
||||
const body = req.body && typeof req.body === "object" ? req.body as Record<string, unknown> : {};
|
||||
const credentialSecretRefs = Array.isArray(body.credentialSecretRefs) ? body.credentialSecretRefs : [];
|
||||
const providerTenant = body.providerTenant && typeof body.providerTenant === "object"
|
||||
? body.providerTenant as { name?: string; externalId?: string }
|
||||
: undefined;
|
||||
const grant = await svc.addConnectionInstallation(connection.id, {
|
||||
providerTenant,
|
||||
credentialSecretRefs,
|
||||
isDefault: body.isDefault === true,
|
||||
}, getActorInfo(req));
|
||||
await logActivity(db, {
|
||||
companyId: connection.companyId,
|
||||
actorType: "user",
|
||||
actorId: req.actor.userId ?? "board",
|
||||
action: "tool_connection.grant_added",
|
||||
entityType: "connection_grant",
|
||||
entityId: grant.id,
|
||||
details: { connectionId: connection.id, kind: grant.kind },
|
||||
});
|
||||
res.status(201).json(grant);
|
||||
});
|
||||
|
||||
router.delete("/tool-connections/:connectionId/grants/:grantId", async (req, res) => {
|
||||
assertBoard(req);
|
||||
const connection = await svc.getConnection(req.params.connectionId as string);
|
||||
await assertBoardToolPermission(req, connection.companyId, "tools:manage_connections");
|
||||
const grant = await svc.revokeConnectionGrant(connection.id, req.params.grantId as string, getActorInfo(req));
|
||||
await logActivity(db, {
|
||||
companyId: connection.companyId,
|
||||
actorType: "user",
|
||||
actorId: req.actor.userId ?? "board",
|
||||
action: "tool_connection.grant_revoked",
|
||||
entityType: "connection_grant",
|
||||
entityId: grant.id,
|
||||
details: { connectionId: connection.id, kind: grant.kind },
|
||||
});
|
||||
res.json(grant);
|
||||
});
|
||||
|
||||
router.get("/tool-connections/:connectionId/usage", async (req, res) => {
|
||||
assertBoard(req);
|
||||
const connection = await svc.getConnection(req.params.connectionId as string);
|
||||
if (!hasCompanyAccess(req, connection.companyId)) throw notFound("Tool connection not found");
|
||||
assertCompanyAccess(req, connection.companyId);
|
||||
const range = req.query.range === "30d" ? "30d" : req.query.range === undefined || req.query.range === "7d" ? "7d" : null;
|
||||
if (!range) throw badRequest("Usage range must be 7d or 30d");
|
||||
res.json(await svc.getConnectionUsage(connection.id, range, connection.companyId));
|
||||
});
|
||||
|
||||
router.get("/tool-connections/:connectionId/installs", async (req, res) => {
|
||||
assertBoard(req);
|
||||
const connection = await svc.getConnection(req.params.connectionId as string);
|
||||
|
|
|
|||
|
|
@ -5,12 +5,14 @@ import type { Db } from "@paperclipai/db";
|
|||
import {
|
||||
activityLog,
|
||||
agents,
|
||||
connectionGrants,
|
||||
connectionTokenIssuances,
|
||||
authUsers,
|
||||
companySecretBindings,
|
||||
companySecrets,
|
||||
heartbeatRuns,
|
||||
issues,
|
||||
issueThreadInteractions,
|
||||
plugins,
|
||||
projects,
|
||||
routines,
|
||||
|
|
@ -2421,15 +2423,45 @@ export function toolAccessService(db: Db, options: ToolAccessServiceOptions = {}
|
|||
if (input.catalogEntryId) await assertCatalogEntry(companyId, input.catalogEntryId);
|
||||
}
|
||||
|
||||
async function getConnectionRow(connectionId: string, companyId?: string) {
|
||||
async function getConnectionRow(idOrUid: string, companyId?: string) {
|
||||
const identifier = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(idOrUid)
|
||||
? eq(toolConnections.id, idOrUid)
|
||||
: eq(toolConnections.uid, idOrUid);
|
||||
const where = companyId
|
||||
? and(eq(toolConnections.id, connectionId), eq(toolConnections.companyId, companyId))
|
||||
: eq(toolConnections.id, connectionId);
|
||||
? and(identifier, eq(toolConnections.companyId, companyId))
|
||||
: identifier;
|
||||
const [row] = await db.select().from(toolConnections).where(where);
|
||||
if (!row) throw notFound("Tool connection not found");
|
||||
return row;
|
||||
}
|
||||
|
||||
async function ensureDefaultWorkspaceGrant(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.isDefault, true),
|
||||
))
|
||||
.limit(1);
|
||||
if (existing) return existing;
|
||||
const [created] = await db
|
||||
.insert(connectionGrants)
|
||||
.values({
|
||||
companyId: connection.companyId,
|
||||
connectionId: connection.id,
|
||||
kind: "workspace",
|
||||
credentialSecretRefs: connection.credentialSecretRefs,
|
||||
status: "active",
|
||||
isDefault: true,
|
||||
})
|
||||
.returning();
|
||||
if (!created) throw new Error("Failed to create default connection grant");
|
||||
return created;
|
||||
}
|
||||
|
||||
async function getProfileRow(profileId: string, companyId?: string) {
|
||||
const where = companyId
|
||||
? and(eq(toolProfiles.id, profileId), eq(toolProfiles.companyId, companyId))
|
||||
|
|
@ -3906,8 +3938,11 @@ export function toolAccessService(db: Db, options: ToolAccessServiceOptions = {}
|
|||
label: string;
|
||||
value: string;
|
||||
actor?: ActorInfo;
|
||||
existingRefs?: typeof connectionGrants.$inferSelect.credentialSecretRefs;
|
||||
}) {
|
||||
const existing = oauthSecretRef(input.connection, input.configPath);
|
||||
const existing = input.existingRefs === undefined
|
||||
? oauthSecretRef(input.connection, input.configPath)
|
||||
: input.existingRefs.find((ref) => ref.configPath === input.configPath);
|
||||
if (existing) {
|
||||
await secrets.rotate(existing.secretId, { value: input.value }, actorForSecret(input.actor));
|
||||
return existing;
|
||||
|
|
@ -4703,7 +4738,7 @@ export function toolAccessService(db: Db, options: ToolAccessServiceOptions = {}
|
|||
async function startOAuth(
|
||||
companyId: string,
|
||||
connectionId: string,
|
||||
input: { redirectUri: string; actor: ActorInfo },
|
||||
input: { redirectUri: string; actor: ActorInfo; subjectUserId?: string; scopes?: string[]; returnTo?: string; issueId?: string },
|
||||
): Promise<ToolOAuthStartResult> {
|
||||
const connection = await getConnectionRow(connectionId, companyId);
|
||||
if (connection.status === "archived") throw conflict("Archived app connections cannot start sign in");
|
||||
|
|
@ -4731,6 +4766,10 @@ export function toolAccessService(db: Db, options: ToolAccessServiceOptions = {}
|
|||
createdByActorType: binding.actorType,
|
||||
createdByActorId: binding.actorId,
|
||||
createdBySessionId: binding.sessionId,
|
||||
subjectUserId: input.subjectUserId,
|
||||
requestedScopes: input.scopes,
|
||||
returnTo: input.returnTo,
|
||||
issueId: input.issueId,
|
||||
expiresAt,
|
||||
});
|
||||
|
||||
|
|
@ -4741,7 +4780,55 @@ export function toolAccessService(db: Db, options: ToolAccessServiceOptions = {}
|
|||
authorizationUrl.searchParams.set("state", state);
|
||||
authorizationUrl.searchParams.set("code_challenge", base64UrlSha256(codeVerifier));
|
||||
authorizationUrl.searchParams.set("code_challenge_method", "S256");
|
||||
if (endpoints.scopes.length > 0) authorizationUrl.searchParams.set("scope", endpoints.scopes.join(" "));
|
||||
const authorizationScopes = input.scopes ?? endpoints.scopes;
|
||||
if (authorizationScopes.length > 0) authorizationUrl.searchParams.set("scope", authorizationScopes.join(" "));
|
||||
|
||||
if (input.subjectUserId && input.issueId && binding.actorType === "agent") {
|
||||
const idempotencyKey = `connection-authorization:${connection.id}:${input.subjectUserId}`;
|
||||
const payload = {
|
||||
version: 1 as const,
|
||||
prompt: `Connect your account to ${connection.name}`,
|
||||
acceptLabel: "Open authorization",
|
||||
rejectLabel: "Not now",
|
||||
detailsMarkdown: "Authorization is required before this agent can act on your behalf.",
|
||||
target: {
|
||||
type: "custom" as const,
|
||||
key: `connection:${connection.uid}:user:${input.subjectUserId}`,
|
||||
revisionId: state,
|
||||
label: `Connect ${connection.name}`,
|
||||
href: authorizationUrl.toString(),
|
||||
},
|
||||
};
|
||||
const [existingInteraction] = await db.select().from(issueThreadInteractions).where(and(
|
||||
eq(issueThreadInteractions.companyId, companyId),
|
||||
eq(issueThreadInteractions.issueId, input.issueId),
|
||||
eq(issueThreadInteractions.idempotencyKey, idempotencyKey),
|
||||
)).limit(1);
|
||||
const [interaction] = existingInteraction
|
||||
? await db.update(issueThreadInteractions).set({
|
||||
status: "pending",
|
||||
payload,
|
||||
result: null,
|
||||
resolvedAt: null,
|
||||
updatedAt: new Date(),
|
||||
}).where(eq(issueThreadInteractions.id, existingInteraction.id)).returning()
|
||||
: await db.insert(issueThreadInteractions).values({
|
||||
companyId,
|
||||
issueId: input.issueId,
|
||||
kind: "request_confirmation",
|
||||
status: "pending",
|
||||
continuationPolicy: "none",
|
||||
idempotencyKey,
|
||||
sourceRunId: binding.actorType === "agent" ? input.actor.sessionId ?? null : null,
|
||||
title: "Connect your account",
|
||||
summary: `Connect ${connection.name} to continue`,
|
||||
createdByAgentId: binding.actorType === "agent" ? binding.actorId : null,
|
||||
payload,
|
||||
}).returning();
|
||||
if (interaction) {
|
||||
await db.update(toolOauthStates).set({ interactionId: interaction.id }).where(eq(toolOauthStates.state, state));
|
||||
}
|
||||
}
|
||||
|
||||
const nextConfig = {
|
||||
...connection.config,
|
||||
|
|
@ -4797,7 +4884,13 @@ export function toolAccessService(db: Db, options: ToolAccessServiceOptions = {}
|
|||
.limit(1);
|
||||
if (!stateRow) throw badRequest("OAuth state was not found or has already been used");
|
||||
if (stateRow.expiresAt.getTime() <= Date.now()) throw badRequest("OAuth state has expired");
|
||||
assertSameOAuthActor(stateRow, input.actor);
|
||||
if (stateRow.subjectUserId) {
|
||||
if (input.actor?.actorType !== "user" || input.actor.actorId !== stateRow.subjectUserId) {
|
||||
throw forbidden("OAuth callback user does not match the requested subject");
|
||||
}
|
||||
} else {
|
||||
assertSameOAuthActor(stateRow, input.actor);
|
||||
}
|
||||
await db.delete(toolOauthStates).where(eq(toolOauthStates.state, input.state));
|
||||
|
||||
let connection = await getConnectionRow(stateRow.connectionId, stateRow.companyId);
|
||||
|
|
@ -4815,6 +4908,17 @@ export function toolAccessService(db: Db, options: ToolAccessServiceOptions = {}
|
|||
codeVerifier: stateRow.codeVerifier,
|
||||
code: input.code,
|
||||
});
|
||||
const [existingUserGrant] = stateRow.subjectUserId
|
||||
? await db.select().from(connectionGrants).where(and(
|
||||
eq(connectionGrants.companyId, connection.companyId),
|
||||
eq(connectionGrants.connectionId, connection.id),
|
||||
eq(connectionGrants.kind, "user"),
|
||||
eq(connectionGrants.subjectUserId, stateRow.subjectUserId),
|
||||
)).limit(1)
|
||||
: [undefined];
|
||||
const subjectCredentialSecretRefs = stateRow.subjectUserId
|
||||
? existingUserGrant?.credentialSecretRefs ?? []
|
||||
: connection.credentialSecretRefs;
|
||||
const accessRef = await createOrRotateOAuthSecret({
|
||||
companyId: connection.companyId,
|
||||
connection,
|
||||
|
|
@ -4822,9 +4926,10 @@ export function toolAccessService(db: Db, options: ToolAccessServiceOptions = {}
|
|||
label: "OAuth access token",
|
||||
value: token.accessToken,
|
||||
actor: input.actor,
|
||||
existingRefs: stateRow.subjectUserId ? subjectCredentialSecretRefs : undefined,
|
||||
});
|
||||
const nextCredentialSecretRefs = [
|
||||
...connection.credentialSecretRefs.filter((ref) => ref.configPath !== "oauth.access_token" && ref.configPath !== "oauth.refresh_token"),
|
||||
...subjectCredentialSecretRefs.filter((ref) => ref.configPath !== "oauth.access_token" && ref.configPath !== "oauth.refresh_token"),
|
||||
accessRef,
|
||||
];
|
||||
if (token.refreshToken) {
|
||||
|
|
@ -4835,12 +4940,63 @@ export function toolAccessService(db: Db, options: ToolAccessServiceOptions = {}
|
|||
label: "OAuth refresh token",
|
||||
value: token.refreshToken,
|
||||
actor: input.actor,
|
||||
existingRefs: stateRow.subjectUserId ? subjectCredentialSecretRefs : undefined,
|
||||
}));
|
||||
} else {
|
||||
const existingRefreshRef = oauthSecretRef(connection, "oauth.refresh_token");
|
||||
const existingRefreshRef = subjectCredentialSecretRefs.find((ref) => ref.configPath === "oauth.refresh_token");
|
||||
if (existingRefreshRef) nextCredentialSecretRefs.push(existingRefreshRef);
|
||||
}
|
||||
const expiresAt = token.expiresIn ? new Date(Date.now() + token.expiresIn * 1000).toISOString() : null;
|
||||
if (stateRow.subjectUserId) {
|
||||
const grantValues = {
|
||||
credentialSecretRefs: nextCredentialSecretRefs,
|
||||
status: "active" as const,
|
||||
revokedAt: null,
|
||||
revokedByAgentId: null,
|
||||
revokedByUserId: null,
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
if (existingUserGrant) {
|
||||
await db.update(connectionGrants).set(grantValues).where(eq(connectionGrants.id, existingUserGrant.id));
|
||||
} else {
|
||||
await db.insert(connectionGrants).values({
|
||||
companyId: connection.companyId,
|
||||
connectionId: connection.id,
|
||||
kind: "user",
|
||||
subjectUserId: stateRow.subjectUserId,
|
||||
...grantValues,
|
||||
isDefault: false,
|
||||
createdByUserId: stateRow.subjectUserId,
|
||||
});
|
||||
}
|
||||
if (stateRow.interactionId) {
|
||||
await db.update(issueThreadInteractions).set({
|
||||
status: "accepted",
|
||||
result: { version: 1, outcome: "accepted" },
|
||||
resolvedByUserId: stateRow.subjectUserId,
|
||||
resolvedAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
}).where(and(
|
||||
eq(issueThreadInteractions.id, stateRow.interactionId),
|
||||
eq(issueThreadInteractions.companyId, connection.companyId),
|
||||
));
|
||||
}
|
||||
const [application] = await db.select().from(toolApplications).where(eq(toolApplications.id, connection.applicationId));
|
||||
if (!application) throw new Error("OAuth connection application was not found");
|
||||
const catalog = (await db.select().from(toolCatalogEntries).where(and(
|
||||
eq(toolCatalogEntries.companyId, connection.companyId),
|
||||
eq(toolCatalogEntries.connectionId, connection.id),
|
||||
))).map(toCatalogEntry);
|
||||
return {
|
||||
connectionId: connection.id,
|
||||
application: toApplication(application),
|
||||
connection: toConnection(connection),
|
||||
catalog,
|
||||
actions: groupedActions(catalog),
|
||||
suggestedDefaults: galleryEntry ? recommendedDefaultsForApp(galleryEntry) : { access: "all_agents", askFirstRiskLevels: ["write", "destructive"] },
|
||||
auth: null,
|
||||
};
|
||||
}
|
||||
const nextConfig = {
|
||||
...connection.config,
|
||||
oauth: {
|
||||
|
|
@ -5106,6 +5262,35 @@ export function toolAccessService(db: Db, options: ToolAccessServiceOptions = {}
|
|||
|
||||
startOAuth,
|
||||
|
||||
startAuthorizationForAgent: async (input: {
|
||||
companyId: string;
|
||||
connectionId: string;
|
||||
agentId: string;
|
||||
runId: string;
|
||||
subjectUserId: string;
|
||||
scopes?: string[];
|
||||
returnTo?: string;
|
||||
redirectUri: string;
|
||||
}) => {
|
||||
const runContext = await loadBrokerRunContext(input);
|
||||
const connection = await getConnectionRow(input.connectionId, input.companyId);
|
||||
if (!runContext.responsibleUserId || runContext.responsibleUserId !== input.subjectUserId) {
|
||||
throw new HttpError(403, "The agent run cannot start authorization for the requested user", {
|
||||
code: "subject_not_permitted",
|
||||
connection: { uid: connection.uid },
|
||||
subject: { type: "user", userId: input.subjectUserId },
|
||||
});
|
||||
}
|
||||
return startOAuth(input.companyId, connection.id, {
|
||||
redirectUri: input.redirectUri,
|
||||
actor: { actorType: "agent", actorId: input.agentId },
|
||||
subjectUserId: input.subjectUserId,
|
||||
scopes: input.scopes,
|
||||
returnTo: input.returnTo,
|
||||
issueId: runContext.issueId ?? undefined,
|
||||
});
|
||||
},
|
||||
|
||||
peekOAuthState,
|
||||
|
||||
completeOAuthCallback,
|
||||
|
|
@ -5424,6 +5609,7 @@ export function toolAccessService(db: Db, options: ToolAccessServiceOptions = {}
|
|||
credentialRefs: input.credentialRefs ?? [],
|
||||
credentialSecretRefs: input.credentialSecretRefs ?? [],
|
||||
}).returning();
|
||||
await ensureDefaultWorkspaceGrant(row);
|
||||
await syncCredentialBindings(row);
|
||||
await ensureRuntimeSlot(row);
|
||||
return toConnection(row);
|
||||
|
|
@ -5435,6 +5621,111 @@ export function toolAccessService(db: Db, options: ToolAccessServiceOptions = {}
|
|||
return connection;
|
||||
},
|
||||
|
||||
listConnectionGrants: async (idOrUid: string, companyId?: string) => {
|
||||
const connection = await getConnectionRow(idOrUid, companyId);
|
||||
const grants = await db.select().from(connectionGrants).where(and(
|
||||
eq(connectionGrants.companyId, connection.companyId),
|
||||
eq(connectionGrants.connectionId, connection.id),
|
||||
)).orderBy(desc(connectionGrants.isDefault), desc(connectionGrants.updatedAt));
|
||||
return { connection: { id: connection.id, uid: connection.uid }, grants };
|
||||
},
|
||||
|
||||
addConnectionInstallation: async (idOrUid: string, input: {
|
||||
providerTenant?: { name?: string; externalId?: string };
|
||||
credentialSecretRefs?: typeof connectionGrants.$inferInsert.credentialSecretRefs;
|
||||
isDefault?: boolean;
|
||||
}, actor?: ActorInfo) => {
|
||||
const connection = await getConnectionRow(idOrUid);
|
||||
await assertSecretRefs(connection.companyId, input.credentialSecretRefs ?? []);
|
||||
if (input.isDefault) {
|
||||
await db.update(connectionGrants).set({ isDefault: false, updatedAt: new Date() }).where(and(
|
||||
eq(connectionGrants.connectionId, connection.id),
|
||||
eq(connectionGrants.kind, "workspace"),
|
||||
));
|
||||
}
|
||||
const binding = actorBinding(actor);
|
||||
const [grant] = await db.insert(connectionGrants).values({
|
||||
companyId: connection.companyId,
|
||||
connectionId: connection.id,
|
||||
kind: "workspace",
|
||||
providerTenant: input.providerTenant,
|
||||
credentialSecretRefs: input.credentialSecretRefs ?? [],
|
||||
status: "active",
|
||||
isDefault: input.isDefault ?? false,
|
||||
createdByAgentId: binding.actorType === "agent" ? binding.actorId : null,
|
||||
createdByUserId: binding.actorType === "user" ? binding.actorId : null,
|
||||
}).returning();
|
||||
if (!grant) throw new Error("Failed to create connection installation");
|
||||
return grant;
|
||||
},
|
||||
|
||||
revokeConnectionGrant: async (idOrUid: string, grantId: string, actor?: ActorInfo) => {
|
||||
const connection = await getConnectionRow(idOrUid);
|
||||
const binding = actorBinding(actor);
|
||||
const [grant] = await db.update(connectionGrants).set({
|
||||
status: "revoked",
|
||||
isDefault: false,
|
||||
revokedAt: new Date(),
|
||||
revokedByAgentId: binding.actorType === "agent" ? binding.actorId : null,
|
||||
revokedByUserId: binding.actorType === "user" ? binding.actorId : null,
|
||||
updatedAt: new Date(),
|
||||
}).where(and(
|
||||
eq(connectionGrants.id, grantId),
|
||||
eq(connectionGrants.companyId, connection.companyId),
|
||||
eq(connectionGrants.connectionId, connection.id),
|
||||
)).returning();
|
||||
if (!grant) throw notFound("Connection grant not found");
|
||||
return grant;
|
||||
},
|
||||
|
||||
getConnectionUsage: async (idOrUid: string, range: "7d" | "30d", companyId?: string) => {
|
||||
const connection = await getConnectionRow(idOrUid, companyId);
|
||||
const days = range === "30d" ? 30 : 7;
|
||||
const start = new Date();
|
||||
start.setUTCHours(0, 0, 0, 0);
|
||||
start.setUTCDate(start.getUTCDate() - days + 1);
|
||||
const [issuances, invocations] = await Promise.all([
|
||||
db.select({ createdAt: connectionTokenIssuances.createdAt, outcome: connectionTokenIssuances.outcome, path: connectionTokenIssuances.path })
|
||||
.from(connectionTokenIssuances).where(and(
|
||||
eq(connectionTokenIssuances.companyId, connection.companyId),
|
||||
eq(connectionTokenIssuances.connectionId, connection.id),
|
||||
gte(connectionTokenIssuances.createdAt, start),
|
||||
)),
|
||||
db.select({ createdAt: toolInvocations.createdAt, riskLevel: toolInvocations.riskLevel })
|
||||
.from(toolInvocations).where(and(
|
||||
eq(toolInvocations.companyId, connection.companyId),
|
||||
eq(toolInvocations.connectionId, connection.id),
|
||||
gte(toolInvocations.createdAt, start),
|
||||
)),
|
||||
]);
|
||||
const buckets = Array.from({ length: days }, (_, offset) => {
|
||||
const date = new Date(start);
|
||||
date.setUTCDate(start.getUTCDate() + offset);
|
||||
return {
|
||||
date: date.toISOString().slice(0, 10),
|
||||
issuances: { total: 0, byOutcome: {} as Record<string, number>, byPath: {} as Record<string, number> },
|
||||
invocations: { total: 0, byRiskLevel: {} as Record<string, number> },
|
||||
deliveries: { received: 0, forwarded: 0 },
|
||||
};
|
||||
});
|
||||
const byDate = new Map(buckets.map((bucket) => [bucket.date, bucket]));
|
||||
for (const row of issuances) {
|
||||
const bucket = byDate.get(row.createdAt.toISOString().slice(0, 10));
|
||||
if (!bucket) continue;
|
||||
bucket.issuances.total += 1;
|
||||
bucket.issuances.byOutcome[row.outcome] = (bucket.issuances.byOutcome[row.outcome] ?? 0) + 1;
|
||||
bucket.issuances.byPath[row.path] = (bucket.issuances.byPath[row.path] ?? 0) + 1;
|
||||
}
|
||||
for (const row of invocations) {
|
||||
const bucket = byDate.get(row.createdAt.toISOString().slice(0, 10));
|
||||
if (!bucket) continue;
|
||||
const riskLevel = row.riskLevel ?? "unknown";
|
||||
bucket.invocations.total += 1;
|
||||
bucket.invocations.byRiskLevel[riskLevel] = (bucket.invocations.byRiskLevel[riskLevel] ?? 0) + 1;
|
||||
}
|
||||
return { connection: { id: connection.id, uid: connection.uid }, range, buckets };
|
||||
},
|
||||
|
||||
listConnectionInstalls,
|
||||
|
||||
putConnectionInstalls: async (
|
||||
|
|
@ -6284,11 +6575,81 @@ export function toolAccessService(db: Db, options: ToolAccessServiceOptions = {}
|
|||
});
|
||||
};
|
||||
|
||||
const fail = async (status: number, message: string, outcome: ConnectionTokenIssuanceOutcome, errorCode: string, details: Record<string, unknown> = {}) => {
|
||||
const fail = async (status: number, message: string, outcome: ConnectionTokenIssuanceOutcome, errorCode: string, details: Record<string, unknown> = {}): Promise<never> => {
|
||||
await recordFailure(outcome, errorCode, details);
|
||||
throw new HttpError(status, message, { code: errorCode, path, ...details });
|
||||
};
|
||||
|
||||
const subject = input.body.subject ?? { type: "app" as const };
|
||||
if (subject.type === "user" && subject.userId !== runContext.responsibleUserId) {
|
||||
await fail(403, "The agent run cannot act as the requested user", "denied", "subject_not_permitted", {
|
||||
connection: { uid: connection.uid },
|
||||
subject,
|
||||
});
|
||||
}
|
||||
|
||||
let grant: typeof connectionGrants.$inferSelect;
|
||||
if (subject.type === "user") {
|
||||
const conditions = [
|
||||
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"),
|
||||
)).limit(1);
|
||||
if (!grant) {
|
||||
await fail(409, "The requested installation is not available", "denied", "installation_required", {
|
||||
connection: { uid: connection.uid },
|
||||
subject,
|
||||
remediation: { action: "add_installation" },
|
||||
});
|
||||
}
|
||||
} else {
|
||||
grant = await ensureDefaultWorkspaceGrant(connection);
|
||||
}
|
||||
|
||||
if (grant.status !== "active") {
|
||||
const code = grant.status === "needs_reauthorization" ? "needs_reauthorization" : "grant_revoked";
|
||||
await fail(409, "The selected connection grant is not active", "denied", code, {
|
||||
connection: { uid: connection.uid },
|
||||
subject,
|
||||
grantId: grant.id,
|
||||
remediation: { action: "reauthorize" },
|
||||
});
|
||||
}
|
||||
const requestedScopeSelectors = new Set(requestedScope);
|
||||
const matchingScopedRefs = grant.credentialSecretRefs.filter(
|
||||
(ref) => ref.keyScope && requestedScopeSelectors.has(ref.keyScope),
|
||||
);
|
||||
const selectedCredentialSecretRefs = matchingScopedRefs.length > 0
|
||||
? grant.credentialSecretRefs.filter((ref) => !ref.keyScope || requestedScopeSelectors.has(ref.keyScope))
|
||||
: grant.credentialSecretRefs.filter((ref) => !ref.keyScope);
|
||||
const rotateBefore = Date.now() + 14 * 24 * 60 * 60 * 1000;
|
||||
const expiringRef = selectedCredentialSecretRefs.find((ref) => ref.expiresAt && Date.parse(ref.expiresAt) <= rotateBefore);
|
||||
if (expiringRef && connection.healthStatus !== "degraded") {
|
||||
await db.update(toolConnections).set({
|
||||
healthStatus: "degraded",
|
||||
healthMessage: `Rotate ${expiringRef.label ?? expiringRef.configPath} before it expires.`,
|
||||
updatedAt: new Date(),
|
||||
}).where(eq(toolConnections.id, connection.id));
|
||||
}
|
||||
const credentialConnection = { ...connection, credentialSecretRefs: selectedCredentialSecretRefs };
|
||||
|
||||
if (!connection.enabled || connection.status !== "active") {
|
||||
await fail(409, "Connection is not active", "denied", "connection_not_active", {
|
||||
connectionStatus: connection.status,
|
||||
|
|
@ -6390,6 +6751,8 @@ export function toolAccessService(db: Db, options: ToolAccessServiceOptions = {}
|
|||
status: "use_env_lease",
|
||||
code: "use_env_lease",
|
||||
connectionId: connection.id,
|
||||
connection: { id: connection.id, uid: connection.uid },
|
||||
grantId: grant.id,
|
||||
path: "static",
|
||||
message: "This connection uses static credentials. Use an audited environment lease projection instead.",
|
||||
scope: issuedScope,
|
||||
|
|
@ -6404,7 +6767,7 @@ export function toolAccessService(db: Db, options: ToolAccessServiceOptions = {}
|
|||
|
||||
try {
|
||||
const minted = await mintExchangeConnectionToken({
|
||||
connection,
|
||||
connection: credentialConnection,
|
||||
application,
|
||||
agentId: input.agentId,
|
||||
runId: input.runId,
|
||||
|
|
@ -6435,6 +6798,7 @@ export function toolAccessService(db: Db, options: ToolAccessServiceOptions = {}
|
|||
outcome: "success",
|
||||
metadata: { tokenRef: tokenHash, tokenType: minted.tokenType },
|
||||
});
|
||||
await db.update(connectionGrants).set({ lastUsedAt: new Date(), updatedAt: new Date() }).where(eq(connectionGrants.id, grant.id));
|
||||
await auditConnectionTokenIssuance({
|
||||
companyId: connection.companyId,
|
||||
connectionId: connection.id,
|
||||
|
|
@ -6447,6 +6811,9 @@ export function toolAccessService(db: Db, options: ToolAccessServiceOptions = {}
|
|||
return {
|
||||
status: "minted",
|
||||
connectionId: connection.id,
|
||||
connection: { id: connection.id, uid: connection.uid },
|
||||
grantId: grant.id,
|
||||
providerTenantId: grant.providerTenant?.externalId,
|
||||
path: "exchange",
|
||||
token: minted.token,
|
||||
tokenType: minted.tokenType,
|
||||
|
|
|
|||
Loading…
Reference in New Issue