git-ents.gitmain
⌘K
foforge
member.rs272 lines · 9.8 KB · rusthistorycomment 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
6use 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)]
16pub struct MemberId(pub String);
17
18impl 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
40impl 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
46impl From<String> for MemberId {
47 fn from(id: String) -> Self {
48 Self(id)
49 }
50}
51
52impl From<&str> for MemberId {
53 fn from(id: &str) -> Self {
54 Self(id.to_owned())
55 }
56}
57
58impl 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)]
78pub 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
86impl 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)]
106pub 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)]
148pub 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
163impl 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)]
217mod 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}