1use std::collections::HashSet;
24use std::path::Path;
25use std::sync::Arc;
26use std::sync::LazyLock;
27
28use cedar_policy::{
29 Authorizer, Context, Entities, Entity, EntityUid, PolicySet, Request, Schema, ValidationMode,
30 Validator,
31};
32use metrics::counter;
33use parking_lot::RwLock;
34use tracing::{debug, info, warn};
35
36use crate::authz::error::{AuthzError, Result};
37use crate::authz::model::{
38 ChronixAction, ChronixNamespace, ChronixPrincipal, ChronixSystem, Decision,
39};
40
41pub const SCHEMA_SRC: &str = include_str!("chronix.cedarschema");
47
48static SCHEMA: LazyLock<Schema> = LazyLock::new(|| {
51 let (schema, warnings) = Schema::from_cedarschema_str(SCHEMA_SRC)
52 .expect("the compiled-in Chronix schema must parse");
53 for w in warnings {
54 warn!(warning = %w, "Chronix Cedar schema warning");
55 }
56 schema
57});
58
59pub struct AuthzEngine {
79 authorizer: Authorizer,
81 policies: RwLock<Arc<PolicySet>>,
84}
85
86impl AuthzEngine {
87 #[must_use]
89 pub fn new() -> Self {
90 Self {
91 authorizer: Authorizer::new(),
92 policies: RwLock::new(Arc::new(PolicySet::new())),
93 }
94 }
95
96 fn validate(ps: &PolicySet) -> Result<()> {
103 let validator = Validator::new(SCHEMA.clone());
104 let result = validator.validate(ps, ValidationMode::Strict);
105 let errors: Vec<String> = result.validation_errors().map(|e| format!("{e}")).collect();
106 if !errors.is_empty() {
107 return Err(AuthzError::PolicyValidation(errors.join("; ")));
108 }
109 let mut never_matches: Vec<String> = Vec::new();
110 for w in result.validation_warnings() {
111 let text = format!("{w}");
112 if text.contains("impossible") || text.contains("will never") {
113 never_matches.push(text);
114 } else {
115 warn!(warning = %text, "Cedar policy validation warning");
116 }
117 }
118 if !never_matches.is_empty() {
119 return Err(AuthzError::PolicyValidation(never_matches.join("; ")));
120 }
121 Ok(())
122 }
123
124 pub fn check_policies(policy_src: &str) -> Result<usize> {
131 let ps = policy_src
132 .parse::<PolicySet>()
133 .map_err(|e| AuthzError::PolicyParse(format!("{e}")))?;
134 Self::validate(&ps)?;
135 Ok(ps.policies().count())
136 }
137
138 pub fn load_policies(&self, policy_src: &str) -> Result<usize> {
145 let ps = policy_src
146 .parse::<PolicySet>()
147 .map_err(|e| AuthzError::PolicyParse(format!("{e}")))?;
148 Self::validate(&ps)?;
149 let count = ps.policies().count();
150 let policy_ids: Vec<String> = ps.policies().map(|p| p.id().to_string()).collect();
151 *self.policies.write() = Arc::new(ps);
152 info!(count, policies = ?policy_ids, "Cedar policies loaded");
153 counter!("chronix_authz_policy_load", "operation" => "load_all").increment(1);
154 Ok(count)
155 }
156
157 pub fn add_policy(&self, id: &str, policy_src: &str) -> Result<()> {
168 let policy = cedar_policy::Policy::parse(Some(cedar_policy::PolicyId::new(id)), policy_src)
169 .map_err(|e| AuthzError::PolicyParse(format!("{e}")))?;
170
171 let mut candidate = {
172 let ps = self.policies.read();
173 let mut candidate = PolicySet::new();
174 for p in ps.policies() {
175 candidate
176 .add(p.clone())
177 .map_err(|e| AuthzError::Internal(format!("Duplicate policy: {e}")))?;
178 }
179 candidate
180 };
181 candidate
182 .add(policy)
183 .map_err(|e| AuthzError::Internal(format!("Failed to add policy: {e}")))?;
184 Self::validate(&candidate)?;
185
186 let total = candidate.policies().count();
187 *self.policies.write() = Arc::new(candidate);
188 info!(policy_id = id, total_policies = total, "Cedar policy added");
189 counter!("chronix_authz_policy_load", "operation" => "add").increment(1);
190 Ok(())
191 }
192
193 pub fn remove_policy(&self, id: &str) -> bool {
195 let mut guard = self.policies.write();
196 let mut ps = PolicySet::new();
197 for p in guard.policies() {
198 if let Err(e) = ps.add(p.clone()) {
199 tracing::error!(error = %e, "failed to re-add policy while removing another");
200 }
201 }
202 match ps.remove_static(cedar_policy::PolicyId::new(id)) {
203 Ok(_) => {
204 let remaining = ps.policies().count();
205 *guard = Arc::new(ps);
206 info!(
207 policy_id = id,
208 remaining_policies = remaining,
209 "Cedar policy removed"
210 );
211 counter!("chronix_authz_policy_load", "operation" => "remove").increment(1);
212 true
213 }
214 Err(_) => {
215 warn!(id, "Policy not found for removal");
216 false
217 }
218 }
219 }
220
221 pub fn load_policies_from_dir(&self, dir: &Path) -> Result<usize> {
228 let mut combined = String::new();
229 let mut file_count = 0;
230
231 let mut paths: Vec<_> = std::fs::read_dir(dir)?
234 .filter_map(std::result::Result::ok)
235 .map(|e| e.path())
236 .filter(|p| p.extension().is_some_and(|e| e == "cedar"))
237 .collect();
238 paths.sort();
239
240 for path in paths {
241 combined.push_str(&std::fs::read_to_string(&path)?);
242 combined.push('\n');
243 file_count += 1;
244 debug!(path = %path.display(), "Loaded policy file");
245 }
246
247 if file_count == 0 {
248 info!(dir = %dir.display(), "No .cedar files found");
249 return Ok(0);
250 }
251 self.load_policies(&combined)
252 }
253
254 #[must_use]
256 pub fn policy_count(&self) -> usize {
257 self.policies.read().policies().count()
258 }
259
260 #[must_use]
262 pub fn policy_ids(&self) -> Vec<String> {
263 self.policies
264 .read()
265 .policies()
266 .map(|p| p.id().to_string())
267 .collect()
268 }
269
270 #[must_use]
276 pub fn authorize_namespace(
277 &self,
278 principal: &ChronixPrincipal,
279 action: ChronixAction,
280 namespace: &ChronixNamespace,
281 ) -> Decision {
282 debug_assert!(
283 !action.is_administrative(),
284 "{action} applies to the system, not to a namespace"
285 );
286 match namespace.to_entity() {
287 Ok(resource) => self.evaluate(
288 principal,
289 action,
290 resource,
291 namespace.to_entity_uid(),
292 "namespace",
293 ),
294 Err(e) => Self::internal_deny(&e),
295 }
296 }
297
298 #[must_use]
300 pub fn authorize_system(
301 &self,
302 principal: &ChronixPrincipal,
303 action: ChronixAction,
304 ) -> Decision {
305 debug_assert!(
306 action.is_administrative(),
307 "{action} applies to a namespace, not to the system"
308 );
309 match ChronixSystem::to_entity() {
310 Ok(resource) => self.evaluate(
311 principal,
312 action,
313 resource,
314 ChronixSystem::to_entity_uid(),
315 "system",
316 ),
317 Err(e) => Self::internal_deny(&e),
318 }
319 }
320
321 fn internal_deny(e: &AuthzError) -> Decision {
323 tracing::error!(error = %e, "Cedar entity construction failed");
324 Decision::Deny {
325 reasons: vec!["internal authorization error".into()],
326 }
327 }
328
329 fn evaluate(
330 &self,
331 principal: &ChronixPrincipal,
332 action: ChronixAction,
333 resource: Entity,
334 resource_uid: EntityUid,
335 resource_label: &'static str,
336 ) -> Decision {
337 let principal_entity = match principal.to_entity() {
338 Ok(e) => e,
339 Err(e) => return Self::internal_deny(&e),
340 };
341 let role_entities = match principal.role_entities() {
342 Ok(v) => v,
343 Err(e) => return Self::internal_deny(&e),
344 };
345
346 let mut all = vec![principal_entity, resource];
347 all.extend(role_entities);
348 let entities = match Entities::from_entities(all, Some(&SCHEMA)) {
349 Ok(e) => e,
350 Err(e) => {
351 return Self::internal_deny(&AuthzError::Internal(format!("entity set: {e}")))
352 }
353 };
354
355 let request = match Request::new(
356 principal.to_entity_uid(),
357 action.to_entity_uid(),
358 resource_uid,
359 Context::empty(),
360 Some(&SCHEMA),
361 ) {
362 Ok(r) => r,
363 Err(e) => return Self::internal_deny(&AuthzError::Internal(format!("request: {e}"))),
364 };
365
366 let policies = Arc::clone(&self.policies.read());
367 let response = self
368 .authorizer
369 .is_authorized(&request, &policies, &entities);
370
371 let decision_label = match response.decision() {
372 cedar_policy::Decision::Allow => "allow",
373 cedar_policy::Decision::Deny => "deny",
374 };
375 counter!(
376 "chronix_authz_decisions_total",
377 "action" => action.to_string(),
378 "resource_type" => resource_label,
379 "decision" => decision_label,
380 )
381 .increment(1);
382
383 match response.decision() {
384 cedar_policy::Decision::Allow => Decision::Allow,
385 cedar_policy::Decision::Deny => {
386 let eval_errors: Vec<String> = response
389 .diagnostics()
390 .errors()
391 .map(std::string::ToString::to_string)
392 .collect();
393 let policy_reasons: Vec<String> = response
394 .diagnostics()
395 .reason()
396 .map(std::string::ToString::to_string)
397 .collect();
398 if !policy_reasons.is_empty() || !eval_errors.is_empty() {
399 debug!(
400 ?policy_reasons,
401 ?eval_errors,
402 %action,
403 principal = %principal.id,
404 "authorization denied with diagnostics"
405 );
406 }
407 Decision::Deny {
408 reasons: vec!["access denied".into()],
409 }
410 }
411 }
412 }
413}
414
415impl Default for AuthzEngine {
416 fn default() -> Self {
417 Self::new()
418 }
419}
420
421impl std::fmt::Debug for AuthzEngine {
422 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
423 f.debug_struct("AuthzEngine")
424 .field("policy_count", &self.policy_count())
425 .finish()
426 }
427}
428
429pub fn schema_action_names() -> Result<HashSet<String>> {
439 let entities = SCHEMA
440 .action_entities()
441 .map_err(|e| AuthzError::Internal(format!("schema actions: {e}")))?;
442 Ok(entities
443 .iter()
444 .map(|e| e.uid().id().escaped().trim_matches('"').to_string())
445 .collect())
446}
447
448#[cfg(test)]
449mod tests {
450 use super::*;
451
452 fn alice(role: &str) -> ChronixPrincipal {
453 ChronixPrincipal::new("alice").with_role(role)
454 }
455
456 fn prod() -> ChronixNamespace {
457 ChronixNamespace::new("prod")
458 }
459
460 #[test]
461 fn the_builtin_schema_parses() {
462 let _ = &*SCHEMA;
465 assert!(!schema_action_names().unwrap().is_empty());
466 }
467
468 #[test]
469 fn the_schema_describes_exactly_the_actions_we_issue() {
470 let mut declared = schema_action_names().unwrap();
475 assert!(declared.remove("Admin"), "the Admin group is gone");
477 let issued: HashSet<String> = ChronixAction::ALL
478 .iter()
479 .map(|a| a.name().to_string())
480 .collect();
481 assert_eq!(declared, issued);
482 }
483
484 #[test]
485 fn a_policy_naming_an_unknown_action_is_refused() {
486 for bad in [
489 r#"permit(principal, action == Chronix::Action::"Query", resource);"#,
490 r#"permit(principal, action == Action::"Write", resource);"#,
491 r#"permit(principal in Group::"platform-team", action, resource);"#,
492 r#"permit(principal, action, resource in Namespace::"team-platform");"#,
493 r#"permit(principal, action == Chronix::Action::"CreateMeasurement", resource);"#,
494 ] {
495 assert!(
496 AuthzEngine::check_policies(bad).is_err(),
497 "should have been refused: {bad}"
498 );
499 }
500 }
501
502 #[test]
503 fn an_admin_action_aimed_at_a_namespace_is_refused() {
504 let bad = r#"
505 permit(
506 principal,
507 action == Chronix::Action::"ManageKeys",
508 resource == Chronix::Namespace::"prod"
509 );
510 "#;
511 assert!(AuthzEngine::check_policies(bad).is_err());
512 }
513
514 #[test]
515 fn default_deny() {
516 let engine = AuthzEngine::new();
517 assert!(engine
518 .authorize_namespace(&alice("reader"), ChronixAction::Read, &prod())
519 .is_denied());
520 assert!(engine
521 .authorize_system(&alice("reader"), ChronixAction::ManageKeys)
522 .is_denied());
523 }
524
525 #[test]
526 fn a_role_grants_within_one_namespace_only() {
527 let engine = AuthzEngine::new();
528 engine
529 .load_policies(
530 r#"
531 permit(
532 principal in Chronix::Role::"prod_reader",
533 action == Chronix::Action::"Read",
534 resource == Chronix::Namespace::"prod"
535 );
536 "#,
537 )
538 .unwrap();
539 let p = alice("prod_reader");
540 assert!(engine
541 .authorize_namespace(&p, ChronixAction::Read, &prod())
542 .is_allowed());
543 assert!(engine
544 .authorize_namespace(&p, ChronixAction::Read, &ChronixNamespace::new("staging"))
545 .is_denied());
546 assert!(engine
547 .authorize_namespace(&p, ChronixAction::Write, &prod())
548 .is_denied());
549 }
550
551 #[test]
552 fn a_principal_without_the_role_is_denied() {
553 let engine = AuthzEngine::new();
554 engine
555 .load_policies(
556 r#"permit(principal in Chronix::Role::"writer",
557 action == Chronix::Action::"Write", resource);"#,
558 )
559 .unwrap();
560 assert!(engine
561 .authorize_namespace(&alice("reader"), ChronixAction::Write, &prod())
562 .is_denied());
563 assert!(engine
564 .authorize_namespace(&alice("writer"), ChronixAction::Write, &prod())
565 .is_allowed());
566 }
567
568 #[test]
569 fn the_admin_group_grants_every_capability_and_one_action_grants_one() {
570 let engine = AuthzEngine::new();
571 engine
572 .load_policies(
573 r#"
574 permit(
575 principal in Chronix::Role::"root",
576 action in Chronix::Action::"Admin",
577 resource
578 );
579 permit(
580 principal in Chronix::Role::"backup_operator",
581 action == Chronix::Action::"ManageBackups",
582 resource
583 );
584 "#,
585 )
586 .unwrap();
587
588 let root = alice("root");
589 for action in ChronixAction::ALL
590 .iter()
591 .copied()
592 .filter(|a| a.is_administrative())
593 {
594 assert!(
595 engine.authorize_system(&root, action).is_allowed(),
596 "the Admin group should cover {action}"
597 );
598 }
599
600 let backup = alice("backup_operator");
604 assert!(engine
605 .authorize_system(&backup, ChronixAction::ManageBackups)
606 .is_allowed());
607 assert!(engine
608 .authorize_system(&backup, ChronixAction::ManageKeys)
609 .is_denied());
610 assert!(engine
611 .authorize_system(&backup, ChronixAction::ManageNamespaces)
612 .is_denied());
613 }
614
615 #[test]
616 fn a_forbid_overrides_a_permit() {
617 let engine = AuthzEngine::new();
618 engine
619 .load_policies(
620 r#"
621 permit(principal, action == Chronix::Action::"Read", resource);
622 forbid(principal, action, resource == Chronix::Namespace::"secret");
623 "#,
624 )
625 .unwrap();
626 assert!(engine
627 .authorize_namespace(&alice("any"), ChronixAction::Read, &prod())
628 .is_allowed());
629 assert!(engine
630 .authorize_namespace(
631 &alice("any"),
632 ChronixAction::Read,
633 &ChronixNamespace::new("secret")
634 )
635 .is_denied());
636 }
637
638 #[test]
639 fn a_principal_named_directly_needs_no_role() {
640 let engine = AuthzEngine::new();
641 engine
642 .load_policies(
643 r#"permit(principal == Chronix::User::"ingest-key",
644 action == Chronix::Action::"Write", resource);"#,
645 )
646 .unwrap();
647 assert!(engine
648 .authorize_namespace(
649 &ChronixPrincipal::new("ingest-key"),
650 ChronixAction::Write,
651 &prod()
652 )
653 .is_allowed());
654 assert!(engine
655 .authorize_namespace(
656 &ChronixPrincipal::new("other-key"),
657 ChronixAction::Write,
658 &prod()
659 )
660 .is_denied());
661 }
662
663 #[test]
664 fn a_rejected_policy_leaves_the_active_set_alone() {
665 let engine = AuthzEngine::new();
666 engine
667 .load_policies(r#"permit(principal, action == Chronix::Action::"Read", resource);"#)
668 .unwrap();
669 assert_eq!(engine.policy_count(), 1);
670 assert!(engine
671 .add_policy(
672 "bad",
673 r#"permit(principal, action == Chronix::Action::"Query", resource);"#
674 )
675 .is_err());
676 assert_eq!(engine.policy_count(), 1);
677 assert!(engine
678 .authorize_namespace(&alice("any"), ChronixAction::Read, &prod())
679 .is_allowed());
680 }
681
682 #[test]
683 fn add_and_remove_a_policy() {
684 let engine = AuthzEngine::new();
685 engine
686 .add_policy(
687 "readers",
688 r#"permit(principal, action == Chronix::Action::"Read", resource);"#,
689 )
690 .unwrap();
691 assert_eq!(engine.policy_ids(), vec!["readers".to_string()]);
692 assert!(engine.remove_policy("readers"));
693 assert!(!engine.remove_policy("readers"));
694 assert_eq!(engine.policy_count(), 0);
695 }
696
697 #[test]
698 fn policies_load_from_a_directory_in_a_stable_order() {
699 let dir = tempfile::tempdir().unwrap();
700 std::fs::write(
701 dir.path().join("b.cedar"),
702 r#"permit(principal, action == Chronix::Action::"Write", resource);"#,
703 )
704 .unwrap();
705 std::fs::write(
706 dir.path().join("a.cedar"),
707 r#"permit(principal, action == Chronix::Action::"Read", resource);"#,
708 )
709 .unwrap();
710 std::fs::write(dir.path().join("notes.txt"), "ignored").unwrap();
711
712 let engine = AuthzEngine::new();
713 assert_eq!(engine.load_policies_from_dir(dir.path()).unwrap(), 2);
714 assert!(engine
715 .authorize_namespace(&alice("any"), ChronixAction::Read, &prod())
716 .is_allowed());
717 }
718
719 #[test]
720 fn an_empty_directory_loads_nothing_and_denies_everything() {
721 let dir = tempfile::tempdir().unwrap();
722 let engine = AuthzEngine::new();
723 assert_eq!(engine.load_policies_from_dir(dir.path()).unwrap(), 0);
724 assert!(engine
725 .authorize_namespace(&alice("any"), ChronixAction::Read, &prod())
726 .is_denied());
727 }
728}