CB-548: config-declared architect slots + Role.ARCHITECT authz
This commit is contained in:
@@ -0,0 +1,67 @@
|
||||
package dev.ltms.bridged.auth;
|
||||
|
||||
import dev.ltms.bridged.config.BridgedConfig;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
/**
|
||||
* CB-548 — the architect-slot registry: the config snapshot of slot → profile, and the live
|
||||
* terminal → slot binding the resolver reads. The role the binding produces is asserted in
|
||||
* {@link CallerResolverTest}; this pins the registry object itself.
|
||||
*/
|
||||
class ArchitectRegistryTest {
|
||||
|
||||
private static final Map<String, BridgedConfig.Architect> SLOTS = Map.of(
|
||||
"lead-designer", new BridgedConfig.Architect("term_design", "sonnet"),
|
||||
"reviewer", new BridgedConfig.Architect(null, "gx10"));
|
||||
|
||||
private final ArchitectRegistry registry =
|
||||
new ArchitectRegistry(SLOTS, () -> Map.of("term_design", "lead-designer"));
|
||||
|
||||
@Test
|
||||
void exposesTheConfiguredSlots() {
|
||||
assertEquals(SLOTS.keySet(), registry.slots().keySet());
|
||||
assertTrue(registry.isSlot("reviewer"));
|
||||
assertFalse(registry.isSlot("nope"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void theSpawnLifecycleReadsTheProfileBackFromASlot() {
|
||||
assertEquals("sonnet", registry.profileForSlot("lead-designer"));
|
||||
assertEquals("gx10", registry.profileForSlot("reviewer"));
|
||||
assertNull(registry.profileForSlot("unknown"), "an unknown slot has no profile");
|
||||
}
|
||||
|
||||
@Test
|
||||
void resolvesTheSlotOfALiveTerminal() {
|
||||
assertEquals("lead-designer", registry.slotForTerminal("term_design"));
|
||||
assertNull(registry.slotForTerminal("term_unbound"));
|
||||
assertNull(registry.slotForTerminal(null), "no terminal ⇒ no slot");
|
||||
}
|
||||
|
||||
@Test
|
||||
void theBindingIsLiveReReadPerCall() {
|
||||
Map<String, String> live = new HashMap<>();
|
||||
ArchitectRegistry r = new ArchitectRegistry(SLOTS, () -> live);
|
||||
|
||||
assertNull(r.slotForTerminal("term_design"));
|
||||
|
||||
live.put("term_design", "lead-designer"); // injected after construction
|
||||
|
||||
assertEquals("lead-designer", r.slotForTerminal("term_design"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void theSlotSnapshotIsFixedByConstruction() {
|
||||
Map<String, BridgedConfig.Architect> mutable = new HashMap<>(SLOTS);
|
||||
ArchitectRegistry r = new ArchitectRegistry(mutable, Map::of);
|
||||
|
||||
mutable.put("hijack", new BridgedConfig.Architect("t", "gx10"));
|
||||
|
||||
assertFalse(r.isSlot("hijack"), "a handed-over map is not offered as live state");
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,8 @@ class AuthzTest {
|
||||
private static final Principal WORKER_A = Principal.worker("term_a", 200);
|
||||
private static final Principal WORKER_B = Principal.worker("term_b", 300);
|
||||
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);
|
||||
|
||||
@Test
|
||||
void anonymousIsAuthorizedForNothing() {
|
||||
@@ -61,6 +63,48 @@ class AuthzTest {
|
||||
"an absent session id must not satisfy the own-session rule");
|
||||
}
|
||||
|
||||
// ── CB-548: the architect matrix ───────────────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
void anArchitectMaySendButNotSpawnStopOrDrain() {
|
||||
assertTrue(Authz.permits(ARCH_DESIGN, SEND, "term_worker"),
|
||||
"delegating a turn to a worker IS the architect's job");
|
||||
assertTrue(Authz.permits(ARCH_DESIGN, SEND, null));
|
||||
|
||||
for (Authz.Action a : new Authz.Action[]{SPAWN, STOP, DRAIN}) {
|
||||
assertFalse(Authz.permits(ARCH_DESIGN, a, null),
|
||||
"an architect must not " + a + " — fleet lifecycle is the primary's alone, so "
|
||||
+ "a coordinator cannot also stand up or tear down the fleet");
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void anArchitectMayReplyAndAskOnlyAsItsOwnPane() {
|
||||
assertTrue(Authz.permits(ARCH_DESIGN, REPLY, "term_design"), "its own pane is its own");
|
||||
assertTrue(Authz.permits(ARCH_DESIGN, ASK, "term_design"));
|
||||
|
||||
assertFalse(Authz.permits(ARCH_DESIGN, REPLY, "term_review"),
|
||||
"architect 'lead-designer' must not reply on reviewer's pane");
|
||||
assertFalse(Authz.permits(ARCH_OTHER, ASK, "term_design"),
|
||||
"reviewer must not ask as lead-designer — no terminal is another's");
|
||||
assertFalse(Authz.permits(ARCH_DESIGN, REPLY, null),
|
||||
"an absent target must not pass the own-session rule");
|
||||
}
|
||||
|
||||
@Test
|
||||
void anArchitectMayReadAndScrapeMetrics() {
|
||||
assertTrue(Authz.permits(ARCH_DESIGN, READ, null));
|
||||
assertTrue(Authz.permits(ARCH_DESIGN, METRICS, null));
|
||||
}
|
||||
|
||||
@Test
|
||||
void anArchitectIsNotCountedAsPrimaryOrWorker() {
|
||||
assertFalse(Authz.permits(ARCH_DESIGN, SPAWN, null), "not a primary — no lifecycle");
|
||||
assertFalse(ARCH_DESIGN.isPrimary());
|
||||
assertFalse(ARCH_DESIGN.isWorker(), "an architect is its own role, not a widened worker");
|
||||
assertTrue(ARCH_DESIGN.isArchitect());
|
||||
}
|
||||
|
||||
@Test
|
||||
void observationIsOpenToBothAuthenticatedRoles() {
|
||||
assertTrue(Authz.permits(PRIMARY, READ, null));
|
||||
|
||||
@@ -298,6 +298,97 @@ class CallerResolverTest {
|
||||
assertEquals(Role.WORKER, r.resolve("127.0.0.1", 42, null).role());
|
||||
}
|
||||
|
||||
// ── CB-548: architect slots ─────────────────────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
void aBoundArchitectPaneResolvesToArchitectBeforeTheWorkerFallback() {
|
||||
Principal p = CallerResolver.withLeadsAndArchitects(workerIdentity(), false, null,
|
||||
Map::of, () -> Map.of("term_a", "lead-designer"))
|
||||
.resolve("127.0.0.1", 42, null);
|
||||
|
||||
assertEquals(Role.ARCHITECT, p.role(),
|
||||
"a terminal bound to an architect slot is an architect, NOT the generic worker it "
|
||||
+ "would otherwise resolve to");
|
||||
assertEquals("lead-designer", p.name(), "whoami must say WHICH slot is asking");
|
||||
assertEquals("term_a", p.terminal(), "the pane identity is carried so ownsSession works");
|
||||
}
|
||||
|
||||
@Test
|
||||
void anArchitectNeedsNoTokenEvenInTokenMode() {
|
||||
Principal p = CallerResolver.withLeadsAndArchitects(workerIdentity(), true, "s3cret",
|
||||
Map::of, () -> Map.of("term_a", "lead-designer"))
|
||||
.resolve("127.0.0.1", 42, null);
|
||||
|
||||
assertEquals(Role.ARCHITECT, p.role(),
|
||||
"the pane mapping is as unforgeable as a worker's — it outranks the token path");
|
||||
}
|
||||
|
||||
@Test
|
||||
void anUnboundPaneStillResolvesAsAWorker() {
|
||||
Map<String, String> arch = Map.of("term_elsewhere", "reviewer");
|
||||
Principal p = CallerResolver.withLeadsAndArchitects(workerIdentity(), false, null,
|
||||
Map::of, () -> arch).resolve("127.0.0.1", 42, null);
|
||||
|
||||
assertEquals(Role.WORKER, p.role());
|
||||
assertNull(p.name());
|
||||
}
|
||||
|
||||
/** CB-548 precedence: lead > architect > worker, so a pane named in BOTH is still a lead. */
|
||||
@Test
|
||||
void aLeadWinsOverAnArchitectBindingForTheSamePane() {
|
||||
Principal p = CallerResolver.withLeadsAndArchitects(workerIdentity(), false, null,
|
||||
() -> Map.of("term_a", "opus-5.0"), () -> Map.of("term_a", "lead-designer"))
|
||||
.resolve("127.0.0.1", 42, null);
|
||||
|
||||
assertEquals(Role.PRIMARY, p.role(),
|
||||
"a pane the config calls a lead must keep resolving as a lead — no behaviour change "
|
||||
+ "when an architect binding is added to an existing fleet");
|
||||
assertEquals("opus-5.0", p.name());
|
||||
}
|
||||
|
||||
/** The registry is live, like leads: a binding injected after construction is honoured. */
|
||||
@Test
|
||||
void anArchitectBoundAfterConstructionIsHonouredWithoutRebuildingTheResolver() {
|
||||
Map<String, String> live = new java.util.HashMap<>();
|
||||
CallerResolver r = CallerResolver.withLeadsAndArchitects(workerIdentity(), false, null,
|
||||
Map::of, () -> live);
|
||||
|
||||
assertEquals(Role.WORKER, r.resolve("127.0.0.1", 42, null).role());
|
||||
|
||||
live.put("term_a", "lead-designer"); // the later lifecycle binds the slot
|
||||
|
||||
assertEquals(Role.ARCHITECT, r.resolve("127.0.0.1", 42, null).role());
|
||||
assertEquals("lead-designer", r.architects().get("term_a"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void theArchitectMapFormIsCopiedSoLaterMutationCannotGrantArchitect() {
|
||||
Map<String, String> mutable = new java.util.LinkedHashMap<>();
|
||||
CallerResolver r = new CallerResolver(workerIdentity(), false, null, Map.of(), mutable);
|
||||
|
||||
mutable.put("term_a", "sneaky");
|
||||
|
||||
assertEquals(Role.WORKER, r.resolve("127.0.0.1", 42, null).role());
|
||||
}
|
||||
|
||||
@Test
|
||||
void describeNamesTheArchitectSlot() {
|
||||
assertEquals("architect:lead-designer",
|
||||
Principal.architect("lead-designer", "term_a", 1).describe());
|
||||
}
|
||||
|
||||
/** An architect acts only as its own pane — the same ownsSession rule as a worker or lead. */
|
||||
@Test
|
||||
void anArchitectOwnsItsOwnPaneAndNoOther() {
|
||||
Principal arch = CallerResolver.withLeadsAndArchitects(workerIdentity(), false, null,
|
||||
Map::of, () -> Map.of("term_a", "lead-designer")).resolve("127.0.0.1", 42, null);
|
||||
|
||||
assertTrue(arch.ownsSession("term_a"));
|
||||
assertTrue(Authz.permits(arch, Authz.Action.REPLY, "term_a"));
|
||||
assertFalse(arch.ownsSession("term_b"));
|
||||
assertFalse(Authz.permits(arch, Authz.Action.REPLY, "term_b"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void tokenModeRequiresANonEmptyConfiguredToken() {
|
||||
ConnectionIdentity id = nonWorkerIdentity();
|
||||
|
||||
@@ -129,6 +129,7 @@ class BridgedConfigTest {
|
||||
broker: {}
|
||||
primary: {}
|
||||
leaders: {}
|
||||
architects: {}
|
||||
leadScan: {}
|
||||
placement: fixed
|
||||
auth: {}
|
||||
@@ -326,6 +327,165 @@ class BridgedConfigTest {
|
||||
assertEquals(Map.of("term_real", "real"), BridgedConfig.load(f).leaderTerminals());
|
||||
}
|
||||
|
||||
// ── CB-548: the architects registry ────────────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
void architectsBlockBindsSlotsByGatewayLocalName(@TempDir Path dir) throws Exception {
|
||||
Path f = dir.resolve("architects.yaml");
|
||||
Files.writeString(f, """
|
||||
bind:
|
||||
port: 8080
|
||||
workers:
|
||||
sonnet:
|
||||
baseUrl: http://gx10.gw:8000
|
||||
architects:
|
||||
lead-designer:
|
||||
terminal: term_design
|
||||
profile: sonnet
|
||||
reviewer:
|
||||
profile: sonnet
|
||||
""");
|
||||
|
||||
BridgedConfig cfg = BridgedConfig.load(f);
|
||||
assertEquals(Set.of("lead-designer", "reviewer"), cfg.architects().keySet(),
|
||||
"slot names are the keys — gateway-local unique by construction");
|
||||
assertEquals("sonnet", cfg.architects().get("lead-designer").profile(),
|
||||
"each slot carries its strong-model profile reference");
|
||||
assertEquals("term_design", cfg.architects().get("lead-designer").terminal());
|
||||
// A slot with no terminal binds nothing yet — the live binding may supply it later.
|
||||
assertTrue(cfg.architects().get("reviewer").terminal() == null
|
||||
|| cfg.architects().get("reviewer").terminal().isBlank());
|
||||
}
|
||||
|
||||
@Test
|
||||
void architectTerminalsMapsEachBoundSlotByItsPane(@TempDir Path dir) throws Exception {
|
||||
Path f = dir.resolve("arch-terminals.yaml");
|
||||
Files.writeString(f, """
|
||||
bind:
|
||||
port: 8080
|
||||
workers:
|
||||
sonnet:
|
||||
baseUrl: http://gx10.gw:8000
|
||||
architects:
|
||||
lead-designer:
|
||||
terminal: term_design
|
||||
profile: sonnet
|
||||
reviewer:
|
||||
terminal: term_review
|
||||
profile: sonnet
|
||||
unbound:
|
||||
profile: sonnet
|
||||
""");
|
||||
|
||||
assertEquals(Map.of("term_design", "lead-designer", "term_review", "reviewer"),
|
||||
BridgedConfig.load(f).architectTerminals(),
|
||||
"a slot with no terminal registers no binding; the value is the slot name");
|
||||
}
|
||||
|
||||
@Test
|
||||
void noArchitectsBlockLeavesNothingBound(@TempDir Path dir) throws Exception {
|
||||
Path f = dir.resolve("no-arch.yaml");
|
||||
Files.writeString(f, "bind:\n port: 8080\n");
|
||||
|
||||
BridgedConfig cfg = BridgedConfig.load(f);
|
||||
assertNull(cfg.architects());
|
||||
assertTrue(cfg.architectTerminals().isEmpty(),
|
||||
"no architects: block ⇒ no architect identity, exactly as before CB-548");
|
||||
}
|
||||
|
||||
@Test
|
||||
void anArchitectSlotMayResolveToTheSoleProfileWithoutPrivileging(@TempDir Path dir) throws Exception {
|
||||
// Even a single unqualified worker profile can back an architect slot — the reference is
|
||||
// by name, not by position, so an explicit name is required.
|
||||
Path f = dir.resolve("arch-single.yaml");
|
||||
Files.writeString(f, """
|
||||
bind:
|
||||
port: 8080
|
||||
worker:
|
||||
profile: ltms-local
|
||||
baseUrl: http://gx10.gw:8000
|
||||
architects:
|
||||
lead-designer:
|
||||
terminal: term_design
|
||||
profile: ltms-local
|
||||
""");
|
||||
|
||||
BridgedConfig cfg = BridgedConfig.load(f);
|
||||
assertDoesNotThrow(cfg::validateArchitects);
|
||||
assertEquals("ltms-local", cfg.architects().get("lead-designer").profile());
|
||||
}
|
||||
|
||||
@Test
|
||||
void anArchitectProfileThatIsNotConfiguredRefusesToStart(@TempDir Path dir) throws Exception {
|
||||
Path f = dir.resolve("arch-bad-profile.yaml");
|
||||
Files.writeString(f, """
|
||||
bind:
|
||||
port: 8080
|
||||
workers:
|
||||
gx10:
|
||||
baseUrl: http://gx10.gw:8000
|
||||
architects:
|
||||
lead-designer:
|
||||
terminal: term_design
|
||||
profile: sonnet
|
||||
""");
|
||||
BridgedConfig cfg = BridgedConfig.load(f);
|
||||
|
||||
IllegalStateException e = assertThrows(IllegalStateException.class, cfg::validateArchitects);
|
||||
assertTrue(e.getMessage().contains("lead-designer"), "the refusal names the slot");
|
||||
assertTrue(e.getMessage().contains("sonnet"), "the refusal names the offending profile");
|
||||
}
|
||||
|
||||
@Test
|
||||
void anArchitectSlotMissingAProfileRefusesToStart(@TempDir Path dir) throws Exception {
|
||||
Path f = dir.resolve("arch-no-profile.yaml");
|
||||
Files.writeString(f, """
|
||||
bind:
|
||||
port: 8080
|
||||
workers:
|
||||
gx10:
|
||||
baseUrl: http://gx10.gw:8000
|
||||
architects:
|
||||
lead-designer:
|
||||
terminal: term_design
|
||||
profile: ""
|
||||
""");
|
||||
BridgedConfig cfg = BridgedConfig.load(f);
|
||||
|
||||
IllegalStateException e = assertThrows(IllegalStateException.class, cfg::validateArchitects);
|
||||
assertTrue(e.getMessage().contains("lead-designer"), "the refusal names the slot");
|
||||
}
|
||||
|
||||
@Test
|
||||
void aValidArchitectRegistryPassesValidation(@TempDir Path dir) throws Exception {
|
||||
Path f = dir.resolve("arch-ok.yaml");
|
||||
Files.writeString(f, """
|
||||
bind:
|
||||
port: 8080
|
||||
workers:
|
||||
sonnet:
|
||||
baseUrl: http://gx10.gw:8000
|
||||
gx10:
|
||||
baseUrl: http://gx10.gw:8000
|
||||
architects:
|
||||
lead-designer:
|
||||
terminal: term_design
|
||||
profile: sonnet
|
||||
reviewer:
|
||||
profile: gx10
|
||||
""");
|
||||
|
||||
assertDoesNotThrow(() -> BridgedConfig.load(f).validateArchitects());
|
||||
}
|
||||
|
||||
@Test
|
||||
void absentArchitectsBlockPassesValidation(@TempDir Path dir) throws Exception {
|
||||
Path f = dir.resolve("no-arch.yaml");
|
||||
Files.writeString(f, "bind:\n port: 8080\n");
|
||||
|
||||
assertDoesNotThrow(() -> BridgedConfig.load(f).validateArchitects());
|
||||
}
|
||||
|
||||
@Test
|
||||
void absentBrokerBlockLeavesInboxSoftState(@TempDir Path dir) throws Exception {
|
||||
Path f = dir.resolve("no-broker.yaml");
|
||||
|
||||
@@ -77,6 +77,7 @@ class BridgeMcpAuthzTest {
|
||||
private static final Principal PRIMARY = Principal.primary(100);
|
||||
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);
|
||||
|
||||
// --- the table, enforced on THIS path too ---------------------------------------------------
|
||||
|
||||
@@ -119,6 +120,33 @@ class BridgeMcpAuthzTest {
|
||||
assertNotNull(m.denyFor(PRIMARY, Authz.Action.ASK, "term_a"));
|
||||
}
|
||||
|
||||
// --- CB-548: the architect on this path ------------------------------------------------
|
||||
|
||||
@Test
|
||||
void anArchitectMaySendAndReadButNotOrchestrateOverMcp() {
|
||||
BridgeMcp m = mcp(true);
|
||||
assertNull(m.denyFor(ARCH_DESIGN, Authz.Action.SEND, "term_a"),
|
||||
"delegating a turn is the architect's job");
|
||||
assertNull(m.denyFor(ARCH_DESIGN, Authz.Action.READ, null));
|
||||
|
||||
for (Authz.Action a : new Authz.Action[]{Authz.Action.SPAWN, Authz.Action.STOP,
|
||||
Authz.Action.DRAIN}) {
|
||||
McpSchema.CallToolResult denied = m.denyFor(ARCH_DESIGN, a, null);
|
||||
assertNotNull(denied, a + " must be refused to an architect");
|
||||
assertTrue(denied.isError(), "a refusal is returned as an MCP tool error");
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void anArchitectMayReplyAndAskOnlyAsItsOwnPaneOverMcp() {
|
||||
BridgeMcp m = mcp(true);
|
||||
assertNull(m.denyFor(ARCH_DESIGN, Authz.Action.REPLY, "term_design"));
|
||||
assertNull(m.denyFor(ARCH_DESIGN, Authz.Action.ASK, "term_design"));
|
||||
|
||||
assertNotNull(m.denyFor(ARCH_DESIGN, Authz.Action.REPLY, "term_a"),
|
||||
"architect 'lead-designer' must not reply on worker term_a's session");
|
||||
}
|
||||
|
||||
@Test
|
||||
void anonymousIsRefusedEverythingAndCountedAsUnauthenticated() {
|
||||
BridgeMcp m = mcp(true);
|
||||
@@ -156,6 +184,11 @@ class BridgeMcpAuthzTest {
|
||||
assertEquals("term_a", BridgeMcp.principalFrom("WORKER", "term_a", 7).terminal());
|
||||
assertEquals(Role.PRIMARY, BridgeMcp.principalFrom("PRIMARY", null, 7).role());
|
||||
assertEquals(Role.ANONYMOUS, BridgeMcp.principalFrom("ANONYMOUS", null, -1).role());
|
||||
// CB-548: an architect round-trips through the same stash, carrying its slot name.
|
||||
Principal arch = BridgeMcp.principalFrom("ARCHITECT", "term_design", 7, "lead-designer");
|
||||
assertEquals(Role.ARCHITECT, arch.role());
|
||||
assertEquals("lead-designer", arch.name());
|
||||
assertEquals("term_design", arch.terminal());
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
@@ -466,4 +466,22 @@ class BridgeMcpTest {
|
||||
assertTrue(out.contains("\"sessionId\":\"term_orphan\""), out);
|
||||
assertFalse(out.contains("profile"), out); // nothing invented for a session we don't track
|
||||
}
|
||||
|
||||
/**
|
||||
* CB-548: an architect reports its role and which gateway-local slot its pane is bound to —
|
||||
* the same shape as a lead, under the architect key, so it can tell a peer where to reach it.
|
||||
*/
|
||||
@Test
|
||||
void whoamiReportsAnArchitectWithItsSlotAndPane() {
|
||||
FakeHerdr h = new FakeHerdr();
|
||||
McpSchema.CallToolResult res = BridgeMcp.whoami(
|
||||
Principal.architect("lead-designer", "term_design", 400),
|
||||
sessionManager(h, "http://gx00.gw:8000", Set.of("gx00.gw")));
|
||||
|
||||
assertNotEquals(Boolean.TRUE, res.isError());
|
||||
String out = textOf(res);
|
||||
assertTrue(out.contains("\"role\":\"architect\""), out);
|
||||
assertTrue(out.contains("\"architect\":\"lead-designer\""), out);
|
||||
assertTrue(out.contains("\"sessionId\":\"term_design\""), out);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user