refactor(tools): isolate connection token broker (#17157)

## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work.
> - Agents can request short-lived tokens for connections that allow
token exchange.
> - Admission, scope checks, exchange protocols and issuance records
lived inside the general tool service.
> - The security flow needs one visible owner while provider payloads
need a separate boundary.
> - This pull request extracts the broker into admission, configuration,
endpoint, exchange and issuance modules.
> - The benefit is a focused issuance flow with its locks, authority
checks and audit order intact.

## Linked Issues or Issue Description

Depends on #17155. Earlier stack: #17144 → #17146 → #17147 → #17155 →
this PR.

**What existing behavior does this improve?**

Maintenance and verification of connection token issuance for agent
runs.

**Current behavior**

A 17,752-line service contains broker configuration, endpoint policy,
credential exchange, admission and issuance.

**Proposed behavior**

A dedicated broker coordinates the same admission and transaction flow.
The exchange owner returns normalized token material. It receives the
existing secret client, including the transaction-scoped client at
issuance time.

**Reason and benefit**

The entry point falls to 16,100 lines, a reduction of 1,652 lines.
Reviewers can see the responsible-user lock and hash-only issuance write
without navigating app setup or OAuth callbacks.

## What Changed

- Extract six broker modules. The largest is 755 lines; together they
contain 1,721 lines.
- Give tool-access audit persistence a named owner shared by the broker
and coordinator.
- Keep current-state checks, policy decisions, rate limits, secret
resolution and transaction order unchanged. Private-host configuration
is still read live.
- Add two public-route integration cases with a real local HTTP server
and disposable PostgreSQL. Cover Pages and RFC 8693 success and upstream
failure.

## Verification

- Baseline: all 466 existing public service tests passed before this
move. The two new real HTTP cases passed on the original implementation
before extraction.
- After extraction: 495 tests pass across the public service and HTTP
rebinding suites, with one database worker. This includes the two new
cases, membership revocation ordering, scope denials, disabled broker
behavior and hash-only persistence.
- Server TypeScript checking, the module-boundary gate and `git diff
--check` pass.
- Normalized syntax comparison finds all 294 pre-existing
function/method bodies unchanged.
- Full typecheck and build passed for the preceding batch. Its
interrupted local full-suite result remains unverified. All current-head
CI gates pass; Greptile is 5/5 with no open review threads.

## Risks

- A new closure could use the wrong secret client or move exchange
outside the membership lock. The existing transaction still passes
`secretService(tx)` to the exchange owner after taking that lock.
- Endpoint validation must preserve live configuration and fail before
credential disclosure. Existing rebinding and private-host tests cover
this path.
- The local fixture proves real HTTP and persisted application behavior.
It does not qualify a live third-party broker.
- No schema, API or access policy change is intended.

## Model Used

OpenAI Codex, GPT-6 family, with repository tools and code execution.
The exact serving model ID and context-window size are not exposed in
this session.

## 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:
DottaandPaperclip authored and GitHub committed 2026-10-21 22:21:42 -05:00
1 parent 233753e577
commit a42f213244
10 files changed
+1853 -1662

No files matched your search

@@ -155,3 +155,23 @@ match normalized syntax across the move. The baseline and post-move runs pass
cases. Server typechecking and the module-boundary gate pass. Existing tests
exercise both full catalog DTOs and lightweight assignments; no new behavior or
fixture-only test was added.
### Connection token broker extraction
The broker owns run admission, explicit mint grants, rate limits, selected grant
checks, issuance and audit. Configuration parsing, endpoint policy, exchange,
and issuance storage are separate modules. The service receives the existing
secret service, policy service, audit function, clock and two connection/grant
operations. The issuance transaction still acquires the responsible membership
lock before resolving the parent credential and performing the exchange. It
stores only the token hash. Private-host policy still reads live environment
values on each request. No resource, cache or timer is created by the factories.
The entry point is 16,100 lines / 644,119 bytes. The six broker modules total
1,721 lines; the largest is 755. All 294 pre-existing function/method bodies
match normalized syntax. Two new public-route tests passed before production
changes and after extraction. They use real HTTP and disposable PostgreSQL for
Pages and RFC 8693 exchanges, plus a failing upstream request, and assert that
issuance/audit rows contain no token material. The complete public service and
HTTP rebinding run passes 495 cases. Server typechecking and module boundaries
pass. These fixtures do not claim live third-party qualification.
@@ -1,5 +1,6 @@
import * as fs from "node:fs/promises";
import { createHash, generateKeyPairSync, randomUUID } from "node:crypto";
import { createServer } from "node:http";
import express from "express";
import request from "supertest";
import {
@@ -978,6 +979,70 @@ describeEmbeddedPostgres("tool access service", () => {
return false;
}
it.each(["pages", "rfc8693"] as const)("mints %s tokens over real HTTP and records upstream failures without token material", async (protocol) => {
vi.stubEnv("PAPERCLIP_TOKEN_BROKER_ALLOWED_HOSTS", "127.0.0.1");
const received: Array<{ authorization: string | undefined; body: string }> = [];
let failExchange = false;
const server = createServer(async (incoming, response) => {
const chunks: Buffer[] = [];
for await (const chunk of incoming) chunks.push(Buffer.from(chunk));
received.push({ authorization: incoming.headers.authorization, body: Buffer.concat(chunks).toString() });
response.writeHead(failExchange ? 503 : 200, { "content-type": "application/json" });
response.end(JSON.stringify(failExchange
? { error: "temporarily_unavailable" }
: { access_token: "real-http-child-token", expires_in: 600, scope: "pages:publish:ns/dotta" }));
});
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
try {
const company = await createCompany(db);
const agent = await createAgent(db, company.id);
const { run } = await createIssueAndRun(db, company.id, agent.id);
const port = (server.address() as { port: number }).port;
const { connection } = await createBrokerConnection(db, company.id, {
tokenUrl: `http://127.0.0.1:${port}/exchange`, protocol,
});
await allowConnectionForAgent(db, company.id, agent.id, connection.id);
const app = createRouteApp(db, agentJwtActor(company.id, agent.id, run.id));
const mint = () => request(app)
.post(`/api/agents/me/connections/${connection.id}/token`)
.send({ scope: "pages:publish:ns/dotta" });
const result = await mint();
expect(result.status).toBe(200);
expect(result.body).toMatchObject({ status: "minted", token: "real-http-child-token", scope: ["pages:publish:ns/dotta"] });
expect(received).toHaveLength(1);
if (protocol === "pages") {
expect(received[0].authorization).toBe("Bearer parent-deploy-token");
expect(JSON.parse(received[0].body)).toMatchObject({ namespace: "dotta", actor: { id: agent.id, runId: run.id, onBehalfOf: "user:user-for-run" } });
} else {
const form = new URLSearchParams(received[0].body);
expect(form.get("grant_type")).toBe("urn:ietf:params:oauth:grant-type:token-exchange");
expect(form.get("subject_token")).toBe("parent-deploy-token");
expect(form.get("scope")).toBe("pages:publish:ns/dotta");
}
failExchange = true;
const failed = await mint();
expect(failed.status).toBe(502);
expect(failed.body).toMatchObject({ code: "upstream_error" });
expect(received).toHaveLength(2);
const issuances = await db.select().from(connectionTokenIssuances)
.where(eq(connectionTokenIssuances.connectionId, connection.id));
expect(issuances).toHaveLength(2);
expect(issuances.find(row => row.outcome === "success")).toMatchObject({
tokenHash: createHash("sha256").update("real-http-child-token").digest("hex"),
companyId: company.id, agentId: agent.id, runId: run.id,
});
expect(issuances.find(row => row.outcome === "upstream_error")).toMatchObject({ tokenHash: null, errorCode: "upstream_error" });
const audits = await db.select().from(toolAccessAuditEvents)
.where(eq(toolAccessAuditEvents.connectionId, connection.id));
const persisted = JSON.stringify({ issuances, audits });
expect(persisted).not.toContain("parent-deploy-token");
expect(persisted).not.toContain("real-http-child-token");
} finally {
server.closeAllConnections();
await new Promise<void>((resolve, reject) => server.close(error => error ? reject(error) : resolve()));
}
});
it("mints generic exchange connection tokens through the agent route and stores only hashes", async () => {
const company = await createCompany(db);
const agent = await createAgent(db, company.id);
File diff suppressed because it is too large. Load diff
+37
View File
@@ -0,0 +1,37 @@
import type { ActorInfo } from "./types.js";
import type { Db } from "@paperclipai/db";
import { toolAccessAuditEvents } from "@paperclipai/db";
import { recordToolRuntimeAuditWriteFailure } from "../tool-runtime-metrics.js";
export function createToolAccessAudit(db: Db) {
async function audit(input: {
companyId: string;
connectionId?: string | null;
catalogEntryId?: string | null;
action: string;
outcome: "success" | "failure";
reasonCode?: string | null;
details?: Record<string, unknown>;
actor?: ActorInfo;
}) {
try {
await db.insert(toolAccessAuditEvents).values({
companyId: input.companyId,
connectionId: input.connectionId ?? null,
catalogEntryId: input.catalogEntryId ?? null,
actorType: input.actor?.actorType ?? "system",
actorId: input.actor?.actorId ?? null,
action: input.action,
outcome: input.outcome,
reasonCode: input.reasonCode ?? null,
details: input.details ?? {},
});
} catch (error) {
await recordToolRuntimeAuditWriteFailure(db, input.companyId);
throw error;
}
}
return audit;
}
export type ToolAccessAudit = ReturnType<typeof createToolAccessAudit>;
@@ -0,0 +1,254 @@
import type { DbTransaction } from "../types.js";
import { captureRunIdentity } from "../../run-identity.js";
import { and, eq, gte, inArray, sql } from "drizzle-orm";
import type { Db } from "@paperclipai/db";
import { connectionTokenIssuances, companyMemberships, heartbeatRuns, toolConnections, toolProfileBindings, toolProfileEntries, toolProfiles } from "@paperclipai/db";
import type { ConnectionTokenIssuancePath } from "@paperclipai/shared";
import { forbidden, HttpError } from "../../../errors.js";
import { narrowestScopeBindings, profileIdsInBindingOrder } from "../../tool-profile-binding-precedence.js";
import { CONNECTION_TOKEN_MINT_TOOL_NAME, tokenBrokerConfig } from "./configuration.js";
function asRecord(value: unknown): Record<string, unknown> {
if (value && typeof value === "object" && !Array.isArray(value))
return value as Record<string, unknown>;
return {};
}
const ACTIVE_BROKER_RUN_STATUSES = new Set(["running"]);
export async function lockAuthorizedBrokerResponsibleMembership(
input: {
companyId: string;
responsibleUserId: string;
},
tx: DbTransaction,
) {
const membership = await tx
.select({
id: companyMemberships.id,
status: companyMemberships.status,
membershipRole: companyMemberships.membershipRole,
})
.from(companyMemberships)
.where(
and(
eq(companyMemberships.companyId, input.companyId),
eq(companyMemberships.principalType, "user"),
eq(companyMemberships.principalId, input.responsibleUserId),
),
)
.limit(1)
.for("update")
.then((rows) => rows[0] ?? null);
if (
!membership ||
membership.status !== "active" ||
!membership.membershipRole ||
membership.membershipRole === "viewer"
) {
throw new HttpError(
403,
"Responsible user is no longer authorized for company write access",
{
code: "responsible_user_unauthorized",
},
);
}
return membership;
}
export function createTokenBrokerAdmission(db: Db, now: () => Date) {
function runSnapshotString(
snapshot: Record<string, unknown>,
...keys: string[]
): string | null {
for (const key of keys) {
const value = snapshot[key];
if (typeof value === "string" && value.trim().length > 0) return value;
}
return null;
}
async function loadBrokerRunContext(input: {
companyId: string;
agentId: string;
runId: string;
}) {
const [initialRun] = await db
.select()
.from(heartbeatRuns)
.where(eq(heartbeatRuns.id, input.runId));
const run = initialRun?.activeIdentityContextId
? (await captureRunIdentity(db, input)).run
: initialRun;
if (
!run ||
run.companyId !== input.companyId ||
run.agentId !== input.agentId
) {
throw forbidden(
"Agent run context does not match the authenticated actor",
);
}
if (!ACTIVE_BROKER_RUN_STATUSES.has(run.status)) {
throw forbidden("Agent run is not active");
}
const snapshot = asRecord(run.contextSnapshot);
const paperclipIssue = asRecord(snapshot.paperclipIssue);
const responsibleUserId = run.activeIdentityContextId
? run.responsibleUserId
: (runSnapshotString(
snapshot,
"responsibleUserId",
"responsible_user_id",
) ??
runSnapshotString(
paperclipIssue,
"responsibleUserId",
"responsible_user_id",
) ??
run.responsibleUserId);
if (!responsibleUserId) {
throw forbidden(
"Agent run has no responsible user for delegated connection access",
);
}
const responsibleMembership = await db
.select({
status: companyMemberships.status,
membershipRole: companyMemberships.membershipRole,
})
.from(companyMemberships)
.where(
and(
eq(companyMemberships.companyId, run.companyId),
eq(companyMemberships.principalType, "user"),
eq(companyMemberships.principalId, responsibleUserId),
),
)
.then((rows) => rows[0] ?? null);
if (
!responsibleMembership ||
responsibleMembership.status !== "active" ||
!responsibleMembership.membershipRole ||
responsibleMembership.membershipRole === "viewer"
) {
throw forbidden(
"Responsible user is no longer authorized for company write access",
);
}
return {
run,
issueId:
runSnapshotString(snapshot, "issueId") ??
runSnapshotString(paperclipIssue, "id"),
projectId:
runSnapshotString(snapshot, "projectId") ??
runSnapshotString(paperclipIssue, "projectId"),
routineId: runSnapshotString(snapshot, "routineId"),
responsibleUserId,
};
}
async function enforceDefaultConnectionTokenRateLimit(input: {
connection: typeof toolConnections.$inferSelect;
agentId: string;
path: ConnectionTokenIssuancePath;
}) {
const broker = tokenBrokerConfig(input.connection);
const configured = Number(broker.rateLimitPerHour ?? 30);
const limit =
Number.isFinite(configured) && configured > 0
? Math.trunc(configured)
: 30;
const since = new Date(now().getTime() - 60 * 60 * 1000);
const [row] = await db
.select({ count: sql<number>`count(*)::int` })
.from(connectionTokenIssuances)
.where(
and(
eq(connectionTokenIssuances.companyId, input.connection.companyId),
eq(connectionTokenIssuances.connectionId, input.connection.id),
eq(connectionTokenIssuances.agentId, input.agentId),
eq(connectionTokenIssuances.outcome, "success"),
gte(connectionTokenIssuances.createdAt, since),
),
);
const count = Number(row?.count ?? 0);
if (count >= limit) {
throw new HttpError(429, "Connection token mint rate limit exceeded", {
code: "rate_limited",
path: input.path,
limit,
windowSeconds: 3600,
});
}
}
async function hasExplicitConnectionTokenMintProfileGrant(input: {
companyId: string;
agentId: string;
issueId: string | null;
projectId: string | null;
routineId: string | null;
}) {
const bindings = await db
.select()
.from(toolProfileBindings)
.where(eq(toolProfileBindings.companyId, input.companyId));
const matchingBindings = bindings.filter((binding) => {
if (binding.targetType === "company")
return binding.targetId === input.companyId;
if (binding.targetType === "agent")
return binding.targetId === input.agentId;
if (binding.targetType === "issue")
return Boolean(input.issueId && binding.targetId === input.issueId);
if (binding.targetType === "project")
return Boolean(input.projectId && binding.targetId === input.projectId);
if (binding.targetType === "routine")
return Boolean(input.routineId && binding.targetId === input.routineId);
return false;
});
const profileIds = profileIdsInBindingOrder(
narrowestScopeBindings(matchingBindings),
);
if (profileIds.length === 0) return false;
const profiles = await db
.select()
.from(toolProfiles)
.where(
and(
eq(toolProfiles.companyId, input.companyId),
inArray(toolProfiles.id, profileIds),
),
);
const activeProfileIds = profiles
.filter((profile) => profile.status === "active")
.map((profile) => profile.id);
if (activeProfileIds.length === 0) return false;
const entries = await db
.select()
.from(toolProfileEntries)
.where(
and(
eq(toolProfileEntries.companyId, input.companyId),
inArray(toolProfileEntries.profileId, activeProfileIds),
),
);
return activeProfileIds.some((profileId) => {
const profileEntries = entries.filter(
(entry) => entry.profileId === profileId,
);
const exactBrokerEntries = profileEntries.filter(
(entry) =>
entry.selectorType === "tool_name" &&
entry.toolName === CONNECTION_TOKEN_MINT_TOOL_NAME &&
Object.keys(asRecord(entry.conditions)).length === 0,
);
if (exactBrokerEntries.some((entry) => entry.effect === "exclude"))
return false;
return exactBrokerEntries.some((entry) => entry.effect === "include");
});
}
return { loadBrokerRunContext, enforceDefaultConnectionTokenRateLimit, hasExplicitConnectionTokenMintProfileGrant };
}
@@ -0,0 +1,237 @@
import { createHash } from "node:crypto";
import { toolApplications, toolConnections } from "@paperclipai/db";
import type { ConnectionTokenIssuancePath, ConnectionTokenRequest } from "@paperclipai/shared";
import { forbidden, unprocessable } from "../../../errors.js";
function asRecord(value: unknown): Record<string, unknown> {
if (value && typeof value === "object" && !Array.isArray(value))
return value as Record<string, unknown>;
return {};
}
export function readConfigString(
record: Record<string, unknown>,
key: string,
): string | null {
const value = record[key];
return typeof value === "string" && value.trim().length > 0
? value.trim()
: null;
}
export function readConfigStringArray(value: unknown): string[] {
if (Array.isArray(value)) {
return value
.filter((item): item is string => typeof item === "string")
.map((item) => item.trim())
.filter(Boolean);
}
if (typeof value === "string")
return value
.split(/\s+/)
.map((item) => item.trim())
.filter(Boolean);
return [];
}
export function normalizeConnectionTokenScopes(
scope: ConnectionTokenRequest["scope"],
): string[] {
if (Array.isArray(scope))
return [...new Set(scope.map((item) => item.trim()).filter(Boolean))];
if (typeof scope === "string")
return [
...new Set(
scope
.split(/\s+/)
.map((item) => item.trim())
.filter(Boolean),
),
];
return [];
}
export function tokenBrokerConfigFromConnectionConfig(
config: Record<string, unknown>,
): Record<string, unknown> {
const broker = asRecord(config.tokenBroker);
if (Object.keys(broker).length > 0) return broker;
return asRecord(config.broker);
}
export function tokenBrokerConfig(
connection: typeof toolConnections.$inferSelect,
): Record<string, unknown> {
return tokenBrokerConfigFromConnectionConfig(asRecord(connection.config));
}
export function configuredTokenBrokerExchangeUrls(
config: Record<string, unknown>,
): string[] {
const broker = tokenBrokerConfigFromConnectionConfig(config);
return [
...new Set(
[
readConfigString(broker, "tokenUrl"),
readConfigString(broker, "exchangeTokenUrl"),
readConfigString(config, "tokenExchangeUrl"),
readConfigString(config, "pagesTokenExchangeUrl"),
].filter((url): url is string => url !== null),
),
];
}
export function connectionTokenBrokerEnabled(
connection: typeof toolConnections.$inferSelect,
): boolean {
const config = asRecord(connection.config);
const tokenBroker = asRecord(config.tokenBroker);
if (Object.keys(tokenBroker).length > 0)
return tokenBroker.enabled === true;
const broker = asRecord(config.broker);
if (Object.keys(broker).length > 0) return broker.enabled === true;
return false;
}
export function isPagesTokenConnection(
connection: typeof toolConnections.$inferSelect,
application?: typeof toolApplications.$inferSelect | null,
) {
const config = asRecord(connection.config);
const broker = tokenBrokerConfig(connection);
const applicationKey = application?.applicationKey ?? "";
return Boolean(
applicationKey === "paperclip-pages" ||
applicationKey === "paperclip.pages" ||
applicationKey === "pages.paperclip" ||
readConfigString(config, "connectionType") === "pages" ||
readConfigString(config, "service") === "pages" ||
readConfigString(broker, "connectionType") === "pages" ||
readConfigString(broker, "service") === "pages" ||
asRecord(config.pages).enabled === true,
);
}
export function inferConnectionTokenPath(
connection: typeof toolConnections.$inferSelect,
application?: typeof toolApplications.$inferSelect | null,
): ConnectionTokenIssuancePath {
const broker = tokenBrokerConfig(connection);
const configuredPath =
readConfigString(broker, "path") ??
readConfigString(asRecord(connection.config), "tokenPath");
if (
configuredPath === "exchange" ||
configuredPath === "oauth_access" ||
configuredPath === "static"
)
return configuredPath;
if (isPagesTokenConnection(connection, application)) return "exchange";
if (
readConfigString(broker, "tokenUrl") ||
readConfigString(asRecord(connection.config), "tokenExchangeUrl")
)
return "exchange";
return "static";
}
export function parentScopesForConnection(
connection: typeof toolConnections.$inferSelect,
): string[] {
const config = asRecord(connection.config);
const broker = tokenBrokerConfig(connection);
const configured = [
...readConfigStringArray(broker.parentScopes),
...readConfigStringArray(broker.scopes),
...readConfigStringArray(config.parentScopes),
...readConfigStringArray(asRecord(config.oauth).scopes),
...readConfigStringArray(asRecord(config.oauth).scope),
];
const namespaceAllowlist = readConfigStringArray(
config.namespaceAllowlist,
).map((namespace) => `pages:publish:ns/${namespace}`);
return [...new Set([...configured, ...namespaceAllowlist])];
}
export function defaultScopesForConnection(
connection: typeof toolConnections.$inferSelect,
): string[] {
const broker = tokenBrokerConfig(connection);
return [
...new Set([
...readConfigStringArray(broker.defaultScopes),
...readConfigStringArray(asRecord(connection.config).defaultScopes),
]),
];
}
export function assertScopeSubset(input: {
requestedScope: string[];
parentScopes: string[];
}) {
if (input.requestedScope.length === 0) return;
const parent = new Set(input.parentScopes);
if (
parent.size === 0 ||
input.requestedScope.some((scope) => !parent.has(scope))
) {
throw forbidden(
"Requested token scope exceeds the connection parent scope",
);
}
}
export function requestedTtlSeconds(
body: ConnectionTokenRequest,
connection: typeof toolConnections.$inferSelect,
): number {
const broker = tokenBrokerConfig(connection);
const configured = Number(
broker.defaultTtlSeconds ?? broker.ttlSeconds ?? 900,
);
const requested = Number(body.requestedTtlSeconds ?? configured);
const finite =
Number.isFinite(requested) && requested > 0 ? Math.trunc(requested) : 900;
return Math.max(1, Math.min(900, finite));
}
export function sha256Hex(value: string): string {
return createHash("sha256").update(value).digest("hex");
}
export function bearerTokenHash(token: string): string {
return sha256Hex(token);
}
export function exchangeTokenUrl(
connection: typeof toolConnections.$inferSelect,
isPages: boolean,
): string {
const broker = tokenBrokerConfig(connection);
const config = asRecord(connection.config);
const url =
readConfigString(broker, "tokenUrl") ??
readConfigString(broker, "exchangeTokenUrl") ??
readConfigString(config, "tokenExchangeUrl") ??
readConfigString(config, "pagesTokenExchangeUrl");
if (url) return url;
const pagesApiBase = process.env.PAPERCLIP_PAGES_API_URL?.trim();
if (isPages && pagesApiBase)
return new URL(
"/v1/tokens/exchange",
pagesApiBase.endsWith("/") ? pagesApiBase : `${pagesApiBase}/`,
).toString();
throw unprocessable("Connection token exchange URL is not configured", {
code: "exchange_url_missing",
});
}
export function pagesNamespaceFromScope(scope: string[]): string | null {
const first = scope[0];
if (!first) return null;
const match = first.match(/^pages:publish:ns\/([^/\s]+)$/);
return match?.[1] ?? null;
}
export const CONNECTION_TOKEN_MINT_TOOL_NAME = "connection_token.mint";
@@ -0,0 +1,74 @@
import { badRequest } from "../../../errors.js";
import { assertPublicRemoteHttpEndpoint, parseRemoteHttpEndpoint } from "../../remote-http-endpoint-guard.js";
import { type GuardedRemoteHttpFetchOptions } from "../../remote-http-fetch.js";
import { configuredTokenBrokerExchangeUrls } from "./configuration.js";
function normalizeTokenBrokerAllowedHost(value: string): string | null {
const trimmed = value.trim();
if (!trimmed) return null;
try {
const parsed = new URL(
trimmed.includes("://") ? trimmed : `http://${trimmed}`,
);
return (
parsed.hostname
.replace(/^\[|\]$/g, "")
.replace(/\.$/, "")
.toLowerCase() || null
);
} catch {
// Invalid allowlist entries grant no access. The configured broker URL is
// still evaluated under the public-only policy below.
return null;
}
}
function tokenBrokerAllowedPrivateHosts(): Set<string> {
const configured = (process.env.PAPERCLIP_TOKEN_BROKER_ALLOWED_HOSTS ?? "")
.split(/[,\s]+/)
.map(normalizeTokenBrokerAllowedHost)
.filter((host): host is string => host !== null);
const pagesApiHost = normalizeTokenBrokerAllowedHost(
process.env.PAPERCLIP_PAGES_API_URL ?? "",
);
if (pagesApiHost) configured.push(pagesApiHost);
return new Set(configured);
}
function tokenBrokerAllowsPrivateNetwork(endpoint: URL): boolean {
const hostname = endpoint.hostname
.replace(/^\[|\]$/g, "")
.replace(/\.$/, "")
.toLowerCase();
return tokenBrokerAllowedPrivateHosts().has(hostname);
}
export function tokenBrokerHttpFetchOptions(
endpoint: URL,
): GuardedRemoteHttpFetchOptions {
return {
allowPrivateNetwork: tokenBrokerAllowsPrivateNetwork(endpoint),
error: (message, code) => badRequest(message, { code }),
};
}
async function assertTokenBrokerHttpUrlAllowed(
value: string,
): Promise<string> {
const endpoint = parseRemoteHttpEndpoint(value, (message, code) =>
badRequest(message, { code }),
);
await assertPublicRemoteHttpEndpoint(
endpoint,
{ allowPrivateNetwork: tokenBrokerAllowsPrivateNetwork(endpoint) },
(message, code) => badRequest(message, { code }),
);
return endpoint.toString();
}
export async function assertConfiguredTokenBrokerEndpointsAllowed(
config: Record<string, unknown>,
): Promise<void> {
for (const url of configuredTokenBrokerExchangeUrls(config)) {
await assertTokenBrokerHttpUrlAllowed(url);
}
}
@@ -0,0 +1,306 @@
import type { ActorInfo } from "../types.js";
import { toolApplications, toolConnections } from "@paperclipai/db";
import { badRequest, HttpError, unprocessable } from "../../../errors.js";
import { parseRemoteHttpEndpoint } from "../../remote-http-endpoint-guard.js";
import { guardedRemoteHttpFetch } from "../../remote-http-fetch.js";
import { secretService } from "../../secrets.js";
import { tokenBrokerHttpFetchOptions } from "./endpoint-policy.js";
import { readConfigString, readConfigStringArray, tokenBrokerConfig, isPagesTokenConnection, exchangeTokenUrl, pagesNamespaceFromScope } from "./configuration.js";
function asRecord(value: unknown): Record<string, unknown> {
if (value && typeof value === "object" && !Array.isArray(value))
return value as Record<string, unknown>;
return {};
}
export function createConnectionTokenExchange(now: () => Date, secrets: ReturnType<typeof secretService>) {
function accessContextForBroker(input: {
connection: typeof toolConnections.$inferSelect;
agentId: string;
runId: string;
issueId: string | null;
actorSource?: ActorInfo["actorType"] | null;
configPath: string;
}) {
return {
consumerType: "tool_connection" as const,
consumerId: input.connection.id,
configPath: input.configPath,
actorType: "agent" as const,
actorId: input.agentId,
actorSource: "agent_jwt" as const,
issueId: input.issueId,
heartbeatRunId: input.runId,
};
}
function findBrokerCredentialRef(
connection: typeof toolConnections.$inferSelect,
) {
const broker = tokenBrokerConfig(connection);
const configuredPath =
readConfigString(broker, "parentCredentialConfigPath") ??
readConfigString(broker, "credentialConfigPath") ??
readConfigString(broker, "secretConfigPath");
const configuredName =
readConfigString(broker, "parentCredentialName") ??
readConfigString(broker, "credentialName");
const secretCandidates = connection.credentialSecretRefs.filter(
(ref) =>
ref.configPath !== "oauth.access_token" &&
ref.configPath !== "oauth.refresh_token" &&
ref.configPath !== "oauth.client_secret",
);
const secretRef = configuredPath
? connection.credentialSecretRefs.find(
(ref) => ref.configPath === configuredPath,
)
: (secretCandidates.find(
(ref) => ref.configPath === "credentials.deploy_token",
) ??
secretCandidates.find(
(ref) => ref.configPath === "pages.deploy_token",
) ??
secretCandidates[0]);
if (secretRef)
return {
kind: "secret_ref" as const,
ref: secretRef,
configPath: secretRef.configPath,
};
const credentialRef = configuredName
? connection.credentialRefs.find((ref) => ref.name === configuredName)
: connection.credentialRefs[0];
if (credentialRef)
return {
kind: "credential_ref" as const,
ref: credentialRef,
configPath: `credentials.${credentialRef.name}`,
};
return null;
}
async function resolveBrokerParentCredential(
input: {
connection: typeof toolConnections.$inferSelect;
agentId: string;
runId: string;
issueId: string | null;
},
secretClient: ReturnType<typeof secretService> = secrets,
) {
const ref = findBrokerCredentialRef(input.connection);
if (!ref) {
throw unprocessable(
"Connection token exchange requires a vault-backed parent credential",
{
code: "parent_credential_missing",
},
);
}
if (ref.kind === "secret_ref") {
return secretClient.resolveSecretValue(
input.connection.companyId,
ref.ref.secretId,
ref.ref.versionSelector ?? "latest",
{
accessContext: accessContextForBroker({
...input,
configPath: ref.configPath,
}),
bindingContext: accessContextForBroker({
...input,
configPath: ref.configPath,
}),
},
);
}
return secretClient.resolveSecretValue(
input.connection.companyId,
ref.ref.secretId,
ref.ref.version ?? "latest",
{
accessContext: accessContextForBroker({
...input,
configPath: ref.configPath,
}),
bindingContext: accessContextForBroker({
...input,
configPath: ref.configPath,
}),
},
);
}
async function mintExchangeConnectionToken(
input: {
connection: typeof toolConnections.$inferSelect;
application: typeof toolApplications.$inferSelect | null;
agentId: string;
runId: string;
issueId: string | null;
responsibleUserId: string | null;
scope: string[];
ttlSeconds: number;
},
secretClient: ReturnType<typeof secretService> = secrets,
) {
const isPages = isPagesTokenConnection(input.connection, input.application);
const parentToken = await resolveBrokerParentCredential(
input,
secretClient,
);
const broker = tokenBrokerConfig(input.connection);
const protocol =
readConfigString(broker, "protocol") ??
readConfigString(broker, "exchangeProtocol") ??
(isPages ? "pages" : "generic");
const url = exchangeTokenUrl(input.connection, isPages);
const actor = {
type: "agent",
id: input.agentId,
runId: input.runId,
...(input.responsibleUserId
? { onBehalfOf: `user:${input.responsibleUserId}` }
: {}),
};
let response: Response;
if (protocol === "rfc8693") {
const body = new URLSearchParams();
body.set("grant_type", "urn:ietf:params:oauth:grant-type:token-exchange");
body.set("subject_token", parentToken);
body.set(
"subject_token_type",
readConfigString(broker, "subjectTokenType") ??
"urn:ietf:params:oauth:token-type:access_token",
);
body.set("scope", input.scope.join(" "));
const audience = readConfigString(broker, "audience");
if (audience) body.set("audience", audience);
body.set(
"requested_token_type",
readConfigString(broker, "requestedTokenType") ??
"urn:ietf:params:oauth:token-type:access_token",
);
body.set(
"actor_token",
Buffer.from(JSON.stringify(actor)).toString("base64url"),
);
body.set(
"actor_token_type",
readConfigString(broker, "actorTokenType") ??
"urn:ietf:params:oauth:token-type:jwt",
);
const endpoint = parseRemoteHttpEndpoint(url, (message, code) =>
badRequest(message, { code }),
);
response = await guardedRemoteHttpFetch(
endpoint,
{
method: "POST",
headers: { "content-type": "application/x-www-form-urlencoded" },
body,
},
tokenBrokerHttpFetchOptions(endpoint),
);
} else {
const namespace = isPages ? pagesNamespaceFromScope(input.scope) : null;
const body =
isPages && namespace
? {
namespace,
ttlSeconds: input.ttlSeconds,
actions: ["publish"],
actor,
}
: {
scope: input.scope,
ttlSeconds: input.ttlSeconds,
actor,
audience: readConfigString(broker, "audience"),
};
const endpoint = parseRemoteHttpEndpoint(url, (message, code) =>
badRequest(message, { code }),
);
response = await guardedRemoteHttpFetch(
endpoint,
{
method: "POST",
headers: {
authorization: `Bearer ${parentToken}`,
"content-type": "application/json",
},
body: JSON.stringify(body),
},
tokenBrokerHttpFetchOptions(endpoint),
);
}
const payload = (await response.json().catch(() => ({}))) as unknown;
const record = asRecord(payload);
if (!response.ok) {
const code =
typeof record.code === "string"
? record.code
: typeof record.error === "string"
? record.error
: "upstream_error";
throw new HttpError(
response.status === 401 || response.status === 403 ? 409 : 502,
"Connection token exchange failed",
{
code:
code === "parent_revoked" ? "credential_revoked" : "upstream_error",
upstreamCode: code,
upstreamStatus: response.status,
upstreamRequestId:
typeof record.requestId === "string" ? record.requestId : null,
},
);
}
const token =
typeof record.token === "string"
? record.token
: typeof record.access_token === "string"
? record.access_token
: null;
if (!token)
throw new HttpError(
502,
"Connection token exchange did not return a token",
{ code: "upstream_token_missing" },
);
const expiresIn =
typeof record.expires_in === "number"
? record.expires_in
: Number(record.expires_in);
const expiresAt =
typeof record.expiresAt === "string" &&
Number.isFinite(Date.parse(record.expiresAt))
? new Date(record.expiresAt)
: typeof record.expires_at === "string" &&
Number.isFinite(Date.parse(record.expires_at))
? new Date(record.expires_at)
: new Date(
now().getTime() +
Math.min(
input.ttlSeconds,
Number.isFinite(expiresIn) && expiresIn > 0
? expiresIn
: input.ttlSeconds,
) *
1000,
);
const responseScope =
readConfigStringArray(record.scope).length > 0
? readConfigStringArray(record.scope)
: input.scope;
return {
token,
tokenType:
typeof record.token_type === "string" ? record.token_type : "Bearer",
expiresAt,
scope: responseScope,
};
}
return mintExchangeConnectionToken;
}
@@ -0,0 +1,95 @@
import type { ToolAccessMutationDb } from "../types.js";
import type { Db } from "@paperclipai/db";
import { connectionTokenIssuances } from "@paperclipai/db";
import type { ConnectionTokenIssuanceOutcome, ConnectionTokenIssuancePath } from "@paperclipai/shared";
import { logActivity } from "../../activity-log.js";
import type { ToolAccessAudit } from "../audit.js";
export function createTokenIssuanceRecorder(db: Db, audit: ToolAccessAudit) {
async function recordConnectionTokenIssuance(
input: {
companyId: string;
applicationId: string | null;
connectionId: string;
agentId: string;
runId: string | null;
issueId: string | null;
projectId: string | null;
responsibleUserId: string | null;
path: ConnectionTokenIssuancePath;
requestedScope: string[];
issuedScope: string[];
ttlSeconds: number | null;
expiresAt: Date | null;
tokenHash: string | null;
outcome: ConnectionTokenIssuanceOutcome;
errorCode?: string | null;
metadata?: Record<string, unknown>;
},
dbClient: ToolAccessMutationDb = db,
) {
await dbClient.insert(connectionTokenIssuances).values({
companyId: input.companyId,
applicationId: input.applicationId,
connectionId: input.connectionId,
agentId: input.agentId,
runId: input.runId,
issueId: input.issueId,
projectId: input.projectId,
responsibleUserId: input.responsibleUserId,
path: input.path,
requestedScope: input.requestedScope,
issuedScope: input.issuedScope,
ttlSeconds: input.ttlSeconds,
expiresAt: input.expiresAt,
tokenHash: input.tokenHash,
outcome: input.outcome,
errorCode: input.errorCode ?? null,
metadata: input.metadata ?? {},
});
}
async function auditConnectionTokenIssuance(input: {
companyId: string;
connectionId: string;
agentId: string;
runId: string;
path: ConnectionTokenIssuancePath;
outcome: ConnectionTokenIssuanceOutcome;
reasonCode?: string | null;
details?: Record<string, unknown>;
}) {
const success = input.outcome === "success";
await audit({
companyId: input.companyId,
connectionId: input.connectionId,
action: success ? "connection_token.minted" : "connection_token.denied",
outcome: success ? "success" : "failure",
reasonCode: input.reasonCode ?? null,
actor: { actorType: "agent", actorId: input.agentId },
details: {
path: input.path,
outcome: input.outcome,
runId: input.runId,
...(input.details ?? {}),
},
});
await logActivity(db, {
companyId: input.companyId,
actorType: "agent",
actorId: input.agentId,
agentId: input.agentId,
runId: input.runId,
action: success ? "connection_token.minted" : "connection_token.denied",
entityType: "tool_connection",
entityId: input.connectionId,
details: {
path: input.path,
outcome: input.outcome,
reasonCode: input.reasonCode ?? null,
...(input.details ?? {}),
},
});
}
return { recordConnectionTokenIssuance, auditConnectionTokenIssuance };
}
@@ -0,0 +1,755 @@
import { isDaytonaConnection } from "../../daytona.js";
import { isBrowserUseConnection } from "../../browser-use-client.js";
import { and, eq, sql } from "drizzle-orm";
import type { Db } from "@paperclipai/db";
import { connectionGrantMembers, connectionGrantDelegations, connectionGrants, companyMemberships, toolApplications, toolConnectionInstalls, toolConnections } from "@paperclipai/db";
import type { ConnectionTokenIssuanceOutcome, ConnectionTokenRequest, ConnectionTokenResponse } from "@paperclipai/shared";
import { forbidden, HttpError, unprocessable } from "../../../errors.js";
import { secretService } from "../../secrets.js";
import { toolAccessPolicyService } from "../../tool-access-policy.js";
import type { ToolAccessAudit } from "../audit.js";
import { createTokenBrokerAdmission, lockAuthorizedBrokerResponsibleMembership } from "./admission.js";
import { createTokenIssuanceRecorder } from "./issuance.js";
import { createConnectionTokenExchange } from "./exchange.js";
import { CONNECTION_TOKEN_MINT_TOOL_NAME, assertScopeSubset, bearerTokenHash, connectionTokenBrokerEnabled, defaultScopesForConnection, inferConnectionTokenPath, normalizeConnectionTokenScopes, parentScopesForConnection, requestedTtlSeconds } from "./configuration.js";
type TokenBrokerDependencies = {
now: () => Date;
secrets: ReturnType<typeof secretService>;
policySvc: Pick<ReturnType<typeof toolAccessPolicyService>, "decide" | "writeAudit">;
audit: ToolAccessAudit;
getConnectionRow(idOrUid: string, companyId?: string): Promise<typeof toolConnections.$inferSelect>;
ensureDefaultOrganizationGrant(connection: typeof toolConnections.$inferSelect): Promise<typeof connectionGrants.$inferSelect>;
};
function asRecord(value: unknown): Record<string, unknown> {
if (value && typeof value === "object" && !Array.isArray(value))
return value as Record<string, unknown>;
return {};
}
export function createConnectionTokenBroker(db: Db, { now, secrets, policySvc, audit, getConnectionRow, ensureDefaultOrganizationGrant }: TokenBrokerDependencies) {
const { loadBrokerRunContext, enforceDefaultConnectionTokenRateLimit, hasExplicitConnectionTokenMintProfileGrant } = createTokenBrokerAdmission(db, now);
const { recordConnectionTokenIssuance, auditConnectionTokenIssuance } = createTokenIssuanceRecorder(db, audit);
const mintExchangeConnectionToken = createConnectionTokenExchange(now, secrets);
async function getConnectionApplication(
connection: typeof toolConnections.$inferSelect,
) {
const [application] = await db
.select()
.from(toolApplications)
.where(eq(toolApplications.id, connection.applicationId));
return application ?? null;
}
return {
loadBrokerRunContext,
mintConnectionTokenForAgent: async (input: {
connectionId: string;
companyId: string;
agentId: string;
runId: string;
body: ConnectionTokenRequest;
}): Promise<ConnectionTokenResponse> => {
const runContext = await loadBrokerRunContext({
companyId: input.companyId,
agentId: input.agentId,
runId: input.runId,
});
const connection = await getConnectionRow(
input.connectionId,
input.companyId,
);
if (isDaytonaConnection(connection)) throw forbidden("Daytona credentials stay in the governed tool gateway and cannot be exported.");
if (isBrowserUseConnection(connection)) throw forbidden("Browser Use credentials stay in the governed tool gateway and cannot be exported.");
if (connection.connectionPurpose === "ai") throw unprocessable("AI credentials are available only through the runtime resolver");
const application = await getConnectionApplication(connection);
const brokerEnabled = connectionTokenBrokerEnabled(connection);
const path = brokerEnabled
? inferConnectionTokenPath(connection, application)
: "static";
const requestedScope = normalizeConnectionTokenScopes(input.body.scope);
const parentScopes = parentScopesForConnection(connection);
const fallbackScopes = defaultScopesForConnection(connection);
const issuedScope =
requestedScope.length > 0
? requestedScope
: fallbackScopes.length > 0
? fallbackScopes
: parentScopes;
const ttlSeconds = requestedTtlSeconds(input.body, connection);
const attribution = {
agentId: input.agentId,
runId: input.runId,
issueId: runContext.issueId,
projectId: runContext.projectId,
responsibleUserId: runContext.responsibleUserId,
};
const recordFailure = async (
outcome: ConnectionTokenIssuanceOutcome,
errorCode: string,
details: Record<string, unknown> = {},
) => {
await recordConnectionTokenIssuance({
companyId: connection.companyId,
applicationId: connection.applicationId,
connectionId: connection.id,
agentId: input.agentId,
runId: input.runId,
issueId: runContext.issueId,
projectId: runContext.projectId,
responsibleUserId: runContext.responsibleUserId,
path,
requestedScope,
issuedScope,
ttlSeconds: outcome === "use_env_lease" ? null : ttlSeconds,
expiresAt: null,
tokenHash: null,
outcome,
errorCode,
metadata: details,
});
await auditConnectionTokenIssuance({
companyId: connection.companyId,
connectionId: connection.id,
agentId: input.agentId,
runId: input.runId,
path,
outcome,
reasonCode: errorCode,
details,
});
};
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 [install] = await db
.select({ id: toolConnectionInstalls.id })
.from(toolConnectionInstalls)
.where(
and(
eq(toolConnectionInstalls.companyId, connection.companyId),
eq(toolConnectionInstalls.connectionId, connection.id),
sql`((${toolConnectionInstalls.targetType} = 'company' and ${toolConnectionInstalls.targetId} = ${connection.companyId}) or (${toolConnectionInstalls.targetType} = 'agent' and ${toolConnectionInstalls.targetId} = ${input.agentId}))`,
),
)
.limit(1);
if (!install) {
await fail(
403,
`Connection ${connection.name} must be installed for this agent before it can mint a token`,
"denied",
"installation_required",
{
connection: {
id: connection.id,
uid: connection.uid,
name: connection.name,
},
agentId: input.agentId,
remediation: {
action: "install_connection",
targetType: "agent",
targetId: input.agentId,
},
},
);
}
if (
runContext.run.activeIdentityContextId &&
(connection.config.sourceTemplateKey === "github" ||
connection.transportConfig?.sourceTemplateKey === "github")
) {
await fail(
409,
"Use managed git, gh, or GitHub tools for this run",
"denied",
"managed_github_invocation_required",
);
}
const requestedSubject = input.body.subject;
if (
requestedSubject?.type === "user" &&
requestedSubject.userId !== runContext.responsibleUserId
) {
await fail(
403,
"The agent run cannot act as the requested user",
"denied",
"subject_not_permitted",
{
connection: { uid: connection.uid },
subject: requestedSubject,
},
);
}
const actingUserId = runContext.responsibleUserId;
const autonomous =
runContext.run.invocationSource === "automation" ||
runContext.run.invocationSource === "timer";
let subject: { type: "app" } | { type: "user"; userId: string } =
connection.credentialPolicy === "shared" ||
connection.credentialPolicy === "per_agent" ||
!actingUserId
? { type: "app" as const }
: { type: "user" as const, userId: actingUserId };
let grant: typeof connectionGrants.$inferSelect | undefined;
if (connection.credentialPolicy === "per_agent") {
[grant] = await db
.select()
.from(connectionGrants)
.where(
and(
eq(connectionGrants.companyId, connection.companyId),
eq(connectionGrants.connectionId, connection.id),
eq(connectionGrants.kind, "agent"),
eq(connectionGrants.subjectAgentId, input.agentId),
),
)
.limit(1);
if (!grant) {
await fail(
409,
"This agent's dedicated authorization is required",
"denied",
"agent_authorization_required",
{
connection: { uid: connection.uid },
agentId: input.agentId,
remediation: {
action: "start_agent_authorization",
agentId: input.agentId,
},
},
);
}
} else if (connection.credentialPolicy !== "shared" && actingUserId) {
// Installation targets express the grant owner's consent to agent use;
// the control-plane-resolved responsible user selects whose grant is in
// force. A separate standing delegation is required only when no such
// responsible user exists for unattended work.
const [membership] = await db
.select({ id: companyMemberships.id })
.from(companyMemberships)
.where(
and(
eq(companyMemberships.companyId, connection.companyId),
eq(companyMemberships.principalType, "user"),
eq(companyMemberships.principalId, actingUserId),
eq(companyMemberships.status, "active"),
),
)
.limit(1);
if (!membership) {
await fail(
403,
"The personal grant owner is not an active company member",
"denied",
"grant_owner_membership_inactive",
{
connection: {
id: connection.id,
uid: connection.uid,
name: connection.name,
},
subject: { type: "user", userId: actingUserId },
remediation: { action: "restore_membership_or_reconnect" },
},
);
}
[grant] = await db
.select()
.from(connectionGrants)
.where(
and(
eq(connectionGrants.companyId, connection.companyId),
eq(connectionGrants.connectionId, connection.id),
eq(connectionGrants.kind, "user"),
eq(connectionGrants.subjectUserId, actingUserId),
),
)
.limit(1);
} else if (connection.credentialPolicy === "per_user" && autonomous) {
const delegated = await db
.select({ grant: connectionGrants })
.from(connectionGrantDelegations)
.innerJoin(
connectionGrants,
and(
eq(connectionGrants.id, connectionGrantDelegations.grantId),
eq(
connectionGrants.companyId,
connectionGrantDelegations.companyId,
),
),
)
.where(
and(
eq(connectionGrantDelegations.companyId, connection.companyId),
eq(connectionGrantDelegations.agentId, input.agentId),
eq(connectionGrants.connectionId, connection.id),
eq(connectionGrants.kind, "user"),
),
)
.limit(2);
if (delegated.length > 1) {
await fail(
409,
"More than one delegated personal authorization matches this run",
"denied",
"subject_not_permitted",
{
connection: { uid: connection.uid },
agentId: input.agentId,
},
);
}
grant = delegated[0]?.grant;
if (grant?.subjectUserId)
subject = { type: "user", userId: grant.subjectUserId };
}
if (!grant && connection.credentialPolicy === "per_user") {
await fail(
409,
"User authorization is required",
"denied",
"user_authorization_required",
{
connection: { uid: connection.uid },
subject: actingUserId
? { type: "user", userId: actingUserId }
: { type: "app" },
remediation: { action: "start_authorization" },
},
);
}
if (!grant) {
grant = await ensureDefaultOrganizationGrant(connection);
}
if (
grant.kind === "user" &&
grant.subjectUserId &&
grant.subjectUserId !== actingUserId
) {
const [membership] = await db
.select({ id: companyMemberships.id })
.from(companyMemberships)
.where(
and(
eq(companyMemberships.companyId, connection.companyId),
eq(companyMemberships.principalType, "user"),
eq(companyMemberships.principalId, grant.subjectUserId),
eq(companyMemberships.status, "active"),
),
)
.limit(1);
if (!membership) {
await fail(
403,
"The delegated personal grant owner is not an active company member",
"denied",
"grant_owner_membership_inactive",
{
connection: {
id: connection.id,
uid: connection.uid,
name: connection.name,
},
subject,
remediation: { action: "restore_membership_or_reconnect" },
},
);
}
}
if (grant.kind === "organization") {
const activeAudienceMember = actingUserId
? await db
.select({ id: companyMemberships.id })
.from(companyMemberships)
.where(
and(
eq(companyMemberships.companyId, connection.companyId),
eq(companyMemberships.principalType, "user"),
eq(companyMemberships.principalId, actingUserId),
eq(companyMemberships.status, "active"),
),
)
.limit(1)
.then((rows) => rows[0] ?? null)
: null;
const audience = await db
.select({ subjectId: connectionGrantMembers.subjectId })
.from(connectionGrantMembers)
.where(
and(
eq(connectionGrantMembers.companyId, connection.companyId),
eq(connectionGrantMembers.grantId, grant.id),
eq(connectionGrantMembers.subjectType, "user"),
),
);
if (
(actingUserId !== null && !activeAudienceMember) ||
(audience.length > 0 &&
(!actingUserId ||
!audience.some((member) => member.subjectId === actingUserId)))
) {
await fail(
403,
"The acting user is not in this grant's audience",
"denied",
"grant_audience_denied",
{
connection: { uid: connection.uid },
subject,
grantId: grant.id,
},
);
}
}
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,
enabled: connection.enabled,
},
);
}
if (
["failed", "error", "missing_secret"].includes(connection.healthStatus)
) {
await fail(
409,
"Connection credential needs attention",
"denied",
"credential_revoked",
{
healthStatus: connection.healthStatus,
healthMessage: connection.healthMessage ?? null,
},
);
}
if (!brokerEnabled) {
await fail(
403,
"Connection token broker is not enabled for this connection",
"denied",
"broker_not_enabled",
{
reason:
"Connections must explicitly opt in with tokenBroker.enabled before agents can request brokered tokens.",
},
);
}
try {
assertScopeSubset({ requestedScope: issuedScope, parentScopes });
} catch {
await fail(
403,
"Requested token scope exceeds the connection parent scope",
"denied",
"scope_exceeds_parent",
{
parentScopeCount: parentScopes.length,
},
);
}
const hasBrokerGrant = await hasExplicitConnectionTokenMintProfileGrant({
companyId: connection.companyId,
agentId: input.agentId,
issueId: runContext.issueId,
projectId: runContext.projectId,
routineId: runContext.routineId,
});
if (!hasBrokerGrant) {
await fail(
403,
"Connection token minting requires an explicit broker profile grant",
"denied",
"broker_mint_not_granted",
{
reason:
"A connection-level profile grant is not sufficient for connection_token.mint.",
},
);
}
const decisionInput = {
companyId: connection.companyId,
actor: {
actorType: "agent" as const,
actorId: input.agentId,
agentId: input.agentId,
},
runContext: {
heartbeatRunId: input.runId,
issueId: runContext.issueId,
projectId: runContext.projectId,
routineId: runContext.routineId,
},
request: {
applicationId: connection.applicationId,
connectionId: connection.id,
providerType: "connection_token_broker",
applicationKey: application?.applicationKey ?? null,
upstreamToolName: CONNECTION_TOKEN_MINT_TOOL_NAME,
riskLevel: "write",
toolName: CONNECTION_TOKEN_MINT_TOOL_NAME,
arguments: {
path,
scope: issuedScope,
requestedTtlSeconds: input.body.requestedTtlSeconds ?? null,
},
},
consumeRateLimit: true,
};
const decision = await policySvc.decide(decisionInput);
await policySvc.writeAudit(decisionInput, decision);
if (!decision.allowed) {
await fail(
decision.decision === "rate_limited" ? 429 : 403,
decision.explanation,
decision.decision === "rate_limited" ? "rate_limited" : "denied",
decision.reasonCode,
{
decision: decision.decision,
effectiveProfileIds: decision.effectiveProfileIds,
matchedPolicyIds: decision.matchedPolicyIds,
rateLimitState: decision.rateLimitState ?? null,
},
);
}
try {
await enforceDefaultConnectionTokenRateLimit({
connection,
agentId: input.agentId,
path,
});
} catch (error) {
if (error instanceof HttpError && error.status === 429) {
await fail(
429,
error.message,
"rate_limited",
"rate_limited",
asRecord(error.details),
);
}
throw error;
}
if (path === "static") {
await recordFailure("use_env_lease", "use_env_lease", {
reason:
"Connection uses durable static credentials; broker token delivery is refused.",
});
return {
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,
attribution,
};
}
if (path === "oauth_access") {
await fail(
422,
"OAuth access-token projection is disabled; configure a short-lived exchange mint path instead",
"denied",
"oauth_access_projection_disabled",
{
reason:
"The broker must not return stored upstream OAuth bearer tokens directly.",
},
);
}
const selectedGrant = grant;
try {
const mintResult = await db.transaction(async (tx) => {
await lockAuthorizedBrokerResponsibleMembership(
{
companyId: connection.companyId,
responsibleUserId: runContext.responsibleUserId,
},
tx,
);
const minted = await mintExchangeConnectionToken(
{
connection: credentialConnection,
application,
agentId: input.agentId,
runId: input.runId,
issueId: runContext.issueId,
responsibleUserId: runContext.responsibleUserId,
scope: issuedScope,
ttlSeconds,
},
secretService(tx),
);
const expiresAt = minted.expiresAt;
const mintedScope = "scope" in minted ? minted.scope : issuedScope;
const effectiveTtlSeconds = Math.max(
1,
Math.min(
900,
Math.ceil((expiresAt.getTime() - now().getTime()) / 1000),
),
);
const tokenHash = bearerTokenHash(minted.token);
await recordConnectionTokenIssuance(
{
companyId: connection.companyId,
applicationId: connection.applicationId,
connectionId: connection.id,
agentId: input.agentId,
runId: input.runId,
issueId: runContext.issueId,
projectId: runContext.projectId,
responsibleUserId: runContext.responsibleUserId,
path,
requestedScope,
issuedScope: mintedScope,
ttlSeconds: effectiveTtlSeconds,
expiresAt,
tokenHash,
outcome: "success",
metadata: { tokenRef: tokenHash, tokenType: minted.tokenType },
},
tx,
);
await tx
.update(connectionGrants)
.set({ lastUsedAt: new Date(), updatedAt: new Date() })
.where(eq(connectionGrants.id, selectedGrant.id));
return {
minted,
expiresAt,
mintedScope,
effectiveTtlSeconds,
tokenHash,
};
});
await auditConnectionTokenIssuance({
companyId: connection.companyId,
connectionId: connection.id,
agentId: input.agentId,
runId: input.runId,
path,
outcome: "success",
details: {
ttlSeconds: mintResult.effectiveTtlSeconds,
scopeCount: mintResult.mintedScope.length,
tokenRef: mintResult.tokenHash,
},
});
return {
status: "minted",
connectionId: connection.id,
connection: { id: connection.id, uid: connection.uid },
grantId: selectedGrant.id,
providerTenantId: selectedGrant.providerTenant?.externalId,
path: "exchange",
token: mintResult.minted.token,
tokenType: mintResult.minted.tokenType,
expiresAt: mintResult.expiresAt.toISOString(),
ttlSeconds: mintResult.effectiveTtlSeconds,
scope: mintResult.mintedScope,
attribution,
};
} catch (error) {
const details =
error instanceof HttpError && asRecord(error.details).code
? asRecord(error.details)
: {};
const errorCode =
typeof details.code === "string" ? details.code : "mint_failed";
const outcome: ConnectionTokenIssuanceOutcome =
errorCode === "upstream_error" ||
errorCode === "upstream_token_missing"
? "upstream_error"
: "failure";
await recordFailure(outcome, errorCode, {
...details,
message: error instanceof Error ? error.message : String(error),
});
throw error;
}
}
};
}