From bb29b001e4a34a2d615ada0467cfa1f250b73099 Mon Sep 17 00:00:00 2001 From: Dai Ha Date: Sun, 4 Oct 2026 00:22:27 +0200 Subject: [PATCH] fleetd #669 Unit C: add the COLLABORATOR role and its authorization row Adds Role.COLLABORATOR, Principal.collaborator(), and the Authz.permits grant: READ/METRICS open, REPLY/ASK via ownership, SEND limited to a configured lead or collaborator via a new classifier parameter, everything else denied. isSpawnedMember() stays WORKER || ARCHITECT. fleet_whoami reports role and collaborator name with no leader key. Per a same-day ticket correction, the existing 3-argument Authz.permits is kept (fail-closed default via the new NO_KNOWN_LEAD_OR_COLLABORATOR classifier) rather than deleted, so the 47 pre-existing call sites in AuthzTest/CallerResolverTest are untouched; both production gates (FleetMcp#denyFor, FleetApp#allow via the new permitsFor seam) call the 4-argument form explicitly with the shared constant. Mutation-verified: removing the classifier conjunct from the SEND arm kills exactly 3 tests (AuthzTest, FleetMcpAuthzTest, FleetAppAuthTest), one per gate, no cascade. Full mvn clean install at this commit: 1974 tests, 0 failures (baseline at f0ff252 was 1964; +10 are the new collaborator-matrix tests). Independently counted from target/surefire-reports/*.xml after rm -rf: 173 report files, aggregate tests=1974 failures=0 errors=0. --- .../main/java/dev/ltms/fleet/auth/Authz.java | 88 +++++++++++++------ .../java/dev/ltms/fleet/auth/Principal.java | 22 ++++- .../main/java/dev/ltms/fleet/auth/Role.java | 11 +++ .../java/dev/ltms/fleet/mcp/FleetMcp.java | 14 ++- .../java/dev/ltms/fleet/rest/FleetApp.java | 13 ++- .../java/dev/ltms/fleet/auth/AuthzTest.java | 84 ++++++++++++++++++ .../dev/ltms/fleet/mcp/FleetMcpAuthzTest.java | 14 +++ .../java/dev/ltms/fleet/mcp/FleetMcpTest.java | 26 ++++++ .../dev/ltms/fleet/rest/FleetAppAuthTest.java | 13 +++ 9 files changed, 256 insertions(+), 29 deletions(-) diff --git a/fleetd/src/main/java/dev/ltms/fleet/auth/Authz.java b/fleetd/src/main/java/dev/ltms/fleet/auth/Authz.java index fd5a617a..355d5b40 100644 --- a/fleetd/src/main/java/dev/ltms/fleet/auth/Authz.java +++ b/fleetd/src/main/java/dev/ltms/fleet/auth/Authz.java @@ -1,5 +1,7 @@ package dev.ltms.fleet.auth; +import java.util.function.Predicate; + /** * The authorization table (CB-505), stated once and enforced on both entry paths. * @@ -60,58 +62,92 @@ public final class Authz { } /** - * 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} + * Stands in for the terminal-to-tab registry a collaborator's {@code SEND} is checked + * against, until one exists: answers no for every target, so a collaborator reaches nothing + * today. Both production gates ({@code FleetMcp#denyFor}, {@code FleetApp#allow}) pass this + * exact instance, so the classifier is defined once and replacing it is a one-line change in + * each. + */ + public static final Predicate NO_KNOWN_LEAD_OR_COLLABORATOR = target -> false; + + /** + * Convenience form for a caller with no classifier to supply. Fails closed: a collaborator's + * {@code SEND} is refused, as if no terminal were a configured lead or collaborator — the + * same decision {@link #NO_KNOWN_LEAD_OR_COLLABORATOR} gives explicitly. Every other action's + * result is identical to the four-argument form's, since none of them consult the classifier. */ public static boolean permits(Principal caller, Action action, String targetSession) { + return permits(caller, action, targetSession, NO_KNOWN_LEAD_OR_COLLABORATOR); + } + + /** + * 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}) and for a + * collaborator's {@code SEND}, ignored otherwise, may be + * {@code null} + * @param knownLeadOrCollaborator whether a terminal is a configured lead or collaborator — + * consulted only for a collaborator's {@code SEND}, to confine + * it to another named peer and never a spawned member's + * terminal + */ + public static boolean permits(Principal caller, Action action, String targetSession, + Predicate knownLeadOrCollaborator) { 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. + // Fleet lifecycle is the primary's alone — spawn, stop, drain. An architect and a + // collaborator deliberately do NOT get these, so neither can tear down or stand up + // workers even though one of them coordinates them; and a worker driving any of these + // would be a worker escalating into the orchestrator role. case SPAWN, STOP, DRAIN, HANDOVER -> caller.isPrimary(); - // Delivering a turn to a local session 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(); + // Delivering a turn to a local session is open to the primary, the architect, and a + // collaborator whose target is itself a configured lead or collaborator: the architect + // delegates to workers (that is the role's point); a collaborator may reach only + // another named peer, never a spawned member's terminal. A worker is excluded — + // sending would be it escalating. + case SEND -> caller.isPrimary() || caller.isArchitect() + || (caller.isCollaborator() && knownLeadOrCollaborator.test(targetSession)); - // Same grant as SEND. Resolving a worker's blocked question is part of delegating to - // it, not a separate capability. + // Resolving a worker's blocked question is part of delegating to it, open to the same + // two roles that may stand up that delegation in the first place. Not a collaborator: + // resuming another session's turn is lifecycle-adjacent, not peer messaging. case ANSWER -> caller.isPrimary() || caller.isArchitect(); - // Same grant as SEND. This leaves the daemon over the coordination broker rather than - // addressing a local session, but the caller who may do one may do the other. + // Leaves the daemon over the coordination broker rather than addressing a local + // session, open to the same two roles as ANSWER. Not a collaborator: it is a + // local-tab peer with no cross-host route. case COORD_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. + // forging a reply for a rendezvous someone else is waiting on. An architect's or a + // collaborator's own pane passes through the same check, so each 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); // READ is roster, profile, and identity observation — fleet_list, fleet_profiles, and // fleet_whoami — and carries no secrets: no ticket reply, no pending question, and no // other session's turn state. Those live under TASK_READ. METRICS is the separate - // Prometheus scrape. Both stay open to every authenticated role. - case READ, METRICS -> caller.isPrimary() || caller.isWorker() || caller.isArchitect(); + // Prometheus scrape. Both are open to every authenticated role, including a + // collaborator. + case READ, METRICS -> caller.isPrimary() || caller.isWorker() || caller.isArchitect() + || caller.isCollaborator(); - // Ticket polling and session status, open to every authenticated role the same as READ. - // Unlike READ, a holder may poll a ticket it did not create, or read another session's - // pending question and the turnId that answers it. + // Ticket polling and session status, open to every role READ is open to except a + // collaborator: ticket ids are a sequential counter with no owner check, so a holder + // could walk every ticket and read another session's delegation reply. case TASK_READ -> caller.isPrimary() || caller.isWorker() || caller.isArchitect(); // fleetd #421: reading held lead-to-lead mail is the primary's alone. An architect // holds READ today (CB-548), so "not primary" must mean not-architect here too — this - // is coordination between leads, not observation of the roster. + // is coordination between leads, not observation of the roster. The same reasoning + // excludes a collaborator. case COORD_READ -> caller.isPrimary(); }; } diff --git a/fleetd/src/main/java/dev/ltms/fleet/auth/Principal.java b/fleetd/src/main/java/dev/ltms/fleet/auth/Principal.java index 3a2526fe..99b4fbc4 100644 --- a/fleetd/src/main/java/dev/ltms/fleet/auth/Principal.java +++ b/fleetd/src/main/java/dev/ltms/fleet/auth/Principal.java @@ -11,7 +11,9 @@ package dev.ltms.fleet.auth; * @param pid the connecting process id, or {@code -1} when not resolvable (audit context) * @param name for a lead resolved from the CB-530 {@code leaders:} registry, which lead it is; * for an architect resolved from the CB-548 {@code architects:} registry, which - * slot it occupies; {@code null} for every other caller, including an unnamed primary + * slot it occupies; for a collaborator resolved from the {@code collaborators:} + * registry, which collaborator it is; {@code null} for every other caller, + * including an unnamed primary */ public record Principal(Role role, String terminal, long pid, String name) { @@ -73,6 +75,19 @@ public record Principal(Role role, String terminal, long pid, String name) { return new Principal(Role.ARCHITECT, terminal, pid, slotName); } + /** + * A collaborator: a human-opened tab recognised by its exact label in the + * {@code collaborators:} registry. + * + *

