Skip to main content

dig_rpc/
role.rs

1//! Peer role resolution.
2//!
3//! The internal RPC server uses mTLS. Every connected peer presents a
4//! client cert that is then resolved to a [`Role`] via a [`RoleMap`]. The
5//! role governs which methods the peer is allowed to call.
6//!
7//! Public-mode servers treat every peer as [`Role::Explorer`] by default
8//! (no client cert needed).
9//!
10//! # Hierarchy
11//!
12//! Roles are ordered:
13//!
14//! ```text
15//! Admin > PairedFullnode > Validator > Explorer
16//! ```
17//!
18//! A method declares `min_role`; any peer whose resolved role is `>=`
19//! `min_role` is allowed.
20
21use std::cmp::Ordering;
22
23use parking_lot::RwLock;
24
25/// Role a peer has been resolved to. Ordered.
26#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
27pub enum Role {
28    /// Public read-only client (explorer, light wallet).
29    Explorer = 0,
30    /// A DIG validator calling its paired fullnode.
31    Validator = 1,
32    /// The paired fullnode (validator-side RPC).
33    PairedFullnode = 2,
34    /// Operator admin; can call any method.
35    Admin = 3,
36}
37
38impl PartialOrd for Role {
39    fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
40        Some(self.cmp(other))
41    }
42}
43
44impl Ord for Role {
45    fn cmp(&self, other: &Self) -> Ordering {
46        (*self as u8).cmp(&(*other as u8))
47    }
48}
49
50impl Role {
51    /// Display name.
52    pub fn as_str(self) -> &'static str {
53        match self {
54            Role::Admin => "admin",
55            Role::PairedFullnode => "paired_fullnode",
56            Role::Validator => "validator",
57            Role::Explorer => "explorer",
58        }
59    }
60}
61
62/// How to match a certificate to a role.
63///
64/// Patterns are evaluated in order; first match wins.
65#[derive(Debug, Clone)]
66pub enum CertMatcher {
67    /// Exact common-name match.
68    ExactCn(String),
69    /// Glob pattern over common-name (`*` matches any sequence).
70    CnGlob(String),
71    /// Glob pattern over DNS subject alternative names.
72    SanDnsGlob(String),
73    /// Exact SHA-256 of the certificate's subject public key (hex-encoded).
74    PublicKeyHashHex(String),
75}
76
77impl CertMatcher {
78    /// Test whether this matcher matches a peer with the given CN / SANs.
79    pub fn matches(&self, cert: &PeerCertInfo) -> bool {
80        match self {
81            CertMatcher::ExactCn(cn) => cert.cn.as_deref() == Some(cn.as_str()),
82            CertMatcher::CnGlob(pat) => cert
83                .cn
84                .as_deref()
85                .map(|cn| glob_match(pat, cn))
86                .unwrap_or(false),
87            CertMatcher::SanDnsGlob(pat) => cert.san_dns.iter().any(|san| glob_match(pat, san)),
88            CertMatcher::PublicKeyHashHex(hex) => cert
89                .spki_sha256_hex
90                .as_deref()
91                .map(|h| h.eq_ignore_ascii_case(hex))
92                .unwrap_or(false),
93        }
94    }
95}
96
97/// Subject information extracted from a client certificate.
98///
99/// The auth layer populates this from the TLS handshake and attaches it to
100/// the request's extension map. Handlers can downcast the extension to
101/// inspect the resolved role or the full cert.
102#[derive(Debug, Clone, Default)]
103pub struct PeerCertInfo {
104    /// Subject common name, if present.
105    pub cn: Option<String>,
106    /// DNS subject alternative names.
107    pub san_dns: Vec<String>,
108    /// SHA-256 of the certificate's subject public key, hex-encoded lowercase.
109    pub spki_sha256_hex: Option<String>,
110}
111
112/// Ordered mapping from cert patterns to roles.
113///
114/// Thread-safe: wrapped in `RwLock` so `reload` can swap the mapping live
115/// without restarting the server.
116#[derive(Debug)]
117pub struct RoleMap {
118    entries: RwLock<Vec<RoleMapEntry>>,
119    default: Role,
120}
121
122/// One entry in a [`RoleMap`].
123#[derive(Debug, Clone)]
124pub struct RoleMapEntry {
125    /// Matcher for this rule.
126    pub matcher: CertMatcher,
127    /// Role to assign on match.
128    pub role: Role,
129}
130
131impl RoleMap {
132    /// Build a role map whose default role for unmatched peers is `default`.
133    pub fn new(default: Role) -> Self {
134        Self {
135            entries: RwLock::new(Vec::new()),
136            default,
137        }
138    }
139
140    /// Add a rule to the end of the chain.
141    pub fn push(&self, entry: RoleMapEntry) {
142        self.entries.write().push(entry);
143    }
144
145    /// Replace the entire rule list atomically.
146    ///
147    /// Useful for live-reload of the role map when the operator rotates
148    /// the private CA or adds a new validator.
149    pub fn reload(&self, entries: Vec<RoleMapEntry>) {
150        *self.entries.write() = entries;
151    }
152
153    /// Resolve a peer cert to its role.
154    ///
155    /// Returns the first matching entry's role, or the `default` role if
156    /// no entry matches.
157    pub fn resolve(&self, cert: &PeerCertInfo) -> Role {
158        let g = self.entries.read();
159        for e in g.iter() {
160            if e.matcher.matches(cert) {
161                return e.role;
162            }
163        }
164        self.default
165    }
166
167    /// Number of entries.
168    pub fn len(&self) -> usize {
169        self.entries.read().len()
170    }
171
172    /// Whether the rule chain is empty (peers always get the default role).
173    pub fn is_empty(&self) -> bool {
174        self.entries.read().is_empty()
175    }
176}
177
178/// Very small glob-match helper: supports `*` as "any sequence of chars".
179///
180/// Case-sensitive. Does NOT support character classes, `?`, or escape
181/// characters — if we ever need them, swap in the `glob` crate.
182pub(crate) fn glob_match(pattern: &str, s: &str) -> bool {
183    // Simple dynamic programming over bytes. Sufficient for typical
184    // cert-CN patterns ("validator-*", "dig-rpc-client-*").
185    let pb = pattern.as_bytes();
186    let sb = s.as_bytes();
187    let (p_len, s_len) = (pb.len(), sb.len());
188    let mut table = vec![vec![false; s_len + 1]; p_len + 1];
189    table[0][0] = true;
190    for (i, &pat_byte) in pb.iter().enumerate() {
191        if pat_byte == b'*' {
192            table[i + 1][0] = table[i][0];
193        }
194    }
195    for (i, &pat_byte) in pb.iter().enumerate() {
196        for (j, &s_byte) in sb.iter().enumerate() {
197            if pat_byte == b'*' {
198                table[i + 1][j + 1] = table[i][j + 1] || table[i + 1][j];
199            } else if pat_byte == s_byte {
200                table[i + 1][j + 1] = table[i][j];
201            }
202        }
203    }
204    table[p_len][s_len]
205}
206
207#[cfg(test)]
208mod tests {
209    use super::*;
210
211    /// **Proves:** `Role` ordering is `Admin > PairedFullnode > Validator >
212    /// Explorer`.
213    ///
214    /// **Why it matters:** Method access is gated by `peer_role >= min_role`.
215    /// If the ordering were reversed (or lexicographic), an `Explorer`
216    /// could call an `Admin`-only method — a critical security bug.
217    ///
218    /// **Catches:** a regression where the `PartialOrd` / `Ord` impls derive
219    /// from an enum whose variants are listed alphabetically rather than
220    /// hierarchically.
221    #[test]
222    fn role_ordering() {
223        assert!(Role::Admin > Role::PairedFullnode);
224        assert!(Role::PairedFullnode > Role::Validator);
225        assert!(Role::Validator > Role::Explorer);
226        assert!(Role::Admin > Role::Explorer);
227        assert_eq!(Role::Admin, Role::Admin);
228    }
229
230    /// **Proves:** an empty `RoleMap` resolves every cert to its declared
231    /// default role.
232    ///
233    /// **Why it matters:** This is the "no rules configured yet" default.
234    /// Production operators must populate the map before exposing the
235    /// internal RPC; if the default were `Admin`, they'd have an open
236    /// admin surface on empty config. So the convention is: default to
237    /// `Explorer` unless the caller explicitly wants broader access.
238    ///
239    /// **Catches:** a regression that hard-codes the default to a higher
240    /// role than the caller requested.
241    #[test]
242    fn empty_map_uses_default() {
243        let rm = RoleMap::new(Role::Explorer);
244        let resolved = rm.resolve(&PeerCertInfo::default());
245        assert_eq!(resolved, Role::Explorer);
246    }
247
248    /// **Proves:** `CertMatcher::ExactCn` matches only its exact CN string.
249    ///
250    /// **Why it matters:** Exact-CN is the most-used rule in operator
251    /// configs ("the validator's cert has CN 'validator-0'"). Any change
252    /// to the comparison (case-insensitive, prefix-only) would silently
253    /// flip role resolution.
254    ///
255    /// **Catches:** a regression that changes `ExactCn` to a case-fold
256    /// or substring compare.
257    #[test]
258    fn exact_cn_matches() {
259        let m = CertMatcher::ExactCn("validator-0".to_string());
260        let hit = PeerCertInfo {
261            cn: Some("validator-0".to_string()),
262            ..Default::default()
263        };
264        let miss_case = PeerCertInfo {
265            cn: Some("VALIDATOR-0".to_string()),
266            ..Default::default()
267        };
268        let miss_other = PeerCertInfo {
269            cn: Some("validator-1".to_string()),
270            ..Default::default()
271        };
272        assert!(m.matches(&hit));
273        assert!(!m.matches(&miss_case));
274        assert!(!m.matches(&miss_other));
275    }
276
277    /// **Proves:** `CertMatcher::CnGlob` with a trailing `*` matches any
278    /// CN with that prefix.
279    ///
280    /// **Why it matters:** Common pattern in deployments — a single rule
281    /// "validator-*" → Role::Validator covers every numbered validator.
282    ///
283    /// **Catches:** a regression in `glob_match` that fails to handle the
284    /// `*`-at-end case, or that treats the entire pattern as literal.
285    #[test]
286    fn glob_cn_matches() {
287        let m = CertMatcher::CnGlob("validator-*".to_string());
288        let hits = ["validator-0", "validator-42", "validator-"];
289        for h in hits {
290            let info = PeerCertInfo {
291                cn: Some(h.to_string()),
292                ..Default::default()
293            };
294            assert!(m.matches(&info), "{h}");
295        }
296
297        let misses = ["valid-0", "VALIDATOR-0"];
298        for miss in misses {
299            let info = PeerCertInfo {
300                cn: Some(miss.to_string()),
301                ..Default::default()
302            };
303            assert!(!m.matches(&info), "{miss}");
304        }
305    }
306
307    /// **Proves:** `glob_match` handles `*` at start, middle, and end of
308    /// the pattern.
309    ///
310    /// **Why it matters:** Operators may use patterns like `*-validator`
311    /// or `dig-*-admin` in production configs.
312    ///
313    /// **Catches:** a regression to a simpler `starts_with` / `ends_with`
314    /// implementation that breaks on middle-`*` patterns.
315    #[test]
316    fn glob_positions() {
317        assert!(glob_match("*", "anything"));
318        assert!(glob_match("abc*", "abcdef"));
319        assert!(glob_match("*def", "abcdef"));
320        assert!(glob_match("a*f", "abcdef"));
321        assert!(glob_match("a*c*f", "abcdef"));
322        assert!(!glob_match("abc", "abcdef"));
323        assert!(!glob_match("abc*", "xyabcdef"));
324    }
325
326    /// **Proves:** the first matching entry in a `RoleMap` wins; later
327    /// entries do not override.
328    ///
329    /// **Why it matters:** Operators order rules by specificity —
330    /// exact-CN matches first, glob fallbacks later. If evaluation order
331    /// were ambiguous or reversed, specific rules would be shadowed.
332    ///
333    /// **Catches:** a regression to reverse-order evaluation.
334    #[test]
335    fn first_match_wins() {
336        let rm = RoleMap::new(Role::Explorer);
337        rm.push(RoleMapEntry {
338            matcher: CertMatcher::ExactCn("foo".to_string()),
339            role: Role::Admin,
340        });
341        rm.push(RoleMapEntry {
342            matcher: CertMatcher::CnGlob("*".to_string()),
343            role: Role::Validator,
344        });
345
346        let admin_cert = PeerCertInfo {
347            cn: Some("foo".to_string()),
348            ..Default::default()
349        };
350        assert_eq!(rm.resolve(&admin_cert), Role::Admin);
351
352        let other_cert = PeerCertInfo {
353            cn: Some("bar".to_string()),
354            ..Default::default()
355        };
356        assert_eq!(rm.resolve(&other_cert), Role::Validator);
357    }
358
359    /// **Proves:** `RoleMap::reload` atomically swaps the rule set.
360    ///
361    /// **Why it matters:** Operators rotate private CAs periodically;
362    /// reload must not produce a window where peers are resolved against
363    /// a partially-updated map.
364    ///
365    /// **Catches:** a regression where `reload` appends rather than
366    /// replaces, leaving stale entries active.
367    #[test]
368    fn reload_replaces_rules() {
369        let rm = RoleMap::new(Role::Explorer);
370        rm.push(RoleMapEntry {
371            matcher: CertMatcher::ExactCn("foo".to_string()),
372            role: Role::Admin,
373        });
374        rm.reload(vec![RoleMapEntry {
375            matcher: CertMatcher::ExactCn("bar".to_string()),
376            role: Role::Validator,
377        }]);
378
379        let foo = PeerCertInfo {
380            cn: Some("foo".to_string()),
381            ..Default::default()
382        };
383        let bar = PeerCertInfo {
384            cn: Some("bar".to_string()),
385            ..Default::default()
386        };
387        assert_eq!(rm.resolve(&foo), Role::Explorer); // default
388        assert_eq!(rm.resolve(&bar), Role::Validator);
389    }
390}