gunnar-grpc 3.0.0

The tonic service implementations and the event bus behind gunnar's gRPC control plane.
Documentation
//! **The first admin, on the identity core over Gunnar's people**
//! (IDENTITY-HOST, 2026-10-27; Stage L before it, 2026-10-04).
//!
//! test2.gunnar.rs that morning: the owner registered his YubiKey with the
//! installer's break-glass token, the console asked gunnar `AddPrincipal
//! rickard`, gunnar refused it (a first push had conjured `rickard/`), and the
//! passkey bound to nothing. Owner: "a YubiKey is used to register me as an
//! admin user. I can THEN add ssh keys to it"; "the UI never creates users or
//! decides anything — it CALLS gunnar-server".
//!
//! The door, with Gunnar's own web verbs gone:
//!
//! 1. `Accounts.ClaimOperator{name, bootstrap_invite_sha256}` — the
//!    installer's word: the operator is made (or adopted over a conjured
//!    namespace), gunnar-admin, and the break-glass token's HASH is recorded
//!    as the invite for his first passkey — which the core reads as a FED
//!    invite onto him (`GunnarStore`);
//! 2. the console relays the creation ceremony through the core
//!    (`IdentityService.RedeemStart/RedeemFinish` with the token as the
//!    secret): the core spends the fed invite, the passkey is his;
//! 3. the session the core minted is HIS credential at the real gate;
//! 4. and he adds his SSH key, signed as him: no attestation, no operator step.

use std::sync::Arc;

use gunnar_grpc::auth::{AuthGate, SESSION_METADATA_KEY};
use gunnar_grpc::identity::{
    system_clock, GunnarHost, GunnarIdentity, GunnarStore, IdentityTables,
};
use gunnar_grpc::{
    sign_credential_for, AccessService, AccountsService, AuthMode, Caller, ControlPlane, EventBus,
    MemoryControlPlane, ServerConfig, ServiceContext, CREDENTIAL_METADATA_KEY,
};
use gunnar_proto::access_server::Access as _;
use gunnar_proto::accounts_server::Accounts as _;
use gunnar_proto::*;
use nornir_identity::conformance::{config, relay, ORIGIN};
use nornir_identity::test_host::SoftPasskey;
use nornir_identity::{Identity, NoMail, Site};
use sha2::Digest as _;
use tonic::{Code, Request};

/// The break-glass token the installer hands the owner (and feeds vm1).
const BREAK_GLASS: &str = "break-glass-of-the-installer-0000000000000000000001";

fn now_ms() -> i64 {
    std::time::SystemTime::now()
        .duration_since(std::time::UNIX_EPOCH)
        .expect("after 1970")
        .as_millis() as i64
}

fn mint() -> ssh_key::PrivateKey {
    ssh_key::PrivateKey::random(
        &mut rand::rand_core::UnwrapErr(rand::rngs::SysRng),
        ssh_key::Algorithm::Ed25519,
    )
    .expect("an ed25519 key")
}

fn hash_of(secret: &str) -> String {
    sha2::Sha256::digest(secret.as_bytes())
        .iter()
        .map(|b| format!("{b:02x}"))
        .collect()
}

/// test2's gunnar: the console (`gunnar-ui`, server Admin: the relay), the
/// core over the same plane, and — on the morning — a namespace `rickard` a
/// first push conjured.
struct Estate {
    plane: Arc<MemoryControlPlane>,
    ctx: ServiceContext,
    identity: Arc<GunnarIdentity>,
    console: Caller,
}

impl Estate {
    fn new(conjured: bool) -> Estate {
        let plane = Arc::new(MemoryControlPlane::new());
        let control: Arc<dyn ControlPlane> = plane.clone();
        let store = GunnarStore::new(
            Arc::clone(&control),
            IdentityTables::default(),
            None,
            system_clock(),
        );
        let identity = Arc::new(Identity::new(
            GunnarHost::new(Site::Private, store, Arc::new(NoMail), system_clock()),
            config(),
        ));
        let ctx = ServiceContext::new(
            control,
            Arc::new(EventBus::new(16)),
            ServerConfig::default(),
        )
        .with_identity(Arc::clone(&identity));
        assert_eq!(ctx.config.auth, AuthMode::Required);
        let key = mint();
        plane
            .add_principal("gunnar-ui", "gunnar-ui", PrincipalKind::Machine)
            .expect("the console");
        plane
            .add_key(
                "gunnar-ui",
                &key.public_key().to_openssh().expect("openssh"),
                "gunnar-ui",
                None,
                "",
                None,
            )
            .expect("key");
        plane.grant_server("gunnar-ui", GrantLevel::Admin);
        if conjured {
            plane
                .create_namespace("rickard", "")
                .expect("the namespace a first push conjured");
        }
        let mut estate = Estate {
            plane,
            ctx,
            identity,
            console: Caller {
                principal_id: String::new(),
                fingerprint: String::new(),
                machine: true,
                authenticated: true,
            },
        };
        estate.console = estate.gate_key(&key).expect("the console's key verifies");
        estate
    }

