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