import { and, eq, inArray } from "drizzle-orm"; import { ForbiddenError, NotFoundError } from "@continuum/ts-core"; import { db } from "../db"; import { farms, orgMembers, printJobs, printerAlerts, printers } from "../db/schema"; import { roleSatisfies, type OrgRole } from "./roles"; /** * Authorization for everything except the edge-gateway socket, which * authenticates as a farm rather than as a user. * * One rule, applied at the top of every handler: resolve the thing being * acted on back to an organisation, and check the caller's membership role * there. user -> org -> farm -> printer, with nothing carrying permissions of * its own in between. * * ## 404 rather than 403 for non-members * * The `require*` helpers below answer "not found" when the record exists but * the caller is not in its organisation. That is deliberate: a 403 would * confirm that a given farm id exists and belongs to someone, which is an * enumeration oracle over every farm and printer in the system. 403 is kept * for the case where the caller *can* see the record and simply is not * allowed to do this to it - a viewer trying to move a print head - where * saying so is useful and leaks nothing they did not already know. */ /** The organisations this user belongs to, with their role in each. */ export function listMemberships(userId: string) { return db .select({ orgId: orgMembers.orgId, role: orgMembers.role, joinedAt: orgMembers.createdAt }) .from(orgMembers) .where(eq(orgMembers.userId, userId)); } /** * Subquery of the org ids this user belongs to, for use inside a `where`. * * A subquery rather than a fetched array of ids so list endpoints stay one * round trip, and so a user in many organisations does not build an IN list * whose length depends on their membership count. */ function orgIdsFor(userId: string) { return db.select({ id: orgMembers.orgId }).from(orgMembers).where(eq(orgMembers.userId, userId)); } /** `where` condition limiting `farms` to those the user can see. */ export function visibleFarms(userId: string) { return inArray(farms.orgId, orgIdsFor(userId)); } /** `where` condition limiting `printers` to those the user can see. */ export function visiblePrinters(userId: string) { return inArray( printers.farmId, db.select({ id: farms.id }).from(farms).where(visibleFarms(userId)), ); } /** `where` condition limiting `print_jobs` to those the user can see. */ export function visibleJobs(userId: string) { return inArray( printJobs.farmId, db.select({ id: farms.id }).from(farms).where(visibleFarms(userId)), ); } /** `where` condition limiting `printer_alerts` to those the user can see. */ export function visibleAlerts(userId: string) { return inArray( printerAlerts.farmId, db.select({ id: farms.id }).from(farms).where(visibleFarms(userId)), ); } function check(role: OrgRole | null, minimum: OrgRole, missing: string, action: string): OrgRole { if (role === null) throw new NotFoundError(missing); if (!roleSatisfies(role, minimum)) { throw new ForbiddenError(`${action} requires the '${minimum}' role; you are '${role}'`); } return role; } /** Asserts the caller holds at least `minimum` in this organisation. */ export async function requireOrg( userId: string, orgId: string, minimum: OrgRole, action = "this operation", ): Promise { const [row] = await db .select({ role: orgMembers.role }) .from(orgMembers) .where(and(eq(orgMembers.orgId, orgId), eq(orgMembers.userId, userId))) .limit(1); return check(row?.role ?? null, minimum, `Organisation ${orgId} not found`, action); } /** * Asserts the caller holds at least `minimum` in the farm's organisation. * * A left join so that "no such farm" and "a farm you are not a member of" * run the same query and return the same 404 - see the note above. */ export async function requireFarm( userId: string, farmId: string, minimum: OrgRole, action = "this operation", ): Promise<{ orgId: string; role: OrgRole }> { const [row] = await db .select({ orgId: farms.orgId, role: orgMembers.role }) .from(farms) .leftJoin( orgMembers, and(eq(orgMembers.orgId, farms.orgId), eq(orgMembers.userId, userId)), ) .where(eq(farms.id, farmId)) .limit(1); const role = check(row?.role ?? null, minimum, `Farm ${farmId} not found`, action); return { orgId: row!.orgId, role }; } /** As `requireFarm`, resolved through the printer's farm. */ export async function requirePrinter( userId: string, printerId: string, minimum: OrgRole, action = "this operation", ): Promise<{ farmId: string; orgId: string; role: OrgRole }> { const [row] = await db .select({ farmId: printers.farmId, orgId: farms.orgId, role: orgMembers.role }) .from(printers) .innerJoin(farms, eq(farms.id, printers.farmId)) .leftJoin( orgMembers, and(eq(orgMembers.orgId, farms.orgId), eq(orgMembers.userId, userId)), ) .where(eq(printers.id, printerId)) .limit(1); const role = check(row?.role ?? null, minimum, `Printer ${printerId} not found`, action); return { farmId: row!.farmId, orgId: row!.orgId, role }; } /** As `requireFarm`, resolved through the job's farm. */ export async function requireJob( userId: string, jobId: string, minimum: OrgRole, action = "this operation", ): Promise<{ farmId: string; orgId: string; role: OrgRole }> { const [row] = await db .select({ farmId: printJobs.farmId, orgId: farms.orgId, role: orgMembers.role }) .from(printJobs) .innerJoin(farms, eq(farms.id, printJobs.farmId)) .leftJoin( orgMembers, and(eq(orgMembers.orgId, farms.orgId), eq(orgMembers.userId, userId)), ) .where(eq(printJobs.id, jobId)) .limit(1); const role = check(row?.role ?? null, minimum, `Print job ${jobId} not found`, action); return { farmId: row!.farmId, orgId: row!.orgId, role }; } /** As `requireFarm`, resolved through the alert's farm. */ export async function requireAlert( userId: string, alertId: string, minimum: OrgRole, action = "this operation", ): Promise<{ farmId: string; orgId: string; role: OrgRole }> { const [row] = await db .select({ farmId: printerAlerts.farmId, orgId: farms.orgId, role: orgMembers.role }) .from(printerAlerts) .innerJoin(farms, eq(farms.id, printerAlerts.farmId)) .leftJoin( orgMembers, and(eq(orgMembers.orgId, farms.orgId), eq(orgMembers.userId, userId)), ) .where(eq(printerAlerts.id, alertId)) .limit(1); const role = check(row?.role ?? null, minimum, `Alert ${alertId} not found`, action); return { farmId: row!.farmId, orgId: row!.orgId, role }; }