server/lib/tests/meta_door_read_gate.rs
//! Regression guard for the Critical-profile authenticated-read guarantee: the
//! cross-repo `/-/search`, `/-/sbom`, `/-/properties` and `/metrics` doors must
//! honor `require_auth_for_reads` (which the Critical profile forces on), not
//! serve unauthenticated. With the gate off they stay open (backward compatible).
use std::io::Write;
use traits::test_support::hold_port;
use holger_server_lib::{read_ron_config, wire_holger, Holger};
fn start(
auth_ron: &str,
grpc: u16,
http: u16,
) -> (tempfile::TempDir, tempfile::NamedTempFile, String) {
let data = tempfile::tempdir().unwrap();
let ron = format!(
r#"(
repositories: [
(ron_name: "raw", ron_repo_type: "raw", ron_upstreams: [], ron_in: None,
ron_out: Some((ron_storage_endpoint: "storage", ron_exposed_endpoint: "default")),
ron_data_dir: Some("{data}")),
],
exposed_endpoints: [
(ron_name: "default", ron_url: "127.0.0.1:{grpc}", ron_http_url: Some("127.0.0.1:{http}")),
],
storage_endpoints: [ (ron_name: "storage", ron_storage_type: "znippy", ron_path: "{data}") ],
{auth}
)"#,
data = data.path().display(),
auth = auth_ron,
);
let mut cfg = tempfile::NamedTempFile::new().unwrap();
cfg.write_all(ron.as_bytes()).unwrap();
let mut holger: Holger = read_ron_config(cfg.path()).unwrap();
holger.instantiate_backends().unwrap();
wire_holger(&mut holger).unwrap();
holger.start().unwrap();
(data, cfg, format!("http://127.0.0.1:{http}"))
}
async fn wait_healthy(c: &reqwest::Client, base: &str) {
for _ in 0..100 {
if let Ok(r) = c.get(format!("{base}/healthz")).send().await {
if r.status() == 200 {
return;
}
}
tokio::time::sleep(std::time::Duration::from_millis(20)).await;
}
panic!("gateway never became healthy at {base}");
}
#[tokio::test(flavor = "multi_thread")]
async fn read_gate_blocks_unauthenticated_meta_doors() {
// require_auth_for_reads on (a StaticToken method configured so something CAN
// authenticate) — an unauthenticated client must be refused on all three doors.
let auth = r#"auth: Some(( methods: [ StaticToken(subject: "ci", token: Some("secret")) ], require_auth_for_reads: true, default_role: Some(Reader) )),"#;
let (_d, _c, base) = start(auth, hold_port(), hold_port());
let cl = tls_roots(reqwest::Client::builder())
.build()
.expect("reqwest client");
wait_healthy(&cl, &base).await;
// Probes stay open.
assert_eq!(
cl.get(format!("{base}/healthz"))
.send()
.await
.unwrap()
.status(),
200
);
// No credential → 401 on every cross-repo read door (the fixed bypass).
for door in [
"/-/search",
"/-/sbom?repo=raw&name=x&version=1",
"/-/properties?repo=raw&name=x&version=1",
] {
let s = cl
.get(format!("{base}{door}"))
.send()
.await
.unwrap()
.status();
assert_eq!(s, 401, "unauthenticated {door} must be 401, got {s}");
}
// A valid bearer (Reader) is NOT blocked — the gate authenticates, it doesn't
// brick the doors.
let ok = cl
.get(format!("{base}/-/search"))
.header("authorization", "Bearer secret")
.send()
.await
.unwrap();
assert_ne!(
ok.status(),
401,
"an authenticated reader must pass the gate"
);
}
#[tokio::test(flavor = "multi_thread")]
async fn gate_off_keeps_meta_doors_open() {
// No auth / gate off: the doors stay open (backward compatible).
let (_d, _c, base) = start("", hold_port(), hold_port());
let cl = tls_roots(reqwest::Client::builder())
.build()
.expect("reqwest client");
wait_healthy(&cl, &base).await;
let s = cl
.get(format!("{base}/-/search"))
.send()
.await
.unwrap()
.status();
assert_ne!(
s, 401,
"with the gate off, /-/search must remain open, got {s}"
);
}
/// **A per-repo `Denied` cannot be stepped around by respelling the query.**
///
/// The `/-/sbom` and `/-/properties` doors resolve the target repository twice:
/// once to scope the read gate, once to serve the coordinate. They used to use
/// two different parsers — the gate's knew only the `repo=` key and did not
/// percent-decode — so a caller denied on `secret-repo` could reach it by asking
/// for `?repository=secret-repo` (the gate then fell back to the GLOBAL floor,
/// which the caller passes) or `?repo=secret%2Drepo` (the gate scoped itself at
/// the literal `secret%2Drepo`, which no ACL names, while the door served
/// `secret-repo`).
///
/// RED-when-broken: with the two parsers back apart, the `repository=` and the
/// percent-encoded spellings answer 200 instead of 403, and the per-repo
/// carve-out — the whole point of the `Denied` role — stops holding on these
/// two doors.
///
/// Both doors are the `supply-chain` plane since the lean cut (2026-10-17): the default
/// build does not serve them and the coordinate answers 404. Gated on the feature; the
/// declared arm in `.nornir/testmatrix-arms.json` runs it.
#[cfg(feature = "supply-chain")]
#[tokio::test(flavor = "multi_thread")]
async fn a_denied_repo_stays_denied_however_the_query_spells_it() {
// `ci` may read in general (the global Reader floor) but is Denied on
// `secret-repo` specifically.
let auth = r#"auth: Some((
methods: [ StaticToken(subject: "ci", token: Some("secret")) ],
require_auth_for_reads: true,
default_role: Some(Reader),
repo_roles: { "secret-repo": { "ci": Denied } },
)),"#;
let (_d, _c, base) = start(auth, hold_port(), hold_port());
let cl = tls_roots(reqwest::Client::builder())
.build()
.expect("reqwest client");
wait_healthy(&cl, &base).await;
// Every spelling of the SAME repository must be refused the same way.
for door in ["/-/sbom", "/-/properties"] {
for spelling in [
"repo=secret-repo",
"repository=secret-repo",
"repo=secret%2Drepo",
"repository=secret%2Drepo",
] {
let url = format!("{base}{door}?{spelling}&name=serde&version=1.0.0");
let s = cl
.get(&url)
.header("authorization", "Bearer secret")
.send()
.await
.unwrap()
.status();
assert_eq!(
s, 403,
"a Denied repository must stay denied for {door}?{spelling}, got {s}"
);
}
}
// The carve-out is a carve-out, not a wall: a repository the policy does not
// deny still reads for the same principal, and the global floor still applies
// to a request that names no repository at all.
for door in ["/-/sbom", "/-/properties"] {
let s = cl
.get(format!("{base}{door}?repo=raw&name=serde&version=1.0.0"))
.header("authorization", "Bearer secret")
.send()
.await
.unwrap()
.status();
assert_ne!(
s, 403,
"an undenied repository must still read on {door}, got {s}"
);
}
}
/// **`/metrics` is a read door and answers to the read gate.**
///
/// The Prometheus door was the one meta surface that returned before the gate:
/// it names every repository on the server and does a per-repo listing on each
/// scrape, and it did both for anybody who asked — including on a Critical-profile
/// node, whose whole promise is that no read is unauthenticated. (The Critical
/// loopback restriction is a separate, narrower control: it hides `/metrics` from
/// off-loopback peers, and says nothing about a scrape arriving over loopback.)
///
/// RED-when-broken: without the gate the unauthenticated scrape answers 200 and
/// hands out the repository inventory.
#[tokio::test(flavor = "multi_thread")]
async fn the_metrics_door_honours_the_read_gate() {
let auth = r#"auth: Some(( methods: [ StaticToken(subject: "ci", token: Some("secret")) ], require_auth_for_reads: true, default_role: Some(Reader) )),"#;
let (grpc, http) = (hold_port(), hold_port());
let (_d, _c, base) = start(auth, grpc, http);
let cl = tls_roots(reqwest::Client::builder())
.build()
.expect("reqwest client");
wait_healthy(&cl, &base).await;
let s = cl
.get(format!("{base}/metrics"))
.send()
.await
.unwrap()
.status();
assert_eq!(s, 401, "an unauthenticated scrape must be refused, got {s}");
// The gate authenticates the scrape; it does not brick it.
let ok = cl
.get(format!("{base}/metrics"))
.header("authorization", "Bearer secret")
.send()
.await
.unwrap();
assert_eq!(ok.status(), 200, "an authenticated reader still scrapes");
assert!(
ok.text().await.unwrap().contains("holger_"),
"…and gets the real exposition, not an empty body"
);
}
/// With the gate off `/metrics` stays exactly as open as it has always been — an
/// existing Prometheus scrape configuration keeps working untouched.
#[tokio::test(flavor = "multi_thread")]
async fn the_metrics_door_stays_open_when_the_gate_is_off() {
let (grpc, http) = (hold_port(), hold_port());
let (_d, _c, base) = start("", grpc, http);
let cl = tls_roots(reqwest::Client::builder())
.build()
.expect("reqwest client");
wait_healthy(&cl, &base).await;
let s = cl
.get(format!("{base}/metrics"))
.send()
.await
.unwrap()
.status();
assert_eq!(
s, 200,
"with the gate off /metrics must remain open, got {s}"
);
}
/// reqwest 0.13 with reqwest 0.12 `rustls-tls` behaviour (2026-10-02): the ring
/// provider and the bundled Mozilla roots (webpki), not 0.13's aws-lc-rs + OS
/// trust store. Roots added later on the builder are merged in, as in 0.12.
#[allow(dead_code)]
fn tls_roots(b: reqwest::ClientBuilder) -> reqwest::ClientBuilder {
let _ = rustls::crypto::ring::default_provider().install_default();
b.tls_certs_only(
webpki_root_certs::TLS_SERVER_ROOT_CERTS
.iter()
.filter_map(|c| reqwest::Certificate::from_der(c).ok()),
)
}