mirror of
https://github.com/alkimake/paperclip.git
synced 2026-06-14 01:50:39 +09:00
Merge pull request #5148 from paperclipai/pap-3474-tenant-identity-deploy
Support Cloud tenant identity bootstrap
This commit is contained in:
commit
e01ffc18d3
3 changed files with 232 additions and 4 deletions
|
|
@ -1,6 +1,6 @@
|
||||||
import express from "express";
|
import express from "express";
|
||||||
import request from "supertest";
|
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";
|
import { actorMiddleware } from "../middleware/auth.js";
|
||||||
|
|
||||||
function createSelectChain(rows: unknown[]) {
|
function createSelectChain(rows: unknown[]) {
|
||||||
|
|
@ -25,6 +25,13 @@ function createDb() {
|
||||||
}
|
}
|
||||||
|
|
||||||
describe("actorMiddleware authenticated session profile", () => {
|
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 () => {
|
it("preserves the signed-in user name and email on the board actor", async () => {
|
||||||
const app = express();
|
const app = express();
|
||||||
app.use(
|
app.use(
|
||||||
|
|
@ -58,4 +65,72 @@ describe("actorMiddleware authenticated session profile", () => {
|
||||||
isInstanceAdmin: false,
|
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 type { Request, RequestHandler } from "express";
|
||||||
import { and, eq, isNull } from "drizzle-orm";
|
import { and, eq, isNull } from "drizzle-orm";
|
||||||
import type { Db } from "@paperclipai/db";
|
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 { verifyLocalAgentJwt } from "../agent-auth-jwt.js";
|
||||||
import type { DeploymentMode } from "@paperclipai/shared";
|
import type { DeploymentMode } from "@paperclipai/shared";
|
||||||
import type { BetterAuthSessionResult } from "../auth/better-auth.js";
|
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");
|
const authHeader = req.header("authorization");
|
||||||
if (!authHeader?.toLowerCase().startsWith("bearer ")) {
|
if (!authHeader?.toLowerCase().startsWith("bearer ")) {
|
||||||
if (opts.deploymentMode === "authenticated" && opts.resolveSession) {
|
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;
|
let session: BetterAuthSessionResult | null = null;
|
||||||
try {
|
try {
|
||||||
session = await opts.resolveSession(req);
|
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) {
|
export function requireBoard(req: Express.Request) {
|
||||||
return req.actor.type === "board";
|
return req.actor.type === "board";
|
||||||
}
|
}
|
||||||
|
|
|
||||||
2
server/src/types/express.d.ts
vendored
2
server/src/types/express.d.ts
vendored
|
|
@ -19,7 +19,7 @@ declare global {
|
||||||
isInstanceAdmin?: boolean;
|
isInstanceAdmin?: boolean;
|
||||||
keyId?: string;
|
keyId?: string;
|
||||||
runId?: 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";
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue