crates/cli/git-ents/src/commands/members.rs
members.rshistorycomment on this file
| 1 | //! `git ents members`: enroll, remove, revoke, unrevoke, and check members |
| 2 | //! (`model.member-identity`, `model.member-revocation`). |
| 3 | |
| 4 | use ents_model::{Member, MemberId, MemberState, Provenance, namespace}; |
| 5 | use ents_receive::{Identity, propose_delete, propose_entity}; |
| 6 | use gix_object::Find; |
| 7 | use gix_ref_store::RefStoreRead; |
| 8 | |
| 9 | use super::{actor, signer}; |
| 10 | use crate::error::{Error, Result}; |
| 11 | use crate::mutate::outcome_to_result; |
| 12 | use crate::root::LocalRoot; |
| 13 | |
| 14 | /// `git ents members list`: every member ref and its current state. |
| 15 | /// |
| 16 | /// Takes the ref and object seams rather than a concrete root so the same |
| 17 | /// read path serves both composition roots — the local CLI and the hosted |
| 18 | /// web mount both need "who are the members," never a root-specific rescan. |
| 19 | /// |
| 20 | /// # Errors |
| 21 | /// |
| 22 | /// Propagates a ref-store or object read failure. |
| 23 | pub fn list(refs: &impl RefStoreRead, objects: &impl Find) -> Result<Vec<(String, Member)>> { |
| 24 | let mut out = Vec::new(); |
| 25 | for entry in refs.iter_prefix("refs/meta/member/")? { |
| 26 | let (name, tip) = entry?; |
| 27 | let path = name.as_bstr().to_string(); |
| 28 | let Some(username) = path.strip_prefix("refs/meta/member/") else { |
| 29 | continue; |
| 30 | }; |
| 31 | if let Some(member) = read_member(objects, tip)? { |
| 32 | out.push((username.to_owned(), member)); |
| 33 | } |
| 34 | } |
| 35 | Ok(out) |
| 36 | } |
| 37 | |
| 38 | /// `git ents members add`: enroll `username` with `pubkey` (or the |
| 39 | /// signer's own public key), admin-registered. |
| 40 | /// |
| 41 | /// # Errors |
| 42 | /// |
| 43 | /// Propagates a signing, serialization, or `receive` failure; see |
| 44 | /// [`crate::mutate::outcome_to_result`] for how a reached refusal renders. |
| 45 | pub fn add( |
| 46 | root: &LocalRoot, |
| 47 | username: &str, |
| 48 | pubkey: Option<String>, |
| 49 | key: Option<std::path::PathBuf>, |
| 50 | ) -> Result<()> { |
| 51 | let signer = signer(root, key)?; |
| 52 | let pubkey = pubkey.unwrap_or_else(|| signer.public_openssh()); |
| 53 | let member = Member::new(MemberId::new(username), pubkey, Provenance::AdminRegistered); |
| 54 | let name = namespace::member_ref(&MemberId::new(username))?; |
| 55 | let identity = Identity { |
| 56 | actor: actor(&signer), |
| 57 | author: None, |
| 58 | sign: &|payload| signer.sign(payload), |
| 59 | }; |
| 60 | let outcome = propose_entity( |
| 61 | &root.refs, |
| 62 | &root.objects, |
| 63 | &root.events, |
| 64 | name, |
| 65 | &member, |
| 66 | &identity, |
| 67 | &format!("Enroll {username}"), |
| 68 | root.mode(), |
| 69 | )?; |
| 70 | outcome_to_result(outcome, None)?; |
| 71 | Ok(()) |
| 72 | } |
| 73 | |
| 74 | /// `git ents members remove`: delete `username`'s ref entirely. |
| 75 | /// |
| 76 | /// # Errors |
| 77 | /// |
| 78 | /// See [`add`]. |
| 79 | pub fn remove(root: &LocalRoot, username: &str, key: Option<std::path::PathBuf>) -> Result<()> { |
| 80 | let signer = signer(root, key)?; |
| 81 | let name = namespace::member_ref(&MemberId::new(username))?; |
| 82 | let outcome = propose_delete(&root.refs, &root.objects, &root.events, name, root.mode())?; |
| 83 | let _ = signer; // signing material is not needed for a deletion transition. |
| 84 | outcome_to_result(outcome, None)?; |
| 85 | Ok(()) |
| 86 | } |
| 87 | |
| 88 | /// `git ents members revoke`/`unrevoke`: flip `username`'s |
| 89 | /// [`MemberState`] without deleting the record (`model.member-revocation`). |
| 90 | /// |
| 91 | /// # Errors |
| 92 | /// |
| 93 | /// [`Error::NotFound`] if `username` has no member ref; otherwise see |
| 94 | /// [`add`]. |
| 95 | pub fn set_revoked( |
| 96 | root: &LocalRoot, |
| 97 | username: &str, |
| 98 | revoked: bool, |
| 99 | key: Option<std::path::PathBuf>, |
| 100 | ) -> Result<()> { |
| 101 | let signer = signer(root, key)?; |
| 102 | let name = namespace::member_ref(&MemberId::new(username))?; |
| 103 | let Some(tip) = root.refs.get(name.as_ref())? else { |
| 104 | return Err(Error::NotFound { |
| 105 | what: format!("member {username}"), |
| 106 | }); |
| 107 | }; |
| 108 | let mut member = read_member(&root.objects, tip)?.ok_or_else(|| Error::NotFound { |
| 109 | what: format!("member {username}"), |
| 110 | })?; |
| 111 | member.state = if revoked { |
| 112 | MemberState::Revoked |
| 113 | } else { |
| 114 | MemberState::Active |
| 115 | }; |
| 116 | let identity = Identity { |
| 117 | actor: actor(&signer), |
| 118 | author: None, |
| 119 | sign: &|payload| signer.sign(payload), |
| 120 | }; |
| 121 | let verb = if revoked { "Revoke" } else { "Unrevoke" }; |
| 122 | let outcome = propose_entity( |
| 123 | &root.refs, |
| 124 | &root.objects, |
| 125 | &root.events, |
| 126 | name, |
| 127 | &member, |
| 128 | &identity, |
| 129 | &format!("{verb} {username}"), |
| 130 | root.mode(), |
| 131 | )?; |
| 132 | outcome_to_result(outcome, Some(tip))?; |
| 133 | Ok(()) |
| 134 | } |
| 135 | |
| 136 | /// `git ents members check`: whether `key` (or the resolved signing key) |
| 137 | /// names an active member, and which username. |
| 138 | /// |
| 139 | /// # Errors |
| 140 | /// |
| 141 | /// Propagates a signing-key or ref-store read failure. |
| 142 | pub fn check( |
| 143 | root: &LocalRoot, |
| 144 | key: Option<std::path::PathBuf>, |
| 145 | ) -> Result<Option<(String, MemberState)>> { |
| 146 | let signer = signer(root, key)?; |
| 147 | find_by_key(&root.refs, &root.objects, &signer.public_openssh()) |
| 148 | } |
| 149 | |
| 150 | /// Resolve `pubkey` to the enrolled member whose stored key matches it, if |
| 151 | /// any -- the shared match loop behind [`check`], `git ents serve`'s own |
| 152 | /// identity-chip label (`crate::commands::serve::build_state`, |
| 153 | /// `roots.web-signing`), and the hosted mount's server-key enrollment |
| 154 | /// check: all need "which member owns this key," never a bespoke re-scan |
| 155 | /// of `list`'s own rows. |
| 156 | /// |
| 157 | /// # Errors |
| 158 | /// |
| 159 | /// Propagates a ref-store or object read failure. |
| 160 | pub fn find_by_key( |
| 161 | refs: &impl RefStoreRead, |
| 162 | objects: &impl Find, |
| 163 | pubkey: &str, |
| 164 | ) -> Result<Option<(String, MemberState)>> { |
| 165 | for (username, member) in list(refs, objects)? { |
| 166 | if member.key == pubkey { |
| 167 | return Ok(Some((username, member.state))); |
| 168 | } |
| 169 | } |
| 170 | Ok(None) |
| 171 | } |
| 172 | |
| 173 | fn read_member(objects: &impl Find, tip: gix_hash::ObjectId) -> Result<Option<Member>> { |
| 174 | let tree = crate::commands::commit_tree(objects, tip)?; |
| 175 | Ok(facet_git_tree::deserialize::<Member>(&tree, objects).ok()) |
| 176 | } |