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

nordisk/gunnar

crates/gunnar-cli/tests/membership_signed_log.rs

#![cfg(all(feature = "server", feature = "control"))]

//! **Group membership is signed, namespaced, verifiable evidence** (punkt 9,
//! the signing half).
//!
//! `gunnar-core`'s `membership` unit tests prove the arithmetic: a change
//! verifies against the actor's key, the wrong key is refused, a signature made
//! under another namespace does not verify, and the history answers "who was a
//! member at time T". This proves the same properties on the **real emitted
//! record** — the line the server actually wrote to the membership log through
//! [`CoreControlPlane`] — because a green about a constructed event says nothing
//! about the one that ships.
//!
//! # Asserted on applied output, red first
//!
//! * The event the server appended **verifies against the actor's registered
//!   key** — and a *different* key is refused on that same emitted event
//!   (`WrongKey`), and the emitted signature does **not** verify as a forge
//!   approval (`Malformed`, cross-namespace). Both reds are driven on the real
//!   record, not a fixture.
//! * A membership change filed in **another principal's name** is refused by the
//!   service before anything is written (`permission_denied`), and the log stays
//!   empty — a service that errored and appended anyway looks identical to the
//!   caller.
//! * **The history is the evidence.** Remove a member and re-add them and the
//!   log holds two more standing events; folding to a past instant answers what
//!   the membership was then, which a mutable row cannot.

mod common;

use std::sync::Arc;

use common::Harness;
use gunnar_cli::server::CoreControlPlane;
use gunnar_core::membership::{
    fold_at, verify_event, MembershipAction, MembershipEvent, SignedMembershipEvent,
};
use gunnar_core::{mint_ed25519, ssh_key, Grant, HashKind, Level, Principal, PrincipalKind};
use gunnar_grpc::{AuthMode, Caller, ControlPlane, EventBus, ServerConfig, ServiceContext};
use gunnar_proto::access_server::Access as _;
use gunnar_proto::{AddGroupMemberRequest, MembershipAction as WireAction};
use tonic::Request;

const NAMESPACE: &str = "team";
const NAME: &str = "proj";

/// Mint a key, add its principal to the identity table, register the key. Returns the
/// private key so a test can sign as this principal.
fn enrol(harness: &Harness, id: &str, kind: PrincipalKind) -> ssh_key::PrivateKey {
    let key = ssh_key::PrivateKey::from_openssh(mint_ed25519().expect("mint")).expect("parse");
    harness
        .stores
        .auth()
        .add_principal(Principal::new(id, id, kind).expect("valid principal"))
        .expect("add principal");
    if kind != PrincipalKind::Group {
        harness
            .stores
            .auth()
            .register_key(id, &key.public_key().to_openssh().expect("openssh"), None)
            .expect("register key");
    }
    key
}

/// The wire form a client sends — re-encoded by hand, not through the server's
/// own converter, so what the test hands the RPC is exactly what a client would
/// (a shared helper could hide a field that never survives the round trip).
fn wire(signed: &SignedMembershipEvent) -> gunnar_proto::SignedMembershipEvent {
    let action = match signed.event.action {
        MembershipAction::Add => WireAction::Add,
        MembershipAction::Remove => WireAction::Remove,
    };
    gunnar_proto::SignedMembershipEvent {
        event: Some(gunnar_proto::MembershipEvent {
            group: signed.event.group.clone(),
            member: signed.event.member.clone(),
            action: action as i32,
            actor: signed.event.actor.clone(),
            at_unix_ms: signed.event.at_unix_ms,
        }),
        key_fingerprint: signed.key_fingerprint.clone(),
        signature: signed.signature.clone(),
    }
}

fn sign(
    key: &ssh_key::PrivateKey,
    group: &str,
    member: &str,
    action: MembershipAction,
    actor: &str,
    at: i64,
) -> SignedMembershipEvent {
    gunnar_core::membership::sign_event(
        key,
        MembershipEvent {
            group: group.into(),
            member: member.into(),
            action,
            actor: actor.into(),
            at_unix_ms: at,
        },
    )
    .expect("sign")
}

/// Read the membership log back as the decoded, signed records the server wrote.
fn log_events(harness: &Harness) -> Vec<SignedMembershipEvent> {
    harness
        .stores
        .membership_log()
        .lines_of()
        .expect("read the membership log")
        .iter()
        .map(|line| serde_json::from_str(line).expect("decode a membership event"))
        .collect()
}

