Files
continuum-backend/src/auth/scope.ts
T
octoturge 847f7cb686 Raise alerts: failed or paused prints, and AI detections
Nothing subscribed to channel:print_alerts and nothing watched a printer
fail, so an operator away from the screen found out when they came back to
a ruined plate. Alerts are now rows in printer_alerts, listed at
GET /alerts (open=true for what still needs a person) and cleared with
POST /alerts/:id/acknowledge, which is operator-only and keeps the first
acknowledger on the record.

Two sources. Telemetry: a change into error is a failed print if the printer
was printing or paused, otherwise a printer error; a print the printer
paused itself (how an AMS fault looks) is announced too. Transitions, not
levels - a printer sitting in error is one alert, not one per frame - and a
connection's first sight of a printer raises nothing, so a gateway that
reconnects does not re-announce known faults. AI: the worker's Valkey
publications are stored when confidence is at least ALERT_MIN_CONFIDENCE
(default 0.6); the worker deliberately does not threshold, so the policy
lives here. A bad message costs one message, not the listener.

e2e-authz.sh gains the scoping/ack cases; new e2e-alerts.sh drives a stand-in
gateway over the real /ws/edge/v1 and the real Valkey channel (checked to
fail when the derivation is broken).
2026-10-29 14:56:24 +00:00

189 lines
6.6 KiB
TypeScript

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<OrgRole> {
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 };
}