Skip to main content

faucet_cli/serve/
rbac.rs

1//! Role-based access control for the `faucet serve` control plane (#205).
2//!
3//! The default single-`--auth-token` mode is one implicit `admin` principal. A
4//! `--auth-config <file>` promotes serve to a multi-principal deployment: a list
5//! of `{ name, token, role }` principals, each token mapped to a [`Role`] that
6//! grants a fixed set of [`Permission`]s. Every `/v1` route declares the
7//! permission it needs ([`required_permission`]); the auth middleware
8//! (`serve::auth::require_auth`) resolves the bearer token to an
9//! [`AuthContext`] and denies (`403`) any request whose role lacks the permission.
10//!
11//! Tokens are compared in constant time (via `serve::auth::constant_time_eq`)
12//! and never appear in `{:?}` output — [`PrincipalSpec`]'s `Debug` masks them,
13//! and the server registers every token with the redaction writer at startup.
14
15use crate::error::{CliError, CliResult};
16use axum::http::Method;
17use serde::{Deserialize, Serialize};
18use std::path::Path;
19
20/// A discrete capability a route requires. Roles grant a fixed set of these.
21#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
22#[serde(rename_all = "snake_case")]
23pub enum Permission {
24    /// Read run records / logs (`GET /v1/runs*`).
25    RunRead,
26    /// Submit / cancel / delete runs (`POST`/`DELETE /v1/runs*`).
27    RunWrite,
28    /// Read the connector/transform schema catalog (`GET /v1/schemas*`).
29    SchemaRead,
30    /// Run the preflight probe (`POST /v1/doctor`).
31    Doctor,
32    /// Fire an event-driven trigger (`POST`/`PUT /v1/triggers/{name}`).
33    TriggerFire,
34    /// Inspect a dead-letter-queue location (`POST /v1/dlq/inspect`) — read-only.
35    DlqRead,
36    /// Replay / discard dead-letter-queue envelopes
37    /// (`POST /v1/dlq/replay`, `POST /v1/dlq/discard`).
38    DlqManage,
39    /// Read the Data Movement Catalog (`GET /v1/catalog/*`, #279) — read-only.
40    CatalogRead,
41    /// Read the audit log (`GET /v1/audit`) — admin-only.
42    AuditRead,
43    /// Hot-reload the server's `--default-config` (`POST /v1/reload`) — admin-only.
44    Reload,
45}
46
47/// A named role. Roles are a fixed, built-in ladder — `viewer` ⊂ `operator` ⊂
48/// `admin` — chosen so the common cases (read-only dashboard user, run
49/// operator, full admin) need no custom permission wiring.
50#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
51#[serde(rename_all = "snake_case")]
52pub enum Role {
53    /// Read-only: runs + logs + schemas.
54    Viewer,
55    /// Everything a viewer can do, plus submit/cancel/delete runs, doctor, and
56    /// firing triggers.
57    Operator,
58    /// Full access, including reading the audit log.
59    Admin,
60}
61
62impl Role {
63    /// Whether this role grants `perm`.
64    pub fn grants(self, perm: Permission) -> bool {
65        use Permission::*;
66        match self {
67            Role::Viewer => matches!(perm, RunRead | SchemaRead | DlqRead | CatalogRead),
68            Role::Operator => {
69                matches!(
70                    perm,
71                    RunRead
72                        | SchemaRead
73                        | DlqRead
74                        | CatalogRead
75                        | RunWrite
76                        | Doctor
77                        | TriggerFire
78                        | DlqManage
79                )
80            }
81            Role::Admin => true,
82        }
83    }
84
85    pub fn as_str(self) -> &'static str {
86        match self {
87            Role::Viewer => "viewer",
88            Role::Operator => "operator",
89            Role::Admin => "admin",
90        }
91    }
92}
93
94/// One principal entry in an `--auth-config` file: a human-readable `name`, its
95/// bearer `token`, and the `role` it is granted.
96#[derive(Clone, Deserialize)]
97#[serde(deny_unknown_fields)]
98pub struct PrincipalSpec {
99    pub name: String,
100    pub token: String,
101    pub role: Role,
102}
103
104// Hand-written Debug so a `{:?}` of a spec (or the RbacConfig embedding it) never
105// prints the bearer token in clear — mirrors `AuthMode`'s masking.
106impl std::fmt::Debug for PrincipalSpec {
107    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
108        f.debug_struct("PrincipalSpec")
109            .field("name", &self.name)
110            .field("token", &"***")
111            .field("role", &self.role)
112            .finish()
113    }
114}
115
116/// File shape for `--auth-config` (`{ principals: [ … ] }`), parsed from YAML or
117/// JSON (YAML is a JSON superset, so one parser handles both).
118#[derive(Debug, Clone, Deserialize)]
119#[serde(deny_unknown_fields)]
120struct AuthConfigFile {
121    principals: Vec<PrincipalSpec>,
122}
123
124/// A validated RBAC configuration: a non-empty set of principals with unique
125/// names and unique, non-empty tokens.
126#[derive(Debug, Clone)]
127pub struct RbacConfig {
128    principals: Vec<PrincipalSpec>,
129}
130
131/// The resolved identity for one request, carried in the request extensions for
132/// handlers (and the audit writer) to read. Holds no token.
133#[derive(Debug, Clone)]
134pub struct AuthContext {
135    pub principal: String,
136    pub role: Role,
137    pub source_ip: Option<String>,
138}
139
140impl AuthContext {
141    /// Actor for a trigger-originated (non-HTTP) submission — `trigger:<name>`,
142    /// treated as an operator for audit attribution.
143    pub fn trigger(name: &str) -> Self {
144        Self {
145            principal: format!("trigger:{name}"),
146            role: Role::Operator,
147            source_ip: None,
148        }
149    }
150}
151
152impl RbacConfig {
153    /// Load + validate an `--auth-config` file (YAML or JSON).
154    pub fn from_file(path: &Path) -> CliResult<Self> {
155        let text = std::fs::read_to_string(path).map_err(|e| {
156            CliError::Serve(format!("reading --auth-config {}: {e}", path.display()))
157        })?;
158        let file: AuthConfigFile = serde_yaml::from_str(&text).map_err(|e| {
159            CliError::Serve(format!("parsing --auth-config {}: {e}", path.display()))
160        })?;
161        Self::new(file.principals)
162    }
163
164    /// Build from an already-parsed principal list, validating invariants.
165    pub fn new(principals: Vec<PrincipalSpec>) -> CliResult<Self> {
166        if principals.is_empty() {
167            return Err(CliError::Serve(
168                "--auth-config must define at least one principal".into(),
169            ));
170        }
171        let mut seen_names = std::collections::HashSet::new();
172        let mut seen_tokens = std::collections::HashSet::new();
173        for p in &principals {
174            if p.name.trim().is_empty() {
175                return Err(CliError::Serve(
176                    "--auth-config: every principal must have a non-empty name".into(),
177                ));
178            }
179            if p.token.is_empty() {
180                return Err(CliError::Serve(format!(
181                    "--auth-config: principal '{}' has an empty token",
182                    p.name
183                )));
184            }
185            if !seen_names.insert(p.name.clone()) {
186                return Err(CliError::Serve(format!(
187                    "--auth-config: duplicate principal name '{}'",
188                    p.name
189                )));
190            }
191            if !seen_tokens.insert(p.token.clone()) {
192                return Err(CliError::Serve(format!(
193                    "--auth-config: principal '{}' reuses a token already assigned to another \
194                     principal",
195                    p.name
196                )));
197            }
198        }
199        Ok(Self { principals })
200    }
201
202    /// Resolve a bearer token to its principal in constant time. Every principal
203    /// is compared (no early return) so the match position doesn't leak via
204    /// timing; the matched role/name is returned after the full scan.
205    pub fn authenticate(&self, token: &str) -> Option<AuthContext> {
206        let mut matched: Option<(&str, Role)> = None;
207        for p in &self.principals {
208            if crate::serve::auth::constant_time_eq(token.as_bytes(), p.token.as_bytes()) {
209                matched = Some((p.name.as_str(), p.role));
210            }
211        }
212        matched.map(|(name, role)| AuthContext {
213            principal: name.to_string(),
214            role,
215            source_ip: None,
216        })
217    }
218
219    /// Every configured token, for redaction registration at startup.
220    pub fn tokens(&self) -> impl Iterator<Item = &str> {
221        self.principals.iter().map(|p| p.token.as_str())
222    }
223}
224
225/// The permission a `(method, matched-route-template)` pair requires. `None`
226/// means the route has no specific mapping and is therefore admin-only (fail
227/// closed for any route added without an explicit entry here).
228pub fn required_permission(method: &Method, matched_path: &str) -> Option<Permission> {
229    use Permission::*;
230    match (method, matched_path) {
231        (&Method::POST, "/v1/runs") => Some(RunWrite),
232        (&Method::GET, "/v1/runs") => Some(RunRead),
233        (&Method::GET, "/v1/runs/{id}") => Some(RunRead),
234        (&Method::DELETE, "/v1/runs/{id}") => Some(RunWrite),
235        (&Method::POST, "/v1/runs/{id}/cancel") => Some(RunWrite),
236        (&Method::GET, "/v1/runs/{id}/logs") => Some(RunRead),
237        (&Method::GET, "/v1/schemas") => Some(SchemaRead),
238        (&Method::GET, "/v1/schemas/{kind}/{name}") => Some(SchemaRead),
239        (&Method::POST, "/v1/doctor") => Some(Doctor),
240        (&Method::POST, "/v1/backfill") => Some(RunWrite),
241        (&Method::POST, "/v1/dlq/inspect") => Some(DlqRead),
242        (&Method::POST, "/v1/dlq/replay") => Some(DlqManage),
243        (&Method::POST, "/v1/dlq/discard") => Some(DlqManage),
244        (&Method::GET, "/v1/audit") => Some(AuditRead),
245        (&Method::POST, "/v1/triggers/{name}") => Some(TriggerFire),
246        (&Method::PUT, "/v1/triggers/{name}") => Some(TriggerFire),
247        (&Method::GET, "/v1/catalog/datasets") => Some(CatalogRead),
248        (&Method::GET, "/v1/catalog/datasets/{id}") => Some(CatalogRead),
249        (&Method::GET, "/v1/catalog/lineage") => Some(CatalogRead),
250        (&Method::POST, "/v1/reload") => Some(Reload),
251        _ => None,
252    }
253}
254
255/// A short, stable audit action label for a `(method, matched-route)` pair.
256pub fn audit_action(method: &Method, matched_path: &str) -> &'static str {
257    match (method, matched_path) {
258        (&Method::POST, "/v1/runs") => "run.submit",
259        (&Method::GET, "/v1/runs") => "run.list",
260        (&Method::GET, "/v1/runs/{id}") => "run.get",
261        (&Method::DELETE, "/v1/runs/{id}") => "run.delete",
262        (&Method::POST, "/v1/runs/{id}/cancel") => "run.cancel",
263        (&Method::GET, "/v1/runs/{id}/logs") => "run.logs",
264        (&Method::GET, "/v1/schemas") => "schema.list",
265        (&Method::GET, "/v1/schemas/{kind}/{name}") => "schema.get",
266        (&Method::POST, "/v1/doctor") => "doctor",
267        (&Method::POST, "/v1/backfill") => "backfill.submit",
268        (&Method::POST, "/v1/dlq/inspect") => "dlq.inspect",
269        (&Method::POST, "/v1/dlq/replay") => "dlq.replay",
270        (&Method::POST, "/v1/dlq/discard") => "dlq.discard",
271        (&Method::GET, "/v1/audit") => "audit.list",
272        (&Method::POST | &Method::PUT, "/v1/triggers/{name}") => "trigger.fire",
273        (&Method::GET, "/v1/catalog/datasets") => "catalog.list",
274        (&Method::GET, "/v1/catalog/datasets/{id}") => "catalog.get",
275        (&Method::GET, "/v1/catalog/lineage") => "catalog.lineage",
276        (&Method::POST, "/v1/reload") => "config.reload",
277        _ => "unknown",
278    }
279}
280
281#[cfg(test)]
282mod tests {
283    use super::*;
284
285    fn spec(name: &str, token: &str, role: Role) -> PrincipalSpec {
286        PrincipalSpec {
287            name: name.into(),
288            token: token.into(),
289            role,
290        }
291    }
292
293    #[test]
294    fn role_permission_ladder() {
295        use Permission::*;
296        // Viewer: reads only.
297        assert!(Role::Viewer.grants(RunRead));
298        assert!(Role::Viewer.grants(SchemaRead));
299        assert!(Role::Viewer.grants(DlqRead));
300        assert!(!Role::Viewer.grants(RunWrite));
301        assert!(!Role::Viewer.grants(Doctor));
302        assert!(!Role::Viewer.grants(DlqManage));
303        assert!(!Role::Viewer.grants(AuditRead));
304        // Operator: reads + writes + doctor + triggers + dlq management, but not audit.
305        assert!(Role::Operator.grants(RunWrite));
306        assert!(Role::Operator.grants(Doctor));
307        assert!(Role::Operator.grants(TriggerFire));
308        assert!(Role::Operator.grants(DlqRead));
309        assert!(Role::Operator.grants(DlqManage));
310        assert!(!Role::Operator.grants(AuditRead));
311        // Admin: everything.
312        for p in [
313            RunRead,
314            RunWrite,
315            SchemaRead,
316            Doctor,
317            TriggerFire,
318            DlqRead,
319            DlqManage,
320            AuditRead,
321        ] {
322            assert!(Role::Admin.grants(p));
323        }
324    }
325
326    #[test]
327    fn authenticate_resolves_token_to_principal() {
328        let cfg = RbacConfig::new(vec![
329            spec("alice", "tok-a", Role::Admin),
330            spec("bob", "tok-b", Role::Viewer),
331        ])
332        .unwrap();
333        let a = cfg.authenticate("tok-a").unwrap();
334        assert_eq!(a.principal, "alice");
335        assert_eq!(a.role, Role::Admin);
336        let b = cfg.authenticate("tok-b").unwrap();
337        assert_eq!(b.role, Role::Viewer);
338        assert!(cfg.authenticate("nope").is_none());
339    }
340
341    #[test]
342    fn rejects_empty_duplicate_and_blank() {
343        assert!(RbacConfig::new(vec![]).is_err());
344        assert!(RbacConfig::new(vec![spec("", "t", Role::Admin)]).is_err());
345        assert!(RbacConfig::new(vec![spec("a", "", Role::Admin)]).is_err());
346        // Duplicate name.
347        assert!(
348            RbacConfig::new(vec![
349                spec("a", "t1", Role::Admin),
350                spec("a", "t2", Role::Viewer),
351            ])
352            .is_err()
353        );
354        // Duplicate token.
355        assert!(
356            RbacConfig::new(vec![
357                spec("a", "dup", Role::Admin),
358                spec("b", "dup", Role::Viewer),
359            ])
360            .is_err()
361        );
362    }
363
364    #[test]
365    fn debug_masks_token() {
366        let s = format!("{:?}", spec("alice", "supersecret", Role::Admin));
367        assert!(!s.contains("supersecret"), "token leaked: {s}");
368        assert!(s.contains("***"));
369    }
370
371    #[test]
372    fn trigger_actor_is_operator() {
373        let ctx = AuthContext::trigger("nightly");
374        assert_eq!(ctx.principal, "trigger:nightly");
375        assert_eq!(ctx.role, Role::Operator);
376        assert!(ctx.source_ip.is_none());
377    }
378
379    #[test]
380    fn tokens_iterates_all_principals() {
381        let cfg = RbacConfig::new(vec![
382            spec("a", "t1", Role::Admin),
383            spec("b", "t2", Role::Viewer),
384        ])
385        .unwrap();
386        let toks: Vec<&str> = cfg.tokens().collect();
387        assert_eq!(toks, vec!["t1", "t2"]);
388    }
389
390    #[test]
391    fn required_permission_covers_all_routes() {
392        use Permission::*;
393        for (m, path, want) in [
394            (Method::GET, "/v1/runs/{id}", RunRead),
395            (Method::DELETE, "/v1/runs/{id}", RunWrite),
396            (Method::POST, "/v1/runs/{id}/cancel", RunWrite),
397            (Method::GET, "/v1/runs/{id}/logs", RunRead),
398            (Method::GET, "/v1/schemas", SchemaRead),
399            (Method::GET, "/v1/schemas/{kind}/{name}", SchemaRead),
400            (Method::POST, "/v1/doctor", Doctor),
401            (Method::POST, "/v1/triggers/{name}", TriggerFire),
402            (Method::PUT, "/v1/triggers/{name}", TriggerFire),
403            (Method::POST, "/v1/backfill", RunWrite),
404            (Method::POST, "/v1/dlq/inspect", DlqRead),
405            (Method::POST, "/v1/dlq/replay", DlqManage),
406            (Method::POST, "/v1/dlq/discard", DlqManage),
407            (Method::GET, "/v1/catalog/datasets", CatalogRead),
408            (Method::GET, "/v1/catalog/datasets/{id}", CatalogRead),
409            (Method::GET, "/v1/catalog/lineage", CatalogRead),
410            (Method::POST, "/v1/reload", Reload),
411        ] {
412            assert_eq!(required_permission(&m, path), Some(want), "{m} {path}");
413        }
414        // Reload is admin-only.
415        assert!(!Role::Viewer.grants(Permission::Reload));
416        assert!(!Role::Operator.grants(Permission::Reload));
417        assert!(Role::Admin.grants(Permission::Reload));
418        // Every role can read the catalog; a viewer still can't write runs.
419        assert!(Role::Viewer.grants(Permission::CatalogRead));
420        assert!(Role::Operator.grants(Permission::CatalogRead));
421        assert!(Role::Admin.grants(Permission::CatalogRead));
422    }
423
424    #[test]
425    fn role_and_permission_serde_snake_case() {
426        assert_eq!(
427            serde_json::to_string(&Role::Operator).unwrap(),
428            "\"operator\""
429        );
430        assert_eq!(
431            serde_json::to_string(&Permission::AuditRead).unwrap(),
432            "\"audit_read\""
433        );
434    }
435
436    #[test]
437    fn required_permission_maps_routes() {
438        assert_eq!(
439            required_permission(&Method::POST, "/v1/runs"),
440            Some(Permission::RunWrite)
441        );
442        assert_eq!(
443            required_permission(&Method::GET, "/v1/runs"),
444            Some(Permission::RunRead)
445        );
446        assert_eq!(
447            required_permission(&Method::GET, "/v1/audit"),
448            Some(Permission::AuditRead)
449        );
450        // Unmapped → admin-only (None).
451        assert_eq!(required_permission(&Method::GET, "/v1/unknown"), None);
452    }
453
454    #[test]
455    fn parses_yaml_and_json() {
456        let yaml = "principals:\n  - name: alice\n    token: tok-a\n    role: admin\n";
457        let cfg: AuthConfigFile = serde_yaml::from_str(yaml).unwrap();
458        assert_eq!(cfg.principals.len(), 1);
459        let json = r#"{"principals":[{"name":"bob","token":"tok-b","role":"viewer"}]}"#;
460        let cfg: AuthConfigFile = serde_yaml::from_str(json).unwrap();
461        assert_eq!(cfg.principals[0].role, Role::Viewer);
462    }
463
464    #[test]
465    fn audit_action_labels() {
466        assert_eq!(audit_action(&Method::POST, "/v1/runs"), "run.submit");
467        assert_eq!(
468            audit_action(&Method::POST, "/v1/runs/{id}/cancel"),
469            "run.cancel"
470        );
471        assert_eq!(audit_action(&Method::GET, "/v1/whatever"), "unknown");
472    }
473}