fn now<F: std::future::Future>(future: F) -> F::Output {
    tokio::runtime::Builder::new_current_thread()
        .enable_all()
        .build()
        .expect("runtime")
        .block_on(future)
}

fn as_caller<T>(message: T, principal: &str) -> Request<T> {
    let mut request = Request::new(message);
    request.extensions_mut().insert(Caller {
        principal_id: principal.to_owned(),
        fingerprint: String::new(),
        machine: false,
        authenticated: true,
    });
    request
}

fn access(plane: &Arc<CoreControlPlane>) -> gunnar_grpc::AccessService {
    let control: Arc<dyn ControlPlane> = plane.clone();
    gunnar_grpc::AccessService::new(ServiceContext::new(
        control,
        Arc::new(EventBus::new(16)),
        ServerConfig {
            auth: AuthMode::Required,
            ..ServerConfig::default()
        },
    ))
}

/// **The emitted record is evidence.** Add a member through the real plane; the
/// event it appended verifies against the actor's registered key, is refused
/// against any other key, and does not verify as a forge approval.
#[test]
fn the_emitted_membership_event_verifies_and_is_namespaced() {
    let harness = Harness::start(NAMESPACE, NAME, HashKind::Sha1);
    let root = enrol(&harness, "root", PrincipalKind::User);
    let _admins = enrol(&harness, "admins", PrincipalKind::Group);
    let _alice = enrol(&harness, "alice", PrincipalKind::User);
    let other = ssh_key::PrivateKey::from_openssh(mint_ed25519().unwrap()).unwrap();

    let plane = Arc::new(CoreControlPlane::new(Arc::clone(&harness.stores)));
    let signed = sign(
        &root,
        "admins",
        "alice",
        MembershipAction::Add,
        "root",
        1_000,
    );
    let index = plane
        .add_group_member("admins", "alice", wire(&signed))
        .expect("the signed add is accepted");
    assert_eq!(index, 0, "the first event lands at index 0");

    // Applied to the live table, not merely logged.
    assert_eq!(
        harness.stores.auth().members_of("admins").expect("members"),
        vec!["alice".to_string()],
        "the add did not reach the live membership table"
    );

    // The record on disk, and it is the evidence.
    let events = log_events(&harness);
    assert_eq!(events.len(), 1, "exactly one event was written");
    let emitted = &events[0];
    assert_eq!(emitted.event.actor, "root");
    assert_eq!(emitted.event.action, MembershipAction::Add);

    // GREEN: the actor's own registered key verifies the emitted event.
    assert_eq!(
        verify_event(emitted, root.public_key()),
        Ok(()),
        "the emitted membership event did not verify against the actor's registered key"
    );

    // RED: a different key is refused on the very same emitted event.
    assert!(
        matches!(
            verify_event(emitted, other.public_key()),
            Err(gunnar_core::EventProblem::WrongKey { .. })
        ),
        "the emitted event verified against a key that did not sign it"
    );

    // RED (namespace isolation): the emitted signature, re-labelled as a forge
    // approval, does not verify — it was made under `gunnar-membership`.
    let smuggled = gunnar_core::forge::SignedEvent {
        event: gunnar_core::forge::ForgeEvent {
            repo: "team/proj".into(),
            pull: 1,
            actor: "root".into(),
            at_unix_ms: 1_000,
            event: gunnar_core::forge::Event::Approved {
                commit: "a".repeat(40),
            },
        },
        key_fingerprint: emitted.key_fingerprint.clone(),
        signature: emitted.signature.clone(),
        attested: None,
    };
    assert!(
        matches!(
            gunnar_core::forge::verify_event(&smuggled, root.public_key()),
            Err(gunnar_core::EventProblem::Malformed(_))
        ),
        "a membership signature was accepted as a forge approval — the namespaces did not isolate"
    );
}

