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:
Dotta 2026-07-21 16:17:02 -05:00 committed by GitHub
parent d23fbf8ae4
commit cac3c0fa1a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
14 changed files with 898 additions and 13 deletions

View File

@ -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");

View File

@ -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
}
]
}

View File

@ -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(),
},

View File

@ -1777,6 +1777,8 @@ export {
toolTrustRuleBatchApprovalSchema,
toolTrustRuleScopeSchema,
connectionTokenRequestSchema,
connectionTokenSubjectSchema,
startConnectionAuthorizationSchema,
toolConnectionTestCallSchema,
toolPolicyTestRequestSchema,
importMcpJsonSchema,

View File

@ -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,

View File

@ -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;

View File

@ -793,6 +793,8 @@ export {
connectionTokenIssuancePathSchema,
connectionTokenRequestSchema,
connectionTokenScopeSchema,
connectionTokenSubjectSchema,
startConnectionAuthorizationSchema,
createToolTrustRuleFromActionRequestSchema,
revokeToolTrustRuleSchema,
toolPolicyTestRequestSchema,

View File

@ -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",

View File

@ -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>;

View File

@ -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");

View File

@ -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;

View File

@ -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",

View File

@ -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);

View File

@ -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,