crates/kernel/ents-model/src/member.rs
member.rshistorycomment on this file
| 1 | //! The Member entity: an enrolled public key, and the trust state it carries. |
| 2 | //! |
| 3 | //! Spec coverage: `model.member-identity`, `model.member-revocation`, |
| 4 | //! `model.member-provenance`, `model.member-worker`. |
| 5 | |
| 6 | use facet::Facet; |
| 7 | |
| 8 | /// The stable id naming one member's ref, `refs/meta/member/<id>` |
| 9 | /// (`namespace::member_ref`). |
| 10 | /// |
| 11 | /// A newtype rather than a bare `String` because a member id is forge |
| 12 | /// vocabulary gitoxide has no concept of — unlike a refname or object id, it |
| 13 | /// is not a git primitive, so wrapping it here does not duplicate one. |
| 14 | #[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Facet)] |
| 15 | #[facet(transparent)] |
| 16 | pub struct MemberId(pub String); |
| 17 | |
| 18 | impl MemberId { |
| 19 | /// Build a member id from any string-like value. |
| 20 | /// |
| 21 | /// # Examples |
| 22 | /// |
| 23 | /// ``` |
| 24 | /// use ents_model::MemberId; |
| 25 | /// |
| 26 | /// let id = MemberId::new("jdc"); |
| 27 | /// assert_eq!(id.as_str(), "jdc"); |
| 28 | /// ``` |
| 29 | pub fn new(id: impl Into<String>) -> Self { |
| 30 | Self(id.into()) |
| 31 | } |
| 32 | |
| 33 | /// Borrow the id as a string slice. |
| 34 | #[must_use] |
| 35 | pub fn as_str(&self) -> &str { |
| 36 | &self.0 |
| 37 | } |
| 38 | } |
| 39 | |
| 40 | impl std::fmt::Display for MemberId { |
| 41 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { |
| 42 | f.write_str(&self.0) |
| 43 | } |
| 44 | } |
| 45 | |
| 46 | impl From<String> for MemberId { |
| 47 | fn from(id: String) -> Self { |
| 48 | Self(id) |
| 49 | } |
| 50 | } |
| 51 | |
| 52 | impl From<&str> for MemberId { |
| 53 | fn from(id: &str) -> Self { |
| 54 | Self(id.to_owned()) |
| 55 | } |
| 56 | } |
| 57 | |
| 58 | impl From<&MemberId> for MemberId { |
| 59 | fn from(id: &MemberId) -> Self { |
| 60 | id.clone() |
| 61 | } |
| 62 | } |
| 63 | |
| 64 | /// Whether a member's key currently authorizes new signatures. |
| 65 | /// |
| 66 | /// `model.member-revocation` requires that revoking a member record a state |
| 67 | /// on the entity rather than delete it, and that a signature made before |
| 68 | /// revocation remain verifiable while one made after is rejected. That |
| 69 | /// before/after judgment is made by walking the member ref's own commit |
| 70 | /// history (`meta-ref.namespace`: the commit chain is the audit trail) for |
| 71 | /// the state in force at the signature's time, exactly as a comment's |
| 72 | /// author and timestamp come from the mutation commit rather than a stored |
| 73 | /// field (`model.comment`) — so this type only needs to carry the *current* |
| 74 | /// state, never a validity window. |
| 75 | // @relation(model.member-revocation, scope=file) |
| 76 | #[derive(Debug, Clone, Copy, PartialEq, Eq, Facet)] |
| 77 | #[repr(u8)] |
| 78 | pub enum MemberState { |
| 79 | /// The key authorizes new signatures. |
| 80 | Active, |
| 81 | /// The key does not authorize new signatures made after the commit that |
| 82 | /// set this state; signatures it made earlier remain verifiable. |
| 83 | Revoked, |
| 84 | } |
| 85 | |
| 86 | impl std::fmt::Display for MemberState { |
| 87 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { |
| 88 | f.write_str(match self { |
| 89 | Self::Active => "active", |
| 90 | Self::Revoked => "revoked", |
| 91 | }) |
| 92 | } |
| 93 | } |
| 94 | |
| 95 | /// How a member came to be enrolled. |
| 96 | /// |
| 97 | /// `model.member-provenance` ties authorization for canonical refs to this |
| 98 | /// field: a self-attested member is limited to its own inbox and self-run |
| 99 | /// namespaces (`meta-ref.inbox`) until an admin-registered member promotes |
| 100 | /// it by an ordinary signed mutation of the member's ref. Enforcing that |
| 101 | /// restriction is `ents-gate`'s job (`effect.admin-only`); this type only |
| 102 | /// records which case applies. |
| 103 | // @relation(model.member-provenance, scope=file) |
| 104 | #[derive(Debug, Clone, Copy, PartialEq, Eq, Facet)] |
| 105 | #[repr(u8)] |
| 106 | pub enum Provenance { |
| 107 | /// Enrolled by an admin-registered member mutating the new member's |
| 108 | /// ref directly. |
| 109 | AdminRegistered, |
| 110 | /// Self-attested through a frontend that lets a key enroll itself. |
| 111 | SelfAttested, |
| 112 | } |
| 113 | |
| 114 | /// A public key enrolled into the forge's trust set. |
| 115 | /// |
| 116 | /// `model.member-identity` requires a `Member` to carry both the key |
| 117 | /// itself and its member id — the id being the natural key the refname |
| 118 | /// `refs/meta/member/<id>` (`namespace::member_ref`) binds to |
| 119 | /// (`meta-ref.identity-binding`): the gate recomputes the refname's final |
| 120 | /// segment from this tree field and refuses a mismatch, so the id is a |
| 121 | /// total function of signed content, not a refname the tree merely trusts. |
| 122 | /// Enrollment is the signed commit that writes the entity, not a field on |
| 123 | /// the struct. |
| 124 | /// |
| 125 | /// `model.member-worker` requires that a machine actor (a CI worker or |
| 126 | /// other automated signer) be an ordinary `Member` with no privileged |
| 127 | /// construction path. This type has exactly one constructor |
| 128 | /// ([`Member::new`]) for both cases; nothing here distinguishes a human |
| 129 | /// key from a machine key beyond the [`Provenance`] every member already |
| 130 | /// carries. |
| 131 | /// |
| 132 | /// # Examples |
| 133 | /// |
| 134 | /// ``` |
| 135 | /// use ents_model::{Member, MemberState, Provenance}; |
| 136 | /// |
| 137 | /// // A human member, admin-registered. |
| 138 | /// let human = Member::new("joey", "ssh-ed25519 AAAA... joey", Provenance::AdminRegistered); |
| 139 | /// assert_eq!(human.state, MemberState::Active); |
| 140 | /// |
| 141 | /// // A CI worker's key is enrolled through the exact same constructor — |
| 142 | /// // `model.member-worker` forbids a separate privileged path. |
| 143 | /// let worker = Member::new("ci-worker", "ssh-ed25519 AAAA... ci-worker", Provenance::AdminRegistered); |
| 144 | /// assert_eq!(worker.provenance, Provenance::AdminRegistered); |
| 145 | /// ``` |
| 146 | // @relation(model.member-identity, model.member-worker, meta-ref.identity-binding, meta-ref.typed-tree, model.extensibility, scope=file) |
| 147 | #[derive(Debug, Clone, PartialEq, Eq, Facet)] |
| 148 | pub struct Member { |
| 149 | /// The member's id — the natural key the refname's final segment binds |
| 150 | /// to (`model.member-identity`, `meta-ref.identity-binding`). |
| 151 | pub id: MemberId, |
| 152 | /// The member's public key material, in whatever text form the |
| 153 | /// deployment's signature verification expects (an OpenSSH public key |
| 154 | /// line, an armored PGP key, etc.). `ents-gate` (phase 3) interprets |
| 155 | /// this; `ents-model` treats it as opaque. |
| 156 | pub key: String, |
| 157 | /// Whether the key currently authorizes new signatures. |
| 158 | pub state: MemberState, |
| 159 | /// How the member was enrolled. |
| 160 | pub provenance: Provenance, |
| 161 | } |
| 162 | |
| 163 | impl Member { |
| 164 | /// Enroll a new member, active from the start. |
| 165 | /// |
| 166 | /// This is the sole constructor — used identically for a human member |
| 167 | /// and a machine actor (`model.member-worker`). `id` MUST equal the |
| 168 | /// final segment of the member's refname, the binding the gate |
| 169 | /// recomputes (`meta-ref.identity-binding`). |
| 170 | #[must_use] |
| 171 | pub fn new(id: impl Into<MemberId>, key: impl Into<String>, provenance: Provenance) -> Self { |
| 172 | Self { |
| 173 | id: id.into(), |
| 174 | key: key.into(), |
| 175 | state: MemberState::Active, |
| 176 | provenance, |
| 177 | } |
| 178 | } |
| 179 | |
| 180 | /// Record a revoked state without deleting the entity |
| 181 | /// (`model.member-revocation`). |
| 182 | /// |
| 183 | /// # Examples |
| 184 | /// |
| 185 | /// ``` |
| 186 | /// use ents_model::{Member, MemberState, Provenance}; |
| 187 | /// |
| 188 | /// let mut member = Member::new("jdc", "key", Provenance::AdminRegistered); |
| 189 | /// member.revoke(); |
| 190 | /// assert_eq!(member.state, MemberState::Revoked); |
| 191 | /// ``` |
| 192 | pub fn revoke(&mut self) { |
| 193 | self.state = MemberState::Revoked; |
| 194 | } |
| 195 | |
| 196 | /// Return the key to authorizing new signatures |
| 197 | /// (`model.member-revocation`'s unrevoke case). The record of the |
| 198 | /// revoked period itself lives in the ref's commit history, not in this |
| 199 | /// struct, so unrevoking alters only the current state. |
| 200 | /// |
| 201 | /// # Examples |
| 202 | /// |
| 203 | /// ``` |
| 204 | /// use ents_model::{Member, MemberState, Provenance}; |
| 205 | /// |
| 206 | /// let mut member = Member::new("jdc", "key", Provenance::AdminRegistered); |
| 207 | /// member.revoke(); |
| 208 | /// member.unrevoke(); |
| 209 | /// assert_eq!(member.state, MemberState::Active); |
| 210 | /// ``` |
| 211 | pub fn unrevoke(&mut self) { |
| 212 | self.state = MemberState::Active; |
| 213 | } |
| 214 | } |
| 215 | |
| 216 | #[cfg(test)] |
| 217 | mod tests { |
| 218 | #![allow(clippy::expect_used, reason = "unit test")] |
| 219 | |
| 220 | use facet_git_tree::{deserialize, serialize}; |
| 221 | use rstest::rstest; |
| 222 | |
| 223 | use super::*; |
| 224 | |
| 225 | #[rstest] |
| 226 | #[case::admin_human(Provenance::AdminRegistered)] |
| 227 | #[case::self_attested_human(Provenance::SelfAttested)] |
| 228 | // @relation(model.member-worker, model.member-provenance, scope=function, role=Verifies) |
| 229 | fn worker_and_human_share_one_constructor(#[case] provenance: Provenance) { |
| 230 | // A "worker" is not a distinct type or constructor — just another |
| 231 | // key enrolled the same way, which this parameterization over the |
| 232 | // same `Member::new` demonstrates directly. |
| 233 | let worker = Member::new("ci-worker", "ssh-ed25519 AAAA... ci-worker", provenance); |
| 234 | let human = Member::new("joey", "ssh-ed25519 AAAA... joey", provenance); |
| 235 | assert_eq!(worker.provenance, human.provenance); |
| 236 | } |
| 237 | |
| 238 | #[rstest] |
| 239 | // @relation(model.member-revocation, scope=function, role=Verifies) |
| 240 | fn revoke_then_unrevoke_round_trips_to_active() { |
| 241 | let mut member = Member::new("jdc", "key", Provenance::AdminRegistered); |
| 242 | assert_eq!(member.state, MemberState::Active); |
| 243 | member.revoke(); |
| 244 | assert_eq!(member.state, MemberState::Revoked); |
| 245 | member.unrevoke(); |
| 246 | assert_eq!(member.state, MemberState::Active); |
| 247 | } |
| 248 | |
| 249 | #[rstest] |
| 250 | #[case::active(MemberState::Active, "active")] |
| 251 | #[case::revoked(MemberState::Revoked, "revoked")] |
| 252 | // @relation(model.member-revocation, scope=function, role=Verifies) |
| 253 | fn member_state_displays_lowercase(#[case] state: MemberState, #[case] expected: &str) { |
| 254 | assert_eq!(state.to_string(), expected); |
| 255 | } |
| 256 | |
| 257 | #[rstest] |
| 258 | #[case::active(MemberState::Active)] |
| 259 | #[case::revoked(MemberState::Revoked)] |
| 260 | // @relation(model.member-identity, meta-ref.typed-tree, scope=function, role=Verifies) |
| 261 | fn member_round_trips_through_a_tree(#[case] state: MemberState) { |
| 262 | let member = Member { |
| 263 | id: MemberId::new("jdc"), |
| 264 | key: "ssh-ed25519 AAAA... jdc".to_owned(), |
| 265 | state, |
| 266 | provenance: Provenance::AdminRegistered, |
| 267 | }; |
| 268 | let (id, store) = serialize(&member).expect("serialize"); |
| 269 | let back: Member = deserialize(&id, &store).expect("deserialize"); |
| 270 | assert_eq!(member, back); |
| 271 | } |
| 272 | } |