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