Support Cloud tenant identity bootstrap
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
|
||||
Vendored
+1
-1
@@ -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";
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user