Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/holger

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()),
    )
}