package dev.ltms.bridged.auth; /** * The authorization table (CB-505), stated once and enforced on both entry paths. * *

Most of these rules are already true de facto — {@code BridgeMcp} derives a worker's identity * from the connection rather than reading it from an argument, so a worker has never been able to * reply as another worker over MCP. What was missing is that the REST surface trusted the * session id in the URL path, and neither surface checked role at all. This class makes the * invariant explicit and testable rather than emergent. */ public final class Authz { private Authz() { } /** A privileged operation, named for the audit trail. */ public enum Action { /** Spawn a worker peer. */ SPAWN, /** Tear a worker peer down. */ STOP, /** Deliver a turn to a session (or answer a worker's question). */ SEND, /** A worker's terminal reply for its own turn. */ REPLY, /** A worker's mid-turn question to the primary. */ ASK, /** Collect held replies from a session's inbox. */ DRAIN, /** Read-only observation: status, roster, profiles, task polling. */ READ, /** Scrape the metrics endpoint. */ METRICS } /** * Whether {@code caller} may perform {@code action} against {@code targetSession}. * * @param targetSession the session id in the request path; only consulted for the worker-scoped * actions ({@code REPLY}, {@code ASK}), ignored otherwise, may be * {@code null} */ public static boolean permits(Principal caller, Action action, String targetSession) { if (caller == null || caller.isAnonymous()) { return false; // authenticated as nothing ⇒ authorized for nothing } return switch (action) { // Fleet lifecycle is the primary's alone — spawn, stop, drain. An architect // deliberately does NOT get these (CB-548), so it cannot tear down or stand up workers // even though it coordinates them; and a worker driving any of these would be a worker // escalating into the orchestrator role. case SPAWN, STOP, DRAIN -> caller.isPrimary(); // Delivering a turn is open to the primary and the architect: an architect delegates // to workers (that is the role's point) but still has no lifecycle rights. A worker is // excluded — sending would be it escalating. case SEND -> caller.isPrimary() || caller.isArchitect(); // The load-bearing rule: a caller acts only as the pane it occupies. CB-532 widened who // that can be — a lead answering another lead is replying for its OWN terminal, which // this already permits — while the rule itself is unchanged, and is what stops anyone // forging a reply for a rendezvous someone else is waiting on. An architect's own pane // passes through the same check, so it can answer a funnel that delegated to it. An // unnamed primary (token/loopback, no pane) owns nothing and is still excluded. case REPLY, ASK -> caller.ownsSession(targetSession); // Observation is open to every authenticated role: a worker legitimately polls its own // status, and the roster carries no secrets. case READ, METRICS -> caller.isPrimary() || caller.isWorker() || caller.isArchitect(); }; } /** * Why a request was refused, for the error body. Distinguishes "you are nobody" from "you are * somebody, but not the right somebody" — the first is a credential problem (401), the second * an authorization one (403). */ public static boolean isUnauthenticated(Principal caller) { return caller == null || caller.isAnonymous(); } }