    fn gate(&self) -> AuthGate {
        AuthGate::new(Arc::clone(&self.ctx.control), self.ctx.config.auth)
            .with_identity(Some(Arc::clone(&self.identity)))
    }

    fn gate_key(&self, key: &ssh_key::PrivateKey) -> Result<Caller, String> {
        use std::sync::atomic::{AtomicU64, Ordering};
        static SEQ: AtomicU64 = AtomicU64::new(0);
        let nonce = format!(
            "nonce-first-admin-{:08}",
            SEQ.fetch_add(1, Ordering::Relaxed)
        );
        let credential = sign_credential_for(key, now_ms(), &nonce, "Admin.Health").expect("sign");
        let mut request = Request::new(());
        request.metadata_mut().insert(
            CREDENTIAL_METADATA_KEY,
            credential.to_header_value().parse().expect("ascii"),
        );
        self.gate()
            .authenticate_bound(request.metadata(), now_ms(), Some("Admin.Health"))
            .map_err(|e| format!("{e:?}"))
    }

    /// The caller the real gate makes of a core session.
    fn gate_session(&self, token: &str) -> Result<Caller, String> {
        let mut request = Request::new(());
        request
            .metadata_mut()
            .insert(SESSION_METADATA_KEY, token.parse().expect("ascii"));
        self.gate()
            .authenticate_request(&request, now_ms())
            .map_err(|e| format!("{e:?}"))
    }

    fn authed<T>(caller: &Caller, body: T) -> Request<T> {
        let mut request = Request::new(body);
        request.extensions_mut().insert(caller.clone());
        request
    }

    /// The installer's word: the operator, and the token's hash as his first invite.
    async fn claim(&self, hash: &str) -> Result<ClaimOperatorResponse, tonic::Status> {
        AccountsService::new(self.ctx.clone())
            .claim_operator(Estate::authed(
                &self.console,
                ClaimOperatorRequest {
                    name: "rickard".into(),
                    bootstrap_invite_sha256: hash.into(),
                },
            ))
            .await
            .map(|r| r.into_inner())
    }

    /// The console relays the creation ceremony through the core.
    fn register(
        &self,
        key: &mut SoftPasskey,
        secret: &str,
    ) -> Result<nornir_identity::LoggedIn, nornir_identity::Refused> {
        let c = self.identity.redeem_start(secret, "", &relay())?;
        self.identity.redeem_finish(key.register(&c, ORIGIN))
    }
}

