dig_rpc/method.rs
1//! Per-method metadata — used by the middleware to gate access and
2//! attribute rate limits.
3//!
4//! Servers register each method they dispatch with a [`MethodMeta`]
5//! describing:
6//!
7//! - `name` — wire name (e.g., `"get_blockchain_state"`).
8//! - `class` — read / write / admin; drives audit logging.
9//! - `min_role` — the minimum [`Role`](crate::role::Role) required to call.
10//! - `rate_bucket` — which token bucket accounts for this call.
11//! - `public_exposed` — whether the method is served on the public port.
12
13use std::collections::HashMap;
14
15use parking_lot::RwLock;
16
17use crate::role::Role;
18
19/// Broad method class, used by the audit log and the public-port filter.
20#[derive(Debug, Clone, Copy, PartialEq, Eq)]
21pub enum MethodClass {
22 /// Read-only lookup.
23 Read,
24 /// State-changing call.
25 Write,
26 /// Operator-only admin (stop_node, ban_peer, etc.).
27 Admin,
28}
29
30/// Named rate-limit bucket.
31#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
32pub enum RateBucket {
33 /// Cheap reads (get_blockchain_state, healthz).
34 ReadLight,
35 /// Expensive reads (get_block, get_coin_records_by_hint).
36 ReadHeavy,
37 /// Cheap writes (submit_partial_checkpoint_signature).
38 WriteLight,
39 /// Expensive writes (push_tx).
40 WriteHeavy,
41 /// Admin-only methods (stop_node, ban_peer).
42 AdminOnly,
43}
44
45/// Per-method metadata.
46#[derive(Debug, Clone)]
47pub struct MethodMeta {
48 /// JSON-RPC method name (snake_case).
49 pub name: &'static str,
50 /// Classification.
51 pub class: MethodClass,
52 /// Minimum role.
53 pub min_role: Role,
54 /// Rate bucket.
55 pub rate_bucket: RateBucket,
56 /// Whether the method is served on the public (non-admin) port.
57 pub public_exposed: bool,
58}
59
60impl MethodMeta {
61 /// Convenience builder for a read-only method.
62 pub const fn read(name: &'static str, min_role: Role, bucket: RateBucket) -> Self {
63 Self {
64 name,
65 class: MethodClass::Read,
66 min_role,
67 rate_bucket: bucket,
68 public_exposed: matches!(min_role, Role::Explorer),
69 }
70 }
71
72 /// Convenience builder for a write method. Never public-exposed.
73 pub const fn write(name: &'static str, min_role: Role, bucket: RateBucket) -> Self {
74 Self {
75 name,
76 class: MethodClass::Write,
77 min_role,
78 rate_bucket: bucket,
79 public_exposed: false,
80 }
81 }
82
83 /// Convenience builder for an admin method.
84 pub const fn admin(name: &'static str) -> Self {
85 Self {
86 name,
87 class: MethodClass::Admin,
88 min_role: Role::Admin,
89 rate_bucket: RateBucket::AdminOnly,
90 public_exposed: false,
91 }
92 }
93}
94
95/// Registry of method metadata.
96///
97/// Servers consult the registry on every request to decide role / rate /
98/// allow-list enforcement. Clone is cheap (`Arc` internally).
99#[derive(Debug, Default)]
100pub struct MethodRegistry {
101 inner: RwLock<HashMap<&'static str, MethodMeta>>,
102}
103
104impl MethodRegistry {
105 /// Build an empty registry.
106 pub fn new() -> Self {
107 Self::default()
108 }
109
110 /// Register a method. Overwrites any existing entry with the same name.
111 pub fn register(&self, meta: MethodMeta) {
112 self.inner.write().insert(meta.name, meta);
113 }
114
115 /// Look up metadata for a method. `None` if not registered (server
116 /// should respond with `MethodNotFound`).
117 pub fn get(&self, name: &str) -> Option<MethodMeta> {
118 self.inner.read().get(name).cloned()
119 }
120
121 /// Register multiple methods at once.
122 pub fn register_all(&self, metas: impl IntoIterator<Item = MethodMeta>) {
123 let mut g = self.inner.write();
124 for m in metas {
125 g.insert(m.name, m);
126 }
127 }
128
129 /// Number of registered methods.
130 pub fn len(&self) -> usize {
131 self.inner.read().len()
132 }
133
134 /// Whether the registry is empty.
135 pub fn is_empty(&self) -> bool {
136 self.inner.read().is_empty()
137 }
138}
139
140#[cfg(test)]
141mod tests {
142 use super::*;
143
144 /// **Proves:** the `read` / `write` / `admin` builders produce meta
145 /// with the classifications baked in correctly — write/admin are
146 /// never public-exposed even if a bug in the enum ordering would allow
147 /// it.
148 ///
149 /// **Why it matters:** `public_exposed` is the last line of defence
150 /// against accidentally serving `stop_node` on the internet. Any
151 /// regression in the builders would silently open attack surface.
152 ///
153 /// **Catches:** a copy-paste regression between the `read` / `write` /
154 /// `admin` builders that swaps `public_exposed` values.
155 #[test]
156 fn builders_set_public_exposed_correctly() {
157 let r = MethodMeta::read("healthz", Role::Explorer, RateBucket::ReadLight);
158 assert!(r.public_exposed);
159 assert_eq!(r.class, MethodClass::Read);
160
161 let r_admin = MethodMeta::read("get_slashing_db", Role::Admin, RateBucket::ReadLight);
162 assert!(!r_admin.public_exposed); // requires Admin -> NOT public
163
164 let w = MethodMeta::write("push_tx", Role::Explorer, RateBucket::WriteHeavy);
165 assert!(!w.public_exposed); // writes are never public
166 assert_eq!(w.class, MethodClass::Write);
167
168 let a = MethodMeta::admin("stop_node");
169 assert!(!a.public_exposed);
170 assert_eq!(a.min_role, Role::Admin);
171 assert_eq!(a.rate_bucket, RateBucket::AdminOnly);
172 }
173
174 /// **Proves:** `MethodRegistry::get` returns metadata after registration
175 /// and `None` otherwise.
176 ///
177 /// **Why it matters:** `None` → server responds `MethodNotFound`. If
178 /// `get` hallucinated metadata for unregistered methods, every method
179 /// call on an empty server would return `Forbidden`-style errors
180 /// instead of the correct `MethodNotFound`.
181 ///
182 /// **Catches:** a regression where `get` falls back to a permissive
183 /// default (Some(MethodMeta::admin("..."))) instead of None.
184 #[test]
185 fn registry_register_and_lookup() {
186 let r = MethodRegistry::new();
187 assert!(r.is_empty());
188 assert!(r.get("healthz").is_none());
189
190 r.register(MethodMeta::read(
191 "healthz",
192 Role::Explorer,
193 RateBucket::ReadLight,
194 ));
195 assert_eq!(r.len(), 1);
196 let meta = r.get("healthz").unwrap();
197 assert_eq!(meta.name, "healthz");
198 assert_eq!(meta.class, MethodClass::Read);
199 }
200
201 /// **Proves:** re-registering the same method name overwrites the
202 /// previous entry.
203 ///
204 /// **Why it matters:** A live-reload of the method catalogue (e.g.,
205 /// feature-flagging a method off) needs to replace the entry rather
206 /// than leave stale metadata behind.
207 ///
208 /// **Catches:** an insert-only regression that accumulates duplicate
209 /// entries.
210 #[test]
211 fn register_overwrites() {
212 let r = MethodRegistry::new();
213 r.register(MethodMeta::read("m", Role::Explorer, RateBucket::ReadLight));
214 r.register(MethodMeta::admin("m"));
215 assert_eq!(r.len(), 1);
216 assert_eq!(r.get("m").unwrap().class, MethodClass::Admin);
217 }
218}