Carries {@link Role#COLLABORATOR}. {@code name} is reporting only — it lets + * {@code fleet_whoami} say which collaborator is asking. Identity is the {@code terminal}: + * like a worker's it comes from the connection, so {@code ownsSession} works exactly as it + * does for a worker — a collaborator acts as its own pane and no other. + */ + public static Principal collaborator(String name, String terminal, long pid) { + return new Principal(Role.COLLABORATOR, terminal, pid, name); + } + public boolean isPrimary() { return role == Role.PRIMARY; } @@ -81,6 +96,10 @@ public record Principal(Role role, String terminal, long pid, String name) { return role == Role.ARCHITECT; } + public boolean isCollaborator() { + return role == Role.COLLABORATOR; + } + public boolean isWorker() { return role == Role.WORKER; } @@ -120,6 +139,7 @@ public record Principal(Role role, String terminal, long pid, String name) { return switch (role) { case WORKER -> "worker:" + terminal; case ARCHITECT -> "architect:" + name; + case COLLABORATOR -> "collaborator:" + name; case PRIMARY -> name == null ? "primary" : "leader:" + name; case ANONYMOUS -> "anonymous"; }; diff --git a/fleetd/src/main/java/dev/ltms/fleet/auth/Role.java b/fleetd/src/main/java/dev/ltms/fleet/auth/Role.java index bdd47c74..ae91056c 100644 --- a/fleetd/src/main/java/dev/ltms/fleet/auth/Role.java +++ b/fleetd/src/main/java/dev/ltms/fleet/auth/Role.java @@ -34,6 +34,17 @@ public enum Role { */ ARCHITECT, + /** + * A config-declared, human-opened tab recognised by its exact label (the {@code + * fleet.collaborators..tab} registry). Never spawned — identity comes from the + * connection, never a request argument, exactly like {@link #WORKER} and {@link #ARCHITECT}. + * May {@code SEND} only to a configured lead or collaborator, {@code REPLY}/{@code ASK} only + * as its own pane, and {@code READ}/{@code METRICS}; may not {@code SPAWN}/{@code STOP}/ + * {@code DRAIN}/{@code HANDOVER}, poll a ticket ({@code TASK_READ}), or reach the + * coordination broker ({@code COORD_SEND}/{@code COORD_READ}). + */ + COLLABORATOR, + /** Authenticated as nothing. Authorized for nothing but {@code /healthz}. */ ANONYMOUS } diff --git a/fleetd/src/main/java/dev/ltms/fleet/mcp/FleetMcp.java b/fleetd/src/main/java/dev/ltms/fleet/mcp/FleetMcp.java index ae59bf71..7d05cc97 100644 --- a/fleetd/src/main/java/dev/ltms/fleet/mcp/FleetMcp.java +++ b/fleetd/src/main/java/dev/ltms/fleet/mcp/FleetMcp.java @@ -689,7 +689,7 @@ public final class FleetMcp { if (!authorizationEnforced) { return null; // AuthorizationMode.UNENFORCED: authorization not enforced (fleetd #518) } - if (Authz.permits(caller, action, target)) { + if (Authz.permits(caller, action, target, Authz.NO_KNOWN_LEAD_OR_COLLABORATOR)) { if (action != Authz.Action.READ && action != Authz.Action.TASK_READ) { AuditLog.allowed(caller, action, target); // reads would drown the trail } @@ -1309,6 +1309,18 @@ public final class FleetMcp { } return text(json(m)); } + if (caller.isCollaborator()) { + // A collaborator's name is its slot in the collaborators: registry; sessionId is its + // pane so a peer knows where to reach it. No leader key: a collaborator is not a + // primary for authorization, unlike a lead. + if (caller.name() != null) { + m.put("collaborator", caller.name()); + } + if (caller.terminal() != null) { + m.put("sessionId", caller.terminal()); + } + return text(json(m)); + } if (!caller.isWorker()) { // CB-530: which lead, once more than one pane is configured as one. `role` deliberately // still reads "primary" — the fallback ladder in CLAUDE.md keys on it, and a lead IS a diff --git a/fleetd/src/main/java/dev/ltms/fleet/rest/FleetApp.java b/fleetd/src/main/java/dev/ltms/fleet/rest/FleetApp.java index aa5b9645..9ae88c3d 100644 --- a/fleetd/src/main/java/dev/ltms/fleet/rest/FleetApp.java +++ b/fleetd/src/main/java/dev/ltms/fleet/rest/FleetApp.java @@ -266,6 +266,17 @@ public final class FleetApp { return app; } + /** + * The authorization decision behind {@link #allow}, taking the caller directly rather than + * pulling it from a servlet {@link Context} — unit-testable without fabricating a live + * request, the same reason {@code FleetMcp#denyFor} is split from {@code FleetMcp#deny}. The + * classifier is the same shared instance {@link #allow} would use, so a test calling this + * exercises the real production gate, not a test-supplied stand-in. + */ + static boolean permitsFor(Principal caller, Authz.Action action, String target) { + return Authz.permits(caller, action, target, Authz.NO_KNOWN_LEAD_OR_COLLABORATOR); + } + /** * Gate a handler on the CB-505 authorization table. Returns {@code true} when the request may * proceed; otherwise writes the error response and returns {@code false}. @@ -279,7 +290,7 @@ public final class FleetApp { return true; // legacy: authorization not enforced } Principal caller = ctx.attribute(CALLER); - if (Authz.permits(caller, action, target)) { + if (permitsFor(caller, action, target)) { if (action != Authz.Action.READ && action != Authz.Action.METRICS && action != Authz.Action.TASK_READ) { AuditLog.allowed(caller, action, target); // reads would drown the trail diff --git a/fleetd/src/test/java/dev/ltms/fleet/auth/AuthzTest.java b/fleetd/src/test/java/dev/ltms/fleet/auth/AuthzTest.java index 72881b5d..6abafd2c 100644 --- a/fleetd/src/test/java/dev/ltms/fleet/auth/AuthzTest.java +++ b/fleetd/src/test/java/dev/ltms/fleet/auth/AuthzTest.java @@ -14,6 +14,7 @@ class AuthzTest { private static final Principal ANON = Principal.anonymous(); private static final Principal ARCH_DESIGN = Principal.architect("lead-designer", "term_design", 400); private static final Principal ARCH_OTHER = Principal.architect("reviewer", "term_review", 500); + private static final Principal COLLABORATOR = Principal.collaborator("ops", "term_collab", 600); @Test void anonymousIsAuthorizedForNothing() { @@ -166,4 +167,87 @@ class AuthzTest { assertFalse(Authz.isUnauthenticated(WORKER_A)); assertFalse(Authz.isUnauthenticated(PRIMARY)); } + + // ── the collaborator matrix ───────────────────────────────────────────────────────────────── + + /** + * {@code SEND} for a collaborator is the one grant that is conditional rather than fixed: + * flipping only the classifier's answer for the target flips only this outcome. + */ + @Test + void aCollaboratorMaySendOnlyWhenTheClassifierAcceptsTheTarget() { + assertTrue(Authz.permits(COLLABORATOR, SEND, "term_lead", target -> true), + "the classifier accepting the target must grant SEND"); + assertFalse(Authz.permits(COLLABORATOR, SEND, "term_lead", target -> false), + "the classifier refusing the target must deny SEND"); + assertFalse(Authz.permits(COLLABORATOR, SEND, "term_lead"), + "the real production classifier recognises no terminal yet, so SEND is refused today"); + } + + /** + * Control for the test above: every other action's result for a collaborator does not move + * when the classifier does. Only {@code SEND} is wired to it. + */ + @Test + void theClassifierMovesOnlySendForACollaborator() { + for (Authz.Action a : Authz.Action.values()) { + if (a == SEND) { + continue; + } + assertEquals( + Authz.permits(COLLABORATOR, a, "term_lead"), + Authz.permits(COLLABORATOR, a, "term_lead", target -> true), + a + " must not depend on the classifier at all"); + } + } + + @Test + void aCollaboratorMayReadAndScrapeMetrics() { + assertTrue(Authz.permits(COLLABORATOR, READ, null)); + assertTrue(Authz.permits(COLLABORATOR, METRICS, null)); + } + + @Test + void aCollaboratorMayReplyAndAskOnlyAsItsOwnPane() { + assertTrue(Authz.permits(COLLABORATOR, REPLY, "term_collab"), + "its own pane is its own"); + assertTrue(Authz.permits(COLLABORATOR, ASK, "term_collab")); + + assertFalse(Authz.permits(COLLABORATOR, REPLY, "term_design"), + "a collaborator must not reply on another pane"); + assertFalse(Authz.permits(COLLABORATOR, REPLY, null), + "an absent target must not pass the own-session rule"); + } + + /** + * Every action denied to a collaborator, asserted denied even when the classifier would + * accept any target — proving none of these is actually gated on the classifier at all. + */ + @Test + void aCollaboratorIsDeniedLifecycleCoordinationAndTicketPolling() { + for (Authz.Action a : new Authz.Action[]{SPAWN, STOP, DRAIN, HANDOVER, ANSWER, COORD_SEND, + COORD_READ, TASK_READ}) { + assertFalse(Authz.permits(COLLABORATOR, a, "term_lead", target -> true), + "a collaborator must not " + a + " even when the classifier accepts every target"); + } + } + + @Test + void aCollaboratorIsNotCountedAsPrimaryWorkerOrArchitect() { + assertFalse(COLLABORATOR.isPrimary()); + assertFalse(COLLABORATOR.isWorker()); + assertFalse(COLLABORATOR.isArchitect()); + assertTrue(COLLABORATOR.isCollaborator()); + } + + /** + * A collaborator is never spawned, so it must not be enrolled in the presence map as an + * available member. Control: both a worker and an architect — which ARE spawned — still are. + */ + @Test + void isSpawnedMemberIsFalseForACollaboratorButTrueForAWorkerAndAnArchitect() { + assertFalse(COLLABORATOR.isSpawnedMember()); + assertTrue(WORKER_A.isSpawnedMember()); + assertTrue(ARCH_DESIGN.isSpawnedMember()); + } } diff --git a/fleetd/src/test/java/dev/ltms/fleet/mcp/FleetMcpAuthzTest.java b/fleetd/src/test/java/dev/ltms/fleet/mcp/FleetMcpAuthzTest.java index 9d7c7f96..47e56faa 100644 --- a/fleetd/src/test/java/dev/ltms/fleet/mcp/FleetMcpAuthzTest.java +++ b/fleetd/src/test/java/dev/ltms/fleet/mcp/FleetMcpAuthzTest.java @@ -97,6 +97,7 @@ class FleetMcpAuthzTest { private static final Principal WORKER_A = Principal.worker("term_a", 200); private static final Principal ANON = Principal.anonymous(); private static final Principal ARCH_DESIGN = Principal.architect("lead-designer", "term_design", 400); + private static final Principal COLLABORATOR = Principal.collaborator("ops", "term_collab", 600); // --- the table, enforced on THIS path too --------------------------------------------------- @@ -226,6 +227,19 @@ class FleetMcpAuthzTest { "the caller IS authenticated — it is just not the right role"); } + /** + * {@code denyFor} passes the real production classifier, not a test-supplied one — no + * terminal is recognised as a configured lead or collaborator, so a collaborator's SEND is + * refused over MCP. + */ + @Test + void aCollaboratorMayNotSendOverMcpWithTheRealProductionClassifier() { + FleetMcp m = mcp(true); + McpSchema.CallToolResult denied = m.denyFor(COLLABORATOR, Authz.Action.SEND, "term_lead"); + assertNotNull(denied, "no terminal is recognised as a lead or collaborator yet"); + assertTrue(denied.isError()); + } + @Test void theLegacyConstructorLeavesTheGateOpen() { // The 22 pre-existing FleetMcpTest cases rely on no authorization being enforced. diff --git a/fleetd/src/test/java/dev/ltms/fleet/mcp/FleetMcpTest.java b/fleetd/src/test/java/dev/ltms/fleet/mcp/FleetMcpTest.java index c0263d7b..1dbc779c 100644 --- a/fleetd/src/test/java/dev/ltms/fleet/mcp/FleetMcpTest.java +++ b/fleetd/src/test/java/dev/ltms/fleet/mcp/FleetMcpTest.java @@ -1837,6 +1837,32 @@ class FleetMcpTest { assertTrue(out.contains("\"sessionId\":\"term_design\""), out); } + /** + * A collaborator reports its own role and name, never the {@code leader} key a lead gets — + * {@code role} already reads {@code "collaborator"}, so a {@code leader} key alongside it + * would be self-contradicting. Control: the same call shape fed a named lead must still carry + * {@code leader}, so this is not passing because the key stopped being emitted for everyone. + */ + @Test + void whoamiReportsACollaboratorWithNoLeaderKeyButALeadStillGetsOne() { + FakeHerdr h = new FakeHerdr(); + SessionManager sessions = sessionManager(h, "http://gx00.gw:8000", Set.of("gx00.gw")); + + McpSchema.CallToolResult collabRes = FleetMcp.whoami( + Principal.collaborator("ops", "term_collab", 700), sessions); + assertNotEquals(Boolean.TRUE, collabRes.isError()); + String collabOut = textOf(collabRes); + assertTrue(collabOut.contains("\"role\":\"collaborator\""), collabOut); + assertTrue(collabOut.contains("\"collaborator\":\"ops\""), collabOut); + assertTrue(collabOut.contains("\"sessionId\":\"term_collab\""), collabOut); + assertFalse(collabOut.contains("leader"), collabOut); + + McpSchema.CallToolResult leadRes = FleetMcp.whoami( + Principal.leader("opus", "term_lead", 100), sessions); + String leadOut = textOf(leadRes); + assertTrue(leadOut.contains("\"leader\":\"opus\""), leadOut); + } + /** * CB-548: an architect SEND delegates as its own pane (recording the per-target delegation) but * must NEVER become the legacy singleton "primary" fallback — the per-target map does not cure diff --git a/fleetd/src/test/java/dev/ltms/fleet/rest/FleetAppAuthTest.java b/fleetd/src/test/java/dev/ltms/fleet/rest/FleetAppAuthTest.java index adfeb8ae..21f8f8ac 100644 --- a/fleetd/src/test/java/dev/ltms/fleet/rest/FleetAppAuthTest.java +++ b/fleetd/src/test/java/dev/ltms/fleet/rest/FleetAppAuthTest.java @@ -6,6 +6,7 @@ import com.fasterxml.jackson.databind.ObjectMapper; import dev.ltms.fleet.auth.CallerResolver; import dev.ltms.fleet.auth.Authz; import dev.ltms.fleet.auth.MemberRegistry; +import dev.ltms.fleet.auth.Principal; import dev.ltms.fleet.config.FleetConfig; import dev.ltms.fleet.guard.SubscriptionGuard; import dev.ltms.fleet.herdr.AgentControl; @@ -202,6 +203,18 @@ class FleetAppAuthTest { } } + /** + * {@code permitsFor} is the exact decision {@link FleetApp#allow} makes, passing the real + * production classifier rather than a test-supplied one — no terminal is recognised as a + * configured lead or collaborator, so a collaborator's SEND is refused through the REST gate. + */ + @Test + void aCollaboratorMayNotSendOverRestWithTheRealProductionClassifier() { + Principal collaborator = Principal.collaborator("ops", "term_collab", 700); + assertFalse(FleetApp.permitsFor(collaborator, Authz.Action.SEND, "term_lead"), + "no terminal is recognised as a lead or collaborator yet"); + } + // --- loopback-trust: the caller is the primary ------------------------------------------- @Test