/// ★ **Fresh estate and test2's morning alike: the break-glass token registers
/// the owner's YubiKey ON THE OPERATOR'S ACCOUNT, his session is himself at
/// the gate, and he adds his SSH key — gunnar decided every step, the console
/// only relayed.**
#[tokio::test]
async fn the_break_glass_token_is_the_operators_first_invite_and_his_passkey_logs_in_as_him() {
    for conjured in [false, true] {
        let estate = Estate::new(conjured);
        let claimed = estate
            .claim(&hash_of(BREAK_GLASS))
            .await
            .expect("the installer's claim");
        let operator = claimed.principal.clone().expect("the operator");
        assert_eq!(operator.id, "rickard", "conjured={conjured}");
        assert_eq!(
            claimed.adopted_namespace, conjured,
            "the conjured namespace is ADOPTED, never a refusal"
        );
        assert!(
            claimed.bootstrap_invite,
            "the token's hash is pending as his first invite"
        );

        // 2. His YubiKey, with the token: the core says whose it is.
        let mut yubikey = SoftPasskey::new(0x37);
        let him = estate
            .register(&mut yubikey, BREAK_GLASS)
            .expect("the first admin passkey registers");
        assert_eq!(him.session.user_id, "rickard");
        assert!(him.session.admin, "the operator is the estate's admin");
        assert_eq!(
            estate
                .plane
                .credentials(Some("rickard"))
                .expect("listed")
                .len(),
            1,
            "the passkey is Gunnar's, on him"
        );

        // 3. His session is HIS credential at the gate.
        let caller = estate
            .gate_session(&him.token)
            .expect("the session authenticates");
        assert_eq!(caller.principal_id, "rickard");

        // 4. "I can THEN add ssh keys to it": as him, no attestation owed.
        let laptop = mint().public_key().to_openssh().expect("openssh");
        AccessService::new(estate.ctx.clone())
            .add_key(Estate::authed(
                &caller,
                AddKeyRequest {
                    principal_id: "rickard".into(),
                    openssh_public_key: laptop,
                    comment: "laptop".into(),
                    attestation: None,
                    expires_at_unix_ms: None,
                },
            ))
            .await
            .expect("his own SSH key, added as him");
        assert!(
            estate
                .plane
                .list_keys(Some("rickard"))
                .expect("keys")
                .into_iter()
                .any(|k| k.comment == "laptop"),
            "gunnar holds his laptop key on rickard"
        );
    }
}

/// **Once he holds a passkey the bootstrap is spent**: the token registers
/// nothing more, and a later claim with the same hash records nothing.
#[tokio::test]
async fn the_bootstrap_is_spent_by_the_first_passkey() {
    let estate = Estate::new(true);
    estate.claim(&hash_of(BREAK_GLASS)).await.expect("claimed");
    estate
        .register(&mut SoftPasskey::new(0x37), BREAK_GLASS)
        .expect("the first passkey");
    let copied = estate
        .register(&mut SoftPasskey::new(0x38), BREAK_GLASS)
        .expect_err("the token is spent");
    assert_eq!(
        copied.code,
        nornir_identity::Code::InviteNotOpen,
        "{copied:?}"
    );
    let again = estate
        .claim(&hash_of(BREAK_GLASS))
        .await
        .expect("a later feed's claim");
    assert!(
        !again.bootstrap_invite,
        "no second bootstrap once he holds a passkey"
    );
    assert!(
        estate.plane.first_invite().expect("read").is_none(),
        "nothing pending at the plane"
    );
}

/// The token itself where its hash belongs is refused by name, before
/// anything is claimed or recorded.
#[tokio::test]
async fn a_token_in_place_of_its_hash_is_refused_and_nothing_is_claimed() {
    let estate = Estate::new(false);
    let refused = estate
        .claim(BREAK_GLASS)
        .await
        .expect_err("never the token");
    assert_eq!(refused.code(), Code::InvalidArgument, "{refused:?}");
    assert!(
        refused.message().contains("never the token itself"),
        "{}",
        refused.message()
    );
    assert!(
        estate.plane.principal("rickard").expect("asked").is_none(),
        "nothing was claimed"
    );
}

/// ★ **RA5-14: gunnar says whether the break-glass token is still the way in**
/// (`Bootstrap.Status.break_glass_open`), so a console decides by gunnar's
/// state and not by a mark in its own store: open while no server admin holds
/// a passkey — with the first invite pending, or none fed at all (the token
/// still opens the console to fix that) — and spent once the operator's
/// passkey exists, whichever console registered it.
#[tokio::test]
async fn bootstrap_status_says_whether_the_break_glass_token_is_spent() {
    use gunnar_proto::bootstrap_server::Bootstrap as _;
    let estate = Estate::new(false);
    let ask = || async {
        gunnar_grpc::BootstrapService::new(estate.ctx.clone())
            .status(Request::new(BootstrapStatusRequest {}))
            .await
            .expect("Status answers")
            .into_inner()
            .break_glass_open
    };
    assert_eq!(
        ask().await,
        Some(true),
        "no admin holds a passkey: the token is the way in"
    );
    estate
        .claim(&hash_of(BREAK_GLASS))
        .await
        .expect("the installer's claim");
    assert_eq!(ask().await, Some(true), "the first invite is pending");
    estate
        .register(&mut SoftPasskey::new(0x37), BREAK_GLASS)
        .expect("the first admin passkey");
    assert_eq!(
        ask().await,
        Some(false),
        "the operator holds a passkey: the bootstrap is spent"
    );
}