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!(
518 engine
519 .authorize_namespace(&alice("reader"), ChronixAction::Read, &prod())
520 .is_denied()
521 );
522 assert!(
523 engine
524 .authorize_system(&alice("reader"), ChronixAction::ManageKeys)
525 .is_denied()
526 );
527 }
528
529 #[test]
530 fn a_role_grants_within_one_namespace_only() {
531 let engine = AuthzEngine::new();
532 engine
533 .load_policies(
534 r#"
535 permit(
536 principal in Chronix::Role::"prod_reader",
537 action == Chronix::Action::"Read",
538 resource == Chronix::Namespace::"prod"
539 );
540 "#,
541 )
542 .unwrap();
543 let p = alice("prod_reader");
544 assert!(
545 engine
546 .authorize_namespace(&p, ChronixAction::Read, &prod())
547 .is_allowed()
548 );
549 assert!(
550 engine
551 .authorize_namespace(&p, ChronixAction::Read, &ChronixNamespace::new("staging"))
552 .is_denied()
553 );
554 assert!(
555 engine
556 .authorize_namespace(&p, ChronixAction::Write, &prod())
557 .is_denied()
558 );
559 }
560
561 #[test]
562 fn a_principal_without_the_role_is_denied() {
563 let engine = AuthzEngine::new();
564 engine
565 .load_policies(
566 r#"permit(principal in Chronix::Role::"writer",
567 action == Chronix::Action::"Write", resource);"#,
568 )
569 .unwrap();
570 assert!(
571 engine
572 .authorize_namespace(&alice("reader"), ChronixAction::Write, &prod())
573 .is_denied()
574 );
575 assert!(
576 engine
577 .authorize_namespace(&alice("writer"), ChronixAction::Write, &prod())
578 .is_allowed()
579 );
580 }
581
582 #[test]
583 fn the_admin_group_grants_every_capability_and_one_action_grants_one() {
584 let engine = AuthzEngine::new();
585 engine
586 .load_policies(
587 r#"
588 permit(
589 principal in Chronix::Role::"root",
590 action in Chronix::Action::"Admin",
591 resource
592 );
593 permit(
594 principal in Chronix::Role::"backup_operator",
595 action == Chronix::Action::"ManageBackups",
596 resource
597 );
598 "#,
599 )
600 .unwrap();
601
602 let root = alice("root");
603 for action in ChronixAction::ALL
604 .iter()
605 .copied()
606 .filter(|a| a.is_administrative())
607 {
608 assert!(
609 engine.authorize_system(&root, action).is_allowed(),
610 "the Admin group should cover {action}"
611 );
612 }
613
614 let backup = alice("backup_operator");
618 assert!(
619 engine
620 .authorize_system(&backup, ChronixAction::ManageBackups)
621 .is_allowed()
622 );
623 assert!(
624 engine
625 .authorize_system(&backup, ChronixAction::ManageKeys)
626 .is_denied()
627 );
628 assert!(
629 engine
630 .authorize_system(&backup, ChronixAction::ManageNamespaces)
631 .is_denied()
632 );
633 }
634
635 #[test]
636 fn a_forbid_overrides_a_permit() {
637 let engine = AuthzEngine::new();
638 engine
639 .load_policies(
640 r#"
641 permit(principal, action == Chronix::Action::"Read", resource);
642 forbid(principal, action, resource == Chronix::Namespace::"secret");
643 "#,
644 )
645 .unwrap();
646 assert!(
647 engine
648 .authorize_namespace(&alice("any"), ChronixAction::Read, &prod())
649 .is_allowed()
650 );
651 assert!(
652 engine
653 .authorize_namespace(
654 &alice("any"),
655 ChronixAction::Read,
656 &ChronixNamespace::new("secret")
657 )
658 .is_denied()
659 );
660 }
661
662 #[test]
663 fn a_principal_named_directly_needs_no_role() {
664 let engine = AuthzEngine::new();
665 engine
666 .load_policies(
667 r#"permit(principal == Chronix::User::"ingest-key",
668 action == Chronix::Action::"Write", resource);"#,
669 )
670 .unwrap();
671 assert!(
672 engine
673 .authorize_namespace(
674 &ChronixPrincipal::new("ingest-key"),
675 ChronixAction::Write,
676 &prod()
677 )
678 .is_allowed()
679 );
680 assert!(
681 engine
682 .authorize_namespace(
683 &ChronixPrincipal::new("other-key"),
684 ChronixAction::Write,
685 &prod()
686 )
687 .is_denied()
688 );
689 }
690
691 #[test]
692 fn a_rejected_policy_leaves_the_active_set_alone() {
693 let engine = AuthzEngine::new();
694 engine
695 .load_policies(r#"permit(principal, action == Chronix::Action::"Read", resource);"#)
696 .unwrap();
697 assert_eq!(engine.policy_count(), 1);
698 assert!(
699 engine
700 .add_policy(
701 "bad",
702 r#"permit(principal, action == Chronix::Action::"Query", resource);"#
703 )
704 .is_err()
705 );
706 assert_eq!(engine.policy_count(), 1);
707 assert!(
708 engine
709 .authorize_namespace(&alice("any"), ChronixAction::Read, &prod())
710 .is_allowed()
711 );
712 }
713
714 #[test]
715 fn add_and_remove_a_policy() {
716 let engine = AuthzEngine::new();
717 engine
718 .add_policy(
719 "readers",
720 r#"permit(principal, action == Chronix::Action::"Read", resource);"#,
721 )
722 .unwrap();
723 assert_eq!(engine.policy_ids(), vec!["readers".to_string()]);
724 assert!(engine.remove_policy("readers"));
725 assert!(!engine.remove_policy("readers"));
726 assert_eq!(engine.policy_count(), 0);
727 }
728
729 #[test]
730 fn policies_load_from_a_directory_in_a_stable_order() {
731 let dir = tempfile::tempdir().unwrap();
732 std::fs::write(
733 dir.path().join("b.cedar"),
734 r#"permit(principal, action == Chronix::Action::"Write", resource);"#,
735 )
736 .unwrap();
737 std::fs::write(
738 dir.path().join("a.cedar"),
739 r#"permit(principal, action == Chronix::Action::"Read", resource);"#,
740 )
741 .unwrap();
742 std::fs::write(dir.path().join("notes.txt"), "ignored").unwrap();
743
744 let engine = AuthzEngine::new();
745 assert_eq!(engine.load_policies_from_dir(dir.path()).unwrap(), 2);
746 assert!(
747 engine
748 .authorize_namespace(&alice("any"), ChronixAction::Read, &prod())
749 .is_allowed()
750 );
751 }
752
753 #[test]
754 fn an_empty_directory_loads_nothing_and_denies_everything() {
755 let dir = tempfile::tempdir().unwrap();
756 let engine = AuthzEngine::new();
757 assert_eq!(engine.load_policies_from_dir(dir.path()).unwrap(), 0);
758 assert!(
759 engine
760 .authorize_namespace(&alice("any"), ChronixAction::Read, &prod())
761 .is_denied()
762 );
763 }
764}