crates/gunnar-core/src/membership.rs
//! **Group membership as signed, append-only evidence** (punkt 9, the signing
//! half).
//!
//! [`crate::auth`] owns *who is in a group right now*: the `memberships` map in
//! the identity table, under the control store's compare-and-swap, is the
//! authority the SSH listener resolves a grant through, and it must be — a
//! membership check on the connection path is one shard probe, not a walk of a
//! log.
//!
//! This module owns the other half: **who changed that membership, and when**,
//! as a signature. A row saying `alice is in admins` is the server's word; an
//! `sshsig` by the acting admin over `add alice to admins at T` is the admin's,
//! and a third party can check it later with `ssh-keygen -Y verify` and the
//! admin's registered public key — no shared secret, no trust in the server
//! that stored it. That is the whole of what gunnar promises about access: not
//! "trust our database", but "here is the evidence, check it yourself".
//!
//! It is the exact shape [`crate::forge`] gives a review approval, and it signs
//! through the same one signer ([`crate::sshsig`], one implementation). The difference is
//! only what is bound and under which namespace:
//!
//! # Events, not state
//!
//! What is signed is *what happened* — `add` or `remove`, naming the group, the
//! member, the actor and the time — and the membership at any instant is
//! [`fold`]ed from the events rather than read from a mutable row. Remove a
//! member and re-add them and the log holds **two** signed events, both
//! standing: "who was in this group at time T" is answered from the log, and
//! the answer to "was alice ever removed" is a fact nobody can quietly erase by
//! writing the row back. A mutable row cannot answer either.
//!
//! # Its own namespace
//!
//! [`MEMBERSHIP_NAMESPACE`] is distinct from every other, so a membership
//! signature can never be replayed as a forge approval, a
//! tree head or a `git` commit — [`crate::sshsig::verify`] binds the namespace
//! and refuses a blob made under any other. The red for that is
//! `a_membership_signature_does_not_verify_as_an_approval` below.
/// The `sshsig` namespace a membership event is signed under.
///
/// Its own, like every signed log in this crate. A signature made here does not
/// verify under [`crate::forge::FORGE_NAMESPACE`], or under `git` — which is what
/// stops "admin added alice to the release group" from being cut out and
/// pasted in as "admin approved commit abc".
pub const MEMBERSHIP_NAMESPACE: &str = "gunnar-membership";
/// What happened to a membership.
///
/// A closed set: an open `kind: String` would let a client write an event no
/// reader can fold, and a log whose state depends on who reads it is not
/// evidence.
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum MembershipAction {
/// The member was added to the group.
Add,
/// The member was removed from the group.
Remove,
}
impl MembershipAction {
/// The word bound into the signature, and the word a reader sees.
pub fn tag(&self) -> &'static str {
match self {
MembershipAction::Add => "add",
MembershipAction::Remove => "remove",
}
}
}
/// One change to one group's membership, before it is signed.
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
#[serde(deny_unknown_fields)]
pub struct MembershipEvent {
/// The group whose membership changed — an ACL principal of kind
/// [`Group`](crate::PrincipalKind::Group).
pub group: String,
/// The principal added to or removed from it (another group is allowed —
/// that is nesting).
pub member: String,
/// `add` or `remove`.
pub action: MembershipAction,
/// **Who did it** — the ACL principal id of the acting admin. Bound into the
/// signature, and checked at the seam against the principal the credential
/// resolved to, so a membership change cannot be filed in another admin's
/// name.
pub actor: String,
/// When.
pub at_unix_ms: i64,
}
impl MembershipEvent {
/// The exact bytes that are signed.
///
/// Line-oriented and fixed-order, and every value length-prefixed, for the
/// reason [`crate::forge::ForgeEvent::signing_bytes`] is: JSON has no
/// canonical form, so signing a serialisation would make the signature
/// depend on the serialiser, and a bare newline in a value could otherwise
/// be made to look like a second field. A principal id is constrained
/// elsewhere, but the log must not rest a signature's meaning on that
/// holding.
pub fn signing_bytes(&self) -> Vec<u8> {
let mut out = String::new();
out.push_str("gunnar-membership-v1\n");
out.push_str(&format!("action {}\n", self.action.tag()));
out.push_str(&format!("at {}\n", self.at_unix_ms));
let mut field = |name: &str, value: &str| {
out.push_str(&format!("{name} {} {value}\n", value.len()));
};
field("group", &self.group);
field("member", &self.member);
field("actor", &self.actor);
out.into_bytes()
}
}
/// A [`MembershipEvent`] with the signature that makes it evidence.
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
#[serde(deny_unknown_fields)]
pub struct SignedMembershipEvent {
/// The event the signature is over.
#[serde(flatten)]
pub event: MembershipEvent,
/// `SHA256:…` of the actor's key.
pub key_fingerprint: String,
/// The `sshsig`, PEM-armoured.
pub signature: String,
}
/// Sign a membership event with the actor's own key.
///
/// Through the one signer ([`crate::sshsig::sign`]) so the namespace is bound
/// into the signature exactly as it is for a forge approval (one implementation).
pub fn sign_event(
key: &ssh_key::PrivateKey,
event: MembershipEvent,
) -> Result<SignedMembershipEvent, ssh_key::Error> {
let (key_fingerprint, signature) =
crate::sshsig::sign(key, MEMBERSHIP_NAMESPACE, &event.signing_bytes())?;
Ok(SignedMembershipEvent {
key_fingerprint,
signature,
event,
})
}
/// Verify a signed membership event against a public key.
///
/// The in-process twin of `ssh-keygen -Y verify -n gunnar-membership`: the same
/// namespace binding, the same refusal of a signature made under any other.
pub fn verify_event(
signed: &SignedMembershipEvent,
key: &ssh_key::PublicKey,
) -> Result<(), crate::sshsig::EventProblem> {
crate::sshsig::verify(
key,
MEMBERSHIP_NAMESPACE,
&signed.event.signing_bytes(),
&signed.key_fingerprint,
&signed.signature,
)
}
/// **Who is a member of one group right now**, folded from its events.
///
/// Direct members only — nesting and the transitive closure are
/// [`crate::auth::AuthTable`]'s to resolve, because that is the question the
/// connection path asks and it must not be a walk of this log. What this
/// answers is the audit question: *who is in `group`, and by whose hand*.
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct Membership {
/// The group this is the membership of.
pub group: String,
/// Current direct members → the provenance of the add that put them there:
/// `(actor, at_unix_ms)`. A later `remove` takes the entry out; a later
/// `add` replaces the provenance, so the map always names the standing
/// reason each member is in the group.
pub members: std::collections::BTreeMap<String, (String, i64)>,
}
impl Membership {
/// Whether `member` is a direct member of the group right now.
pub fn contains(&self, member: &str) -> bool {
self.members.contains_key(member)
}
}
/// Fold one group's membership events, **up to and including** `as_of_unix_ms`.
///
/// Pass `i64::MAX` for "now". Events for other groups are ignored, so a caller
/// may hand in the whole server's log; events strictly after the cutoff are
/// ignored, which is what makes *"who was a member at time T"* answerable from
/// the signed log rather than from a mutable row that only remembers the
/// present.
///
/// Ordering is by the event's own `at_unix_ms`, then by position in the slice,
/// so a stable append order breaks ties the way the log was written.
pub fn fold_at(group: &str, as_of_unix_ms: i64, events: &[MembershipEvent]) -> Membership {
let mut ordered: Vec<(usize, &MembershipEvent)> = events
.iter()
.enumerate()
.filter(|(_, e)| e.group == group && e.at_unix_ms <= as_of_unix_ms)
.collect();
ordered.sort_by_key(|(i, e)| (e.at_unix_ms, *i));
let mut state = Membership {
group: group.to_owned(),
..Membership::default()
};
for (_, event) in ordered {
match event.action {
MembershipAction::Add => {
state.members.insert(
event.member.clone(),
(event.actor.clone(), event.at_unix_ms),
);
}
MembershipAction::Remove => {
state.members.remove(&event.member);
}
}
}
state
}
/// The membership of `group` **right now** — [`fold_at`] with no cutoff.
pub fn fold(group: &str, events: &[MembershipEvent]) -> Membership {
fold_at(group, i64::MAX, events)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::sshsig::EventProblem;
fn key() -> ssh_key::PrivateKey {
ssh_key::PrivateKey::from_openssh(crate::mint_ed25519().expect("mint")).expect("parse")
}
fn event(
group: &str,
member: &str,
action: MembershipAction,
actor: &str,
at: i64,
) -> MembershipEvent {
MembershipEvent {
group: group.into(),
member: member.into(),
action,
actor: actor.into(),
at_unix_ms: at,
}
}
/// **A membership change is evidence**: signed by the acting admin, over the
/// group, the member, the action, the actor and the time — and no field can
/// be moved under the signature.
#[test]
fn a_membership_change_verifies_and_no_field_can_be_moved_under_it() {
let admin = key();
let mallory = key();
let signed = sign_event(
&admin,
event(
"admins",
"alice",
MembershipAction::Add,
"root",
1_700_000_000_000,
),
)
.expect("sign");
// Green: the actor's own key verifies it.
assert_eq!(verify_event(&signed, admin.public_key()), Ok(()));
// Red: a DIFFERENT key must be refused — a membership event signed by
// the wrong key is not evidence about the actor it names.
assert!(
matches!(
verify_event(&signed, mallory.public_key()),
Err(EventProblem::WrongKey { .. })
),
"a membership event verified against a key that did not sign it"
);
// Red: every field is bound. Move any one and the signature must fail.
for mutate in [
|e: &mut MembershipEvent| e.action = MembershipAction::Remove,
|e: &mut MembershipEvent| e.member = "mallory".into(),
|e: &mut MembershipEvent| e.group = "everyone".into(),
|e: &mut MembershipEvent| e.actor = "mallory".into(),
|e: &mut MembershipEvent| e.at_unix_ms += 1,
] {
let mut tampered = signed.clone();
mutate(&mut tampered.event);
assert_eq!(
verify_event(&tampered, admin.public_key()),
Err(EventProblem::NotVerified),
"a field moved under the signature and still verified: {:?}",
tampered.event
);
}
}
/// **Namespace isolation.** A membership signature must never verify as a
/// forge approval, and a `git` commit signature must never verify as a
/// membership event — the namespace is bound into what is signed.
#[test]
fn a_membership_signature_does_not_verify_as_an_approval() {
let admin = key();
let ev = event("admins", "alice", MembershipAction::Add, "root", 1);
// The genuine membership signature, re-labelled as a forge event with
// the same bytes: it is `Malformed` under the forge namespace, because
// the blob carries `gunnar-membership` and the forge verifier binds
// `gunnar-forge`.
let signed = sign_event(&admin, ev.clone()).expect("sign");
let smuggled = crate::forge::SignedEvent {
event: crate::forge::ForgeEvent {
repo: "team/repo".into(),
pull: 1,
actor: "root".into(),
at_unix_ms: 1,
event: crate::forge::Event::Approved {
commit: "a".repeat(40),
},
},
key_fingerprint: signed.key_fingerprint.clone(),
signature: signed.signature.clone(),
attested: None,
};
assert!(
matches!(
crate::forge::verify_event(&smuggled, admin.public_key()),
Err(EventProblem::Malformed(_))
),
"a membership signature was accepted as a forge approval — namespaces did not isolate"
);
// And the other direction: a signature made under `git` (a commit) is
// Malformed as a membership event.
let elsewhere = admin
.sign("git", ssh_key::HashAlg::Sha512, &ev.signing_bytes())
.expect("sign under git");
let as_membership = SignedMembershipEvent {
key_fingerprint: crate::sshsig::fingerprint(admin.public_key()),
signature: elsewhere.to_pem(ssh_key::LineEnding::LF).expect("pem"),
event: ev,
};
assert!(
matches!(
verify_event(&as_membership, admin.public_key()),
Err(EventProblem::Malformed(_))
),
"a git commit signature was accepted as a membership event"
);
}
/// **A value cannot smuggle a field past the signature.** Each is
/// length-prefixed, so a member id that reads `alice\nactor mallory` is
/// text, not two fields.
#[test]
fn a_value_cannot_smuggle_a_field() {
let honest = event("admins", "alice", MembershipAction::Add, "root", 1);
let sneaky = event(
"admins",
"alice\nactor mallory",
MembershipAction::Add,
"root",
1,
);
assert_ne!(honest.signing_bytes(), sneaky.signing_bytes());
let text = String::from_utf8(sneaky.signing_bytes()).expect("utf-8");
assert!(
text.contains("member 19 alice\nactor mallory"),
"the member id must be length-prefixed: {text}"
);
}
/// **The history is the evidence.** Remove then re-add a member and the log
/// holds two standing signed events; the membership at any instant is
/// answered by folding to that instant, not by reading a row that only
/// knows the present.
#[test]
fn remove_then_readd_leaves_two_events_and_time_is_answerable() {
let events = vec![
event("admins", "alice", MembershipAction::Add, "root", 100),
event("admins", "bob", MembershipAction::Add, "root", 150),
event("admins", "alice", MembershipAction::Remove, "root", 200),
event("admins", "alice", MembershipAction::Add, "carol", 300),
];
// At T=120, only alice is in.
let at_120 = fold_at("admins", 120, &events);
assert!(at_120.contains("alice"));
assert!(!at_120.contains("bob"));
// At T=250, alice has been removed; bob remains.
let at_250 = fold_at("admins", 250, &events);
assert!(
!at_250.contains("alice"),
"the removal at T=200 was not honoured"
);
assert!(at_250.contains("bob"));
// Now: alice is back, and the provenance names the RE-ADD (carol at
// 300), not the original add — the mutable row could not tell these
// apart, the log does.
let now = fold("admins", &events);
assert!(now.contains("alice"));
assert_eq!(now.members.get("alice"), Some(&("carol".to_string(), 300)));
assert_eq!(now.members.get("bob"), Some(&("root".to_string(), 150)));
}
/// Another group's events never leak into this fold.
#[test]
fn a_fold_is_scoped_to_its_group() {
let events = vec![
event("admins", "alice", MembershipAction::Add, "root", 1),
event("release", "alice", MembershipAction::Add, "root", 2),
];
let release = fold("release", &events);
assert!(release.contains("alice"));
assert_eq!(
release.members.len(),
1,
"an event for another group leaked in"
);
}
}