CB-5xx: Stage 5 hardening — auth, authz+audit, metrics, CI, supervision
Closes out single-host before the cross-host work. Sequenced BEFORE CB-308 deliberately: federation's own gating concern is the trust model, and it inherits whatever identity shape lands here. The finding this stage is built around: bridged had exactly ONE security control, the loopback bind. ConnectionIdentity resolves a worker from its connection (unforgeable), but every caller that was not a recognised worker pane fell through to being treated as the PRIMARY -- the most privileged role on the bus. Latent today; load-bearing the moment a bind widens. CB-501 auth: - Role/Principal/CallerResolver: connection identity first, bearer token second, ANONYMOUS third. Inverts the old default so absence of identity means nothing, not everything. - Worker identity is never token-gated, so enabling auth cannot lock the fleet out of bridge_reply. - Constant-time token compare (MessageDigest.isEqual). - validateAuthExposure(): a non-loopback bind under loopback-trust now REFUSES TO START. Makes the dangerous config unrepresentable rather than merely documented. - TLS terminates at a reverse proxy by design (D3), not in the JVM. CB-505 authz + audit, enforced on BOTH entry paths: - The docs describe MCP as "a thin adapter over the REST core"; at code level it is not. BridgeMcp calls MessageService directly, and /mcp is a raw servlet on Jetty's context handler that never traverses Javalin's before filter. Enforcing only at REST would have left /mcp open. - Load-bearing rule is own-session-only: a worker may reply/ask only as itself. Structurally true over MCP already; over REST the session id in the URL path had simply been trusted. - Audit: JSON lines to a dedicated appender, additivity=false. Never records message content -- this bus carries source and prompts. CB-502 metrics: zero new dependencies. A ~150-line Prometheus text renderer instead of the specced Micrometer, because this pom already hand-pins jackson-annotations to reconcile Jackson 2/3, imports a Jetty BOM against skew, and carries four accepted-CVE advisories -- and CLAUDE.md's mandated dependency CVE gate could not be run (no JetBrains MCP server connected). Instrumented at MessageService, the single funnel both surfaces share. CB-503 CI: .gitea/workflows/ci.yml against the already-running Gitea runner. Needs no contract-exclusion flag -- the pom's default-excludes profile already sets excludedGroups=contract, so plain `mvn clean install` IS the mock-socket surface. Provisions JDK 25 explicitly (runner default-jdk is older). CB-504 supervision: launchd agent (the real target -- this host is macOS, there is no systemd) plus a systemd unit for the Linux gateways CB-308 adds. Ordering directives are advisory, so the actual fix is that startup now waits up to 30s for the herdr socket and then serves degraded, instead of crashing into a restart loop on a boot-order race. Also fixes drift found while surveying: - bridged.example.yaml documented spawn_ready_timeout_ms in snake_case; config binds via plain Jackson with ignoreUnknown, so uncommenting it would have been silently dropped and the default kept. Now camelCase, with a test that loads the shipped example and one that pins every documented knob's spelling -- no test had ever loaded that file. - Added the 6 shipped-but-undocumented knobs (worktreeRoot, parityOverlay, gitTokenEnv, gitHostEnv, configDir, primary:). - README "Next" listed bridge_ask and session lifecycle as upcoming; both shipped long ago. - docs/CB-301-ext and docs/CB-402 status headers said "design"/"pre- implementation" for work already merged. 307 unit/acceptance tests green (was 266), mvn clean install BUILD SUCCESS. Note: CLAUDE.md's per-file ide_diagnostics gate and the pom Mend.io CVE check could not be run -- no JetBrains/intellij-index MCP server is connected this session. mvn clean install is the only gate that ran.
This commit is contained in:
@@ -0,0 +1,80 @@
|
||||
package dev.ltms.bridged.auth;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import static dev.ltms.bridged.auth.Authz.Action.*;
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
/** CB-505 — the authorization table, pinned so it cannot drift silently. */
|
||||
class AuthzTest {
|
||||
|
||||
private static final Principal PRIMARY = Principal.primary(100);
|
||||
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();
|
||||
|
||||
@Test
|
||||
void anonymousIsAuthorizedForNothing() {
|
||||
for (Authz.Action a : Authz.Action.values()) {
|
||||
assertFalse(Authz.permits(ANON, a, "term_a"),
|
||||
a + " must be refused to an unauthenticated caller");
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void aNullCallerIsTreatedAsAnonymous() {
|
||||
assertFalse(Authz.permits(null, READ, null));
|
||||
assertTrue(Authz.isUnauthenticated(null));
|
||||
}
|
||||
|
||||
@Test
|
||||
void orchestrationBelongsToThePrimaryAlone() {
|
||||
for (Authz.Action a : new Authz.Action[]{SPAWN, STOP, SEND, DRAIN}) {
|
||||
assertTrue(Authz.permits(PRIMARY, a, "term_a"), "the primary orchestrates: " + a);
|
||||
assertFalse(Authz.permits(WORKER_A, a, "term_a"),
|
||||
"a worker performing " + a + " would be escalating into the orchestrator role");
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void aWorkerMayReplyAndAskOnlyAsItself() {
|
||||
assertTrue(Authz.permits(WORKER_A, REPLY, "term_a"));
|
||||
assertTrue(Authz.permits(WORKER_A, ASK, "term_a"));
|
||||
|
||||
assertFalse(Authz.permits(WORKER_A, REPLY, "term_b"),
|
||||
"worker A must not be able to reply on worker B's session");
|
||||
assertFalse(Authz.permits(WORKER_B, ASK, "term_a"),
|
||||
"worker B must not be able to ask as worker A");
|
||||
}
|
||||
|
||||
@Test
|
||||
void thePrimaryMayNotForgeAWorkersReply() {
|
||||
// Not a hypothetical nicety: a forged reply would resolve the rendezvous the primary is
|
||||
// itself blocked on, corrupting the correlation between a turn and its answer.
|
||||
assertFalse(Authz.permits(PRIMARY, REPLY, "term_a"));
|
||||
assertFalse(Authz.permits(PRIMARY, ASK, "term_a"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void aWorkerWithNoTargetCannotReply() {
|
||||
assertFalse(Authz.permits(WORKER_A, REPLY, null),
|
||||
"an absent session id must not satisfy the own-session rule");
|
||||
}
|
||||
|
||||
@Test
|
||||
void observationIsOpenToBothAuthenticatedRoles() {
|
||||
assertTrue(Authz.permits(PRIMARY, READ, null));
|
||||
assertTrue(Authz.permits(WORKER_A, READ, null));
|
||||
assertTrue(Authz.permits(PRIMARY, METRICS, null));
|
||||
assertTrue(Authz.permits(WORKER_A, METRICS, null));
|
||||
}
|
||||
|
||||
@Test
|
||||
void unauthenticatedIsDistinguishedFromMerelyForbidden() {
|
||||
// Drives the 401-vs-403 split: a missing credential is fixable by the caller, a wrong role
|
||||
// is not.
|
||||
assertTrue(Authz.isUnauthenticated(ANON));
|
||||
assertFalse(Authz.isUnauthenticated(WORKER_A));
|
||||
assertFalse(Authz.isUnauthenticated(PRIMARY));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
package dev.ltms.bridged.auth;
|
||||
|
||||
import dev.ltms.bridged.herdr.FakeHerdr;
|
||||
import dev.ltms.bridged.herdr.PaneLocator;
|
||||
import dev.ltms.bridged.mcp.ConnectionIdentity;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
/**
|
||||
* CB-501. The behaviour under test is the inversion of the pre-CB-501 default: failing every
|
||||
* identity check must yield {@link Role#ANONYMOUS}, not {@code PRIMARY}.
|
||||
*/
|
||||
class CallerResolverTest {
|
||||
|
||||
private final FakeHerdr herdr = new FakeHerdr();
|
||||
|
||||
/** Identity resolving the canned worker pane, keyed off a faked peer-PID lookup. */
|
||||
private ConnectionIdentity identity(long pid) {
|
||||
return new ConnectionIdentity(new PaneLocator(herdr), _ -> pid);
|
||||
}
|
||||
|
||||
/** A PID that owns a worker pane in the fake. */
|
||||
private ConnectionIdentity workerIdentity() {
|
||||
return identity(FakeHerdr.WORKER_PID);
|
||||
}
|
||||
|
||||
/** A PID that owns no pane — i.e. the primary, or any other local process. */
|
||||
private ConnectionIdentity nonWorkerIdentity() {
|
||||
return identity(999_999);
|
||||
}
|
||||
|
||||
@Test
|
||||
void aLoopbackWorkerPaneResolvesToWorkerRegardlessOfAuthMode() {
|
||||
Principal underTrust = new CallerResolver(workerIdentity()).resolve("127.0.0.1", 42, null);
|
||||
Principal underToken = new CallerResolver(workerIdentity(), true, "s3cret")
|
||||
.resolve("127.0.0.1", 42, null);
|
||||
|
||||
assertEquals(Role.WORKER, underTrust.role());
|
||||
assertEquals("term_a", underTrust.terminal());
|
||||
assertEquals(Role.WORKER, underToken.role(),
|
||||
"worker identity is unforgeable and must never be token-gated — otherwise enabling "
|
||||
+ "auth would lock the whole fleet out of bridge_reply");
|
||||
assertEquals("term_a", underToken.terminal());
|
||||
}
|
||||
|
||||
@Test
|
||||
void loopbackTrustTreatsANonWorkerLoopbackCallerAsThePrimary() {
|
||||
Principal p = new CallerResolver(nonWorkerIdentity()).resolve("127.0.0.1", 99, null);
|
||||
|
||||
assertEquals(Role.PRIMARY, p.role(), "the historical behaviour, now an explicit choice");
|
||||
}
|
||||
|
||||
@Test
|
||||
void tokenModeRefusesANonWorkerCallerThatPresentsNoToken() {
|
||||
Principal p = new CallerResolver(nonWorkerIdentity(), true, "s3cret")
|
||||
.resolve("127.0.0.1", 99, null);
|
||||
|
||||
assertEquals(Role.ANONYMOUS, p.role(),
|
||||
"no credential must mean NOTHING, not the most privileged role on the bus");
|
||||
}
|
||||
|
||||
@Test
|
||||
void tokenModeAcceptsAValidBearerTokenAsThePrimary() {
|
||||
Principal p = new CallerResolver(nonWorkerIdentity(), true, "s3cret")
|
||||
.resolve("127.0.0.1", 99, "Bearer s3cret");
|
||||
|
||||
assertEquals(Role.PRIMARY, p.role());
|
||||
}
|
||||
|
||||
@Test
|
||||
void tokenModeRejectsAWrongOrMalformedCredential() {
|
||||
CallerResolver r = new CallerResolver(nonWorkerIdentity(), true, "s3cret");
|
||||
|
||||
assertEquals(Role.ANONYMOUS, r.resolve("127.0.0.1", 99, "Bearer wrong").role());
|
||||
assertEquals(Role.ANONYMOUS, r.resolve("127.0.0.1", 99, "s3cret").role(), "scheme required");
|
||||
assertEquals(Role.ANONYMOUS, r.resolve("127.0.0.1", 99, "Bearer ").role(), "empty credential");
|
||||
assertEquals(Role.ANONYMOUS, r.resolve("127.0.0.1", 99, "Basic s3cret").role(), "wrong scheme");
|
||||
}
|
||||
|
||||
@Test
|
||||
void theBearerSchemeIsCaseInsensitivePerRfc7235() {
|
||||
CallerResolver r = new CallerResolver(nonWorkerIdentity(), true, "s3cret");
|
||||
|
||||
assertEquals(Role.PRIMARY, r.resolve("127.0.0.1", 99, "bearer s3cret").role());
|
||||
assertEquals(Role.PRIMARY, r.resolve("127.0.0.1", 99, "BEARER s3cret").role());
|
||||
}
|
||||
|
||||
@Test
|
||||
void aNonLoopbackCallerIsNeverThePrimaryUnderLoopbackTrust() {
|
||||
// Defence in depth: startup already refuses this pairing (validateAuthExposure), but if a
|
||||
// proxy ever forwards a remote peer onto the loopback listener, the resolver must not
|
||||
// hand it the primary role.
|
||||
Principal p = new CallerResolver(nonWorkerIdentity()).resolve("10.0.0.7", 99, null);
|
||||
|
||||
assertEquals(Role.ANONYMOUS, p.role());
|
||||
}
|
||||
|
||||
@Test
|
||||
void tokenModeRequiresANonEmptyConfiguredToken() {
|
||||
ConnectionIdentity id = nonWorkerIdentity();
|
||||
|
||||
assertThrows(IllegalArgumentException.class, () -> new CallerResolver(id, true, null));
|
||||
assertThrows(IllegalArgumentException.class, () -> new CallerResolver(id, true, " "));
|
||||
}
|
||||
}
|
||||
@@ -233,4 +233,136 @@ class BridgedConfigTest {
|
||||
assertEquals(java.util.List.of("opencode"), cfg.workerProfiles().get("gemini").argv(),
|
||||
"an opencode worker with no argv defaults to the opencode binary, never claude");
|
||||
}
|
||||
|
||||
@Test
|
||||
void authDefaultsToLoopbackTrustSoExistingConfigsBehaveAsBefore(@TempDir Path dir) throws Exception {
|
||||
Path f = dir.resolve("no-auth-block.yaml");
|
||||
Files.writeString(f, "bind:\n host: 127.0.0.1\n port: 8765\n");
|
||||
|
||||
BridgedConfig cfg = BridgedConfig.load(f);
|
||||
assertNotNull(cfg.auth(), "auth must default rather than be null");
|
||||
assertFalse(cfg.auth().tokenMode());
|
||||
assertEquals("BRIDGED_API_TOKEN", cfg.auth().tokenEnv(), "documented default env var");
|
||||
assertDoesNotThrow(cfg::validateAuthExposure, "loopback + loopback-trust is the safe pairing");
|
||||
}
|
||||
|
||||
/**
|
||||
* CB-501's highest-value check. Under loopback-trust, "not a known worker" means "the primary" —
|
||||
* sound only while the OS refuses remote connections. Widening the bind without token mode
|
||||
* would silently promote every reachable client to the most privileged role on the bus.
|
||||
*/
|
||||
@Test
|
||||
void aNonLoopbackBindWithoutTokenModeIsRefusedAtStartup(@TempDir Path dir) throws Exception {
|
||||
Path f = dir.resolve("exposed.yaml");
|
||||
Files.writeString(f, "bind:\n host: 0.0.0.0\n port: 8765\n");
|
||||
|
||||
BridgedConfig cfg = BridgedConfig.load(f);
|
||||
IllegalStateException e = assertThrows(IllegalStateException.class, cfg::validateAuthExposure);
|
||||
assertTrue(e.getMessage().contains("auth.mode: token"),
|
||||
"the error must say how to fix it, not just that it refused");
|
||||
}
|
||||
|
||||
@Test
|
||||
void aNonLoopbackBindIsAllowedOnceTokenModeIsOn(@TempDir Path dir) throws Exception {
|
||||
Path f = dir.resolve("exposed-with-token.yaml");
|
||||
Files.writeString(f, """
|
||||
bind:
|
||||
host: 0.0.0.0
|
||||
port: 8765
|
||||
auth:
|
||||
mode: token
|
||||
tokenEnv: MY_TOKEN
|
||||
""");
|
||||
|
||||
BridgedConfig cfg = BridgedConfig.load(f);
|
||||
assertTrue(cfg.auth().tokenMode());
|
||||
assertEquals("MY_TOKEN", cfg.auth().tokenEnv());
|
||||
assertDoesNotThrow(cfg::validateAuthExposure);
|
||||
}
|
||||
|
||||
@Test
|
||||
void loopbackFormsAreAllRecognised(@TempDir Path dir) throws Exception {
|
||||
for (String host : new String[]{"127.0.0.1", "localhost", "::1", "127.0.0.53"}) {
|
||||
Path f = dir.resolve("lb-" + host.replace(':', '_') + ".yaml");
|
||||
Files.writeString(f, "bind:\n host: \"" + host + "\"\n port: 8765\n");
|
||||
assertDoesNotThrow(() -> BridgedConfig.load(f).validateAuthExposure(),
|
||||
host + " is loopback and must not trip the exposure guard");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The shipped {@code bridged.example.yaml} must actually parse. Config binds through a plain
|
||||
* Jackson mapper with {@code ignoreUnknown = true}, so a misspelled key in the example is
|
||||
* silently dropped and the operator gets a default they did not ask for — exactly how a
|
||||
* {@code spawn_ready_timeout_ms} typo survived in the example until the CB-5xx wrap-up.
|
||||
*/
|
||||
@Test
|
||||
void shippedExampleConfigParses() {
|
||||
Path example = Path.of("bridged.example.yaml");
|
||||
assertTrue(Files.exists(example), "bridged.example.yaml must ship next to the pom");
|
||||
|
||||
BridgedConfig cfg = BridgedConfig.load(example);
|
||||
assertEquals(8765, cfg.bind().port(), "example binds the documented default port");
|
||||
assertTrue(cfg.workerProfiles().containsKey("gx10"), "example documents the gx10 profile");
|
||||
assertEquals("gx10", cfg.defaultProfile(), "example's defaultWorker resolves");
|
||||
assertTrue(cfg.guard().hostSet().contains("gx01.gw"),
|
||||
"every example profile's base_url host must be in the example allowlist");
|
||||
}
|
||||
|
||||
/**
|
||||
* Every optional knob the example documents must bind under the exact spelling used there.
|
||||
* Keep this list in step with {@code bridged.example.yaml}: a rename that updates the record
|
||||
* but not the example (or vice versa) fails here instead of silently no-op'ing in production.
|
||||
*/
|
||||
@Test
|
||||
void everyOptionalKnobDocumentedInTheExampleBinds(@TempDir Path dir) throws Exception {
|
||||
Path f = dir.resolve("all-knobs.yaml");
|
||||
Files.writeString(f, """
|
||||
bind:
|
||||
host: 127.0.0.1
|
||||
port: 8765
|
||||
spawnReadyTimeoutMs: 25000
|
||||
spawnReadyPollMs: 400
|
||||
worktreeRoot: /tmp/bridged-worktrees
|
||||
workers:
|
||||
gx10:
|
||||
kind: claude-code
|
||||
baseUrl: http://gx01.gw:8000
|
||||
configDir: /tmp/ccs/gx10
|
||||
cwd: /tmp/repo
|
||||
parityOverlay: [".mcp.json", ".env"]
|
||||
gitTokenEnv: GITEA_TOKEN
|
||||
gitHostEnv: GITEA_HOST
|
||||
lifecycle:
|
||||
idleTtlSeconds: 300
|
||||
contextCap: 10
|
||||
drainTimeoutSeconds: 5
|
||||
broker:
|
||||
uri: amqp://guest:guest@127.0.0.1:5672
|
||||
primary:
|
||||
terminal: term_abc123
|
||||
pushReminders: 5
|
||||
pushBackoffMs: 15000
|
||||
""");
|
||||
|
||||
BridgedConfig cfg = BridgedConfig.load(f);
|
||||
assertEquals(25000, cfg.spawnReadyTimeoutMs(), "spawnReadyTimeoutMs is camelCase, not snake_case");
|
||||
assertEquals(400, cfg.spawnReadyPollMs(), "spawnReadyPollMs is camelCase, not snake_case");
|
||||
assertEquals("/tmp/bridged-worktrees", cfg.worktreeRoot());
|
||||
|
||||
BridgedConfig.Worker w = cfg.workerProfiles().get("gx10");
|
||||
assertEquals("/tmp/ccs/gx10", w.configDir());
|
||||
assertEquals("/tmp/repo", w.cwd());
|
||||
assertEquals(java.util.List.of(".mcp.json", ".env"), w.parityOverlay());
|
||||
assertTrue(w.hasGitToken(), "gitTokenEnv binds and enables the CB-302 PR grant");
|
||||
assertEquals("GITEA_HOST", w.gitHostEnv());
|
||||
|
||||
assertEquals(300, cfg.lifecycle().idleTtlSeconds());
|
||||
assertEquals(10, cfg.lifecycle().contextCap());
|
||||
assertEquals(5, cfg.lifecycle().drainTimeoutSeconds());
|
||||
assertEquals("amqp://guest:guest@127.0.0.1:5672", cfg.broker().uri());
|
||||
assertEquals("term_abc123", cfg.primary().terminal());
|
||||
assertEquals(5, cfg.primary().remindersOrDefault());
|
||||
assertEquals(15000L, cfg.primary().backoffMsOrDefault());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
package dev.ltms.bridged.metrics;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
/** CB-502 — the zero-dependency Prometheus text renderer. */
|
||||
class MetricsTest {
|
||||
|
||||
@Test
|
||||
void countersAccumulatePerLabelSet() {
|
||||
Metrics m = new Metrics();
|
||||
m.inc("bridged_sends_total", "outcome", "replied");
|
||||
m.inc("bridged_sends_total", "outcome", "replied");
|
||||
m.inc("bridged_sends_total", "outcome", "timeout");
|
||||
|
||||
assertEquals(2, m.count("bridged_sends_total", "outcome", "replied"));
|
||||
assertEquals(1, m.count("bridged_sends_total", "outcome", "timeout"));
|
||||
assertEquals(0, m.count("bridged_sends_total", "outcome", "failed"),
|
||||
"an untouched series reads as zero, not an error");
|
||||
}
|
||||
|
||||
@Test
|
||||
void rendersHelpAndTypeOncePerFamily() {
|
||||
Metrics m = new Metrics();
|
||||
m.describe("bridged_sends_total", "counter", "Delegated sends by outcome.");
|
||||
m.inc("bridged_sends_total", "outcome", "replied");
|
||||
m.inc("bridged_sends_total", "outcome", "timeout");
|
||||
|
||||
String out = m.render();
|
||||
assertEquals(1, countOccurrences(out, "# HELP bridged_sends_total"),
|
||||
"HELP is per family, not per series");
|
||||
assertEquals(1, countOccurrences(out, "# TYPE bridged_sends_total counter"));
|
||||
assertTrue(out.contains("bridged_sends_total{outcome=\"replied\"} 1"));
|
||||
assertTrue(out.contains("bridged_sends_total{outcome=\"timeout\"} 1"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void labelsAreSortedSoScrapesAreByteStable() {
|
||||
Metrics a = new Metrics();
|
||||
a.inc("m", "b", "2", "a", "1");
|
||||
Metrics b = new Metrics();
|
||||
b.inc("m", "a", "1", "b", "2");
|
||||
|
||||
assertEquals(a.render(), b.render(), "label order in the call must not change the output");
|
||||
assertTrue(a.render().contains("m{a=\"1\",b=\"2\"}"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void gaugesAreEvaluatedAtScrapeTimeNotRegistrationTime() {
|
||||
Metrics m = new Metrics();
|
||||
int[] live = {1};
|
||||
m.gauge("bridged_sessions", () -> live[0], "state", "ready");
|
||||
|
||||
assertTrue(m.render().contains("bridged_sessions{state=\"ready\"} 1"));
|
||||
live[0] = 5;
|
||||
assertTrue(m.render().contains("bridged_sessions{state=\"ready\"} 5"),
|
||||
"the gauge must read current state on every scrape");
|
||||
}
|
||||
|
||||
@Test
|
||||
void aThrowingGaugeDoesNotBreakTheWholeScrape() {
|
||||
Metrics m = new Metrics();
|
||||
m.inc("good_total");
|
||||
m.gauge("bad_gauge", () -> {
|
||||
throw new IllegalStateException("herdr is down");
|
||||
});
|
||||
|
||||
String out = assertDoesNotThrow(m::render);
|
||||
assertTrue(out.contains("good_total 1"), "healthy series must still be exported");
|
||||
assertFalse(out.contains("bad_gauge"), "the broken series is simply absent");
|
||||
}
|
||||
|
||||
@Test
|
||||
void collectorsDiscoverTheirLabelSetPerScrape() {
|
||||
Metrics m = new Metrics();
|
||||
Map<String, Number> depths = new LinkedHashMap<>();
|
||||
m.collector("bridged_inbox_depth", "target", () -> depths);
|
||||
|
||||
assertFalse(m.render().contains("bridged_inbox_depth"), "no targets yet ⇒ no series");
|
||||
|
||||
depths.put("term_a", 2);
|
||||
depths.put("term_b", 0);
|
||||
String out = m.render();
|
||||
assertTrue(out.contains("bridged_inbox_depth{target=\"term_a\"} 2"));
|
||||
assertTrue(out.contains("bridged_inbox_depth{target=\"term_b\"} 0"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void labelValuesAreEscaped() {
|
||||
Metrics m = new Metrics();
|
||||
m.inc("m", "detail", "he said \"hi\"\nand \\left");
|
||||
|
||||
String out = m.render();
|
||||
assertTrue(out.contains("\\\""), "quotes escaped");
|
||||
assertTrue(out.contains("\\n"), "newlines escaped — a raw one would corrupt the exposition");
|
||||
assertTrue(out.contains("\\\\"), "backslashes escaped");
|
||||
}
|
||||
|
||||
@Test
|
||||
void wholeNumberGaugesRenderWithoutADecimalPoint() {
|
||||
Metrics m = new Metrics();
|
||||
m.gauge("whole", () -> 3.0);
|
||||
m.gauge("fractional", () -> 1.5);
|
||||
|
||||
String out = m.render();
|
||||
assertTrue(out.contains("whole 3"), "3.0 should not render as 3.0");
|
||||
assertTrue(out.contains("fractional 1.5"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void oddLabelCountIsRejected() {
|
||||
Metrics m = new Metrics();
|
||||
assertThrows(IllegalArgumentException.class, () -> m.inc("m", "dangling"));
|
||||
}
|
||||
|
||||
private static int countOccurrences(String haystack, String needle) {
|
||||
int n = 0;
|
||||
for (int i = haystack.indexOf(needle); i >= 0; i = haystack.indexOf(needle, i + 1)) {
|
||||
n++;
|
||||
}
|
||||
return n;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,225 @@
|
||||
package dev.ltms.bridged.rest;
|
||||
|
||||
import dev.ltms.bridged.auth.CallerResolver;
|
||||
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.mcp.ConnectionIdentity;
|
||||
import dev.ltms.bridged.metrics.BridgedMetrics;
|
||||
import dev.ltms.bridged.metrics.Metrics;
|
||||
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.worker.ClaudeCodeLauncher;
|
||||
import io.javalin.Javalin;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.net.URI;
|
||||
import java.net.http.HttpClient;
|
||||
import java.net.http.HttpRequest;
|
||||
import java.net.http.HttpResponse;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
/**
|
||||
* CB-501/505 enforcement over real HTTP. The unit tests pin the policy; these pin that the policy
|
||||
* is actually reached from a request — a rule enforced nowhere is not a control.
|
||||
*/
|
||||
class BridgedAppAuthTest {
|
||||
|
||||
private final HttpClient http = HttpClient.newHttpClient();
|
||||
private Javalin app;
|
||||
private Metrics metrics;
|
||||
|
||||
@AfterEach
|
||||
void stop() {
|
||||
if (app != null) app.stop();
|
||||
}
|
||||
|
||||
/**
|
||||
* Start the app with the given identity/auth wiring.
|
||||
*
|
||||
* @param pid the PID every connection resolves to — {@link FakeHerdr#WORKER_PID} makes the
|
||||
* caller worker {@code term_a}, anything else makes it a non-worker
|
||||
*/
|
||||
private int start(long pid, boolean tokenMode, String token) {
|
||||
FakeHerdr herdr = new FakeHerdr();
|
||||
BridgedConfig.Worker wcfg = new BridgedConfig.Worker(
|
||||
"ltms-local", "http://gx00.gw:8000", "coder", null, "BRIDGED_WORKER_TOKEN", null,
|
||||
"tab", "bridged-workers", "worker: {profile} #{n}", null, null, null);
|
||||
AgentControl agents = new AgentControl(herdr);
|
||||
ClaudeCodeLauncher workers = new ClaudeCodeLauncher(
|
||||
agents, new WorkspaceControl(herdr), new SubscriptionGuard(Set.of("gx00.gw")),
|
||||
Map.of(wcfg.profile(), wcfg), wcfg.profile(),
|
||||
k -> "BRIDGED_WORKER_TOKEN".equals(k) ? "tok-abc" : null);
|
||||
SessionManager sessions = new SessionManager(workers, new FakeWorktrees());
|
||||
Injector injector = new Injector(agents);
|
||||
MessageService messages = new MessageService(agents, injector, new Rendezvous());
|
||||
|
||||
ConnectionIdentity identity = new ConnectionIdentity(new PaneLocator(herdr), _ -> pid);
|
||||
CallerResolver callers = tokenMode
|
||||
? new CallerResolver(identity, true, token)
|
||||
: new CallerResolver(identity);
|
||||
metrics = BridgedMetrics.create(sessions, new dev.ltms.bridged.msg.InMemoryReplyInbox());
|
||||
|
||||
app = new BridgedApp(herdr, workers, sessions, messages, sessions.asPresence(), null,
|
||||
callers, metrics).build().start("127.0.0.1", 0);
|
||||
return app.port();
|
||||
}
|
||||
|
||||
private HttpResponse<String> send(int port, String method, String path, String body, String auth)
|
||||
throws Exception {
|
||||
HttpRequest.Builder b = HttpRequest.newBuilder(URI.create("http://127.0.0.1:" + port + path))
|
||||
.header("Content-Type", "application/json");
|
||||
if (auth != null) {
|
||||
b.header("Authorization", auth);
|
||||
}
|
||||
b = switch (method) {
|
||||
case "POST" -> b.POST(body == null
|
||||
? HttpRequest.BodyPublishers.noBody()
|
||||
: HttpRequest.BodyPublishers.ofString(body));
|
||||
case "DELETE" -> b.DELETE();
|
||||
default -> b.GET();
|
||||
};
|
||||
return http.send(b.build(), HttpResponse.BodyHandlers.ofString());
|
||||
}
|
||||
|
||||
// --- loopback-trust: the caller is the primary -------------------------------------------
|
||||
|
||||
@Test
|
||||
void thePrimaryMayOrchestrateButMayNotForgeAWorkerReply() throws Exception {
|
||||
int port = start(999_999, false, null); // no pane ⇒ primary
|
||||
|
||||
HttpResponse<String> read = send(port, "GET", "/profiles", null, null);
|
||||
assertEquals(200, read.statusCode(), "the primary may observe");
|
||||
|
||||
HttpResponse<String> reply = send(port, "POST", "/sessions/term_a/reply",
|
||||
"{\"content\":\"forged\"}", null);
|
||||
assertEquals(403, reply.statusCode(),
|
||||
"a forged reply would resolve the rendezvous the primary is itself waiting on");
|
||||
assertTrue(reply.body().contains("forbidden"));
|
||||
}
|
||||
|
||||
// --- loopback-trust: the caller is a worker ------------------------------------------------
|
||||
|
||||
@Test
|
||||
void aWorkerMayReplyAsItselfButNotAsAnother() throws Exception {
|
||||
int port = start(FakeHerdr.WORKER_PID, false, null); // resolves to term_a
|
||||
|
||||
HttpResponse<String> own = send(port, "POST", "/sessions/term_a/reply",
|
||||
"{\"content\":\"done\"}", null);
|
||||
assertEquals(200, own.statusCode(), "a worker replies on its own session");
|
||||
|
||||
HttpResponse<String> other = send(port, "POST", "/sessions/term_b/reply",
|
||||
"{\"content\":\"not mine\"}", null);
|
||||
assertEquals(403, other.statusCode(),
|
||||
"REST trusted the path id before CB-505; this is the hole being closed");
|
||||
}
|
||||
|
||||
@Test
|
||||
void aWorkerMayNotOrchestrate() throws Exception {
|
||||
int port = start(FakeHerdr.WORKER_PID, false, null);
|
||||
|
||||
assertEquals(403, send(port, "POST", "/workers", null, null).statusCode(),
|
||||
"a worker spawning workers would be escalating into the orchestrator role");
|
||||
assertEquals(403, send(port, "DELETE", "/workers/w2:p7", null, null).statusCode());
|
||||
assertEquals(403, send(port, "POST", "/sessions/term_b/message",
|
||||
"{\"content\":\"hi\"}", null).statusCode());
|
||||
assertEquals(403, send(port, "GET", "/sessions/term_a/replies", null, null).statusCode(),
|
||||
"draining an inbox is the primary's collection step");
|
||||
}
|
||||
|
||||
// --- token mode ---------------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
void tokenModeRejectsAnUncredentialedNonWorkerWith401() throws Exception {
|
||||
int port = start(999_999, true, "s3cret");
|
||||
|
||||
HttpResponse<String> res = send(port, "GET", "/profiles", null, null);
|
||||
assertEquals(401, res.statusCode(), "no credential ⇒ authenticated as nothing");
|
||||
assertTrue(res.body().contains("unauthenticated"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void tokenModeAcceptsAValidBearerToken() throws Exception {
|
||||
int port = start(999_999, true, "s3cret");
|
||||
|
||||
assertEquals(200, send(port, "GET", "/profiles", null, "Bearer s3cret").statusCode());
|
||||
}
|
||||
|
||||
@Test
|
||||
void tokenModeStillHonoursConnectionDerivedWorkerIdentity() throws Exception {
|
||||
// The fleet must keep working when auth is switched on: a worker presents no token, and
|
||||
// must still be able to reply.
|
||||
int port = start(FakeHerdr.WORKER_PID, true, "s3cret");
|
||||
|
||||
assertEquals(200, send(port, "POST", "/sessions/term_a/reply",
|
||||
"{\"content\":\"done\"}", null).statusCode());
|
||||
}
|
||||
|
||||
// --- health, metrics ----------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
void healthzStaysOpenWithoutCredentials() throws Exception {
|
||||
int port = start(999_999, true, "s3cret");
|
||||
|
||||
assertEquals(200, send(port, "GET", "/healthz", null, null).statusCode(),
|
||||
"a supervisor must be able to probe liveness before any credential is configured");
|
||||
}
|
||||
|
||||
@Test
|
||||
void metricsRequireAuthenticationAndRenderPrometheusText() throws Exception {
|
||||
int port = start(999_999, true, "s3cret");
|
||||
|
||||
assertEquals(401, send(port, "GET", "/metrics", null, null).statusCode());
|
||||
|
||||
HttpResponse<String> ok = send(port, "GET", "/metrics", null, "Bearer s3cret");
|
||||
assertEquals(200, ok.statusCode());
|
||||
assertTrue(ok.headers().firstValue("Content-Type").orElse("").startsWith("text/plain"));
|
||||
assertTrue(ok.body().contains("bridged_sessions{state=\"ready\"}"),
|
||||
"the session census gauge is exported even when empty");
|
||||
}
|
||||
|
||||
@Test
|
||||
void refusalsAreCounted() throws Exception {
|
||||
int port = start(999_999, true, "s3cret");
|
||||
|
||||
send(port, "GET", "/profiles", null, null); // 401
|
||||
send(port, "POST", "/sessions/term_a/reply", "{}", "Bearer s3cret"); // 403
|
||||
|
||||
assertEquals(1, metrics.count(BridgedMetrics.AUTH_FAILURES, "reason", "unauthenticated"));
|
||||
assertEquals(1, metrics.count(BridgedMetrics.AUTH_FAILURES, "reason", "forbidden"));
|
||||
}
|
||||
|
||||
// --- legacy constructor -------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
void theLegacyConstructorLeavesAuthorizationOff() throws Exception {
|
||||
// The 29 pre-existing acceptance tests rely on this: no auth fixture, no enforcement.
|
||||
FakeHerdr herdr = new FakeHerdr();
|
||||
AgentControl agents = new AgentControl(herdr);
|
||||
BridgedConfig.Worker wcfg = new BridgedConfig.Worker(
|
||||
"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(wcfg.profile(), wcfg), wcfg.profile(), _ -> "tok");
|
||||
SessionManager sessions = new SessionManager(workers, new FakeWorktrees());
|
||||
MessageService messages = new MessageService(agents, new Injector(agents), new Rendezvous());
|
||||
app = new BridgedApp(herdr, workers, sessions, messages, sessions.asPresence(), null)
|
||||
.build().start("127.0.0.1", 0);
|
||||
|
||||
assertEquals(200, send(app.port(), "POST", "/sessions/term_a/reply",
|
||||
"{\"content\":\"x\"}", null).statusCode());
|
||||
assertEquals(404, send(app.port(), "GET", "/metrics", null, null).statusCode(),
|
||||
"no registry supplied ⇒ the endpoint is not mounted at all");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user