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        // MCP endpoint (#420): baseline access needs only a read scope (Viewer+);
252        // the mutating `run_pipeline` tool is separately gated on RunWrite inside
253        // the handler.
254        (&Method::POST, "/mcp") => Some(SchemaRead),
255        _ => None,
256    }
257}
258
259/// A short, stable audit action label for a `(method, matched-route)` pair.
260pub fn audit_action(method: &Method, matched_path: &str) -> &'static str {
261    match (method, matched_path) {
262        (&Method::POST, "/v1/runs") => "run.submit",
263        (&Method::GET, "/v1/runs") => "run.list",
264        (&Method::GET, "/v1/runs/{id}") => "run.get",
265        (&Method::DELETE, "/v1/runs/{id}") => "run.delete",
266        (&Method::POST, "/v1/runs/{id}/cancel") => "run.cancel",
267        (&Method::GET, "/v1/runs/{id}/logs") => "run.logs",
268        (&Method::GET, "/v1/schemas") => "schema.list",
269        (&Method::GET, "/v1/schemas/{kind}/{name}") => "schema.get",
270        (&Method::POST, "/v1/doctor") => "doctor",
271        (&Method::POST, "/v1/backfill") => "backfill.submit",
272        (&Method::POST, "/v1/dlq/inspect") => "dlq.inspect",
273        (&Method::POST, "/v1/dlq/replay") => "dlq.replay",
274        (&Method::POST, "/v1/dlq/discard") => "dlq.discard",
275        (&Method::GET, "/v1/audit") => "audit.list",
276        (&Method::POST | &Method::PUT, "/v1/triggers/{name}") => "trigger.fire",
277        (&Method::GET, "/v1/catalog/datasets") => "catalog.list",
278        (&Method::GET, "/v1/catalog/datasets/{id}") => "catalog.get",
279        (&Method::GET, "/v1/catalog/lineage") => "catalog.lineage",
280        (&Method::POST, "/v1/reload") => "config.reload",
281        (&Method::POST, "/mcp") => "mcp",
282        _ => "unknown",
283    }
284}
285
286#[cfg(test)]
287mod tests {
288    use super::*;
289
290    fn spec(name: &str, token: &str, role: Role) -> PrincipalSpec {
291        PrincipalSpec {
292            name: name.into(),
293            token: token.into(),
294            role,
295        }
296    }
297
298    #[test]
299    fn role_permission_ladder() {
300        use Permission::*;
301        // Viewer: reads only.
302        assert!(Role::Viewer.grants(RunRead));
303        assert!(Role::Viewer.grants(SchemaRead));
304        assert!(Role::Viewer.grants(DlqRead));
305        assert!(!Role::Viewer.grants(RunWrite));
306        assert!(!Role::Viewer.grants(Doctor));
307        assert!(!Role::Viewer.grants(DlqManage));
308        assert!(!Role::Viewer.grants(AuditRead));
309        // Operator: reads + writes + doctor + triggers + dlq management, but not audit.
310        assert!(Role::Operator.grants(RunWrite));
311        assert!(Role::Operator.grants(Doctor));
312        assert!(Role::Operator.grants(TriggerFire));
313        assert!(Role::Operator.grants(DlqRead));
314        assert!(Role::Operator.grants(DlqManage));
315        assert!(!Role::Operator.grants(AuditRead));
316        // Admin: everything.
317        for p in [
318            RunRead,
319            RunWrite,
320            SchemaRead,
321            Doctor,
322            TriggerFire,
323            DlqRead,
324            DlqManage,
325            AuditRead,
326        ] {
327            assert!(Role::Admin.grants(p));
328        }
329    }
330
331    #[test]
332    fn authenticate_resolves_token_to_principal() {
333        let cfg = RbacConfig::new(vec![
334            spec("alice", "tok-a", Role::Admin),
335            spec("bob", "tok-b", Role::Viewer),
336        ])
337        .unwrap();
338        let a = cfg.authenticate("tok-a").unwrap();
339        assert_eq!(a.principal, "alice");
340        assert_eq!(a.role, Role::Admin);
341        let b = cfg.authenticate("tok-b").unwrap();
342        assert_eq!(b.role, Role::Viewer);
343        assert!(cfg.authenticate("nope").is_none());
344    }
345
346    #[test]
347    fn rejects_empty_duplicate_and_blank() {
348        assert!(RbacConfig::new(vec![]).is_err());
349        assert!(RbacConfig::new(vec![spec("", "t", Role::Admin)]).is_err());
350        assert!(RbacConfig::new(vec![spec("a", "", Role::Admin)]).is_err());
351        // Duplicate name.
352        assert!(
353            RbacConfig::new(vec![
354                spec("a", "t1", Role::Admin),
355                spec("a", "t2", Role::Viewer),
356            ])
357            .is_err()
358        );
359        // Duplicate token.
360        assert!(
361            RbacConfig::new(vec![
362                spec("a", "dup", Role::Admin),
363                spec("b", "dup", Role::Viewer),
364            ])
365            .is_err()
366        );
367    }
368
369    #[test]
370    fn debug_masks_token() {
371        let s = format!("{:?}", spec("alice", "supersecret", Role::Admin));
372        assert!(!s.contains("supersecret"), "token leaked: {s}");
373        assert!(s.contains("***"));
374    }
375
376    #[test]
377    fn trigger_actor_is_operator() {
378        let ctx = AuthContext::trigger("nightly");
379        assert_eq!(ctx.principal, "trigger:nightly");
380        assert_eq!(ctx.role, Role::Operator);
381        assert!(ctx.source_ip.is_none());
382    }
383
384    #[test]
385    fn tokens_iterates_all_principals() {
386        let cfg = RbacConfig::new(vec![
387            spec("a", "t1", Role::Admin),
388            spec("b", "t2", Role::Viewer),
389        ])
390        .unwrap();
391        let toks: Vec<&str> = cfg.tokens().collect();
392        assert_eq!(toks, vec!["t1", "t2"]);
393    }
394
395    #[test]
396    fn required_permission_covers_all_routes() {
397        use Permission::*;
398        for (m, path, want) in [
399            (Method::GET, "/v1/runs/{id}", RunRead),
400            (Method::DELETE, "/v1/runs/{id}", RunWrite),
401            (Method::POST, "/v1/runs/{id}/cancel", RunWrite),
402            (Method::GET, "/v1/runs/{id}/logs", RunRead),
403            (Method::GET, "/v1/schemas", SchemaRead),
404            (Method::GET, "/v1/schemas/{kind}/{name}", SchemaRead),
405            (Method::POST, "/v1/doctor", Doctor),
406            (Method::POST, "/v1/triggers/{name}", TriggerFire),
407            (Method::PUT, "/v1/triggers/{name}", TriggerFire),
408            (Method::POST, "/v1/backfill", RunWrite),
409            (Method::POST, "/v1/dlq/inspect", DlqRead),
410            (Method::POST, "/v1/dlq/replay", DlqManage),
411            (Method::POST, "/v1/dlq/discard", DlqManage),
412            (Method::GET, "/v1/catalog/datasets", CatalogRead),
413            (Method::GET, "/v1/catalog/datasets/{id}", CatalogRead),
414            (Method::GET, "/v1/catalog/lineage", CatalogRead),
415            (Method::POST, "/v1/reload", Reload),
416        ] {
417            assert_eq!(required_permission(&m, path), Some(want), "{m} {path}");
418        }
419        // Reload is admin-only.
420        assert!(!Role::Viewer.grants(Permission::Reload));
421        assert!(!Role::Operator.grants(Permission::Reload));
422        assert!(Role::Admin.grants(Permission::Reload));
423        // Every role can read the catalog; a viewer still can't write runs.
424        assert!(Role::Viewer.grants(Permission::CatalogRead));
425        assert!(Role::Operator.grants(Permission::CatalogRead));
426        assert!(Role::Admin.grants(Permission::CatalogRead));
427    }
428
429    #[test]
430    fn role_and_permission_serde_snake_case() {
431        assert_eq!(
432            serde_json::to_string(&Role::Operator).unwrap(),
433            "\"operator\""
434        );
435        assert_eq!(
436            serde_json::to_string(&Permission::AuditRead).unwrap(),
437            "\"audit_read\""
438        );
439    }
440
441    #[test]
442    fn required_permission_maps_routes() {
443        assert_eq!(
444            required_permission(&Method::POST, "/v1/runs"),
445            Some(Permission::RunWrite)
446        );
447        assert_eq!(
448            required_permission(&Method::GET, "/v1/runs"),
449            Some(Permission::RunRead)
450        );
451        assert_eq!(
452            required_permission(&Method::GET, "/v1/audit"),
453            Some(Permission::AuditRead)
454        );
455        // Unmapped → admin-only (None).
456        assert_eq!(required_permission(&Method::GET, "/v1/unknown"), None);
457    }
458
459    #[test]
460    fn parses_yaml_and_json() {
461        let yaml = "principals:\n  - name: alice\n    token: tok-a\n    role: admin\n";
462        let cfg: AuthConfigFile = serde_yaml::from_str(yaml).unwrap();
463        assert_eq!(cfg.principals.len(), 1);
464        let json = r#"{"principals":[{"name":"bob","token":"tok-b","role":"viewer"}]}"#;
465        let cfg: AuthConfigFile = serde_yaml::from_str(json).unwrap();
466        assert_eq!(cfg.principals[0].role, Role::Viewer);
467    }
468
469    #[test]
470    fn audit_action_labels() {
471        assert_eq!(audit_action(&Method::POST, "/v1/runs"), "run.submit");
472        assert_eq!(
473            audit_action(&Method::POST, "/v1/runs/{id}/cancel"),
474            "run.cancel"
475        );
476        assert_eq!(audit_action(&Method::GET, "/v1/whatever"), "unknown");
477    }
478}