Skip to main content

chronix_security/authz/
engine.rs

1//! Cedar policy authorizer engine.
2//!
3//! This module wraps the Cedar policy engine and provides the core
4//! `authorize()` function for Chronix.
5
6use 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
23/// Maximum number of policy versions retained in the history ring buffer.
24const MAX_POLICY_VERSIONS: usize = 10;
25
26/// A snapshot of a policy set at a particular version.
27#[derive(Debug, Clone)]
28pub struct PolicyVersion {
29    /// Monotonically increasing version number.
30    pub version: u64,
31    /// The policy set at this version.
32    pub policies: Arc<PolicySet>,
33    /// Human-readable description of the change (e.g. "full reload", "add policy foo").
34    pub source: String,
35    /// Unix timestamp (seconds) when this version was created.
36    pub timestamp: i64,
37}
38
39/// The Cedar-based authorization engine.
40///
41/// Thread-safe — can be shared across threads via `Arc<AuthzEngine>`.
42///
43/// # Policy Versioning & Rollback
44///
45/// The engine maintains an in-memory ring buffer of the last
46/// `MAX_POLICY_VERSIONS` policy snapshots. Every mutating operation
47/// (`load_policies`, `add_policy`, `remove_policy`) bumps the version
48/// counter and pushes the *previous* state into the history before
49/// swapping in the new set. Rollback to any retained version is
50/// available via [`revert_to_version()`](Self::revert_to_version).
51///
52/// A dry-run mode ([`dryrun_load_policies()`](Self::dryrun_load_policies))
53/// parses and validates a policy string without modifying the active set.
54///
55/// # Cross-Tenant Isolation
56///
57/// There is no implicit "deny cross-tenant access" policy built into
58/// the engine. Tenant isolation is enforced at two layers:
59///
60/// 1. **Namespace scoping** — every request carries a namespace context
61///    (see `ChronixNamespace`). The storage and query layers filter by
62///    namespace before authorization is even checked.
63/// 2. **Explicit Cedar policies** — operators must deploy `forbid`
64///    policies that deny access when `resource.namespace != principal.namespace`.
65///    See `docs/security.md` for a reference policy template.
66///
67/// This explicit-policy approach avoids magic built-in rules that are
68/// invisible to policy auditors.
69pub struct AuthzEngine {
70    /// The Cedar authorizer (stateless, thread-safe).
71    authorizer: Authorizer,
72    /// The active policy set — wrapped in Arc so authorize() can
73    /// snapshot the pointer without holding the lock during evaluation
74    /// (Lock-free read path.)
75    policies: RwLock<Arc<PolicySet>>,
76    /// Optional Cedar schema for policy validation.
77    schema: Option<Schema>,
78    /// When true, policy loads fail if no schema is set.
79    require_schema: bool,
80    /// Monotonically increasing version counter.
81    version_counter: AtomicU64,
82    /// Ring buffer of previous policy versions for rollback.
83    history: RwLock<VecDeque<PolicyVersion>>,
84}
85
86impl AuthzEngine {
87    /// Create a new authorizer with the built-in cross-tenant isolation
88    /// policy auto-loaded (default-deny + namespace isolation).
89    ///
90    /// Schema validation is automatically enabled when a schema
91    /// is loaded via `with_schema()` or `load_schema()`. Without a
92    /// schema, policies are accepted without type-level validation.
93    /// Call `.with_require_schema(true)` to force a schema requirement.
94    #[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        // Auto-load the cross-tenant isolation policy so that
106        // namespace-scoped principals are always denied access to
107        // resources in a different namespace by default.
108        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    /// Create an authorizer with the given policy set.
116    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    /// Set the Cedar schema for policy validation.
133    ///
134    /// When a schema is set, `load_policies()` and `add_policy()` will
135    /// validate policies against it and reject those that reference
136    /// unknown entity types, actions, or attributes.
137    ///
138    /// Setting a schema automatically enables `require_schema`,
139    /// which means all policies MUST validate against the schema.
140    pub fn with_schema(mut self, schema: Schema) -> Self {
141        self.schema = Some(schema);
142        self.require_schema = true;
143        self
144    }
145
146    /// Load a Cedar schema from its string representation.
147    ///
148    /// Loading a schema automatically enables `require_schema`.
149    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    /// Require a Cedar schema for all policy operations.
159    /// When enabled, `load_policies()` and `add_policy()` will fail
160    /// if no schema has been loaded.
161    pub fn with_require_schema(mut self, require: bool) -> Self {
162        self.require_schema = require;
163        self
164    }
165
166    /// Validate a policy set against the schema (if present).
167    ///
168    /// Logs a warning when no schema is set. If `require_schema`
169    /// is true, returns an error instead.
170    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    /// Load policies from a Cedar policy string.
201    ///
202    /// Replaces all existing policies. If a schema is set, policies
203    /// are validated against it before being accepted.
204    ///
205    /// The previous policy set is pushed into the version history ring
206    /// buffer before being replaced.
207    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    /// Dry-run policy loading: parse and validate without modifying the
225    /// active policy set.
226    ///
227    /// Returns the number of policies that would be loaded, or an error
228    /// if parsing or schema validation fails. The active policy set is
229    /// **not** modified.
230    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    /// Add a single policy from source.
241    ///
242    /// Uses a clone-and-swap strategy: clones the current policy set
243    /// under a brief read lock, builds and validates the candidate set
244    /// without holding any lock, and only acquires a write lock for
245    /// the final atomic swap.  If parsing or validation fails, the
246    /// current set is left untouched (automatic rollback).
247    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        // 1. Snapshot current policies under read lock (brief)
252        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        // 2. Build and validate candidate (no lock held)
264        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        // 3. Atomic swap under write lock
270        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    /// Remove a policy by ID.
285    ///
286    /// Returns `true` if the policy was found and removed.
287    pub fn remove_policy(&self, id: &str) -> bool {
288        let mut guard = self.policies.write();
289        // Clone the policy set out of the Arc so we can mutate it
290        let mut ps = PolicySet::new();
291        for p in guard.policies() {
292            // Log rather than silently dropping add failures.
293            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                // Push history *before* swapping (guard is held, so
302                // snapshot is the current live set).
303                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    /// Push the current active policy set into the history ring buffer.
335    ///
336    /// Called internally before every mutating operation.
337    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            // The version being *replaced* is the current counter
346            // before the caller incremented it. Since the caller does
347            // fetch_add(1) + 1, the version of the old set is (new - 1).
348            version: ver.saturating_sub(1),
349            policies: current,
350            source: source.to_string(),
351            timestamp: now_unix(),
352        });
353    }
354
355    /// Return the current policy version number.
356    #[must_use]
357    pub fn current_version(&self) -> u64 {
358        self.version_counter.load(Ordering::Relaxed)
359    }
360
361    /// List all retained policy versions (oldest first).
362    #[must_use]
363    pub fn policy_versions(&self) -> Vec<PolicyVersion> {
364        self.history.read().iter().cloned().collect()
365    }
366
367    /// Revert the active policy set to a previously retained version
368    ///.
369    ///
370    /// The version must exist in the history ring buffer. On success
371    /// the reverted-to snapshot becomes the active set and the
372    /// *pre-revert* state is pushed into history (so it can itself be
373    /// rolled back).
374    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    /// Load policies from all `.cedar` files in a directory.
408    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    /// Evaluate an authorization request.
433    ///
434    /// Returns `Decision::Allow` if any policy explicitly permits the request,
435    /// or `Decision::Deny` with reasons otherwise (default-deny).
436    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        // Build role entities (for membership) using cached type names
462        #[allow(clippy::result_large_err)] // cedar_policy error type; not on a hot path
463        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        // Include namespace entity so Cedar can resolve `resource in Chronix::Namespace::"..."`.
489        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        // Snapshot the policy set via Arc clone (brief read lock),
520        // then drop the lock before the expensive authorization
521        // evaluation. This prevents policy hot-reloads from blocking
522        // concurrent authorize() calls.
523        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                // Log detailed diagnostics server-side for debugging,
540                // but only return a generic reason to callers to avoid
541                // leaking policy IDs or evaluation internals to clients.
542                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    /// Evaluate a granular admin action, falling back to the `Admin`
572    /// super-action if the specific action is denied.
573    ///
574    /// Policies that grant `Action::"Admin"` automatically cover
575    /// all granular admin actions (`ManageNodes`, `ManageKeys`, etc.).
576    /// This avoids breaking existing policies while enabling least-privilege
577    /// grants for new deployments.
578    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        // Granular action was denied — try the super-action.
589        self.authorize(principal, ChronixAction::Admin, resource)
590    }
591
592    /// Evaluate an authorization request against a namespace resource.
593    ///
594    /// This is used for namespace-level policies, e.g.:
595    /// ```cedar
596    /// permit(
597    ///   principal in Chronix::Role::"ns_admin",
598    ///   action,
599    ///   resource == Chronix::Namespace::"production"
600    /// );
601    /// ```
602    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)] // cedar_policy error type; not on a hot path
628        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        // Snapshot policy set via Arc clone for lock-free evaluation.
680        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    /// Returns the number of active policies.
731    #[must_use]
732    pub fn policy_count(&self) -> usize {
733        self.policies.read().policies().count()
734    }
735
736    /// List all policy IDs.
737    #[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    /// **** Load the default cross-tenant isolation policy.
747    ///
748    /// This policy forbids any principal from accessing resources in a
749    /// namespace that differs from the principal's own namespace attribute.
750    /// Without this, data is visible across tenants unless explicit Cedar
751    /// `forbid` policies are deployed.
752    ///
753    /// The policy is idempotent — calling this multiple times is safe
754    /// (the existing built-in policy is removed before re-adding).
755    pub fn load_cross_tenant_isolation_policy(&self) -> Result<()> {
756        const POLICY_ID: &str = "chronix-builtin-cross-tenant-deny";
757        // Remove if already present (idempotent)
758        self.remove_policy(POLICY_ID);
759        self.add_policy(POLICY_ID, Self::CROSS_TENANT_ISOLATION_POLICY)
760    }
761
762    /// **** Check if any namespace-scoped policies are loaded.
763    ///
764    /// Logs a warning if no policies reference `Chronix::Namespace`,
765    /// which likely means cross-tenant isolation is not enforced.
766    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    /// Built-in Cedar policy that denies cross-tenant resource access.
783    ///
784    /// Uses Cedar's `forbid` with `unless` to deny any request where
785    /// the resource's namespace does not match the principal's namespace,
786    /// unless the principal has the `admin` role.
787    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
804/// Returns the current Unix timestamp in seconds.
805fn 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"); // no role
897        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        // Allowed: forecast on energy
928        assert!(engine
929            .authorize(
930                &ds,
931                ChronixAction::Forecast,
932                &ChronixResource::measurement("energy")
933            )
934            .is_allowed());
935
936        // Denied: forecast on cpu (wrong measurement)
937        assert!(engine
938            .authorize(
939                &ds,
940                ChronixAction::Forecast,
941                &ChronixResource::measurement("cpu")
942            )
943            .is_denied());
944
945        // Denied: write on energy (wrong action)
946        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        // Engine starts with 1 built-in cross-tenant isolation policy
959        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        // Engine starts with built-in cross-tenant isolation policy
993        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        // Hot-reload with admin policy
1035        engine.load_policies(admin_allow_all_policy()).unwrap();
1036
1037        // Now only admin role has access
1038        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        // Non-cedar files should be ignored
1058        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        // ops can read and write
1108        assert!(engine
1109            .authorize(&ops, ChronixAction::Read, &resource)
1110            .is_allowed());
1111        assert!(engine
1112            .authorize(&ops, ChronixAction::Write, &resource)
1113            .is_allowed());
1114        // ops cannot delete or admin
1115        assert!(engine
1116            .authorize(&ops, ChronixAction::Delete, &resource)
1117            .is_denied());
1118        assert!(engine
1119            .authorize(&ops, ChronixAction::Admin, &resource)
1120            .is_denied());
1121        // admin can do everything
1122        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        // Can read normal measurements
1143        assert!(engine
1144            .authorize(
1145                &user,
1146                ChronixAction::Read,
1147                &ChronixResource::measurement("cpu")
1148            )
1149            .is_allowed());
1150        // Cannot read secret measurement (forbid overrides permit)
1151        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        // data-science can forecast and detect anomalies
1188        assert!(engine
1189            .authorize(&ds, ChronixAction::Forecast, &resource)
1190            .is_allowed());
1191        assert!(engine
1192            .authorize(&ds, ChronixAction::DetectAnomalies, &resource)
1193            .is_allowed());
1194        // data-science cannot write
1195        assert!(engine
1196            .authorize(&ds, ChronixAction::Write, &resource)
1197            .is_denied());
1198
1199        // ops can read but not forecast
1200        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                // Only a generic message should be exposed — no policy IDs
1245                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        // Policy: allow reads on any resource that is *in* the "prod" namespace.
1254        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        // Measurement in "prod" namespace → allowed
1268        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        // Measurement in "staging" namespace → denied
1277        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        // Measurement with no namespace → denied (not in any namespace)
1286        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        // Write is not permitted even in prod
1295        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    // ── Namespace authorization tests ────────────────────────────────
1304
1305    #[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        // ns_admin can access "production"
1332        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        // ns_admin cannot access other namespaces
1339        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    /// Cross-tenant isolation policy can be loaded and denies
1375    /// cross-namespace access.
1376    #[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    /// Cross-tenant isolation policy is idempotent.
1386    #[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        // Still only 1 policy (replaced)
1392        assert_eq!(engine.policy_count(), 1);
1393    }
1394
1395    /// Full cross-tenant integration test.
1396    ///
1397    /// Creates two namespaces with independent principals and verifies that
1398    /// the Cedar cross-tenant isolation policy prevents any principal from
1399    /// accessing resources in another namespace — for all actions.
1400    #[test]
1401    fn cross_tenant_full_isolation_integration() {
1402        let engine = AuthzEngine::new();
1403        // Use add_policy (not load_policies) to preserve the auto-loaded
1404        // cross-tenant isolation forbid policy.
1405        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        // Team A principal in namespace "team_a"
1419        let alice = ChronixPrincipal::new("alice")
1420            .with_role("team_a_dev")
1421            .with_namespace("team_a");
1422
1423        // Team B principal in namespace "team_b"
1424        let bob = ChronixPrincipal::new("bob")
1425            .with_role("team_b_dev")
1426            .with_namespace("team_b");
1427
1428        // Resources in each namespace
1429        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        // ── Same-namespace access: ALLOWED ──
1435        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        // ── Cross-namespace access: DENIED ──
1451        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        // ── Admin override: admin principal CAN cross namespaces ──
1475        let admin = ChronixPrincipal::new("superadmin")
1476            .with_role("admin")
1477            .with_namespace("team_a");
1478        // Need an admin-allow policy
1479        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    /// warn_if_no_namespace_policies doesn't panic.
1494    #[test]
1495    fn warn_if_no_namespace_policies_works() {
1496        let engine = AuthzEngine::new();
1497        // Should log warning but not panic
1498        engine.warn_if_no_namespace_policies();
1499
1500        // After loading cross-tenant policy, no warning
1501        engine.load_cross_tenant_isolation_policy().unwrap();
1502        engine.warn_if_no_namespace_policies();
1503    }
1504
1505    // ── Policy Versioning & Rollback ─────────────────────────
1506
1507    #[test]
1508    fn version_increments_on_load() {
1509        let engine = AuthzEngine::new();
1510        // Version starts at 1 after auto-loading isolation policy
1511        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        // History contains entries from auto-loaded policy + 2 loads
1545        assert!(versions.len() >= 2);
1546        // The last two entries should be the ones from our loads
1547        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        // Currently admin_allow_all — non-admin denied
1562        assert!(engine
1563            .authorize(&user, ChronixAction::Read, &resource)
1564            .is_denied());
1565
1566        // Revert to the read_only_policy version
1567        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        // Should have at least 3 entries from our operations
1596        assert!(versions.len() >= 3);
1597
1598        // Can revert back to admin_allow_all version
1599        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        // Load 15 times — history should only retain MAX_POLICY_VERSIONS
1611        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        // Version and active set unchanged
1631        assert_eq!(engine.current_version(), v_before);
1632        assert_eq!(engine.policy_count(), count_before);
1633
1634        // Authorization still uses old policy
1635        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        // At least 1 entry from auto-loaded policy + our load
1661        assert!(!versions.is_empty());
1662        // The latest entry should be from our load
1663        let latest = versions.last().unwrap();
1664        assert!(latest.timestamp >= before);
1665    }
1666
1667    // ── granular admin action tests ─────────────────────────
1668
1669    #[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        // Admin super-action is allowed.
1685        assert!(engine
1686            .authorize_admin(&admin, ChronixAction::Admin, &resource)
1687            .is_allowed());
1688        // All granular admin actions are covered via fallback.
1689        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        // Grant only ManageModels — must NOT grant other admin ops.
1709        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        // Must NOT have other admin capabilities.
1726        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        // Non-admin actions should return false.
1744        assert!(!ChronixAction::Read.is_admin_action());
1745        assert!(!ChronixAction::Write.is_admin_action());
1746        assert!(!ChronixAction::Subscribe.is_admin_action());
1747    }
1748}