83 lines
3.9 KiB
Java
83 lines
3.9 KiB
Java
package dev.ltms.bridged.auth;
|
|
|
|
/**
|
|
* The authorization table (CB-505), stated once and enforced on both entry paths.
|
|
*
|
|
* <p>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 <em>as</em> 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();
|
|
}
|
|
}
|