Merge pull request #5148 from paperclipai/pap-3474-tenant-identity-deploy

Support Cloud tenant identity bootstrap
This commit is contained in:
Dotta
2026-05-03 21:57:28 -05:00
committed by GitHub
3 changed files with 232 additions and 4 deletions
@@ -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<string, unknown> }> = [];
const db = {
insert: vi.fn(() => {
const chain = {
values(values: Record<string, unknown>) {
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,
});
});
});
+155 -2
View File
@@ -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<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,
},
});
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";
}
+1 -1
View File
@@ -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";
};
}
}