diff --git a/server/src/__tests__/auth-session-route.test.ts b/server/src/__tests__/auth-session-route.test.ts index 91eeb8a0..65f6cbd3 100644 --- a/server/src/__tests__/auth-session-route.test.ts +++ b/server/src/__tests__/auth-session-route.test.ts @@ -1,6 +1,6 @@ import express from "express"; import request from "supertest"; -import { describe, expect, it, vi } from "vitest"; +import { afterEach, describe, expect, it, vi } from "vitest"; import { actorMiddleware } from "../middleware/auth.js"; function createSelectChain(rows: unknown[]) { @@ -25,6 +25,13 @@ function createDb() { } describe("actorMiddleware authenticated session profile", () => { + const originalCloudTenantToken = process.env.PAPERCLIP_CLOUD_TENANT_SERVER_TOKEN; + + afterEach(() => { + if (originalCloudTenantToken === undefined) delete process.env.PAPERCLIP_CLOUD_TENANT_SERVER_TOKEN; + else process.env.PAPERCLIP_CLOUD_TENANT_SERVER_TOKEN = originalCloudTenantToken; + }); + it("preserves the signed-in user name and email on the board actor", async () => { const app = express(); app.use( @@ -58,4 +65,72 @@ describe("actorMiddleware authenticated session profile", () => { isInstanceAdmin: false, }); }); + + it("trusts Cloud tenant identity headers and seeds board access", async () => { + process.env.PAPERCLIP_CLOUD_TENANT_SERVER_TOKEN = "tenant-token"; + const inserts: Array<{ values: Record }> = []; + const db = { + insert: vi.fn(() => { + const chain = { + values(values: Record) { + inserts.push({ values }); + return chain; + }, + onConflictDoUpdate() { + return chain; + }, + onConflictDoNothing() { + return chain; + }, + returning() { + return Promise.resolve([{ + companyId: inserts.at(-1)?.values.companyId, + membershipRole: inserts.at(-1)?.values.membershipRole, + status: inserts.at(-1)?.values.status, + }]); + }, + }; + return chain; + }), + select: vi.fn(), + } as any; + const app = express(); + app.use( + actorMiddleware(db, { + deploymentMode: "authenticated", + resolveSession: async () => null, + }), + ); + app.get("/actor", (req, res) => { + res.json(req.actor); + }); + + const res = await request(app) + .get("/actor") + .set("x-paperclip-cloud-tenant-token", "tenant-token") + .set("x-paperclip-cloud-user-id", "global-user-1") + .set("x-paperclip-cloud-user-email", "owner@example.com") + .set("x-paperclip-cloud-user-name", "Stack Owner") + .set("x-paperclip-cloud-stack-id", "stack-alpha") + .set("x-paperclip-cloud-paperclip-company-id", "paperclip-stack-alpha") + .set("x-paperclip-cloud-stack-role", "owner"); + + expect(res.status).toBe(200); + expect(res.body).toMatchObject({ + type: "board", + userId: "global-user-1", + userName: "Stack Owner", + userEmail: "owner@example.com", + source: "cloud_tenant", + isInstanceAdmin: true, + memberships: [expect.objectContaining({ membershipRole: "owner", status: "active" })], + }); + expect(res.body.companyIds[0]).toMatch(/^[0-9a-f-]{36}$/); + expect(inserts).toHaveLength(4); + expect(inserts[0]?.values).toMatchObject({ + id: "global-user-1", + email: "owner@example.com", + emailVerified: true, + }); + }); }); diff --git a/server/src/middleware/auth.ts b/server/src/middleware/auth.ts index 2438aee3..ad0b4289 100644 --- a/server/src/middleware/auth.ts +++ b/server/src/middleware/auth.ts @@ -1,8 +1,8 @@ -import { createHash } from "node:crypto"; +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 { agentApiKeys, agents, companyMemberships, instanceUserRoles } from "@paperclipai/db"; +import { agentApiKeys, agents, authUsers, companies, companyMemberships, instanceUserRoles } from "@paperclipai/db"; import { verifyLocalAgentJwt } from "../agent-auth-jwt.js"; import type { DeploymentMode } from "@paperclipai/shared"; import type { BetterAuthSessionResult } from "../auth/better-auth.js"; @@ -38,6 +38,16 @@ export function actorMiddleware(db: Db, opts: ActorMiddlewareOptions): RequestHa 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); @@ -189,6 +199,149 @@ export function actorMiddleware(db: Db, opts: ActorMiddlewareOptions): RequestHa }; } +async function resolveCloudTenantActor(db: Db, req: Request): Promise { + 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, + }, + }); + + await db + .insert(instanceUserRoles) + .values({ + userId, + role: "instance_admin", + updatedAt: now, + }) + .onConflictDoNothing({ + target: [instanceUserRoles.userId, instanceUserRoles.role], + }); + + 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", + }); + + return { + type: "board", + userId, + userName, + userEmail, + companyIds: [companyId], + memberships: [{ + companyId, + membershipRole: membership.membershipRole, + status: membership.status, + }], + isInstanceAdmin: true, + 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"; } diff --git a/server/src/types/express.d.ts b/server/src/types/express.d.ts index 1915e2ff..17f94e75 100644 --- a/server/src/types/express.d.ts +++ b/server/src/types/express.d.ts @@ -19,7 +19,7 @@ declare global { isInstanceAdmin?: boolean; keyId?: string; runId?: string; - source?: "local_implicit" | "session" | "board_key" | "agent_key" | "agent_jwt" | "none"; + source?: "local_implicit" | "session" | "board_key" | "agent_key" | "agent_jwt" | "cloud_tenant" | "none"; }; } }