mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-23 09:07:46 +02:00
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:
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);
|
||||
|
||||
+10
-1662
File diff suppressed because it is too large.
Load diff
@@ -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;
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
Reference in new issue
Block a user