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 TemplateRead,
44 TemplateWrite,
49 AuditRead,
51 Reload,
53}
54
55#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
59#[serde(rename_all = "snake_case")]
60pub enum Role {
61 Viewer,
63 Operator,
66 Admin,
68}
69
70impl Role {
71 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#[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
119impl 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#[derive(Debug, Clone, Deserialize)]
134#[serde(deny_unknown_fields)]
135struct AuthConfigFile {
136 principals: Vec<PrincipalSpec>,
137}
138
139#[derive(Debug, Clone)]
142pub struct RbacConfig {
143 principals: Vec<PrincipalSpec>,
144}
145
146#[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 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 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 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 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 pub fn tokens(&self) -> impl Iterator<Item = &str> {
236 self.principals.iter().map(|p| p.token.as_str())
237 }
238}
239
240pub 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 (&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 (&Method::POST, "/mcp") => Some(SchemaRead),
282 _ => None,
283 }
284}
285
286pub 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 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 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 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 assert!(
394 RbacConfig::new(vec![
395 spec("a", "t1", Role::Admin),
396 spec("a", "t2", Role::Viewer),
397 ])
398 .is_err()
399 );
400 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 assert!(!Role::Viewer.grants(Permission::Reload));
471 assert!(!Role::Operator.grants(Permission::Reload));
472 assert!(Role::Admin.grants(Permission::Reload));
473 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 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}