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/door_reserved_property_floor.rs

//! **The reserved-property ADMIN floor is on BOTH write doors, or it is on
//! neither.**
//!
//! A handful of property keys are not labels describing an artifact — they are
//! inputs the SERVER's own policy reads: the serve-time quarantine flag, the
//! property the promotion gate demands, and the retention pin. Writing one is an
//! admin action, and the gRPC `SetProperty` RPC enforces that.
//!
//! The HTTP `/-/properties` door writes the SAME store, through the same
//! `PropertyStore::set`. It enforced nothing but the plain Writer gate, so a
//! Writer refused at one door simply used the other — setting the `keep`
//! retention pin that `retention --protect-property` is built to police, forging
//! the `signed=true` the promote gate trusts, or clearing a quarantine flag. A
//! policy gate on one of two doors is not a gate.
//!
//! This is a DOOR-level test on purpose. A unit test over the shared
//! `is_reserved_property` rule passes whether or not the door calls it, which is
//! exactly the kind of test that reports a fix nobody wired.
//!
//! Both doors are the `supply-chain` plane (lean cut 2026-10-17): without it the
//! property store and its write door are not built, and the arm in
//! `.nornir/testmatrix-arms.json` runs this file.

use std::io::Write;
use traits::test_support::hold_port;

use holger_server_lib::{read_ron_config, wire_holger, Holger};

struct Running {
    _data: tempfile::TempDir,
    _cfg: tempfile::NamedTempFile,
    base: String,
}

/// A `raw` repository behind a cleartext HTTP door with RBAC ON: a static bearer
/// per role, `writer` mapped to Writer and `boss` to Admin. `raw` because it is
/// the backend that can map a served path to a coordinate, which the quarantine
/// boot gate requires — and because it keeps this test about authorization only.
fn start() -> Running {
    let mut last = String::new();
    for _ in 0..8 {
        let (grpc, http) = (hold_port(), hold_port());
        match try_start(grpc, http) {
            Ok(r) => return r,
            Err(e) => last = e,
        }
    }
    panic!("gateway never started; last error: {last}");
}

fn try_start(grpc: u16, http: u16) -> Result<Running, 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: Some((
        methods: [
            StaticToken(subject: "writer", token: Some("w-token")),
            StaticToken(subject: "boss", token: Some("a-token")),
        ],
        roles: {{ "writer": Writer, "boss": Admin }},
        default_role: Some(Reader),
    )),
    audit: (dir: "{data}/audit", enabled: true),
)"#,
        data = data.path().display(),
    );
    let mut cfg = tempfile::NamedTempFile::new().unwrap();
    cfg.write_all(ron.as_bytes()).unwrap();
    let mut holger: Holger = read_ron_config(cfg.path()).map_err(|e| format!("config: {e}"))?;
    holger
        .instantiate_backends()
        .map_err(|e| format!("backends: {e}"))?;
    wire_holger(&mut holger).map_err(|e| format!("wire: {e}"))?;
    holger.start().map_err(|e| format!("start: {e}"))?;
    Ok(Running {
        _data: data,
        _cfg: cfg,
        base: 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}");
}

fn props_url(base: &str, key: &str, value: Option<&str>) -> String {
    let v = value.map(|v| format!("&value={v}")).unwrap_or_default();
    format!("{base}/-/properties?repo=raw&name=serde&version=1.0.0&key={key}{v}")
}

/// A Writer may label an artifact and may NOT write the server's policy inputs —
/// on the HTTP door, exactly as on the gRPC one.
#[tokio::test(flavor = "multi_thread")]
async fn a_writer_cannot_set_a_reserved_property_over_http() {
    let r = start();
    let cl = tls_roots(reqwest::Client::builder())
        .build()
        .expect("reqwest client");
    wait_healthy(&cl, &r.base).await;

    // An ordinary label: a Writer's own business, and it must still work. If this
    // fails the floor is too broad and the test below proves nothing.
    let label = cl
        .put(props_url(&r.base, "team", Some("core")))
        .bearer_auth("w-token")
        .send()
        .await
        .unwrap();
    assert_eq!(
        label.status(),
        200,
        "a Writer must still be able to label an artifact"
    );

    // `keep` is reserved by default (DEFAULT_RESERVED_PROPERTY_KEYS) — no special
    // configuration needed, which is what made this reachable everywhere.
    let pin = cl
        .put(props_url(&r.base, "keep", Some("true")))
        .bearer_auth("w-token")
        .send()
        .await
        .unwrap();
    assert_eq!(
        pin.status(),
        403,
        "a Writer must NOT be able to set the retention pin over HTTP"
    );

    // …and REMOVAL rides the writer-gated PUT, not the admin-gated DELETE: a PUT
    // with no `value=` clears the key. The floor has to cover that shape too, or
    // it only stops the half of the attack that sets.
    let clear = cl
        .put(props_url(&r.base, "keep", None))
        .bearer_auth("w-token")
        .send()
        .await
        .unwrap();
    assert_eq!(
        clear.status(),
        403,
        "removing a reserved key is equally an admin action"
    );

    // An Admin can, which is the point: the key is gated, not forbidden.
    let admin = cl
        .put(props_url(&r.base, "keep", Some("true")))
        .bearer_auth("a-token")
        .send()
        .await
        .unwrap();
    assert_eq!(admin.status(), 200, "an Admin sets the pin");

    // The pin really landed, so the refusals above were about authorization and
    // not about the write failing for some other reason. Read signed in: an estate build
    // gates reads (HCON-5), and this test is about the write floor, not the read gate.
    let read = cl
        .get(format!(
            "{}/-/properties?repo=raw&name=serde&version=1.0.0",
            r.base
        ))
        .bearer_auth("w-token")
        .send()
        .await
        .unwrap();
    let body = read.text().await.unwrap();
    assert!(
        body.contains("keep"),
        "the admin's write is visible: {body}"
    );
}

/// An unauthenticated caller gets 401 rather than 403 on a reserved key, so the
/// door does not tell an anonymous prober that a key exists and is merely gated.
#[tokio::test(flavor = "multi_thread")]
async fn an_anonymous_caller_is_unauthenticated_not_merely_forbidden() {
    let r = start();
    let cl = tls_roots(reqwest::Client::builder())
        .build()
        .expect("reqwest client");
    wait_healthy(&cl, &r.base).await;

    let resp = cl
        .put(props_url(&r.base, "keep", Some("true")))
        .send()
        .await
        .unwrap();
    assert_eq!(resp.status(), 401);
}

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