e501d39988
A BACKEND_EXHAUSTED classification (stage A) now puts that profile's credential into a BackendQuarantine for a configurable cooldown. A spawn onto a quarantined profile is refused naming the credential and roughly when it lifts; weighted/round-robin/fixed placement skip a quarantined candidate; the quarantine lifts itself on the injected clock; and it is visible on bridge_profiles. Keyed by credential, not by profile name, via the new Profile.credentialId (profiles sharing one credential quarantine together — e.g. two models on one account) and effectiveCredentialId() (unset ⇒ quarantines alone, today's behaviour unchanged). Fixed a related gap along the way: a reload changing exhaustedPattern was silently reported "applied" even though it's deferred — sameLaunchSettings() now catches it too.
205 lines
9.4 KiB
Java
205 lines
9.4 KiB
Java
package dev.ltms.bridged.mcp;
|
|
|
|
import dev.ltms.bridged.auth.Authz;
|
|
import dev.ltms.bridged.auth.CallerResolver;
|
|
import dev.ltms.bridged.auth.MemberRegistry;
|
|
import dev.ltms.bridged.auth.Principal;
|
|
import dev.ltms.bridged.auth.Role;
|
|
import dev.ltms.bridged.config.BridgedConfig;
|
|
import dev.ltms.bridged.guard.SubscriptionGuard;
|
|
import dev.ltms.bridged.herdr.AgentControl;
|
|
import dev.ltms.bridged.herdr.FakeHerdr;
|
|
import dev.ltms.bridged.herdr.PaneLocator;
|
|
import dev.ltms.bridged.herdr.WorkspaceControl;
|
|
import dev.ltms.bridged.inject.Injector;
|
|
import dev.ltms.bridged.metrics.BridgedMetrics;
|
|
import dev.ltms.bridged.metrics.Metrics;
|
|
import dev.ltms.bridged.msg.InMemoryReplyInbox;
|
|
import dev.ltms.bridged.msg.MessageService;
|
|
import dev.ltms.bridged.msg.Rendezvous;
|
|
import dev.ltms.bridged.session.FakeWorktrees;
|
|
import dev.ltms.bridged.session.SessionManager;
|
|
import dev.ltms.bridged.member.ClaudeCodeLauncher;
|
|
import io.modelcontextprotocol.spec.McpSchema;
|
|
import org.junit.jupiter.api.AfterEach;
|
|
import org.junit.jupiter.api.Test;
|
|
|
|
import java.util.Map;
|
|
import java.util.Set;
|
|
|
|
import static org.junit.jupiter.api.Assertions.*;
|
|
|
|
/**
|
|
* CB-513 — the CB-505 authorization gate on the <strong>MCP</strong> entry path.
|
|
*
|
|
* <p>Why this file exists: CB-505 claimed authorization is "enforced on both entry paths", and it
|
|
* is — but only REST was ever tested ({@code BridgedAppAuthTest}). Coverage showed
|
|
* {@code BridgeMcp.deny()}, {@code principal()} and every tool-registration lambda at <em>zero</em>
|
|
* executed lines, because no test had ever constructed a {@code BridgeMcp} — the existing
|
|
* {@code BridgeMcpTest} calls only the static handler methods. An unexercised security control is
|
|
* a claim, not a control.
|
|
*
|
|
* <p>These tests construct a real {@code BridgeMcp} (which also exercises the constructor and the
|
|
* tool wiring) and drive the policy half of the gate directly.
|
|
*/
|
|
class BridgeMcpAuthzTest {
|
|
|
|
private final FakeHerdr herdr = new FakeHerdr();
|
|
private final AgentControl agents = new AgentControl(herdr);
|
|
private Metrics metrics;
|
|
private BridgeMcp mcp;
|
|
|
|
@AfterEach
|
|
void close() {
|
|
if (mcp != null) mcp.close();
|
|
}
|
|
|
|
/** A fully wired BridgeMcp on fakes — constructing it is itself part of what is under test. */
|
|
private BridgeMcp mcp(boolean enforce) {
|
|
BridgedConfig.Profile cfg = new BridgedConfig.Profile(
|
|
"ltms-local", "http://gx00.gw:8000", "coder", null, "BRIDGED_WORKER_TOKEN", null,
|
|
"tab", "bridged-workers", "worker: {profile} #{n}", null, null, null);
|
|
ClaudeCodeLauncher workers = new ClaudeCodeLauncher(agents, new WorkspaceControl(herdr),
|
|
new SubscriptionGuard(Set.of("gx00.gw")), Map.of(cfg.profile(), cfg), cfg.profile(),
|
|
_ -> "tok");
|
|
SessionManager sessions = new SessionManager(workers, new FakeWorktrees());
|
|
MessageService messages = new MessageService(agents, new Injector(agents), new Rendezvous(),
|
|
new InMemoryReplyInbox());
|
|
ConnectionIdentity identity = new ConnectionIdentity(new PaneLocator(herdr), _ -> 999_999);
|
|
metrics = BridgedMetrics.create(sessions, new InMemoryReplyInbox());
|
|
|
|
mcp = new BridgeMcp(messages, workers, sessions, identity, sessions.asPresence(),
|
|
new PrimaryRegistry(null),
|
|
enforce ? CallerResolver.withLeadsAndMembers(identity, false, null,
|
|
Map::of, new MemberRegistry(null)) : null,
|
|
metrics, BridgeMcp.CapacitySource.none(), new BridgeMcp.HealthCoverageSource(() -> "off"),
|
|
BridgeMcp.QuarantineSource.none());
|
|
return mcp;
|
|
}
|
|
|
|
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 ---------------------------------------------------
|
|
|
|
@Test
|
|
void primaryMayOrchestrate() {
|
|
BridgeMcp m = mcp(true);
|
|
for (Authz.Action a : new Authz.Action[]{Authz.Action.SPAWN, Authz.Action.STOP,
|
|
Authz.Action.SEND, Authz.Action.DRAIN, Authz.Action.READ}) {
|
|
assertNull(m.denyFor(PRIMARY, a, "term_a"), a + " is the primary's to perform");
|
|
}
|
|
}
|
|
|
|
@Test
|
|
void aWorkerMayNotOrchestrateOverMcp() {
|
|
BridgeMcp m = mcp(true);
|
|
for (Authz.Action a : new Authz.Action[]{Authz.Action.SPAWN, Authz.Action.STOP,
|
|
Authz.Action.SEND, Authz.Action.DRAIN}) {
|
|
McpSchema.CallToolResult denied = m.denyFor(WORKER_A, a, "term_a");
|
|
assertNotNull(denied, a + " must be refused to a worker");
|
|
assertTrue(denied.isError(), "a refusal is returned as an MCP tool error");
|
|
}
|
|
}
|
|
|
|
@Test
|
|
void aWorkerMayReplyAndAskOnlyAsItself() {
|
|
BridgeMcp m = mcp(true);
|
|
assertNull(m.denyFor(WORKER_A, Authz.Action.REPLY, "term_a"), "its own session is allowed");
|
|
assertNull(m.denyFor(WORKER_A, Authz.Action.ASK, "term_a"));
|
|
|
|
assertNotNull(m.denyFor(WORKER_A, Authz.Action.REPLY, "term_b"),
|
|
"worker A must not reply on worker B's session");
|
|
assertNotNull(m.denyFor(WORKER_A, Authz.Action.ASK, "term_b"));
|
|
}
|
|
|
|
@Test
|
|
void thePrimaryMayNotForgeAWorkerReplyOverMcp() {
|
|
BridgeMcp m = mcp(true);
|
|
// A forged reply would resolve the very rendezvous the primary is blocked on.
|
|
assertNotNull(m.denyFor(PRIMARY, Authz.Action.REPLY, "term_a"));
|
|
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);
|
|
McpSchema.CallToolResult denied = m.denyFor(ANON, Authz.Action.READ, null);
|
|
|
|
assertNotNull(denied, "authenticated as nothing ⇒ authorized for nothing");
|
|
assertEquals(1, metrics.count(BridgedMetrics.AUTH_FAILURES, "reason", "unauthenticated"));
|
|
assertEquals(0, metrics.count(BridgedMetrics.AUTH_FAILURES, "reason", "forbidden"),
|
|
"a missing credential is 401-shaped, not 403-shaped");
|
|
}
|
|
|
|
@Test
|
|
void aWrongRoleIsCountedAsForbiddenNotUnauthenticated() {
|
|
BridgeMcp m = mcp(true);
|
|
assertNotNull(m.denyFor(WORKER_A, Authz.Action.SPAWN, null));
|
|
|
|
assertEquals(1, metrics.count(BridgedMetrics.AUTH_FAILURES, "reason", "forbidden"));
|
|
assertEquals(0, metrics.count(BridgedMetrics.AUTH_FAILURES, "reason", "unauthenticated"),
|
|
"the caller IS authenticated — it is just not the right role");
|
|
}
|
|
|
|
@Test
|
|
void theLegacyConstructorLeavesTheGateOpen() {
|
|
// The 22 pre-existing BridgeMcpTest cases rely on no authorization being enforced.
|
|
BridgeMcp m = mcp(false);
|
|
assertNull(m.denyFor(ANON, Authz.Action.SPAWN, null),
|
|
"no CallerResolver supplied ⇒ authorization not enforced (legacy behaviour)");
|
|
}
|
|
|
|
// --- identity reconstruction from the transport context ------------------------------------
|
|
|
|
@Test
|
|
void principalIsRebuiltFromTheStashedRole() {
|
|
assertEquals(Role.WORKER, BridgeMcp.principalFrom("WORKER", "term_a", 7).role());
|
|
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
|
|
void aMissingRoleFallsBackToTheHistoricalInterpretation() {
|
|
// Legacy path: no role stashed. A terminal means worker; its absence meant "the primary",
|
|
// which is exactly the pre-CB-501 default CB-501 inverted — preserved only here.
|
|
assertEquals(Role.WORKER, BridgeMcp.principalFrom(null, "term_a", 7).role());
|
|
assertEquals(Role.PRIMARY, BridgeMcp.principalFrom(null, null, 7).role());
|
|
}
|
|
}
|