mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-23 00:53:38 +02:00
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work > - Governed MCP access spans contracts, runtime enforcement, adapters, UI surfaces, and operator verification > - The parity reference PR #9534 is too large for effective automated or human review > - The feature therefore needs a linear stack whose individual diffs stay below the 100-file review limit > - This pull request is split 3/8 and focuses on tool-access policy and authorization core > - The benefit is a standalone, testable review boundary while preserving byte-for-byte parity at the top of the stack ## Linked Issues or Issue Description - Related parity reference: #9534 - Problem: Authorization, OAuth binding, secret projection, content guards, and policy evaluation need a security-reviewable server boundary. - Proposed solution: Adds tool-access services/routes/tests plus the runtime service dependencies directly imported by the core, without registering the routes in the application. - Alternatives considered: keeping #9534 as one 403-file review, or rewriting the feature to manufacture seams; both were rejected in favor of path extraction plus compile-driven boundary moves. - Roadmap alignment: this advances the existing governed MCP/tool-access work already represented by #9534; it does not introduce a separate roadmap initiative. - Stack position: base branch is `pap10341-split/02-schema-shared`. - Merge policy: merge bottom-up, in order, only after the complete eight-PR stack has been reviewed and the top-of-stack parity gate remains empty. - Requested review: SecurityEngineer for authz, OAuth, secrets, and content guards; Greptile on every PR. ## What Changed - Adds tool-access services/routes/tests plus the runtime service dependencies directly imported by the core, without registering the routes in the application. - Keeps this PR below 100 changed files and independently typecheckable. - Preserves the final tree from #9534 when combined with the other seven stack levels. ## Verification - `pnpm typecheck` - Focused server Vitest run — 4 files, 143 tests passed ## Risks - Authorization bugs could permit cross-company or over-broad tool access; the PR remains inert until PR 4 wiring and requires dedicated security review. - Stack risk: merging out of order can expose incomplete layers; mitigate by following the documented bottom-up merge policy. - Parity risk: later edits to an intermediate branch can drift from #9534; mitigate by re-running the empty top-of-stack diff before merge. > For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and discuss it in `#dev` before opening the PR. Feature PRs that overlap with planned core work may need to be redirected — check the roadmap first. See `CONTRIBUTING.md`. ## Model Used - OpenAI Codex, exact model ID `gpt-5.4`; runtime-managed context window; medium reasoning with repository, shell, Git, GitHub CLI, and code-execution tools enabled. ## 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] Internal references are omitted except the execution-plan link explicitly required for this coordinated split stack - [x] My branch name describes the change 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 - [ ] All Paperclip CI gates are green - [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge ## Stack Coordination - Internal execution plan: [PAP-13874](/PAP/issues/PAP-13874#document-plan) - Parity reference: #9534 - Stack: #9556 → #9557 → #9558 → #9559 → #9560 → #9561 → #9562 → #9563 - Merge bottom-up only after full-stack review and an empty parity diff at #9563. --------- Co-authored-by: Paperclip <noreply@paperclip.ing>
532 lines
17 KiB
TypeScript
532 lines
17 KiB
TypeScript
import { createHash, timingSafeEqual } from "node:crypto";
|
|
import type { Request, RequestHandler } from "express";
|
|
import { and, eq, isNull } from "drizzle-orm";
|
|
import type { Db } from "@paperclipai/db";
|
|
import {
|
|
activityLog,
|
|
agentApiKeys,
|
|
agents,
|
|
authUsers,
|
|
companies,
|
|
companyMemberships,
|
|
heartbeatRuns,
|
|
instanceUserRoles,
|
|
} from "@paperclipai/db";
|
|
import { verifyLocalAgentJwt } from "../agent-auth-jwt.js";
|
|
import { isUuidLike, normalizeAgentApiKeyScope, type DeploymentMode } from "@paperclipai/shared";
|
|
import type { BetterAuthSessionResult } from "../auth/better-auth.js";
|
|
import { logger } from "./logger.js";
|
|
import { boardAuthService } from "../services/board-auth.js";
|
|
import { ensureHumanRoleDefaultGrants } from "../services/principal-access-compatibility.js";
|
|
import { forbidden, unprocessable } from "../errors.js";
|
|
|
|
function hashToken(token: string) {
|
|
return createHash("sha256").update(token).digest("hex");
|
|
}
|
|
|
|
function normalizeOptionalString(value: string | null | undefined) {
|
|
return value?.trim() || null;
|
|
}
|
|
|
|
async function resolveLegacyRunResponsibleUserId(
|
|
db: Db,
|
|
input: { companyId: string; agentId: string; runId: string },
|
|
) {
|
|
if (!isUuidLike(input.runId)) return null;
|
|
const run = await db
|
|
.select({ responsibleUserId: heartbeatRuns.responsibleUserId })
|
|
.from(heartbeatRuns)
|
|
.where(
|
|
and(
|
|
eq(heartbeatRuns.id, input.runId),
|
|
eq(heartbeatRuns.companyId, input.companyId),
|
|
eq(heartbeatRuns.agentId, input.agentId),
|
|
),
|
|
)
|
|
.then((rows) => rows[0] ?? null);
|
|
return normalizeOptionalString(run?.responsibleUserId);
|
|
}
|
|
|
|
async function loadResponsibleUserMemberships(
|
|
db: Db,
|
|
input: { companyId: string; userId: string | null },
|
|
) {
|
|
if (!input.userId) return [];
|
|
const [user, memberships] = await Promise.all([
|
|
db
|
|
.select({ id: authUsers.id })
|
|
.from(authUsers)
|
|
.where(eq(authUsers.id, input.userId))
|
|
.then((rows) => rows[0] ?? null),
|
|
db
|
|
.select({
|
|
companyId: companyMemberships.companyId,
|
|
membershipRole: companyMemberships.membershipRole,
|
|
status: companyMemberships.status,
|
|
})
|
|
.from(companyMemberships)
|
|
.where(
|
|
and(
|
|
eq(companyMemberships.companyId, input.companyId),
|
|
eq(companyMemberships.principalType, "user"),
|
|
eq(companyMemberships.principalId, input.userId),
|
|
eq(companyMemberships.status, "active"),
|
|
),
|
|
),
|
|
]);
|
|
return user ? memberships : [];
|
|
}
|
|
|
|
async function auditAgentJwtRunHeaderMismatch(
|
|
db: Db,
|
|
input: { companyId: string; agentId: string; claimRunId: string; headerRunId: string; method: string; url: string },
|
|
) {
|
|
try {
|
|
await db.insert(activityLog).values({
|
|
companyId: input.companyId,
|
|
actorType: "agent",
|
|
actorId: input.agentId,
|
|
action: "auth.agent_jwt_run_header_mismatch",
|
|
entityType: "heartbeat_run",
|
|
entityId: input.claimRunId,
|
|
...(isUuidLike(input.agentId) ? { agentId: input.agentId } : {}),
|
|
...(isUuidLike(input.claimRunId) ? { runId: input.claimRunId } : {}),
|
|
details: {
|
|
claimRunId: input.claimRunId,
|
|
headerRunId: input.headerRunId,
|
|
method: input.method,
|
|
url: input.url,
|
|
},
|
|
});
|
|
} catch (err) {
|
|
logger.warn(
|
|
{ err, companyId: input.companyId, agentId: input.agentId, claimRunId: input.claimRunId },
|
|
"Failed to audit rejected agent JWT run header mismatch",
|
|
);
|
|
}
|
|
}
|
|
|
|
async function auditAgentKeyMissingResponsibleUser(
|
|
db: Db,
|
|
input: { companyId: string; agentId: string; keyId: string; method: string; url: string },
|
|
) {
|
|
try {
|
|
await db.insert(activityLog).values({
|
|
companyId: input.companyId,
|
|
actorType: "agent",
|
|
actorId: input.agentId,
|
|
action: "auth.agent_key_missing_responsible_user",
|
|
entityType: "agent_api_key",
|
|
entityId: input.keyId,
|
|
...(isUuidLike(input.agentId) ? { agentId: input.agentId } : {}),
|
|
details: {
|
|
method: input.method,
|
|
url: input.url,
|
|
},
|
|
});
|
|
} catch (err) {
|
|
logger.warn(
|
|
{ err, companyId: input.companyId, agentId: input.agentId, keyId: input.keyId },
|
|
"Failed to audit rejected agent key without responsible user binding",
|
|
);
|
|
}
|
|
}
|
|
|
|
interface ActorMiddlewareOptions {
|
|
deploymentMode: DeploymentMode;
|
|
resolveSession?: (req: Request) => Promise<BetterAuthSessionResult | null>;
|
|
}
|
|
|
|
export function actorMiddleware(db: Db, opts: ActorMiddlewareOptions): RequestHandler {
|
|
const boardAuth = boardAuthService(db);
|
|
return async (req, _res, next) => {
|
|
req.actor =
|
|
opts.deploymentMode === "local_trusted"
|
|
? {
|
|
type: "board",
|
|
userId: "local-board",
|
|
userName: "Local Board",
|
|
userEmail: null,
|
|
isInstanceAdmin: true,
|
|
source: "local_implicit",
|
|
}
|
|
: { type: "none", source: "none" };
|
|
|
|
const runIdHeader = req.header("x-paperclip-run-id");
|
|
|
|
const authHeader = req.header("authorization");
|
|
if (!authHeader?.toLowerCase().startsWith("bearer ")) {
|
|
if (opts.deploymentMode === "authenticated" && opts.resolveSession) {
|
|
const cloudTenantActor = await resolveCloudTenantActor(db, req);
|
|
if (cloudTenantActor) {
|
|
req.actor = {
|
|
...cloudTenantActor,
|
|
runId: runIdHeader ?? undefined,
|
|
};
|
|
next();
|
|
return;
|
|
}
|
|
|
|
let session: BetterAuthSessionResult | null = null;
|
|
try {
|
|
session = await opts.resolveSession(req);
|
|
} catch (err) {
|
|
logger.warn(
|
|
{ err, method: req.method, url: req.originalUrl },
|
|
"Failed to resolve auth session from request headers",
|
|
);
|
|
}
|
|
if (session?.user?.id && session.session?.id) {
|
|
const userId = session.user.id;
|
|
const [roleRow, memberships] = await Promise.all([
|
|
db
|
|
.select({ id: instanceUserRoles.id })
|
|
.from(instanceUserRoles)
|
|
.where(and(eq(instanceUserRoles.userId, userId), eq(instanceUserRoles.role, "instance_admin")))
|
|
.then((rows) => rows[0] ?? null),
|
|
db
|
|
.select({
|
|
companyId: companyMemberships.companyId,
|
|
membershipRole: companyMemberships.membershipRole,
|
|
status: companyMemberships.status,
|
|
})
|
|
.from(companyMemberships)
|
|
.where(
|
|
and(
|
|
eq(companyMemberships.principalType, "user"),
|
|
eq(companyMemberships.principalId, userId),
|
|
eq(companyMemberships.status, "active"),
|
|
),
|
|
),
|
|
]);
|
|
req.actor = {
|
|
type: "board",
|
|
userId,
|
|
sessionId: session.session.id,
|
|
userName: session.user.name ?? null,
|
|
userEmail: session.user.email ?? null,
|
|
companyIds: memberships.map((row) => row.companyId),
|
|
memberships,
|
|
isInstanceAdmin: Boolean(roleRow),
|
|
runId: runIdHeader ?? undefined,
|
|
source: "session",
|
|
};
|
|
next();
|
|
return;
|
|
}
|
|
}
|
|
if (runIdHeader) req.actor.runId = runIdHeader;
|
|
next();
|
|
return;
|
|
}
|
|
|
|
const token = authHeader.slice("bearer ".length).trim();
|
|
if (!token) {
|
|
next();
|
|
return;
|
|
}
|
|
|
|
const boardKey = await boardAuth.findBoardApiKeyByToken(token);
|
|
if (boardKey) {
|
|
const access = await boardAuth.resolveBoardAccess(boardKey.userId);
|
|
if (access.user) {
|
|
await boardAuth.touchBoardApiKey(boardKey.id);
|
|
req.actor = {
|
|
type: "board",
|
|
userId: boardKey.userId,
|
|
userName: access.user?.name ?? null,
|
|
userEmail: access.user?.email ?? null,
|
|
companyIds: access.companyIds,
|
|
memberships: access.memberships,
|
|
isInstanceAdmin: access.isInstanceAdmin,
|
|
keyId: boardKey.id,
|
|
runId: runIdHeader || undefined,
|
|
source: "board_key",
|
|
};
|
|
next();
|
|
return;
|
|
}
|
|
}
|
|
|
|
const tokenHash = hashToken(token);
|
|
const key = await db
|
|
.select()
|
|
.from(agentApiKeys)
|
|
.where(and(eq(agentApiKeys.keyHash, tokenHash), isNull(agentApiKeys.revokedAt)))
|
|
.then((rows) => rows[0] ?? null);
|
|
|
|
if (!key) {
|
|
const claims = verifyLocalAgentJwt(token);
|
|
if (!claims) {
|
|
next();
|
|
return;
|
|
}
|
|
|
|
const agentRecord = await db
|
|
.select()
|
|
.from(agents)
|
|
.where(eq(agents.id, claims.sub))
|
|
.then((rows) => rows[0] ?? null);
|
|
|
|
if (!agentRecord || agentRecord.companyId !== claims.company_id) {
|
|
next();
|
|
return;
|
|
}
|
|
|
|
if (agentRecord.status === "terminated" || agentRecord.status === "pending_approval") {
|
|
next();
|
|
return;
|
|
}
|
|
|
|
const normalizedRunIdHeader = normalizeOptionalString(runIdHeader);
|
|
if (normalizedRunIdHeader && normalizedRunIdHeader !== claims.run_id) {
|
|
await auditAgentJwtRunHeaderMismatch(db, {
|
|
companyId: claims.company_id,
|
|
agentId: claims.sub,
|
|
claimRunId: claims.run_id,
|
|
headerRunId: normalizedRunIdHeader,
|
|
method: req.method,
|
|
url: req.originalUrl,
|
|
});
|
|
next(
|
|
unprocessable("X-Paperclip-Run-Id does not match signed agent JWT run_id", {
|
|
code: "agent_jwt_run_id_mismatch",
|
|
claimRunId: claims.run_id,
|
|
headerRunId: normalizedRunIdHeader,
|
|
}),
|
|
);
|
|
return;
|
|
}
|
|
|
|
const onBehalfOfUserId = claims.responsible_user_id !== undefined
|
|
? normalizeOptionalString(claims.responsible_user_id)
|
|
: await resolveLegacyRunResponsibleUserId(db, {
|
|
companyId: claims.company_id,
|
|
agentId: claims.sub,
|
|
runId: claims.run_id,
|
|
});
|
|
const onBehalfOfMemberships = await loadResponsibleUserMemberships(db, {
|
|
companyId: claims.company_id,
|
|
userId: onBehalfOfUserId,
|
|
});
|
|
|
|
req.actor = {
|
|
type: "agent",
|
|
agentId: claims.sub,
|
|
companyId: claims.company_id,
|
|
keyId: undefined,
|
|
keyScope: normalizeAgentApiKeyScope(claims.key_scope),
|
|
runId: claims.run_id,
|
|
onBehalfOfUserId,
|
|
onBehalfOfMemberships,
|
|
source: "agent_jwt",
|
|
};
|
|
next();
|
|
return;
|
|
}
|
|
|
|
await db
|
|
.update(agentApiKeys)
|
|
.set({ lastUsedAt: new Date() })
|
|
.where(eq(agentApiKeys.id, key.id));
|
|
|
|
const agentRecord = await db
|
|
.select()
|
|
.from(agents)
|
|
.where(eq(agents.id, key.agentId))
|
|
.then((rows) => rows[0] ?? null);
|
|
|
|
if (!agentRecord || agentRecord.status === "terminated" || agentRecord.status === "pending_approval") {
|
|
next();
|
|
return;
|
|
}
|
|
|
|
const responsibleUserId = normalizeOptionalString(key.responsibleUserId);
|
|
if (!responsibleUserId) {
|
|
await auditAgentKeyMissingResponsibleUser(db, {
|
|
companyId: key.companyId,
|
|
agentId: key.agentId,
|
|
keyId: key.id,
|
|
method: req.method,
|
|
url: req.originalUrl,
|
|
});
|
|
next(forbidden("Responsible user is unavailable for this agent key", {
|
|
code: "RESPONSIBLE_USER_UNAVAILABLE",
|
|
}));
|
|
return;
|
|
}
|
|
|
|
req.actor = {
|
|
type: "agent",
|
|
agentId: key.agentId,
|
|
companyId: key.companyId,
|
|
keyId: key.id,
|
|
keyScope: normalizeAgentApiKeyScope(key.scopeConfig),
|
|
onBehalfOfUserId: responsibleUserId,
|
|
onBehalfOfMemberships: await loadResponsibleUserMemberships(db, {
|
|
companyId: key.companyId,
|
|
userId: responsibleUserId,
|
|
}),
|
|
runId: runIdHeader || undefined,
|
|
source: "agent_key",
|
|
};
|
|
|
|
next();
|
|
};
|
|
}
|
|
|
|
export async function resolveCloudTenantActor(db: Db, req: Request): Promise<Express.Request["actor"] | null> {
|
|
const expectedToken = process.env.PAPERCLIP_CLOUD_TENANT_SERVER_TOKEN?.trim();
|
|
if (!expectedToken) return null;
|
|
|
|
const token = req.header("x-paperclip-cloud-tenant-token")?.trim();
|
|
if (!token || !constantTimeStringEqual(token, expectedToken)) return null;
|
|
|
|
const userId = requiredCloudHeader(req, "x-paperclip-cloud-user-id");
|
|
const userEmail = requiredCloudHeader(req, "x-paperclip-cloud-user-email").toLowerCase();
|
|
const stackId = requiredCloudHeader(req, "x-paperclip-cloud-stack-id");
|
|
const stackRole = stackMembershipRole(req.header("x-paperclip-cloud-stack-role"));
|
|
const userName = req.header("x-paperclip-cloud-user-name")?.trim() || userEmail;
|
|
const paperclipCompanyId = req.header("x-paperclip-cloud-paperclip-company-id")?.trim();
|
|
const companyId = cloudTenantCompanyId(stackId);
|
|
const companyName = paperclipCompanyId || `${stackId} Paperclip`;
|
|
const now = new Date();
|
|
|
|
await db
|
|
.insert(authUsers)
|
|
.values({
|
|
id: userId,
|
|
name: userName,
|
|
email: userEmail,
|
|
emailVerified: true,
|
|
image: null,
|
|
createdAt: now,
|
|
updatedAt: now,
|
|
})
|
|
.onConflictDoUpdate({
|
|
target: authUsers.id,
|
|
set: {
|
|
name: userName,
|
|
email: userEmail,
|
|
emailVerified: true,
|
|
updatedAt: now,
|
|
},
|
|
});
|
|
|
|
// Earlier cloud_tenant builds granted every tenant user `instance_admin`.
|
|
// Stale rows from those deployments would still elevate this user through
|
|
// the BetterAuth session path, board API keys, and the authorization
|
|
// service's own instanceUserRoles lookup — so actively purge them on every
|
|
// trusted-header authentication instead of merely no longer inserting them.
|
|
await db
|
|
.delete(instanceUserRoles)
|
|
.where(and(eq(instanceUserRoles.userId, userId), eq(instanceUserRoles.role, "instance_admin")));
|
|
|
|
await db
|
|
.insert(companies)
|
|
.values({
|
|
id: companyId,
|
|
name: companyName,
|
|
description: `Provisioned by Paperclip Cloud for stack ${stackId}.`,
|
|
status: "active",
|
|
issuePrefix: issuePrefixForCloudStack(stackId),
|
|
updatedAt: now,
|
|
})
|
|
.onConflictDoNothing({
|
|
target: companies.id,
|
|
});
|
|
|
|
const membershipRole = stackRole === "owner" || stackRole === "admin" ? "owner" : stackRole;
|
|
const membership = await db
|
|
.insert(companyMemberships)
|
|
.values({
|
|
companyId,
|
|
principalType: "user",
|
|
principalId: userId,
|
|
status: "active",
|
|
membershipRole,
|
|
updatedAt: now,
|
|
})
|
|
.onConflictDoUpdate({
|
|
target: [
|
|
companyMemberships.companyId,
|
|
companyMemberships.principalType,
|
|
companyMemberships.principalId,
|
|
],
|
|
set: {
|
|
status: "active",
|
|
membershipRole,
|
|
updatedAt: now,
|
|
},
|
|
})
|
|
.returning()
|
|
.then((rows) => rows[0] ?? {
|
|
companyId,
|
|
membershipRole,
|
|
status: "active",
|
|
});
|
|
|
|
// Without instance-admin elevation, cloud tenant users are authorized purely
|
|
// through company-scoped permission grants — seed the same role defaults the
|
|
// regular membership flows create.
|
|
await ensureHumanRoleDefaultGrants(db, {
|
|
companyId,
|
|
principalId: userId,
|
|
membershipRole: membership.membershipRole,
|
|
grantedByUserId: null,
|
|
});
|
|
|
|
return {
|
|
type: "board",
|
|
userId,
|
|
userName,
|
|
userEmail,
|
|
companyIds: [companyId],
|
|
memberships: [{
|
|
companyId,
|
|
membershipRole: membership.membershipRole,
|
|
status: membership.status,
|
|
}],
|
|
isInstanceAdmin: false,
|
|
source: "cloud_tenant",
|
|
};
|
|
}
|
|
|
|
function requiredCloudHeader(req: Request, name: string): string {
|
|
const value = req.header(name)?.trim();
|
|
if (!value) {
|
|
throw new Error(`Missing trusted Cloud tenant header ${name}`);
|
|
}
|
|
return value;
|
|
}
|
|
|
|
function stackMembershipRole(value: string | undefined): "owner" | "admin" | "member" | "support" {
|
|
if (value === "owner" || value === "admin" || value === "member" || value === "support") {
|
|
return value;
|
|
}
|
|
throw new Error("Invalid trusted Cloud tenant stack role");
|
|
}
|
|
|
|
function constantTimeStringEqual(left: string, right: string): boolean {
|
|
const leftBuffer = Buffer.from(left);
|
|
const rightBuffer = Buffer.from(right);
|
|
return leftBuffer.length === rightBuffer.length && timingSafeEqual(leftBuffer, rightBuffer);
|
|
}
|
|
|
|
function cloudTenantCompanyId(stackId: string): string {
|
|
const bytes = createHash("sha256").update(`paperclip-cloud-tenant-company:${stackId}`).digest();
|
|
bytes[6] = (bytes[6] & 0x0f) | 0x50;
|
|
bytes[8] = (bytes[8] & 0x3f) | 0x80;
|
|
const hex = bytes.subarray(0, 16).toString("hex");
|
|
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20, 32)}`;
|
|
}
|
|
|
|
function issuePrefixForCloudStack(stackId: string): string {
|
|
const hash = createHash("sha256").update(stackId).digest("hex").slice(0, 4).toUpperCase();
|
|
return `PC${hash}`;
|
|
}
|
|
|
|
export function requireBoard(req: Express.Request) {
|
|
return req.actor.type === "board";
|
|
}
|