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}