crates/cli/ents-web/src/identity.rs
identity.rshistorycomment on this file
| 1 | //! The signing-identity seam (`roots.web-agnostic`, `roots.web-signing`): |
| 2 | //! the one new trait this crate introduces, because gitoxide and the |
| 3 | //! kernel are both silent on "who signs a web-originated commit" -- exactly |
| 4 | //! the carve-out `arch.no-object-store-trait` reserves for "the pluggable |
| 5 | //! ref store, server-side receive framing, and reachability artifacts... |
| 6 | //! and new seams where upstream is silent." |
| 7 | //! |
| 8 | //! Every page that proposes a mutation is handed a |
| 9 | //! `&dyn SigningIdentity` through [`crate::state::AppState`]; nothing in |
| 10 | //! [`crate::pages`] ever loads a key, resolves `user.signingkey`, or knows |
| 11 | //! whether the identity behind it belongs to the local operator or a |
| 12 | //! hosted server's own worker account. That is the whole point |
| 13 | //! (`roots.web-signing`): a hosted deployment's composition root wires an |
| 14 | //! identity backed by the server's own enrolled member key, a local |
| 15 | //! deployment's wires one backed by the user's own key |
| 16 | //! (`git_ents::sign::Signer`, injected by `git-ents`'s own `serve` |
| 17 | //! command) -- both satisfy this same trait, and no branch anywhere in |
| 18 | //! this crate asks which one it was handed. |
| 19 | |
| 20 | /// Everything a page handler needs to sign a mutation commit on behalf of |
| 21 | /// the current request, injected by the composition root |
| 22 | /// (`roots.web-agnostic`). |
| 23 | /// |
| 24 | /// # Examples |
| 25 | /// |
| 26 | /// A fixture identity, standing in for either a local user's key or a |
| 27 | /// hosted server's worker key -- [`crate::pages`] cannot tell which from |
| 28 | /// this trait alone, which is exactly `roots.web-signing`'s requirement. |
| 29 | /// |
| 30 | /// ``` |
| 31 | /// use ents_web::identity::SigningIdentity; |
| 32 | /// |
| 33 | /// struct Fixed; |
| 34 | /// impl SigningIdentity for Fixed { |
| 35 | /// fn actor(&self) -> gix::actor::Signature { |
| 36 | /// gix::actor::Signature { |
| 37 | /// name: "fixture".into(), |
| 38 | /// email: "fixture@ents.test".into(), |
| 39 | /// time: gix::date::Time { seconds: 0, offset: 0 }, |
| 40 | /// } |
| 41 | /// } |
| 42 | /// fn sign(&self, _payload: &[u8]) -> String { |
| 43 | /// "-----BEGIN SSH SIGNATURE-----\n-----END SSH SIGNATURE-----\n".to_owned() |
| 44 | /// } |
| 45 | /// fn public_openssh(&self) -> String { |
| 46 | /// "ssh-ed25519 AAAA... fixture".to_owned() |
| 47 | /// } |
| 48 | /// } |
| 49 | /// |
| 50 | /// let identity: Box<dyn SigningIdentity> = Box::new(Fixed); |
| 51 | /// assert_eq!(identity.actor().name, "fixture"); |
| 52 | /// // `label` defaults to `actor().name` when a composition root has no |
| 53 | /// // better identifier (a resolved member's own username, for instance). |
| 54 | /// assert_eq!(identity.label(), "fixture"); |
| 55 | /// ``` |
| 56 | // @relation(roots.web-signing, roots.web-agnostic, scope=file) |
| 57 | pub trait SigningIdentity: Send + Sync { |
| 58 | /// The commit author/committer signature every mutation this identity |
| 59 | /// signs carries. |
| 60 | fn actor(&self) -> gix::actor::Signature; |
| 61 | |
| 62 | /// Sign `payload` (a commit's to-be-signed bytes), returning the |
| 63 | /// armored SSHSIG PEM block for the commit's `gpgsig` header. |
| 64 | fn sign(&self, payload: &[u8]) -> String; |
| 65 | |
| 66 | /// The public half of this identity's key, in OpenSSH single-line |
| 67 | /// format -- used to resolve which enrolled [`ents_model::Member`] is |
| 68 | /// acting, exactly as `git ents account create` resolves its own |
| 69 | /// signer's member when `--member` is omitted. |
| 70 | fn public_openssh(&self) -> String; |
| 71 | |
| 72 | /// This identity's display label for `crate::pages::layout`'s |
| 73 | /// `.id-chip` (`roots.web-signing`) -- the one place this crate names |
| 74 | /// "who is acting" for a human reader, as opposed to [`Self::actor`]'s |
| 75 | /// commit-authorship signature. |
| 76 | /// |
| 77 | /// Defaults to [`Self::actor`]'s own author name: good enough when a |
| 78 | /// composition root has nothing better to show. `git-ents`'s own |
| 79 | /// `LocalIdentity` overrides this with the enrolled member's username |
| 80 | /// resolved from the signer's public key (falling back to a short key |
| 81 | /// fingerprint when no member matches), since `actor().name` there is |
| 82 | /// a fixed wordmark ("git-ents"), not a signer identity -- showing it |
| 83 | /// in the chip would just duplicate the site logo next to it. |
| 84 | fn label(&self) -> String { |
| 85 | self.actor().name.to_string() |
| 86 | } |
| 87 | } |
| 88 | |
| 89 | /// Build the [`ents_receive::Identity`] every mutation page hands to |
| 90 | /// `propose_entity`/`propose_delete`. |
| 91 | /// |
| 92 | /// This is a macro, not a function, deliberately: `ents_receive::Identity` |
| 93 | /// borrows its `sign` closure (`sign: &'a dyn Fn(&[u8]) -> String`), so the |
| 94 | /// closure literal must live in the caller's own stack frame -- a helper |
| 95 | /// function that built and returned an `Identity` would return a |
| 96 | /// reference to a temporary dropped at that function's end. Every page in |
| 97 | /// [`crate::pages`] expands this at its own call site instead, exactly the |
| 98 | /// shape `git_ents::commands::comment::add` and its siblings already use. |
| 99 | #[macro_export] |
| 100 | macro_rules! receive_identity { |
| 101 | ($identity:expr) => { |
| 102 | $crate::receive_identity!($identity, None) |
| 103 | }; |
| 104 | // The attributed form (`receive.attributed-author`, |
| 105 | // `roots.web-signing`): `$author` is an |
| 106 | // `Option<gix::actor::Signature>` naming the signed-in member -- |
| 107 | // `pages::member_author(&session)` at every mutation call site -- so |
| 108 | // hosted history reads "member via the web" while the committer and |
| 109 | // signature stay the injected identity. Under a `Trusted` policy the |
| 110 | // session never holds a member, the option is `None`, and the commit |
| 111 | // is byte-identical to the single-argument form's. |
| 112 | ($identity:expr, $author:expr) => { |
| 113 | ents_receive::Identity { |
| 114 | actor: $identity.actor(), |
| 115 | author: $author, |
| 116 | sign: &|payload| $identity.sign(payload), |
| 117 | } |
| 118 | }; |
| 119 | } |