git-ents.gitmain
⌘K
foforge
identity.rs119 lines · 5.4 KB · rusthistorycomment 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)
57pub 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]
100macro_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}