diff --git a/server/src/__tests__/auth-session-route.test.ts b/server/src/__tests__/auth-session-route.test.ts index 549ebd713e..43cdf64c66 100644 --- a/server/src/__tests__/auth-session-route.test.ts +++ b/server/src/__tests__/auth-session-route.test.ts @@ -1,8 +1,10 @@ import express from "express"; import request from "supertest"; import { afterEach, describe, expect, it, vi } from "vitest"; -import { instanceUserRoles } from "@paperclipai/db"; +import { companyMemberships, instanceUserRoles } from "@paperclipai/db"; import { actorMiddleware } from "../middleware/auth.js"; +import { errorHandler } from "../middleware/error-handler.js"; +import { assertCompanyAccess } from "../routes/authz.js"; function createSelectChain(rows: unknown[]) { return { @@ -138,6 +140,83 @@ describe("actorMiddleware authenticated session profile", () => { }); }); + it("lets the cloud tenant actor through assertCompanyAccess for a company it holds a membership row in", async () => { + process.env.PAPERCLIP_CLOUD_TENANT_SERVER_TOKEN = "tenant-token"; + // A company created on the instance after provisioning (e.g. by a company + // import) — the user has a real membership row, but it is not the stack's + // seeded primary company. + const importedCompanyId = "33333333-3333-4333-8333-333333333333"; + const unrelatedCompanyId = "44444444-4444-4444-8444-444444444444"; + const insertChain = { + values() { + return insertChain; + }, + onConflictDoUpdate() { + return insertChain; + }, + onConflictDoNothing() { + return insertChain; + }, + returning() { + return Promise.resolve([{ companyId: "company-1", membershipRole: "member", status: "active" }]); + }, + then(resolve: (value: unknown) => unknown) { + return Promise.resolve(undefined).then(resolve); + }, + }; + const db = { + select: vi.fn(() => ({ + from: (table: unknown) => ({ + where: () => + Promise.resolve( + table === companyMemberships + ? [{ companyId: importedCompanyId, membershipRole: "member", status: "active" }] + : [], + ), + }), + })), + insert: vi.fn(() => insertChain), + delete: vi.fn(() => ({ where: () => Promise.resolve(undefined) })), + } as any; + const app = express(); + app.use( + actorMiddleware(db, { + deploymentMode: "authenticated", + resolveSession: async () => null, + }), + ); + app.get("/companies/:companyId/resource", (req, res) => { + assertCompanyAccess(req, req.params.companyId); + res.json({ ok: true, companyIds: req.actor.companyIds }); + }); + app.post("/companies/:companyId/resource", (req, res) => { + assertCompanyAccess(req, req.params.companyId); + res.json({ ok: true }); + }); + app.use(errorHandler); + + const cloudHeaders = { + "x-paperclip-cloud-tenant-token": "tenant-token", + "x-paperclip-cloud-user-id": "global-user-1", + "x-paperclip-cloud-user-email": "owner@example.com", + "x-paperclip-cloud-stack-id": "stack-alpha", + "x-paperclip-cloud-stack-role": "member", + }; + + // Reads and writes both reach the imported company through the real + // membership row (write access also consults actor.memberships). + const read = await request(app).get(`/companies/${importedCompanyId}/resource`).set(cloudHeaders); + expect(read.status).toBe(200); + expect(read.body.companyIds).toContain(importedCompanyId); + + const write = await request(app).post(`/companies/${importedCompanyId}/resource`).set(cloudHeaders); + expect(write.status).toBe(200); + + // Companies the user holds no membership row in stay unreachable. + const denied = await request(app).get(`/companies/${unrelatedCompanyId}/resource`).set(cloudHeaders); + expect(denied.status).toBe(403); + }); + it("purges a stale instance_admin row so the session path stops elevating the cloud-tenant user", async () => { process.env.PAPERCLIP_CLOUD_TENANT_SERVER_TOKEN = "tenant-token"; // Simulates a deployment that previously ran the pre-hardening cloud_tenant diff --git a/server/src/middleware/auth.ts b/server/src/middleware/auth.ts index 8ad775b489..f5a532e76b 100644 --- a/server/src/middleware/auth.ts +++ b/server/src/middleware/auth.ts @@ -78,6 +78,28 @@ async function loadResponsibleUserMemberships( return user ? memberships : []; } +/** + * The user's own active company memberships — the exact company scope a + * locally authenticated session actor carries. Shared by the session path + * and the Cloud trusted-header path so both resolve the same access set. + */ +async function loadActiveUserCompanyMemberships(db: Db, userId: string) { + return 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"), + ), + ); +} + async function auditAgentJwtRunHeaderMismatch( db: Db, input: { companyId: string; agentId: string; claimRunId: string; headerRunId: string; method: string; url: string }, @@ -185,20 +207,7 @@ export function actorMiddleware(db: Db, opts: ActorMiddlewareOptions): RequestHa .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"), - ), - ), + loadActiveUserCompanyMemberships(db, userId), ]); req.actor = { type: "board", @@ -518,17 +527,42 @@ export async function resolveCloudTenantActor(db: Db, req: Request): Promise row.companyId !== companyId, + ); + } catch (err) { + logger.warn( + { err, userId, stackId }, + "Failed to load cloud tenant user's company memberships; scoping actor to the stack's primary company", + ); + } + return { type: "board", userId, userName, userEmail, - companyIds: [companyId], - memberships: [{ - companyId, - membershipRole: membership.membershipRole, - status: membership.status, - }], + companyIds: [companyId, ...additionalMemberships.map((row) => row.companyId)], + memberships: [ + { + companyId, + membershipRole: membership.membershipRole, + status: membership.status, + }, + ...additionalMemberships, + ], // Computed per request, never persisted: the stack owner is elevated to // instance admin of their own dedicated instance only while the // `enableOwnerInstanceAdmin` flag is on. Non-owner stack roles stay diff --git a/server/src/middleware/cloud-tenant-actor.test.ts b/server/src/middleware/cloud-tenant-actor.test.ts index 8dcba8d6bb..afa7157b8f 100644 --- a/server/src/middleware/cloud-tenant-actor.test.ts +++ b/server/src/middleware/cloud-tenant-actor.test.ts @@ -1,5 +1,6 @@ import { afterEach, beforeEach, describe, expect, it } from "vitest"; import type { Request } from "express"; +import { and, eq } from "drizzle-orm"; import type { Db } from "@paperclipai/db"; import { authUsers, companies, companyMemberships, instanceSettings, instanceUserRoles } from "@paperclipai/db"; import { resolveCloudTenantActor } from "./auth.js"; @@ -7,11 +8,14 @@ import { resolveCloudTenantActor } from "./auth.js"; // Minimal fake Drizzle Db: records every table passed to .insert() / .delete() and // supports the chained call shapes used by resolveCloudTenantActor (values / // onConflictDo* / returning().then() / delete().where()), plus the -// select().from(instanceSettings).where().then() read the owner-elevation flag -// resolution performs through instanceSettingsService. The chain is awaitable so +// select().from(table).where() reads: instanceSettings for the owner-elevation +// flag resolution through instanceSettingsService, and companyMemberships for +// the user's own membership rows (rows configurable via membershipQueryRows, +// where-conditions captured in selectWheres). The chain is awaitable so // directly-awaited statements resolve. function createFakeDb(options: { membershipRow?: { companyId: string; membershipRole: string; status: string }; + membershipQueryRows?: Array<{ companyId: string; membershipRole: string | null; status: string }>; settingsRow?: Record | null; selectThrows?: boolean; } = {}) { @@ -31,6 +35,7 @@ function createFakeDb(options: { : options.settingsRow; const insertedTables: unknown[] = []; const deletedTables: unknown[] = []; + const selectWheres: Array<{ table: unknown; condition: unknown }> = []; const chain: Record = {}; chain.values = () => chain; chain.onConflictDoUpdate = () => chain; @@ -51,15 +56,23 @@ function createFakeDb(options: { if (options.selectThrows) throw new Error("select unavailable"); return { from: (table: unknown) => ({ - where: () => ({ - then: (resolve: (v: unknown) => unknown) => - Promise.resolve(table === instanceSettings && settingsRow ? [settingsRow] : []).then(resolve), - }), + where: (condition: unknown) => { + selectWheres.push({ table, condition }); + const rows = + table === instanceSettings && settingsRow + ? [settingsRow] + : table === companyMemberships + ? (options.membershipQueryRows ?? []) + : []; + return { + then: (resolve: (v: unknown) => unknown) => Promise.resolve(rows).then(resolve), + }; + }, }), }; }, } as unknown as Db; - return { db, insertedTables, deletedTables }; + return { db, insertedTables, deletedTables, selectWheres }; } function settingsRowWith(experimental: Record) { @@ -121,7 +134,7 @@ describe("resolveCloudTenantActor (shared-pool hardening)", () => { expect(insertedTables).not.toContain(instanceUserRoles); }); - it("is scoped to exactly the one company from its stack", async () => { + it("stays scoped to exactly the stack's company when the user has no other membership rows", async () => { const { db } = createFakeDb(); const actor = await resolveCloudTenantActor(db, fakeReq(VALID_HEADERS)); expect(actor!.companyIds).toHaveLength(1); @@ -165,6 +178,59 @@ describe("resolveCloudTenantActor (shared-pool hardening)", () => { expect(actor?.memberships?.[0]?.membershipRole).toBe("member"); }); + describe("company membership union", () => { + // The primary company id is derived from the stack id; resolve it once + // through the same code path instead of duplicating the hash here. + async function resolvePrimaryCompanyId() { + const { db } = createFakeDb(); + const actor = await resolveCloudTenantActor(db, fakeReq(VALID_HEADERS)); + return actor!.companyIds![0]!; + } + + it("unions the pinned primary with the user's own active membership rows, primary first and deduped", async () => { + const primaryCompanyId = await resolvePrimaryCompanyId(); + const { db } = createFakeDb({ + membershipQueryRows: [ + // The user's own row for the primary company comes back from the + // query too — it must not appear twice. + { companyId: primaryCompanyId, membershipRole: "owner", status: "active" }, + { companyId: "company-imported", membershipRole: "member", status: "active" }, + ], + }); + const actor = await resolveCloudTenantActor(db, fakeReq(VALID_HEADERS)); + expect(actor!.companyIds).toEqual([primaryCompanyId, "company-imported"]); + expect(actor!.memberships).toEqual([ + { companyId: primaryCompanyId, membershipRole: "owner", status: "active" }, + { companyId: "company-imported", membershipRole: "member", status: "active" }, + ]); + }); + + it("loads memberships with the session path's exact own-user active-status filter", async () => { + const { db, selectWheres } = createFakeDb(); + await resolveCloudTenantActor(db, fakeReq(VALID_HEADERS)); + const membershipSelects = selectWheres.filter((entry) => entry.table === companyMemberships); + expect(membershipSelects).toHaveLength(1); + // Rows for other users and rows in any non-active status are excluded + // by the query itself: the filter binds exactly this user id and the + // "active" status — the same condition the session path applies. + expect(membershipSelects[0]!.condition).toEqual( + and( + eq(companyMemberships.principalType, "user"), + eq(companyMemberships.principalId, "user-123"), + eq(companyMemberships.status, "active"), + ), + ); + }); + + it("degrades to the primary company when the membership read fails", async () => { + const { db } = createFakeDb({ selectThrows: true }); + const actor = await resolveCloudTenantActor(db, fakeReq(VALID_HEADERS)); + expect(actor).not.toBeNull(); + expect(actor!.companyIds).toHaveLength(1); + expect(actor!.memberships).toHaveLength(1); + }); + }); + describe("owner instance-admin elevation (enableOwnerInstanceAdmin)", () => { it("elevates the owner while the flag is enabled, still without any role row", async () => { const { db, insertedTables, deletedTables } = createFakeDb({