/// **A membership change may not be filed in another principal's name.** The
/// service refuses an event whose actor is not the caller, and nothing is
/// written — driven red before the honest add is accepted.
#[test]
fn the_service_refuses_an_event_filed_in_anothers_name() {
    let harness = Harness::start(NAMESPACE, NAME, HashKind::Sha1);
    let root = enrol(&harness, "root", PrincipalKind::User);
    let _mallory = enrol(&harness, "mallory", PrincipalKind::User);
    let _admins = enrol(&harness, "admins", PrincipalKind::Group);
    let _alice = enrol(&harness, "alice", PrincipalKind::User);
    // root is the admin the gate lets through.
    harness
        .stores
        .auth()
        .grant(Grant::server("root", Level::Admin))
        .expect("grant root server admin");

    let plane = Arc::new(CoreControlPlane::new(Arc::clone(&harness.stores)));
    let service = access(&plane);

    // RED: root signs an add attributed to "mallory" and files it as root.
    let misattributed = sign(
        &root,
        "admins",
        "alice",
        MembershipAction::Add,
        "mallory",
        10,
    );
    let refused = now(service.add_group_member(as_caller(
        AddGroupMemberRequest {
            group_id: "admins".into(),
            member_id: "alice".into(),
            signed: Some(wire(&misattributed)),
        },
        "root",
    )))
    .expect_err("an event filed in another principal's name was accepted");
    assert_eq!(
        refused.code(),
        tonic::Code::PermissionDenied,
        "wrong refusal: {refused:?}"
    );
    assert!(
        log_events(&harness).is_empty(),
        "the refused change was still appended to the log"
    );

    // GREEN: root signs and files its own, and it lands.
    let honest = sign(&root, "admins", "alice", MembershipAction::Add, "root", 20);
    now(service.add_group_member(as_caller(
        AddGroupMemberRequest {
            group_id: "admins".into(),
            member_id: "alice".into(),
            signed: Some(wire(&honest)),
        },
        "root",
    )))
    .expect("the honest add is accepted");
    let events = log_events(&harness);
    assert_eq!(events.len(), 1, "exactly the honest event is in the log");
    assert_eq!(verify_event(&events[0], root.public_key()), Ok(()));
}

/// **The history is the evidence.** Remove then re-add a member; the log holds
/// three standing signed events, and folding to a past instant answers what the
/// membership was then — a mutable row only knows the present.
#[test]
fn remove_then_readd_leaves_a_signed_history() {
    let harness = Harness::start(NAMESPACE, NAME, HashKind::Sha1);
    let root = enrol(&harness, "root", PrincipalKind::User);
    let _admins = enrol(&harness, "admins", PrincipalKind::Group);
    let _alice = enrol(&harness, "alice", PrincipalKind::User);

    let plane = Arc::new(CoreControlPlane::new(Arc::clone(&harness.stores)));

    plane
        .add_group_member(
            "admins",
            "alice",
            wire(&sign(
                &root,
                "admins",
                "alice",
                MembershipAction::Add,
                "root",
                100,
            )),
        )
        .expect("add");
    let (was_member, _) = plane
        .remove_group_member(
            "admins",
            "alice",
            wire(&sign(
                &root,
                "admins",
                "alice",
                MembershipAction::Remove,
                "root",
                200,
            )),
        )
        .expect("remove");
    assert!(was_member, "alice was a member before the remove");
    plane
        .add_group_member(
            "admins",
            "alice",
            wire(&sign(
                &root,
                "admins",
                "alice",
                MembershipAction::Add,
                "root",
                300,
            )),
        )
        .expect("re-add");

    // Three standing events, each verifying — the removal did not erase the add.
    let events = log_events(&harness);
    assert_eq!(
        events.len(),
        3,
        "remove-then-re-add left {} events",
        events.len()
    );
    for ev in &events {
        assert_eq!(
            verify_event(ev, root.public_key()),
            Ok(()),
            "an emitted event did not verify"
        );
    }

    // Time is answerable from the signed log.
    let bare: Vec<MembershipEvent> = events.iter().map(|s| s.event.clone()).collect();
    assert!(
        fold_at("admins", 150, &bare).contains("alice"),
        "alice was a member at T=150"
    );
    assert!(
        !fold_at("admins", 250, &bare).contains("alice"),
        "alice was removed by T=250"
    );
    assert!(
        fold_at("admins", 350, &bare).contains("alice"),
        "alice was re-added by T=350"
    );

    // The live table agrees with the present fold.
    assert_eq!(
        harness.stores.auth().members_of("admins").expect("members"),
        vec!["alice".to_string()],
    );
}