1use std::collections::{HashMap, HashSet, VecDeque};
7use std::path::Path;
8use std::sync::atomic::{AtomicU64, Ordering};
9use std::sync::Arc;
10
11use cedar_policy::{
12 Authorizer, Context, Entities, PolicySet, Request, Schema, ValidationMode, Validator,
13};
14use metrics::counter;
15use parking_lot::RwLock;
16use tracing::{debug, error, info, warn};
17
18use crate::authz::error::{AuthzError, Result};
19use crate::authz::model::{
20 ChronixAction, ChronixNamespace, ChronixPrincipal, ChronixResource, Decision,
21};
22
23const MAX_POLICY_VERSIONS: usize = 10;
25
26#[derive(Debug, Clone)]
28pub struct PolicyVersion {
29 pub version: u64,
31 pub policies: Arc<PolicySet>,
33 pub source: String,
35 pub timestamp: i64,
37}
38
39pub struct AuthzEngine {
70 authorizer: Authorizer,
72 policies: RwLock<Arc<PolicySet>>,
76 schema: Option<Schema>,
78 require_schema: bool,
80 version_counter: AtomicU64,
82 history: RwLock<VecDeque<PolicyVersion>>,
84}
85
86impl AuthzEngine {
87 #[must_use]
95 pub fn new() -> Self {
96 let engine = Self {
97 authorizer: Authorizer::new(),
98 policies: RwLock::new(Arc::new(PolicySet::new())),
99 schema: None,
100 require_schema: false,
101 version_counter: AtomicU64::new(0),
102 history: RwLock::new(VecDeque::new()),
103 };
104
105 if let Err(e) = engine.load_cross_tenant_isolation_policy() {
109 tracing::error!(error = %e, "failed to auto-load cross-tenant isolation policy");
110 }
111
112 engine
113 }
114
115 pub fn with_policies(policies: PolicySet) -> Self {
117 let count = policies.policies().count();
118 info!(
119 policy_count = count,
120 "AuthzEngine initialized with policies"
121 );
122 Self {
123 authorizer: Authorizer::new(),
124 policies: RwLock::new(Arc::new(policies)),
125 schema: None,
126 require_schema: false,
127 version_counter: AtomicU64::new(1),
128 history: RwLock::new(VecDeque::new()),
129 }
130 }
131
132 pub fn with_schema(mut self, schema: Schema) -> Self {
141 self.schema = Some(schema);
142 self.require_schema = true;
143 self
144 }
145
146 pub fn load_schema(&mut self, schema_src: &str) -> Result<()> {
150 let (schema, _warnings) = Schema::from_cedarschema_str(schema_src)
151 .map_err(|e| AuthzError::SchemaParse(format!("{e}")))?;
152 self.schema = Some(schema);
153 self.require_schema = true;
154 info!("Cedar schema loaded (require_schema auto-enabled)");
155 Ok(())
156 }
157
158 pub fn with_require_schema(mut self, require: bool) -> Self {
162 self.require_schema = require;
163 self
164 }
165
166 fn validate_policies(&self, ps: &PolicySet) -> Result<()> {
171 if let Some(ref schema) = self.schema {
172 let validator = Validator::new(schema.clone());
173 let result = validator.validate(ps, ValidationMode::default());
174 let errors: Vec<String> = result.validation_errors().map(|e| format!("{e}")).collect();
175 if !errors.is_empty() {
176 return Err(AuthzError::PolicyValidation(errors.join("; ")));
177 }
178 let warnings: Vec<String> = result
179 .validation_warnings()
180 .map(|w| format!("{w}"))
181 .collect();
182 for w in &warnings {
183 warn!(warning = %w, "Cedar policy validation warning");
184 }
185 } else if self.require_schema {
186 return Err(AuthzError::PolicyValidation(
187 "no Cedar schema loaded but require_schema is enabled; \
188 call load_schema() or with_schema() before loading policies"
189 .into(),
190 ));
191 } else {
192 tracing::warn!(
193 "no Cedar schema loaded — policies are not validated \
194 against a schema; typos in entity/action names will be silently accepted"
195 );
196 }
197 Ok(())
198 }
199
200 pub fn load_policies(&self, policy_src: &str) -> Result<usize> {
208 let ps = policy_src
209 .parse::<PolicySet>()
210 .map_err(|e| AuthzError::PolicyParse(format!("{e}")))?;
211 self.validate_policies(&ps)?;
212 let count = ps.policies().count();
213 let policy_ids: Vec<String> = ps.policies().map(|p| p.id().to_string()).collect();
214
215 let new_version = self.version_counter.fetch_add(1, Ordering::Relaxed) + 1;
216 self.push_history("full reload");
217
218 *self.policies.write() = Arc::new(ps);
219 info!(count, version = new_version, policies = ?policy_ids, "Policies loaded (full reload)");
220 counter!("chronix_authz_policy_load", "operation" => "load_all").increment(1);
221 Ok(count)
222 }
223
224 pub fn dryrun_load_policies(&self, policy_src: &str) -> Result<usize> {
231 let ps = policy_src
232 .parse::<PolicySet>()
233 .map_err(|e| AuthzError::PolicyParse(format!("{e}")))?;
234 self.validate_policies(&ps)?;
235 let count = ps.policies().count();
236 info!(count, "Dry-run policy validation passed");
237 Ok(count)
238 }
239
240 pub fn add_policy(&self, id: &str, policy_src: &str) -> Result<()> {
248 let policy = cedar_policy::Policy::parse(Some(cedar_policy::PolicyId::new(id)), policy_src)
249 .map_err(|e| AuthzError::PolicyParse(format!("{e}")))?;
250
251 let mut new_ps = {
253 let ps = self.policies.read();
254 let mut candidate = PolicySet::new();
255 for p in ps.policies() {
256 candidate
257 .add(p.clone())
258 .map_err(|e| AuthzError::Internal(format!("Duplicate policy: {e}")))?;
259 }
260 candidate
261 };
262
263 new_ps
265 .add(policy)
266 .map_err(|e| AuthzError::Internal(format!("Failed to add policy: {e}")))?;
267 self.validate_policies(&new_ps)?;
268
269 let total = new_ps.policies().count();
271 let new_version = self.version_counter.fetch_add(1, Ordering::Relaxed) + 1;
272 self.push_history(&format!("add policy {id}"));
273 *self.policies.write() = Arc::new(new_ps);
274 info!(
275 policy_id = id,
276 total_policies = total,
277 version = new_version,
278 "Policy added"
279 );
280 counter!("chronix_authz_policy_load", "operation" => "add").increment(1);
281 Ok(())
282 }
283
284 pub fn remove_policy(&self, id: &str) -> bool {
288 let mut guard = self.policies.write();
289 let mut ps = PolicySet::new();
291 for p in guard.policies() {
292 if let Err(e) = ps.add(p.clone()) {
294 tracing::error!(error = %e, "failed to re-add policy during remove_policy rebuild");
295 }
296 }
297 let policy_id = cedar_policy::PolicyId::new(id);
298 match ps.remove_static(policy_id) {
299 Ok(_) => {
300 let remaining = ps.policies().count();
301 let prev = Arc::clone(&guard);
304 let ver = self.version_counter.fetch_add(1, Ordering::Relaxed) + 1;
305 {
306 let mut hist = self.history.write();
307 if hist.len() >= MAX_POLICY_VERSIONS {
308 hist.pop_front();
309 }
310 hist.push_back(PolicyVersion {
311 version: ver.saturating_sub(1),
312 policies: prev,
313 source: format!("before remove policy {id}"),
314 timestamp: now_unix(),
315 });
316 }
317 *guard = Arc::new(ps);
318 info!(
319 policy_id = id,
320 remaining_policies = remaining,
321 version = ver,
322 "Policy removed"
323 );
324 counter!("chronix_authz_policy_load", "operation" => "remove").increment(1);
325 true
326 }
327 Err(_) => {
328 warn!(id, "Policy not found for removal");
329 false
330 }
331 }
332 }
333
334 fn push_history(&self, source: &str) {
338 let current = Arc::clone(&self.policies.read());
339 let ver = self.version_counter.load(Ordering::Relaxed);
340 let mut hist = self.history.write();
341 if hist.len() >= MAX_POLICY_VERSIONS {
342 hist.pop_front();
343 }
344 hist.push_back(PolicyVersion {
345 version: ver.saturating_sub(1),
349 policies: current,
350 source: source.to_string(),
351 timestamp: now_unix(),
352 });
353 }
354
355 #[must_use]
357 pub fn current_version(&self) -> u64 {
358 self.version_counter.load(Ordering::Relaxed)
359 }
360
361 #[must_use]
363 pub fn policy_versions(&self) -> Vec<PolicyVersion> {
364 self.history.read().iter().cloned().collect()
365 }
366
367 pub fn revert_to_version(&self, version: u64) -> Result<()> {
375 let snapshot = {
376 let hist = self.history.read();
377 hist.iter()
378 .find(|v| v.version == version)
379 .map(|v| Arc::clone(&v.policies))
380 };
381 let snapshot = snapshot.ok_or_else(|| {
382 AuthzError::Internal(format!(
383 "Policy version {version} not found in history (retained: {})",
384 self.history
385 .read()
386 .iter()
387 .map(|v| v.version.to_string())
388 .collect::<Vec<_>>()
389 .join(", ")
390 ))
391 })?;
392
393 let new_version = self.version_counter.fetch_add(1, Ordering::Relaxed) + 1;
394 self.push_history(&format!("before revert to v{version}"));
395 let count = snapshot.policies().count();
396 *self.policies.write() = snapshot;
397 info!(
398 reverted_to = version,
399 new_version = new_version,
400 policy_count = count,
401 "Policy set reverted"
402 );
403 counter!("chronix_authz_policy_load", "operation" => "revert").increment(1);
404 Ok(())
405 }
406
407 pub fn load_policies_from_dir(&self, dir: &Path) -> Result<usize> {
409 let mut combined = String::new();
410 let mut file_count = 0;
411
412 for entry in std::fs::read_dir(dir)? {
413 let entry = entry?;
414 let path = entry.path();
415 if path.extension().is_some_and(|e| e == "cedar") {
416 let content = std::fs::read_to_string(&path)?;
417 combined.push_str(&content);
418 combined.push('\n');
419 file_count += 1;
420 debug!(path = %path.display(), "Loaded policy file");
421 }
422 }
423
424 if file_count == 0 {
425 info!(dir = %dir.display(), "No .cedar files found");
426 return Ok(0);
427 }
428
429 self.load_policies(&combined)
430 }
431
432 pub fn authorize(
437 &self,
438 principal: &ChronixPrincipal,
439 action: ChronixAction,
440 resource: &ChronixResource,
441 ) -> Decision {
442 let principal_entity = match principal.to_entity() {
443 Ok(e) => e,
444 Err(e) => {
445 error!(error = %e, "Cedar principal entity construction failed");
446 return Decision::Deny {
447 reasons: vec!["Internal authorization error".into()],
448 };
449 }
450 };
451 let resource_entity = match resource.to_entity() {
452 Ok(e) => e,
453 Err(e) => {
454 error!(error = %e, "Cedar resource entity construction failed");
455 return Decision::Deny {
456 reasons: vec!["Internal authorization error".into()],
457 };
458 }
459 };
460
461 #[allow(clippy::result_large_err)] let role_entities: std::result::Result<Vec<cedar_policy::Entity>, _> = principal
464 .roles
465 .iter()
466 .map(|role| {
467 let uid = cedar_policy::EntityUid::from_type_name_and_id(
468 crate::authz::model::role_type_name().clone(),
469 cedar_policy::EntityId::new(role),
470 );
471 cedar_policy::Entity::new(uid, HashMap::new(), HashSet::new())
472 })
473 .collect();
474
475 let role_entities = match role_entities {
476 Ok(v) => v,
477 Err(e) => {
478 error!(error = %e, "Cedar role entity construction failed");
479 return Decision::Deny {
480 reasons: vec!["Internal authorization error".into()],
481 };
482 }
483 };
484
485 let mut all_entities = vec![principal_entity, resource_entity];
486 all_entities.extend(role_entities);
487
488 if let Some(ns_entity) = resource.namespace_entity() {
490 all_entities.push(ns_entity);
491 }
492
493 let entities = match Entities::from_entities(all_entities, None) {
494 Ok(e) => e,
495 Err(e) => {
496 error!(error = %e, "Cedar entity set construction failed");
497 return Decision::Deny {
498 reasons: vec!["Internal authorization error".into()],
499 };
500 }
501 };
502
503 let request = match Request::new(
504 principal.to_entity_uid(),
505 action.to_entity_uid(),
506 resource.to_entity_uid(),
507 Context::empty(),
508 None,
509 ) {
510 Ok(r) => r,
511 Err(e) => {
512 error!(error = %e, "Cedar authorization request construction failed");
513 return Decision::Deny {
514 reasons: vec!["Internal authorization error".into()],
515 };
516 }
517 };
518
519 let policies = Arc::clone(&self.policies.read());
524 let response = self
525 .authorizer
526 .is_authorized(&request, &policies, &entities);
527
528 let decision_label = match response.decision() {
529 cedar_policy::Decision::Allow => "allow",
530 cedar_policy::Decision::Deny => "deny",
531 };
532
533 counter!("chronix_authz_decisions_total", "action" => action.to_string(), "decision" => decision_label)
534 .increment(1);
535
536 match response.decision() {
537 cedar_policy::Decision::Allow => Decision::Allow,
538 cedar_policy::Decision::Deny => {
539 let eval_errors: Vec<String> = response
543 .diagnostics()
544 .errors()
545 .map(std::string::ToString::to_string)
546 .collect();
547
548 let policy_reasons: Vec<String> = response
549 .diagnostics()
550 .reason()
551 .map(std::string::ToString::to_string)
552 .collect();
553
554 if !policy_reasons.is_empty() || !eval_errors.is_empty() {
555 tracing::debug!(
556 ?policy_reasons,
557 ?eval_errors,
558 %action,
559 principal = %principal.id,
560 "authorization denied with diagnostics"
561 );
562 }
563
564 Decision::Deny {
565 reasons: vec!["access denied".into()],
566 }
567 }
568 }
569 }
570
571 pub fn authorize_admin(
579 &self,
580 principal: &ChronixPrincipal,
581 action: ChronixAction,
582 resource: &ChronixResource,
583 ) -> Decision {
584 let result = self.authorize(principal, action, resource);
585 if result.is_allowed() || action == ChronixAction::Admin {
586 return result;
587 }
588 self.authorize(principal, ChronixAction::Admin, resource)
590 }
591
592 pub fn authorize_namespace(
603 &self,
604 principal: &ChronixPrincipal,
605 action: ChronixAction,
606 namespace: &ChronixNamespace,
607 ) -> Decision {
608 let principal_entity = match principal.to_entity() {
609 Ok(e) => e,
610 Err(e) => {
611 error!(error = %e, "Cedar principal entity construction failed");
612 return Decision::Deny {
613 reasons: vec!["Internal authorization error".into()],
614 };
615 }
616 };
617 let namespace_entity = match namespace.to_entity() {
618 Ok(e) => e,
619 Err(e) => {
620 error!(error = %e, "Cedar namespace entity construction failed");
621 return Decision::Deny {
622 reasons: vec!["Internal authorization error".into()],
623 };
624 }
625 };
626
627 #[allow(clippy::result_large_err)] let role_entities: std::result::Result<Vec<cedar_policy::Entity>, _> = principal
629 .roles
630 .iter()
631 .map(|role| {
632 let uid = cedar_policy::EntityUid::from_type_name_and_id(
633 crate::authz::model::role_type_name().clone(),
634 cedar_policy::EntityId::new(role),
635 );
636 cedar_policy::Entity::new(uid, HashMap::new(), HashSet::new())
637 })
638 .collect();
639
640 let role_entities = match role_entities {
641 Ok(v) => v,
642 Err(e) => {
643 error!(error = %e, "Cedar role entity construction failed");
644 return Decision::Deny {
645 reasons: vec!["Internal authorization error".into()],
646 };
647 }
648 };
649
650 let mut all_entities = vec![principal_entity, namespace_entity];
651 all_entities.extend(role_entities);
652
653 let entities = match Entities::from_entities(all_entities, None) {
654 Ok(e) => e,
655 Err(e) => {
656 error!(error = %e, "Cedar entity set construction failed");
657 return Decision::Deny {
658 reasons: vec!["Internal authorization error".into()],
659 };
660 }
661 };
662
663 let request = match Request::new(
664 principal.to_entity_uid(),
665 action.to_entity_uid(),
666 namespace.to_entity_uid(),
667 Context::empty(),
668 None,
669 ) {
670 Ok(r) => r,
671 Err(e) => {
672 error!(error = %e, "Cedar authorization request construction failed");
673 return Decision::Deny {
674 reasons: vec!["Internal authorization error".into()],
675 };
676 }
677 };
678
679 let policies = Arc::clone(&self.policies.read());
681 let response = self
682 .authorizer
683 .is_authorized(&request, &policies, &entities);
684
685 let decision_label = match response.decision() {
686 cedar_policy::Decision::Allow => "allow",
687 cedar_policy::Decision::Deny => "deny",
688 };
689
690 counter!(
691 "chronix_authz_decisions_total",
692 "action" => action.to_string(),
693 "resource_type" => "namespace",
694 "decision" => decision_label
695 )
696 .increment(1);
697
698 match response.decision() {
699 cedar_policy::Decision::Allow => Decision::Allow,
700 cedar_policy::Decision::Deny => {
701 let eval_errors: Vec<String> = response
702 .diagnostics()
703 .errors()
704 .map(std::string::ToString::to_string)
705 .collect();
706 let policy_reasons: Vec<String> = response
707 .diagnostics()
708 .reason()
709 .map(std::string::ToString::to_string)
710 .collect();
711
712 if !policy_reasons.is_empty() || !eval_errors.is_empty() {
713 tracing::debug!(
714 ?policy_reasons,
715 ?eval_errors,
716 %action,
717 principal = %principal.id,
718 namespace = %namespace.name,
719 "namespace authorization denied with diagnostics"
720 );
721 }
722
723 Decision::Deny {
724 reasons: vec!["access denied".into()],
725 }
726 }
727 }
728 }
729
730 #[must_use]
732 pub fn policy_count(&self) -> usize {
733 self.policies.read().policies().count()
734 }
735
736 #[must_use]
738 pub fn policy_ids(&self) -> Vec<String> {
739 self.policies
740 .read()
741 .policies()
742 .map(|p| p.id().to_string())
743 .collect()
744 }
745
746 pub fn load_cross_tenant_isolation_policy(&self) -> Result<()> {
756 const POLICY_ID: &str = "chronix-builtin-cross-tenant-deny";
757 self.remove_policy(POLICY_ID);
759 self.add_policy(POLICY_ID, Self::CROSS_TENANT_ISOLATION_POLICY)
760 }
761
762 pub fn warn_if_no_namespace_policies(&self) {
767 let policies = self.policies.read();
768 let has_ns = policies.policies().any(|p| {
769 let src = format!("{p}");
770 src.contains("Namespace")
771 });
772 if !has_ns {
773 warn!(
774 "No namespace-scoped Cedar policies detected! \
775 Cross-tenant data isolation may not be enforced. \
776 Call `load_cross_tenant_isolation_policy()` or deploy \
777 explicit namespace policies. See docs/security.md."
778 );
779 }
780 }
781
782 pub const CROSS_TENANT_ISOLATION_POLICY: &'static str = r#"
788 forbid(
789 principal,
790 action,
791 resource
792 )
793 when {
794 resource has namespace &&
795 principal has namespace &&
796 resource.namespace != principal.namespace
797 }
798 unless {
799 principal in Chronix::Role::"admin"
800 };
801 "#;
802}
803
804fn now_unix() -> i64 {
806 std::time::SystemTime::now()
807 .duration_since(std::time::UNIX_EPOCH)
808 .unwrap_or_default()
809 .as_secs() as i64
810}
811
812impl Default for AuthzEngine {
813 fn default() -> Self {
814 Self::new()
815 }
816}
817
818impl std::fmt::Debug for AuthzEngine {
819 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
820 f.debug_struct("AuthzEngine")
821 .field("policy_count", &self.policy_count())
822 .finish()
823 }
824}
825
826#[cfg(test)]
827mod tests {
828 use super::*;
829
830 fn admin_allow_all_policy() -> &'static str {
831 r#"
832 permit(
833 principal in Chronix::Role::"admin",
834 action,
835 resource
836 );
837 "#
838 }
839
840 fn read_only_policy() -> &'static str {
841 r#"
842 permit(
843 principal,
844 action == Chronix::Action::"Read",
845 resource
846 );
847 "#
848 }
849
850 fn measurement_scoped_policy() -> &'static str {
851 r#"
852 permit(
853 principal in Chronix::Role::"data-science",
854 action == Chronix::Action::"Forecast",
855 resource == Chronix::Measurement::"energy"
856 );
857 "#
858 }
859
860 #[test]
861 fn default_deny_no_policies() {
862 let engine = AuthzEngine::new();
863 let principal = ChronixPrincipal::new("alice");
864 let resource = ChronixResource::measurement("cpu");
865 let decision = engine.authorize(&principal, ChronixAction::Read, &resource);
866 assert!(decision.is_denied());
867 }
868
869 #[test]
870 fn admin_role_allows_all() {
871 let engine = AuthzEngine::new();
872 engine.load_policies(admin_allow_all_policy()).unwrap();
873
874 let admin = ChronixPrincipal::new("alice").with_role("admin");
875 let resource = ChronixResource::measurement("cpu");
876
877 assert!(engine
878 .authorize(&admin, ChronixAction::Write, &resource)
879 .is_allowed());
880 assert!(engine
881 .authorize(&admin, ChronixAction::Read, &resource)
882 .is_allowed());
883 assert!(engine
884 .authorize(&admin, ChronixAction::Delete, &resource)
885 .is_allowed());
886 assert!(engine
887 .authorize(&admin, ChronixAction::Admin, &resource)
888 .is_allowed());
889 }
890
891 #[test]
892 fn non_admin_denied_without_matching_policy() {
893 let engine = AuthzEngine::new();
894 engine.load_policies(admin_allow_all_policy()).unwrap();
895
896 let user = ChronixPrincipal::new("bob"); let resource = ChronixResource::measurement("cpu");
898
899 assert!(engine
900 .authorize(&user, ChronixAction::Write, &resource)
901 .is_denied());
902 }
903
904 #[test]
905 fn read_only_allows_read_denies_write() {
906 let engine = AuthzEngine::new();
907 engine.load_policies(read_only_policy()).unwrap();
908
909 let user = ChronixPrincipal::new("bob");
910 let resource = ChronixResource::measurement("cpu");
911
912 assert!(engine
913 .authorize(&user, ChronixAction::Read, &resource)
914 .is_allowed());
915 assert!(engine
916 .authorize(&user, ChronixAction::Write, &resource)
917 .is_denied());
918 }
919
920 #[test]
921 fn measurement_scoped_authorization() {
922 let engine = AuthzEngine::new();
923 engine.load_policies(measurement_scoped_policy()).unwrap();
924
925 let ds = ChronixPrincipal::new("carol").with_role("data-science");
926
927 assert!(engine
929 .authorize(
930 &ds,
931 ChronixAction::Forecast,
932 &ChronixResource::measurement("energy")
933 )
934 .is_allowed());
935
936 assert!(engine
938 .authorize(
939 &ds,
940 ChronixAction::Forecast,
941 &ChronixResource::measurement("cpu")
942 )
943 .is_denied());
944
945 assert!(engine
947 .authorize(
948 &ds,
949 ChronixAction::Write,
950 &ChronixResource::measurement("energy")
951 )
952 .is_denied());
953 }
954
955 #[test]
956 fn add_and_remove_policy() {
957 let engine = AuthzEngine::new();
958 let builtin_count = engine.policy_count();
960 assert!(builtin_count >= 1);
961
962 engine
963 .add_policy(
964 "read-all",
965 r#"permit(principal, action == Chronix::Action::"Read", resource);"#,
966 )
967 .unwrap();
968 assert_eq!(engine.policy_count(), builtin_count + 1);
969
970 let user = ChronixPrincipal::new("alice");
971 let resource = ChronixResource::measurement("cpu");
972 assert!(engine
973 .authorize(&user, ChronixAction::Read, &resource)
974 .is_allowed());
975
976 assert!(engine.remove_policy("read-all"));
977 assert_eq!(engine.policy_count(), builtin_count);
978 assert!(engine
979 .authorize(&user, ChronixAction::Read, &resource)
980 .is_denied());
981 }
982
983 #[test]
984 fn remove_nonexistent_policy() {
985 let engine = AuthzEngine::new();
986 assert!(!engine.remove_policy("does-not-exist"));
987 }
988
989 #[test]
990 fn policy_ids_listing() {
991 let engine = AuthzEngine::new();
992 let builtin_count = engine.policy_ids().len();
994 engine
995 .add_policy(
996 "p1",
997 r#"permit(principal, action == Chronix::Action::"Read", resource);"#,
998 )
999 .unwrap();
1000 engine
1001 .add_policy(
1002 "p2",
1003 r#"permit(principal, action == Chronix::Action::"Write", resource);"#,
1004 )
1005 .unwrap();
1006
1007 let ids = engine.policy_ids();
1008 assert_eq!(ids.len(), builtin_count + 2);
1009 assert!(ids.contains(&"p1".to_string()));
1010 assert!(ids.contains(&"p2".to_string()));
1011 }
1012
1013 #[test]
1014 fn invalid_policy_rejected() {
1015 let engine = AuthzEngine::new();
1016 let result = engine.load_policies("this is not valid cedar");
1017 assert!(result.is_err());
1018 }
1019
1020 #[test]
1021 fn hot_reload_replaces_policies() {
1022 let engine = AuthzEngine::new();
1023 engine.load_policies(read_only_policy()).unwrap();
1024
1025 let user = ChronixPrincipal::new("alice");
1026 let resource = ChronixResource::measurement("cpu");
1027 assert!(engine
1028 .authorize(&user, ChronixAction::Read, &resource)
1029 .is_allowed());
1030 assert!(engine
1031 .authorize(&user, ChronixAction::Write, &resource)
1032 .is_denied());
1033
1034 engine.load_policies(admin_allow_all_policy()).unwrap();
1036
1037 assert!(engine
1039 .authorize(&user, ChronixAction::Read, &resource)
1040 .is_denied());
1041 let admin = ChronixPrincipal::new("alice").with_role("admin");
1042 assert!(engine
1043 .authorize(&admin, ChronixAction::Write, &resource)
1044 .is_allowed());
1045 }
1046
1047 #[test]
1048 fn load_from_directory() {
1049 let dir = tempfile::tempdir().unwrap();
1050 let policy_path = dir.path().join("allow_read.cedar");
1051 std::fs::write(
1052 &policy_path,
1053 r#"permit(principal, action == Chronix::Action::"Read", resource);"#,
1054 )
1055 .unwrap();
1056
1057 std::fs::write(dir.path().join("notes.txt"), "not a policy").unwrap();
1059
1060 let engine = AuthzEngine::new();
1061 let count = engine.load_policies_from_dir(dir.path()).unwrap();
1062 assert_eq!(count, 1);
1063
1064 let user = ChronixPrincipal::new("alice");
1065 let resource = ChronixResource::measurement("cpu");
1066 assert!(engine
1067 .authorize(&user, ChronixAction::Read, &resource)
1068 .is_allowed());
1069 }
1070
1071 #[test]
1072 fn empty_directory_loads_zero() {
1073 let dir = tempfile::tempdir().unwrap();
1074 let engine = AuthzEngine::new();
1075 let count = engine.load_policies_from_dir(dir.path()).unwrap();
1076 assert_eq!(count, 0);
1077 }
1078
1079 #[test]
1080 fn multiple_actions_with_combined_policies() {
1081 let policies = r#"
1082 permit(
1083 principal in Chronix::Role::"ops",
1084 action == Chronix::Action::"Read",
1085 resource
1086 );
1087 permit(
1088 principal in Chronix::Role::"ops",
1089 action == Chronix::Action::"Write",
1090 resource
1091 );
1092 permit(
1093 principal in Chronix::Role::"admin",
1094 action,
1095 resource
1096 );
1097 "#;
1098
1099 let engine = AuthzEngine::new();
1100 engine.load_policies(policies).unwrap();
1101 assert_eq!(engine.policy_count(), 3);
1102
1103 let ops = ChronixPrincipal::new("dave").with_role("ops");
1104 let admin = ChronixPrincipal::new("eve").with_role("admin");
1105 let resource = ChronixResource::measurement("cpu");
1106
1107 assert!(engine
1109 .authorize(&ops, ChronixAction::Read, &resource)
1110 .is_allowed());
1111 assert!(engine
1112 .authorize(&ops, ChronixAction::Write, &resource)
1113 .is_allowed());
1114 assert!(engine
1116 .authorize(&ops, ChronixAction::Delete, &resource)
1117 .is_denied());
1118 assert!(engine
1119 .authorize(&ops, ChronixAction::Admin, &resource)
1120 .is_denied());
1121 assert!(engine
1123 .authorize(&admin, ChronixAction::Delete, &resource)
1124 .is_allowed());
1125 assert!(engine
1126 .authorize(&admin, ChronixAction::Forecast, &resource)
1127 .is_allowed());
1128 }
1129
1130 #[test]
1131 fn forbid_policy_overrides_permit() {
1132 let policies = r#"
1133 permit(principal, action == Chronix::Action::"Read", resource);
1134 forbid(principal, action == Chronix::Action::"Read", resource == Chronix::Measurement::"secret");
1135 "#;
1136
1137 let engine = AuthzEngine::new();
1138 engine.load_policies(policies).unwrap();
1139
1140 let user = ChronixPrincipal::new("alice");
1141
1142 assert!(engine
1144 .authorize(
1145 &user,
1146 ChronixAction::Read,
1147 &ChronixResource::measurement("cpu")
1148 )
1149 .is_allowed());
1150 assert!(engine
1152 .authorize(
1153 &user,
1154 ChronixAction::Read,
1155 &ChronixResource::measurement("secret")
1156 )
1157 .is_denied());
1158 }
1159
1160 #[test]
1161 fn analytics_authorization() {
1162 let policies = r#"
1163 permit(
1164 principal in Chronix::Role::"data-science",
1165 action == Chronix::Action::"Forecast",
1166 resource
1167 );
1168 permit(
1169 principal in Chronix::Role::"data-science",
1170 action == Chronix::Action::"DetectAnomalies",
1171 resource
1172 );
1173 permit(
1174 principal in Chronix::Role::"ops",
1175 action == Chronix::Action::"Read",
1176 resource
1177 );
1178 "#;
1179
1180 let engine = AuthzEngine::new();
1181 engine.load_policies(policies).unwrap();
1182
1183 let ds = ChronixPrincipal::new("data-alice").with_role("data-science");
1184 let ops = ChronixPrincipal::new("ops-bob").with_role("ops");
1185 let resource = ChronixResource::measurement("energy");
1186
1187 assert!(engine
1189 .authorize(&ds, ChronixAction::Forecast, &resource)
1190 .is_allowed());
1191 assert!(engine
1192 .authorize(&ds, ChronixAction::DetectAnomalies, &resource)
1193 .is_allowed());
1194 assert!(engine
1196 .authorize(&ds, ChronixAction::Write, &resource)
1197 .is_denied());
1198
1199 assert!(engine
1201 .authorize(&ops, ChronixAction::Read, &resource)
1202 .is_allowed());
1203 assert!(engine
1204 .authorize(&ops, ChronixAction::Forecast, &resource)
1205 .is_denied());
1206 }
1207
1208 #[test]
1209 fn subscribe_action_authorization() {
1210 let policies = r#"
1211 permit(
1212 principal in Chronix::Role::"subscriber",
1213 action == Chronix::Action::"Subscribe",
1214 resource
1215 );
1216 "#;
1217
1218 let engine = AuthzEngine::new();
1219 engine.load_policies(policies).unwrap();
1220
1221 let sub = ChronixPrincipal::new("stream-user").with_role("subscriber");
1222 let non_sub = ChronixPrincipal::new("basic-user");
1223 let resource = ChronixResource::measurement("cpu");
1224
1225 assert!(engine
1226 .authorize(&sub, ChronixAction::Subscribe, &resource)
1227 .is_allowed());
1228 assert!(engine
1229 .authorize(&non_sub, ChronixAction::Subscribe, &resource)
1230 .is_denied());
1231 }
1232
1233 #[test]
1234 fn decision_deny_includes_reasons() {
1235 let engine = AuthzEngine::new();
1236 let decision = engine.authorize(
1237 &ChronixPrincipal::new("nobody"),
1238 ChronixAction::Write,
1239 &ChronixResource::measurement("cpu"),
1240 );
1241 match decision {
1242 Decision::Deny { reasons } => {
1243 assert!(!reasons.is_empty());
1244 assert!(reasons[0].contains("access denied"));
1246 }
1247 Decision::Allow => panic!("Expected deny"),
1248 }
1249 }
1250
1251 #[test]
1252 fn namespace_scoped_measurement_policy() {
1253 let policies = r#"
1255 permit(
1256 principal in Chronix::Role::"reader",
1257 action == Chronix::Action::"Read",
1258 resource in Chronix::Namespace::"prod"
1259 );
1260 "#;
1261
1262 let engine = AuthzEngine::new();
1263 engine.load_policies(policies).unwrap();
1264
1265 let reader = ChronixPrincipal::new("alice").with_role("reader");
1266
1267 let cpu_prod = ChronixResource::measurement("cpu").with_namespace("prod");
1269 assert!(
1270 engine
1271 .authorize(&reader, ChronixAction::Read, &cpu_prod)
1272 .is_allowed(),
1273 "measurement in prod namespace should be readable"
1274 );
1275
1276 let cpu_staging = ChronixResource::measurement("cpu").with_namespace("staging");
1278 assert!(
1279 engine
1280 .authorize(&reader, ChronixAction::Read, &cpu_staging)
1281 .is_denied(),
1282 "measurement in staging namespace should be denied"
1283 );
1284
1285 let cpu_none = ChronixResource::measurement("cpu");
1287 assert!(
1288 engine
1289 .authorize(&reader, ChronixAction::Read, &cpu_none)
1290 .is_denied(),
1291 "measurement without namespace should be denied"
1292 );
1293
1294 assert!(
1296 engine
1297 .authorize(&reader, ChronixAction::Write, &cpu_prod)
1298 .is_denied(),
1299 "write should be denied even in prod namespace"
1300 );
1301 }
1302
1303 #[test]
1306 fn namespace_default_deny() {
1307 let engine = AuthzEngine::new();
1308 let user = ChronixPrincipal::new("alice");
1309 let ns = ChronixNamespace::new("production");
1310 let decision = engine.authorize_namespace(&user, ChronixAction::Read, &ns);
1311 assert!(decision.is_denied());
1312 }
1313
1314 #[test]
1315 fn namespace_scoped_permit() {
1316 let policies = r#"
1317 permit(
1318 principal in Chronix::Role::"ns_admin",
1319 action,
1320 resource == Chronix::Namespace::"production"
1321 );
1322 "#;
1323
1324 let engine = AuthzEngine::new();
1325 engine.load_policies(policies).unwrap();
1326
1327 let ns_admin = ChronixPrincipal::new("alice").with_role("ns_admin");
1328 let production = ChronixNamespace::new("production");
1329 let staging = ChronixNamespace::new("staging");
1330
1331 assert!(engine
1333 .authorize_namespace(&ns_admin, ChronixAction::Read, &production)
1334 .is_allowed());
1335 assert!(engine
1336 .authorize_namespace(&ns_admin, ChronixAction::Write, &production)
1337 .is_allowed());
1338 assert!(engine
1340 .authorize_namespace(&ns_admin, ChronixAction::Read, &staging)
1341 .is_denied());
1342 }
1343
1344 #[test]
1345 fn namespace_forbid_cross_namespace() {
1346 let policies = r#"
1347 permit(
1348 principal in Chronix::Role::"team_a",
1349 action,
1350 resource == Chronix::Namespace::"team_a_ns"
1351 );
1352 forbid(
1353 principal in Chronix::Role::"team_a",
1354 action,
1355 resource == Chronix::Namespace::"team_b_ns"
1356 );
1357 "#;
1358
1359 let engine = AuthzEngine::new();
1360 engine.load_policies(policies).unwrap();
1361
1362 let team_a = ChronixPrincipal::new("dev1").with_role("team_a");
1363 let ns_a = ChronixNamespace::new("team_a_ns");
1364 let ns_b = ChronixNamespace::new("team_b_ns");
1365
1366 assert!(engine
1367 .authorize_namespace(&team_a, ChronixAction::Write, &ns_a)
1368 .is_allowed());
1369 assert!(engine
1370 .authorize_namespace(&team_a, ChronixAction::Read, &ns_b)
1371 .is_denied());
1372 }
1373
1374 #[test]
1377 fn cross_tenant_isolation_policy_loads() {
1378 let engine = AuthzEngine::new();
1379 engine.load_cross_tenant_isolation_policy().unwrap();
1380 assert!(engine
1381 .policy_ids()
1382 .contains(&"chronix-builtin-cross-tenant-deny".to_string()));
1383 }
1384
1385 #[test]
1387 fn cross_tenant_isolation_policy_idempotent() {
1388 let engine = AuthzEngine::new();
1389 engine.load_cross_tenant_isolation_policy().unwrap();
1390 engine.load_cross_tenant_isolation_policy().unwrap();
1391 assert_eq!(engine.policy_count(), 1);
1393 }
1394
1395 #[test]
1401 fn cross_tenant_full_isolation_integration() {
1402 let engine = AuthzEngine::new();
1403 engine
1406 .add_policy(
1407 "team_a_permit",
1408 r#"permit(principal in Chronix::Role::"team_a_dev", action, resource);"#,
1409 )
1410 .unwrap();
1411 engine
1412 .add_policy(
1413 "team_b_permit",
1414 r#"permit(principal in Chronix::Role::"team_b_dev", action, resource);"#,
1415 )
1416 .unwrap();
1417
1418 let alice = ChronixPrincipal::new("alice")
1420 .with_role("team_a_dev")
1421 .with_namespace("team_a");
1422
1423 let bob = ChronixPrincipal::new("bob")
1425 .with_role("team_b_dev")
1426 .with_namespace("team_b");
1427
1428 let cpu_a = ChronixResource::measurement("cpu").with_namespace("team_a");
1430 let cpu_b = ChronixResource::measurement("cpu").with_namespace("team_b");
1431 let mem_a = ChronixResource::measurement("memory").with_namespace("team_a");
1432 let mem_b = ChronixResource::measurement("memory").with_namespace("team_b");
1433
1434 for action in [
1436 ChronixAction::Read,
1437 ChronixAction::Write,
1438 ChronixAction::Delete,
1439 ] {
1440 assert!(
1441 engine.authorize(&alice, action, &cpu_a).is_allowed(),
1442 "Alice should access team_a resources ({action:?})"
1443 );
1444 assert!(
1445 engine.authorize(&bob, action, &cpu_b).is_allowed(),
1446 "Bob should access team_b resources ({action:?})"
1447 );
1448 }
1449
1450 for action in [
1452 ChronixAction::Read,
1453 ChronixAction::Write,
1454 ChronixAction::Delete,
1455 ] {
1456 assert!(
1457 engine.authorize(&alice, action, &cpu_b).is_denied(),
1458 "Alice must NOT access team_b cpu ({action:?})"
1459 );
1460 assert!(
1461 engine.authorize(&alice, action, &mem_b).is_denied(),
1462 "Alice must NOT access team_b memory ({action:?})"
1463 );
1464 assert!(
1465 engine.authorize(&bob, action, &cpu_a).is_denied(),
1466 "Bob must NOT access team_a cpu ({action:?})"
1467 );
1468 assert!(
1469 engine.authorize(&bob, action, &mem_a).is_denied(),
1470 "Bob must NOT access team_a memory ({action:?})"
1471 );
1472 }
1473
1474 let admin = ChronixPrincipal::new("superadmin")
1476 .with_role("admin")
1477 .with_namespace("team_a");
1478 engine
1480 .add_policy(
1481 "admin_allow",
1482 r#"permit(principal in Chronix::Role::"admin", action, resource);"#,
1483 )
1484 .unwrap();
1485 assert!(
1486 engine
1487 .authorize(&admin, ChronixAction::Read, &cpu_b)
1488 .is_allowed(),
1489 "Admin should cross namespace boundaries"
1490 );
1491 }
1492
1493 #[test]
1495 fn warn_if_no_namespace_policies_works() {
1496 let engine = AuthzEngine::new();
1497 engine.warn_if_no_namespace_policies();
1499
1500 engine.load_cross_tenant_isolation_policy().unwrap();
1502 engine.warn_if_no_namespace_policies();
1503 }
1504
1505 #[test]
1508 fn version_increments_on_load() {
1509 let engine = AuthzEngine::new();
1510 let base = engine.current_version();
1512
1513 engine.load_policies(read_only_policy()).unwrap();
1514 assert_eq!(engine.current_version(), base + 1);
1515
1516 engine.load_policies(admin_allow_all_policy()).unwrap();
1517 assert_eq!(engine.current_version(), base + 2);
1518 }
1519
1520 #[test]
1521 fn version_increments_on_add_and_remove() {
1522 let engine = AuthzEngine::new();
1523 let base = engine.current_version();
1524 engine
1525 .add_policy(
1526 "p1",
1527 r#"permit(principal, action == Chronix::Action::"Read", resource);"#,
1528 )
1529 .unwrap();
1530 assert_eq!(engine.current_version(), base + 1);
1531
1532 assert!(engine.remove_policy("p1"));
1533 assert_eq!(engine.current_version(), base + 2);
1534 }
1535
1536 #[test]
1537 fn history_contains_previous_versions() {
1538 let engine = AuthzEngine::new();
1539 let base = engine.current_version();
1540 engine.load_policies(read_only_policy()).unwrap();
1541 engine.load_policies(admin_allow_all_policy()).unwrap();
1542
1543 let versions = engine.policy_versions();
1544 assert!(versions.len() >= 2);
1546 let last_two = &versions[versions.len() - 2..];
1548 assert_eq!(last_two[0].version, base);
1549 assert_eq!(last_two[1].version, base + 1);
1550 }
1551
1552 #[test]
1553 fn revert_to_previous_version() {
1554 let engine = AuthzEngine::new();
1555 let base = engine.current_version();
1556 engine.load_policies(read_only_policy()).unwrap();
1557 engine.load_policies(admin_allow_all_policy()).unwrap();
1558
1559 let user = ChronixPrincipal::new("alice");
1560 let resource = ChronixResource::measurement("cpu");
1561 assert!(engine
1563 .authorize(&user, ChronixAction::Read, &resource)
1564 .is_denied());
1565
1566 engine.revert_to_version(base + 1).unwrap();
1568 assert!(engine
1569 .authorize(&user, ChronixAction::Read, &resource)
1570 .is_allowed());
1571 assert!(engine
1572 .authorize(&user, ChronixAction::Write, &resource)
1573 .is_denied());
1574 }
1575
1576 #[test]
1577 fn revert_to_nonexistent_version_fails() {
1578 let engine = AuthzEngine::new();
1579 engine.load_policies(read_only_policy()).unwrap();
1580 let err = engine.revert_to_version(999);
1581 assert!(err.is_err());
1582 let msg = format!("{}", err.unwrap_err());
1583 assert!(msg.contains("999"));
1584 }
1585
1586 #[test]
1587 fn revert_itself_creates_history_entry() {
1588 let engine = AuthzEngine::new();
1589 let base = engine.current_version();
1590 engine.load_policies(read_only_policy()).unwrap();
1591 engine.load_policies(admin_allow_all_policy()).unwrap();
1592 engine.revert_to_version(base + 1).unwrap();
1593
1594 let versions = engine.policy_versions();
1595 assert!(versions.len() >= 3);
1597
1598 engine.revert_to_version(base + 2).unwrap();
1600 let admin = ChronixPrincipal::new("alice").with_role("admin");
1601 let resource = ChronixResource::measurement("cpu");
1602 assert!(engine
1603 .authorize(&admin, ChronixAction::Write, &resource)
1604 .is_allowed());
1605 }
1606
1607 #[test]
1608 fn history_ring_buffer_bounded() {
1609 let engine = AuthzEngine::new();
1610 for _ in 0..15 {
1612 engine.load_policies(read_only_policy()).unwrap();
1613 }
1614 let versions = engine.policy_versions();
1615 assert!(versions.len() <= super::MAX_POLICY_VERSIONS);
1616 }
1617
1618 #[test]
1619 fn dryrun_does_not_modify_active_set() {
1620 let engine = AuthzEngine::new();
1621 engine.load_policies(read_only_policy()).unwrap();
1622 let v_before = engine.current_version();
1623 let count_before = engine.policy_count();
1624
1625 let dryrun_count = engine
1626 .dryrun_load_policies(admin_allow_all_policy())
1627 .unwrap();
1628 assert!(dryrun_count > 0);
1629
1630 assert_eq!(engine.current_version(), v_before);
1632 assert_eq!(engine.policy_count(), count_before);
1633
1634 let user = ChronixPrincipal::new("alice");
1636 let resource = ChronixResource::measurement("cpu");
1637 assert!(engine
1638 .authorize(&user, ChronixAction::Read, &resource)
1639 .is_allowed());
1640 }
1641
1642 #[test]
1643 fn dryrun_rejects_invalid_policy() {
1644 let engine = AuthzEngine::new();
1645 let result = engine.dryrun_load_policies("not valid cedar syntax {{}}");
1646 assert!(result.is_err());
1647 }
1648
1649 #[test]
1650 fn policy_version_has_timestamp() {
1651 let engine = AuthzEngine::new();
1652 let before = std::time::SystemTime::now()
1653 .duration_since(std::time::UNIX_EPOCH)
1654 .unwrap()
1655 .as_secs() as i64;
1656
1657 engine.load_policies(read_only_policy()).unwrap();
1658
1659 let versions = engine.policy_versions();
1660 assert!(!versions.is_empty());
1662 let latest = versions.last().unwrap();
1664 assert!(latest.timestamp >= before);
1665 }
1666
1667 #[test]
1670 fn ent06_admin_super_action_covers_granular() {
1671 let policies = r#"
1672 permit(
1673 principal in Chronix::Role::"admin",
1674 action == Chronix::Action::"Admin",
1675 resource
1676 );
1677 "#;
1678 let engine = AuthzEngine::new();
1679 engine.load_policies(policies).unwrap();
1680
1681 let admin = ChronixPrincipal::new("alice").with_role("admin");
1682 let resource = ChronixResource::measurement("cpu");
1683
1684 assert!(engine
1686 .authorize_admin(&admin, ChronixAction::Admin, &resource)
1687 .is_allowed());
1688 assert!(engine
1690 .authorize_admin(&admin, ChronixAction::ManageNodes, &resource)
1691 .is_allowed());
1692 assert!(engine
1693 .authorize_admin(&admin, ChronixAction::ManageKeys, &resource)
1694 .is_allowed());
1695 assert!(engine
1696 .authorize_admin(&admin, ChronixAction::ManageBackups, &resource)
1697 .is_allowed());
1698 assert!(engine
1699 .authorize_admin(&admin, ChronixAction::ManageModels, &resource)
1700 .is_allowed());
1701 assert!(engine
1702 .authorize_admin(&admin, ChronixAction::ViewCluster, &resource)
1703 .is_allowed());
1704 }
1705
1706 #[test]
1707 fn ent06_granular_action_without_admin_super() {
1708 let policies = r#"
1710 permit(
1711 principal in Chronix::Role::"ml-eng",
1712 action == Chronix::Action::"ManageModels",
1713 resource
1714 );
1715 "#;
1716 let engine = AuthzEngine::new();
1717 engine.load_policies(policies).unwrap();
1718
1719 let ml_eng = ChronixPrincipal::new("bob").with_role("ml-eng");
1720 let resource = ChronixResource::measurement("cpu");
1721
1722 assert!(engine
1723 .authorize_admin(&ml_eng, ChronixAction::ManageModels, &resource)
1724 .is_allowed());
1725 assert!(engine
1727 .authorize_admin(&ml_eng, ChronixAction::ManageKeys, &resource)
1728 .is_denied());
1729 assert!(engine
1730 .authorize_admin(&ml_eng, ChronixAction::ManageBackups, &resource)
1731 .is_denied());
1732 assert!(engine
1733 .authorize_admin(&ml_eng, ChronixAction::Admin, &resource)
1734 .is_denied());
1735 }
1736
1737 #[test]
1738 fn ent06_is_admin_action_classification() {
1739 assert!(ChronixAction::Admin.is_admin_action());
1740 assert!(ChronixAction::ManageNodes.is_admin_action());
1741 assert!(ChronixAction::ManageKeys.is_admin_action());
1742 assert!(ChronixAction::ManageNamespaces.is_admin_action());
1743 assert!(!ChronixAction::Read.is_admin_action());
1745 assert!(!ChronixAction::Write.is_admin_action());
1746 assert!(!ChronixAction::Subscribe.is_admin_action());
1747 }
1748}