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

nordisk/gunnar

crates/gunnar-server/tests/setting_change_audited_rpc.rs

//! ★ RA2-9 / RA4-8 **through the real entry point**: `Admin.SetSetting` as a box serves it,
//! the real gRPC router with its gate in front, over the real `CoreControlPlane`, with the
//! ledger `serve.rs` installs (`grpc_gate::activity_ledger` over the hash-chained trail).
//! `setting_change_audited.rs` calls `AdminService` with a `Caller` put in by hand; here every
//! call carries a signed key the gate must verify.
//!
//! * the server admin's change writes its change record (`SETTING <name>: old -> new`) into
//!   the hash-chained trail, and the chain verifies;
//! * a person without the server's `Admin` and a call with no credential are refused, and no
//!   change record is written for them, nor any change applied;
//! * a trail that can no longer be appended to refuses the change by name
//!   (`audit.unavailable`) and applies nothing.

use std::net::SocketAddr;
use std::sync::Arc;
use std::time::{Duration, Instant};

use gunnar_core::ssh_key::PrivateKey;
use gunnar_core::{AuditChain, Principal, PrincipalKind};
use gunnar_grpc::client_signer::{authed, plain_channel, ControlChannel};
use gunnar_grpc::{ControlPlane, EventBus, ServerConfig, ServiceContext};
use gunnar_proto::admin_client::AdminClient;
use gunnar_proto::{GetSettingRequest, SetSettingRequest};
use gunnar_server::control_plane::CoreControlPlane;
use gunnar_server::stores::ServerStores;
use tonic::{Code, Request};

fn free_addr() -> SocketAddr {
    let l = std::net::TcpListener::bind("127.0.0.1:0").expect("bind");
    l.local_addr().expect("addr")
}

fn mint() -> (PrivateKey, String) {
    let pem = gunnar_core::mint_ed25519().expect("mint");
    let k = PrivateKey::from_openssh(std::str::from_utf8(&pem).unwrap()).expect("parse");
    let line = k.public_key().to_openssh().expect("line");
    (k, line)
}

/// One box: the real router, the server's own ledger over the chain at `audit/chain.jsonl`,
/// its first admin (`admin`) and a person with no server grant (`bob`).
struct Rig {
    dir: tempfile::TempDir,
    rt: tokio::runtime::Runtime,
    plane: Arc<CoreControlPlane>,
    chain: Arc<AuditChain>,
    at: String,
    admin: PrivateKey,
    bob: PrivateKey,
    stop: Option<tokio::sync::oneshot::Sender<()>>,
}

impl Drop for Rig {
    fn drop(&mut self) {
        if let Some(stop) = self.stop.take() {
            let _ = stop.send(());
        }
    }
}

impl Rig {
    fn start() -> Rig {
        let dir = tempfile::tempdir().unwrap();
        let stores = Arc::new(ServerStores::open(dir.path()).expect("stores"));
        let (admin, admin_line) = mint();
        stores
            .auth()
            .bootstrap_admin(&admin_line)
            .expect("the first admin");
        stores
            .auth()
            .add_principal(Principal::new("bob", "bob", PrincipalKind::User).expect("legal id"))
            .expect("principal");
        let (bob, bob_line) = mint();
        stores
            .auth()
            .register_key("bob", &bob_line, None)
            .expect("bob's SSH key");
        let plane = Arc::new(CoreControlPlane::new(Arc::clone(&stores)));
        let control: Arc<dyn ControlPlane> = plane.clone();
        let chain = Arc::new(
            AuditChain::open(dir.path().join("audit").join("chain.jsonl")).expect("the chain"),
        );
        // What serve.rs installs: the activity stamp over the hash-chained trail.
        let ledger = gunnar_server::grpc_gate::activity_ledger(
            Some(gunnar_server::grpc_gate::control_audit_ledger(Arc::clone(
                &chain,
            ))),
            Arc::clone(stores.activity()),
        );
        let ctx = ServiceContext::new(
            control,
            Arc::new(EventBus::new(16)),
            ServerConfig::default(),
        )
        .with_audit_ledger(ledger);
        let rt = tokio::runtime::Builder::new_multi_thread()
            .worker_threads(2)
            .enable_all()
            .build()
            .expect("rt");
        let addr = free_addr();
        let (tx, rx) = tokio::sync::oneshot::channel::<()>();
        rt.spawn(async move {
            let _ = gunnar_grpc::router(ctx, || 0)
                .serve_with_shutdown(addr, async {
                    let _ = rx.await;
                })
                .await;
        });
        let t0 = Instant::now();
        while std::net::TcpStream::connect(addr).is_err() {
            assert!(t0.elapsed() < Duration::from_secs(10), "the box listens");
            std::thread::sleep(Duration::from_millis(25));
        }
        Rig {
            dir,
            rt,
            plane,
            chain,
            at: addr.to_string(),
            admin,
            bob,
            stop: Some(tx),
        }
    }

