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.
This commit is contained in:
@@ -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<String> 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<String> 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();
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
*
|
||||
* <p>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";
|
||||
};
|
||||
|
||||
@@ -34,6 +34,17 @@ public enum Role {
|
||||
*/
|
||||
ARCHITECT,
|
||||
|
||||
/**
|
||||
* A config-declared, human-opened tab recognised by its exact label (the {@code
|
||||
* fleet.collaborators.<name>.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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user