1use crate::error::{CliError, CliResult};
16use axum::http::Method;
17use serde::{Deserialize, Serialize};
18use std::path::Path;
19
20#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
22#[serde(rename_all = "snake_case")]
23pub enum Permission {
24 RunRead,
26 RunWrite,
28 SchemaRead,
30 Doctor,
32 TriggerFire,
34 DlqRead,
36 DlqManage,
39 CatalogRead,
41 AuditRead,
43 Reload,
45}
46
47#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
51#[serde(rename_all = "snake_case")]
52pub enum Role {
53 Viewer,
55 Operator,
58 Admin,
60}
61
62impl Role {
63 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#[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
104impl 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#[derive(Debug, Clone, Deserialize)]
119#[serde(deny_unknown_fields)]
120struct AuthConfigFile {
121 principals: Vec<PrincipalSpec>,
122}
123
124#[derive(Debug, Clone)]
127pub struct RbacConfig {
128 principals: Vec<PrincipalSpec>,
129}
130
131#[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 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 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 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 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 pub fn tokens(&self) -> impl Iterator<Item = &str> {
221 self.principals.iter().map(|p| p.token.as_str())
222 }
223}
224
225pub 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 (&Method::POST, "/mcp") => Some(SchemaRead),
255 _ => None,
256 }
257}
258
259pub 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 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 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 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 assert!(
353 RbacConfig::new(vec![
354 spec("a", "t1", Role::Admin),
355 spec("a", "t2", Role::Viewer),
356 ])
357 .is_err()
358 );
359 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 assert!(!Role::Viewer.grants(Permission::Reload));
421 assert!(!Role::Operator.grants(Permission::Reload));
422 assert!(Role::Admin.grants(Permission::Reload));
423 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 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}