    fn admin_client(&self) -> AdminClient<ControlChannel> {
        AdminClient::new(plain_channel(&self.at).expect("a channel"))
    }

    /// The change records in the trail: (principal, reason).
    fn changes(&self) -> Vec<(String, String)> {
        self.chain
            .entries()
            .expect("the chain reads")
            .into_iter()
            .filter(|e| e.method == "Admin.SetSetting" && e.reason.starts_with("SETTING "))
            .map(|e| (e.principal, e.reason))
            .collect()
    }
}

/// ★ **A setting change through the router writes its audit row**; refusals write none and
/// change nothing.
#[test]
fn a_setting_change_through_the_router_writes_its_audit_row() {
    let r = Rig::start();
    r.rt.block_on(async {
        let mut admin = r.admin_client();
        let set = |value: &[u8]| SetSettingRequest {
            name: "console.a-switch".into(),
            value: value.to_vec(),
        };

        // No credential, and a person without the server's Admin: refused, nothing written.
        let anon = admin
            .set_setting(Request::new(set(b"on")))
            .await
            .unwrap_err();
        assert_eq!(anon.code(), Code::Unauthenticated, "{anon:?}");
        let refused = admin
            .set_setting(authed(&r.bob, set(b"on")))
            .await
            .unwrap_err();
        assert_eq!(refused.code(), Code::PermissionDenied, "{refused:?}");
        assert_eq!(
            r.plane.setting("console.a-switch").unwrap(),
            None,
            "a refusal applied a change"
        );
        assert!(
            r.changes().is_empty(),
            "a refused caller wrote a change record: {:?}",
            r.changes()
        );

        // The admin's two changes: each writes its record, old -> new.
        admin
            .set_setting(authed(&r.admin, set(b"on")))
            .await
            .expect("Admin.SetSetting through the gate");
        let previous = admin
            .set_setting(authed(&r.admin, set(b"off")))
            .await
            .expect("the second change")
            .into_inner();
        assert!(previous.had && previous.previous == b"on", "{previous:?}");
        let read = admin
            .get_setting(authed(
                &r.admin,
                GetSettingRequest {
                    name: "console.a-switch".into(),
                },
            ))
            .await
            .expect("Admin.GetSetting")
            .into_inner();
        assert!(read.present && read.value == b"off", "{read:?}");
    });
    let changes = r.changes();
    let admin_id = changes
        .first()
        .map(|(who, _)| who.clone())
        .unwrap_or_default();
    assert!(!admin_id.is_empty() && admin_id != "bob", "{changes:?}");
    assert_eq!(
        changes,
        [
            (
                admin_id.clone(),
                "SETTING console.a-switch: (unset) -> \"on\"".to_owned()
            ),
            (
                admin_id,
                "SETTING console.a-switch: \"on\" -> \"off\"".to_owned()
            ),
        ],
        "a change record per change, in the hash-chained trail"
    );
    let entries = r.chain.entries().unwrap();
    assert!(
        entries.iter().any(|e| e.method == "Admin.SetSetting"
            && e.principal == "bob"
            && e.decision == "deny"),
        "the gate's refusal of bob is in the trail: {entries:#?}"
    );
    assert_eq!(r.chain.verify().expect("the chain verifies"), entries.len());
}

/// ★ **RA4-8 through the router: a change the trail cannot hold is refused by name and not
/// applied.**
#[test]
fn a_setting_change_the_trail_cannot_hold_is_refused_through_the_router() {
    let r = Rig::start();
    // The chain's file goes; a directory takes its name, so no append can land.
    let path = r.dir.path().join("audit").join("chain.jsonl");
    let _ = std::fs::remove_file(&path);
    std::fs::create_dir_all(&path).unwrap();
    r.rt.block_on(async {
        let refused = r
            .admin_client()
            .set_setting(authed(
                &r.admin,
                SetSettingRequest {
                    name: "console.a-switch".into(),
                    value: b"on".to_vec(),
                },
            ))
            .await
            .expect_err("a change the trail cannot hold is refused");
        assert_eq!(refused.code(), Code::Unavailable, "{refused:?}");
        assert!(
            refused.message().starts_with("audit.unavailable"),
            "refused by name: {refused:?}"
        );
    });
    assert_eq!(
        r.plane.setting("console.a-switch").unwrap(),
        None,
        "and not applied"
    );
}