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

//! **holger-server serves the ONE IdentityService** (IDENTITY-HOST step 3): the core's
//! `nornir.identity.v1.IdentityService` answers on the box's gRPC door, by code, the same
//! adapter gunnar-server mounts; and a box with no passkey method serves none.
#![cfg(feature = "auth-passkey")]

use std::io::Write;

use holger_server_lib::{read_ron_config, wire_holger, Holger};
use nornir_identity::grpc::pb::identity_service_client::IdentityServiceClient;
use nornir_identity::grpc::{pb, refusal_of};
use nornir_identity::Code;
use traits::test_support::hold_port;

struct Server {
    _data: tempfile::TempDir,
    _cfg: tempfile::NamedTempFile,
    grpc: String,
}

fn start(passkey: bool) -> Server {
    start_at(passkey.then_some("idh.mock.invalid"), |_| {})
}

/// A box whose passkey relying party is `rp` (none: no passkey method), `shape` run on the
/// config before it starts (what holger-server's boot sets from the installer's feed).
fn start_at(rp: Option<&str>, shape: impl FnOnce(&mut Holger)) -> Server {
    let (grpc, http) = (hold_port(), hold_port());
    let data = tempfile::tempdir().unwrap();
    let method = rp
        .map(|rp| format!(r#"Passkey(rp_id: "{rp}", rp_origin: "https://{rp}"),"#))
        .unwrap_or_default();
    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}") ],
    audit: (dir: "{data}/audit", enabled: true),
    auth: Some((
        methods: [
            StaticToken(subject: "bootstrap", token: Some("bootstrap-admin-token-for-the-identity-test")),
            {method}
        ],
        roles: {{"bootstrap": Admin}},
        group_roles: {{"holger-admins": Admin, "holger-readers": Reader}},
        default_role: Some(Reader),
    )),
)"#,
        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()).expect("the RON must parse");
    shape(&mut holger);
    holger.instantiate_backends().unwrap();
    wire_holger(&mut holger).unwrap();
    holger.start().unwrap();
    Server {
        _data: data,
        _cfg: cfg,
        grpc: format!("http://127.0.0.1:{grpc}"),
    }
}

async fn client(at: &str) -> IdentityServiceClient<tonic::transport::Channel> {
    for _ in 0..200 {
        if let Ok(c) = IdentityServiceClient::connect(at.to_owned()).await {
            return c;
        }
        tokio::time::sleep(std::time::Duration::from_millis(20)).await;
    }
    panic!("the gRPC door never opened at {at}");
}

/// ★ The box answers the IdentityService by code: a box that does not know where it sits is
/// invite only (`signup_closed`), an unknown session is `no_session`, a login opens a ceremony for this
/// box's relying party and refuses another's.
#[tokio::test(flavor = "multi_thread")]
async fn a_holger_with_passkeys_serves_the_identity_service() {
    let s = start(true);
    let mut c = client(&s.grpc).await;
    let r = c
        .sign_up(pb::SignUpRequest {
            email: "kim@mock.invalid".into(),
            display: "Kim".into(),
        })
        .await
        .unwrap_err();
    // An open build sells nothing, and a box neither enrolled nor fed a site is invite only
    // (RA5-1: never a product site by its passkey host's name).
    assert_eq!(refusal_of(&r), Some(Code::SignUpClosed), "{r:?}");
    let r = c
        .whoami(pb::WhoamiRequest {
            session: "nis1.not-a-session".into(),
        })
        .await
        .unwrap_err();
    assert_eq!(refusal_of(&r), Some(Code::NoSession), "{r:?}");
    let opened = c
        .login_start(pb::LoginStartRequest {
            rp_id: "idh.mock.invalid".into(),
            origin: "https://idh.mock.invalid".into(),
            client: String::new(),
        })
        .await
        .unwrap()
        .into_inner();
    assert_eq!(opened.rp_id, "idh.mock.invalid");
    assert!(!opened.ceremony_id.is_empty() && !opened.challenge.is_empty());
    let r = c
        .login_start(pb::LoginStartRequest {
            rp_id: "evil.mock.invalid".into(),
            origin: "https://evil.mock.invalid".into(),
            client: String::new(),
        })
        .await
        .unwrap_err();
    assert_eq!(refusal_of(&r), Some(Code::Ceremony), "{r:?}");
}

/// ★ A box with no passkey method serves no IdentityService (as it serves no passkey door).
#[tokio::test(flavor = "multi_thread")]
async fn a_holger_without_passkeys_serves_none() {
    let s = start(false);
    let mut c = client(&s.grpc).await;
    let r = c
        .whoami(pb::WhoamiRequest {
            session: "nis1.x".into(),
        })
        .await
        .unwrap_err();
    assert_eq!(r.code(), tonic::Code::Unimplemented, "{r:?}");
}

/// ★ RA5-1: **a shop-laid sold private Holger is invite only.** The shop lay writes no
/// `fleet/fleet.json` (only the manual `enrol` does) and feeds the estate late, so the box knows
/// nothing but its passkey host, the estate's DNS name. That name is no product site: a
/// stranger's `IdentityService/SignUp` on `repo.acme…` is refused by name (`signup_closed`),
/// never mailed a first-login link into the customer's private box.
#[tokio::test(flavor = "multi_thread")]
async fn a_shop_laid_sold_private_holger_refuses_a_strangers_sign_up() {
    let s = start_at(Some("repo.acme.mock.invalid"), |_| {});
    let mut c = client(&s.grpc).await;
    let r = c
        .sign_up(pb::SignUpRequest {
            email: "stranger@mock.invalid".into(),
            display: "Stranger".into(),
        })
        .await
        .unwrap_err();
    assert_eq!(
        refusal_of(&r),
        Some(Code::SignUpClosed),
        "a sold private estate sells no sign-up: {r:?}"
    );
}

/// ★ RA5-1: **the site is what the ONE box fed.** An operator site's main (fed its estate's site
/// variant) sells sign-up: the core takes the stranger's address and, fed no mail relay here,
/// refuses to mail the link (`mail_refused`); a sold estate's main (fed the sold variant) is
/// invite only. An open build sells nothing, whatever it is fed.
#[tokio::test(flavor = "multi_thread")]
async fn the_site_the_one_box_fed_decides_sign_up() {
    use holger_server_lib::identity_host::{site_from_feed, SITE_VARIANT, VARIANT_FEED};
    let metered = cfg!(feature = "metered");
    for (variant, want) in [
        (
            SITE_VARIANT,
            if metered {
                Code::MailRefused
            } else {
                Code::SignUpClosed
            },
        ),
        ("holger-twin-monetize", Code::SignUpClosed),
    ] {
        let feed = [(VARIANT_FEED.to_string(), variant.to_string())].into();
        let s = start_at(Some("repo.fed.mock.invalid"), |h| {
            h.estate_site = site_from_feed(&feed, metered)
        });
        let mut c = client(&s.grpc).await;
        let r = c
            .sign_up(pb::SignUpRequest {
                email: "kim@mock.invalid".into(),
                display: "Kim".into(),
            })
            .await
            .unwrap_err();
        assert_eq!(refusal_of(&r), Some(want), "{variant}: {r:?}");
    }
}