Skip to main content

melin_app/
auth.rs

1//! Connection-level permission model for application access control,
2//! plus the `authorized_keys` file loader that maps Ed25519 public
3//! keys to permissions.
4//!
5//! Both live in `melin-app` (next to [`Application`](crate::Application))
6//! because the role taxonomy ("who can do what to my app") and the
7//! deployment-time mapping of operator-managed keys to roles are
8//! application-shaped concerns, not wire-format concerns. The
9//! wire-shaped helper for the challenge-response signing payload
10//! lives in `melin-protocol::auth`.
11
12use std::collections::HashMap;
13use std::io;
14use std::path::Path;
15
16use base64::Engine;
17use base64::engine::general_purpose::STANDARD as BASE64;
18
19/// Permission level assigned to an authenticated connection.
20///
21/// Five specialized roles with no overlap — separation of duties:
22///   Operator: exchange configuration (instruments, risk, circuit breakers)
23///   Trader: order submission and cancellation
24///   Custodian: fund management (deposit/withdraw)
25///   ReadOnly: observation only (heartbeats, future market data)
26///   Replication: journal streaming between primary and replica servers
27///
28/// No single role has full access. An organization needing both trading
29/// and admin uses separate keys for each role.
30///
31/// Checked on the reader thread (cold per-request check) with zero
32/// cost on the matching engine hot path.
33#[derive(Debug, Clone, Copy, PartialEq, Eq)]
34pub enum Permission {
35    /// Exchange configuration: instrument management, circuit breakers,
36    /// risk limits, fee schedules, end-of-day, stats. Cannot trade or
37    /// manage funds.
38    Operator,
39    /// Submit/cancel orders and heartbeats. Cannot perform admin ops
40    /// or fund management (deposit/withdraw).
41    Trader,
42    /// Deposit and withdraw only. Cannot trade or perform admin ops.
43    /// Separates fund management from trading and exchange administration.
44    Custodian,
45    /// Heartbeats only. Future: market data subscriptions.
46    ReadOnly,
47    /// Replication only. Authorizes a replica to connect and receive
48    /// journal streams. Cannot trade, manage funds, or configure the
49    /// exchange. Infrastructure role, not client-facing.
50    Replication,
51}
52
53impl Permission {
54    /// Whether this permission level allows trading operations
55    /// (submit order, cancel order, cancel all, cancel-replace).
56    pub fn can_trade(self) -> bool {
57        matches!(self, Permission::Trader)
58    }
59
60    /// Whether this permission level allows administrative operations
61    /// (add instrument, set risk limits, circuit breakers, fee schedules,
62    /// end-of-day, query stats).
63    pub fn is_operator(self) -> bool {
64        matches!(self, Permission::Operator)
65    }
66
67    /// Whether this permission level allows fund management operations
68    /// (deposit, withdraw).
69    pub fn can_manage_funds(self) -> bool {
70        matches!(self, Permission::Custodian)
71    }
72
73    /// Whether this permission level authorizes replication connections
74    /// (journal streaming between primary and replica).
75    pub fn is_replication(self) -> bool {
76        matches!(self, Permission::Replication)
77    }
78}
79
80/// Maps Ed25519 public keys to permission levels.
81///
82/// HashMap for O(1) lookup by public key bytes. Loaded once at server
83/// startup and shared (immutably) across threads via `Arc`.
84#[derive(Debug)]
85pub struct AuthorizedKeys {
86    /// Public key bytes (32 bytes) → permission level.
87    keys: HashMap<[u8; 32], Permission>,
88}
89
90impl AuthorizedKeys {
91    /// Load authorized keys from a file.
92    ///
93    /// File format (one entry per line):
94    /// ```text
95    /// # <permission> <base64-encoded-public-key> <optional-comment>
96    /// admin AAAA...base64... ops-team
97    /// trader BBBB...base64... market-maker-1
98    /// readonly DDDD...base64... monitoring
99    /// ```
100    ///
101    /// Lines starting with `#` and empty lines are ignored.
102    pub fn load(path: &Path) -> io::Result<Self> {
103        let content = std::fs::read_to_string(path)?;
104        Self::parse(&content).map_err(|e| io::Error::other(format!("{path:?}: {e}")))
105    }
106
107    /// Parse authorized keys from a string. Separated from `load` for testing.
108    pub fn parse(content: &str) -> Result<Self, String> {
109        let mut keys = HashMap::new();
110
111        for (line_num, line) in content.lines().enumerate() {
112            let line = line.trim();
113            if line.is_empty() || line.starts_with('#') {
114                continue;
115            }
116
117            let mut parts = line.split_whitespace();
118            let perm_str = parts
119                .next()
120                .ok_or_else(|| format!("line {}: missing permission", line_num + 1))?;
121            let key_b64 = parts
122                .next()
123                .ok_or_else(|| format!("line {}: missing public key", line_num + 1))?;
124
125            let permission = match perm_str {
126                "operator" => Permission::Operator,
127                "trader" => Permission::Trader,
128                "custodian" => Permission::Custodian,
129                "readonly" => Permission::ReadOnly,
130                "replication" => Permission::Replication,
131                other => {
132                    return Err(format!(
133                        "line {}: unknown permission '{}' (expected operator/trader/custodian/readonly/replication)",
134                        line_num + 1,
135                        other
136                    ));
137                }
138            };
139
140            let key_bytes = BASE64
141                .decode(key_b64)
142                .map_err(|e| format!("line {}: invalid base64: {e}", line_num + 1))?;
143
144            if key_bytes.len() != 32 {
145                return Err(format!(
146                    "line {}: public key must be 32 bytes, got {}",
147                    line_num + 1,
148                    key_bytes.len()
149                ));
150            }
151
152            let mut key = [0u8; 32];
153            key.copy_from_slice(&key_bytes);
154            keys.insert(key, permission);
155        }
156
157        Ok(Self { keys })
158    }
159
160    /// Look up the permission for a public key. Returns `None` if the
161    /// key is not authorized.
162    pub fn lookup(&self, public_key: &[u8; 32]) -> Option<Permission> {
163        self.keys.get(public_key).copied()
164    }
165
166    /// Number of authorized keys.
167    pub fn len(&self) -> usize {
168        self.keys.len()
169    }
170
171    /// Whether the keys file is empty (no authorized keys).
172    pub fn is_empty(&self) -> bool {
173        self.keys.is_empty()
174    }
175}
176
177#[cfg(test)]
178mod tests {
179    use super::*;
180
181    #[test]
182    fn permission_can_trade() {
183        assert!(!Permission::Operator.can_trade());
184        assert!(Permission::Trader.can_trade());
185        assert!(!Permission::Custodian.can_trade());
186        assert!(!Permission::ReadOnly.can_trade());
187        assert!(!Permission::Replication.can_trade());
188    }
189
190    #[test]
191    fn permission_is_operator() {
192        assert!(Permission::Operator.is_operator());
193        assert!(!Permission::Trader.is_operator());
194        assert!(!Permission::Custodian.is_operator());
195        assert!(!Permission::ReadOnly.is_operator());
196        assert!(!Permission::Replication.is_operator());
197    }
198
199    #[test]
200    fn permission_can_manage_funds() {
201        assert!(!Permission::Operator.can_manage_funds());
202        assert!(!Permission::Trader.can_manage_funds());
203        assert!(Permission::Custodian.can_manage_funds());
204        assert!(!Permission::ReadOnly.can_manage_funds());
205        assert!(!Permission::Replication.can_manage_funds());
206    }
207
208    #[test]
209    fn permission_is_replication() {
210        assert!(!Permission::Operator.is_replication());
211        assert!(!Permission::Trader.is_replication());
212        assert!(!Permission::Custodian.is_replication());
213        assert!(!Permission::ReadOnly.is_replication());
214        assert!(Permission::Replication.is_replication());
215    }
216
217    // --- AuthorizedKeys ---
218
219    #[test]
220    fn parse_valid_keys_file() {
221        let content = "\
222# Auth keys file
223operator AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA= ops-team
224trader AQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQE= market-maker-1
225readonly AgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgI= monitoring
226";
227        let keys = AuthorizedKeys::parse(content).unwrap();
228        assert_eq!(keys.len(), 3);
229
230        let admin_key = BASE64
231            .decode("AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=")
232            .unwrap();
233        let mut k = [0u8; 32];
234        k.copy_from_slice(&admin_key);
235        assert_eq!(keys.lookup(&k), Some(Permission::Operator));
236    }
237
238    #[test]
239    fn parse_skips_comments_and_blanks() {
240        let content = "\
241# comment
242   # indented comment
243
244operator AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA= test
245";
246        let keys = AuthorizedKeys::parse(content).unwrap();
247        assert_eq!(keys.len(), 1);
248    }
249
250    #[test]
251    fn parse_rejects_unknown_permission() {
252        let content = "superuser AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA= test\n";
253        let result = AuthorizedKeys::parse(content);
254        assert!(result.is_err());
255        assert!(result.unwrap_err().contains("unknown permission"));
256    }
257
258    #[test]
259    fn parse_rejects_wrong_key_length() {
260        let content = "operator AQID test\n"; // 3 bytes, not 32
261        let result = AuthorizedKeys::parse(content);
262        assert!(result.is_err());
263        assert!(result.unwrap_err().contains("32 bytes"));
264    }
265
266    #[test]
267    fn lookup_missing_key_returns_none() {
268        let keys = AuthorizedKeys::parse("").unwrap();
269        assert!(keys.lookup(&[0u8; 32]).is_none());
270    }
271
272    #[test]
273    fn replication_key_parsed_from_file() {
274        let content = "replication AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA= replica-1\n";
275        let keys = AuthorizedKeys::parse(content).unwrap();
276        let pub_key = [0u8; 32];
277        assert_eq!(keys.lookup(&pub_key), Some(Permission::Replication));
278    }
279
280    #[test]
281    fn custodian_key_parsed_from_file() {
282        let content = "custodian AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA= treasury\n";
283        let keys = AuthorizedKeys::parse(content).unwrap();
284        let pub_key = [0u8; 32];
285        assert_eq!(keys.lookup(&pub_key), Some(Permission::Custodian));
286    }
287
288    #[test]
289    fn duplicate_key_last_permission_wins() {
290        let content = "\
291operator AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA= first
292readonly AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA= second
293";
294        let keys = AuthorizedKeys::parse(content).unwrap();
295        // HashMap insert overwrites, so the last entry wins.
296        assert_eq!(keys.len(), 1);
297        let key = BASE64
298            .decode("AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=")
299            .unwrap();
300        let mut k = [0u8; 32];
301        k.copy_from_slice(&key);
302        assert_eq!(keys.lookup(&k), Some(Permission::ReadOnly));
303    }
304
305    #[test]
306    fn empty_file_produces_empty_keys() {
307        let keys = AuthorizedKeys::parse("").unwrap();
308        assert!(keys.is_empty());
309        assert_eq!(keys.len(), 0);
310        assert!(keys.lookup(&[0u8; 32]).is_none());
311    }
312
313    #[test]
314    fn parse_rejects_invalid_base64() {
315        let content = "operator not-valid-base64!!! test\n";
316        let result = AuthorizedKeys::parse(content);
317        assert!(result.is_err());
318        assert!(result.unwrap_err().contains("invalid base64"));
319    }
320
321    #[test]
322    fn parse_rejects_missing_key_field() {
323        let content = "admin\n";
324        let result = AuthorizedKeys::parse(content);
325        assert!(result.is_err());
326        assert!(result.unwrap_err().contains("missing public key"));
327    }
328
329    #[test]
330    fn comments_only_file_produces_empty_keys() {
331        let content = "\
332# only comments
333# nothing else
334  # indented
335";
336        let keys = AuthorizedKeys::parse(content).unwrap();
337        assert!(keys.is_empty());
338    }
339
340    #[test]
341    fn load_nonexistent_file_is_error() {
342        let result = AuthorizedKeys::load(std::path::Path::new("/nonexistent/path/keys.txt"));
343        assert!(result.is_err());
344    }
345
346    #[test]
347    fn load_from_file() {
348        let dir = tempfile::tempdir().unwrap();
349        let path = dir.path().join("keys.txt");
350        std::fs::write(
351            &path,
352            "trader AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA= test\n",
353        )
354        .unwrap();
355        let keys = AuthorizedKeys::load(&path).unwrap();
356        assert_eq!(keys.len(), 1);
357    }
358}