Skip to main content

chronix_security/authz/
engine.rs

1//! The Cedar policy engine, and the two questions `chronixd` asks it.
2//!
3//! * **May this principal do this to this namespace?** — the data plane. The
4//!   namespace gate every HTTP, gRPC and Flight SQL request passes through
5//!   asks it with `Read`, `Write` or `Delete`.
6//! * **May this principal do this to the server?** — the control plane. Each
7//!   administrative route group asks it with its own capability.
8//!
9//! There is no third question, and the model in [`super::model`] has no third
10//! resource. A policy language whose vocabulary is wider than the set of
11//! questions asked is one that accepts rules nothing ever consults.
12//!
13//! # The schema is not optional
14//!
15//! [`SCHEMA_SRC`] is compiled in and loaded by [`AuthzEngine::new`], so every
16//! policy is validated before it can be installed: an unknown action, a
17//! misspelt entity type, an administrative action aimed at a namespace are
18//! all load-time errors. Without it Cedar accepts anything syntactically
19//! well-formed, and a `permit` that names something the server never asks
20//! about is indistinguishable from a lockout while a `forbid` that does is
21//! indistinguishable from protection.
22
23use std::collections::HashSet;
24use std::path::Path;
25use std::sync::Arc;
26use std::sync::LazyLock;
27
28use cedar_policy::{
29    Authorizer, Context, Entities, Entity, EntityUid, PolicySet, Request, Schema, ValidationMode,
30    Validator,
31};
32use metrics::counter;
33use parking_lot::RwLock;
34use tracing::{debug, info, warn};
35
36use crate::authz::error::{AuthzError, Result};
37use crate::authz::model::{
38    ChronixAction, ChronixNamespace, ChronixPrincipal, ChronixSystem, Decision,
39};
40
41/// The Chronix Cedar schema, compiled into the binary.
42///
43/// Published as well as used: an operator writing policies needs it, and a
44/// copy in the documentation is a copy that drifts, so the security guide
45/// points at this constant and the site renders it.
46pub const SCHEMA_SRC: &str = include_str!("chronix.cedarschema");
47
48/// The parsed schema. Parsed once; a failure here is a build-time mistake in
49/// a compiled-in constant, which `the_builtin_schema_parses` catches.
50static SCHEMA: LazyLock<Schema> = LazyLock::new(|| {
51    let (schema, warnings) = Schema::from_cedarschema_str(SCHEMA_SRC)
52        .expect("the compiled-in Chronix schema must parse");
53    for w in warnings {
54        warn!(warning = %w, "Chronix Cedar schema warning");
55    }
56    schema
57});
58
59/// The Cedar-based authorization engine.
60///
61/// Thread-safe — share it as `Arc<AuthzEngine>`.
62///
63/// # Default-deny
64///
65/// A request no policy permits is denied. An engine with no policies at all
66/// therefore denies everything, which is why `chronixd` treats "no policy
67/// directory configured" as "no policy engine" rather than as an empty one.
68///
69/// # Cross-tenant isolation is not a policy
70///
71/// It is a property of the credential: a key or token carries the namespaces
72/// it may act in, and the gate refuses a header naming any other *before*
73/// Cedar is consulted. There used to be a built-in `forbid` policy for this
74/// which nothing loaded and which compared a `principal.namespace` attribute
75/// nothing set — an isolation guarantee that existed only as a string
76/// constant. Policies narrow what a credential may do; they are not what
77/// keeps tenants apart.
78pub struct AuthzEngine {
79    /// The Cedar authorizer (stateless, thread-safe).
80    authorizer: Authorizer,
81    /// The active policy set, behind an `Arc` so `authorize` can snapshot the
82    /// pointer under a brief read lock and evaluate without holding it.
83    policies: RwLock<Arc<PolicySet>>,
84}
85
86impl AuthzEngine {
87    /// Create an engine with no policies and the built-in schema.
88    #[must_use]
89    pub fn new() -> Self {
90        Self {
91            authorizer: Authorizer::new(),
92            policies: RwLock::new(Arc::new(PolicySet::new())),
93        }
94    }
95
96    /// Validate a candidate policy set against the schema.
97    ///
98    /// Warnings are surfaced as errors for one class only — a policy Cedar
99    /// can prove never matches — because that is precisely the failure this
100    /// whole mechanism exists to catch, and it is reported as a warning
101    /// rather than an error.
102    fn validate(ps: &PolicySet) -> Result<()> {
103        let validator = Validator::new(SCHEMA.clone());
104        let result = validator.validate(ps, ValidationMode::Strict);
105        let errors: Vec<String> = result.validation_errors().map(|e| format!("{e}")).collect();
106        if !errors.is_empty() {
107            return Err(AuthzError::PolicyValidation(errors.join("; ")));
108        }
109        let mut never_matches: Vec<String> = Vec::new();
110        for w in result.validation_warnings() {
111            let text = format!("{w}");
112            if text.contains("impossible") || text.contains("will never") {
113                never_matches.push(text);
114            } else {
115                warn!(warning = %text, "Cedar policy validation warning");
116            }
117        }
118        if !never_matches.is_empty() {
119            return Err(AuthzError::PolicyValidation(never_matches.join("; ")));
120        }
121        Ok(())
122    }
123
124    /// Parse and validate `policy_src` without installing it.
125    ///
126    /// # Errors
127    ///
128    /// [`AuthzError::PolicyParse`] if it is not Cedar,
129    /// [`AuthzError::PolicyValidation`] if it does not fit the schema.
130    pub fn check_policies(policy_src: &str) -> Result<usize> {
131        let ps = policy_src
132            .parse::<PolicySet>()
133            .map_err(|e| AuthzError::PolicyParse(format!("{e}")))?;
134        Self::validate(&ps)?;
135        Ok(ps.policies().count())
136    }
137
138    /// Replace the active policy set.
139    ///
140    /// # Errors
141    ///
142    /// As [`check_policies`](Self::check_policies); the active set is left
143    /// untouched when either fails.
144    pub fn load_policies(&self, policy_src: &str) -> Result<usize> {
145        let ps = policy_src
146            .parse::<PolicySet>()
147            .map_err(|e| AuthzError::PolicyParse(format!("{e}")))?;
148        Self::validate(&ps)?;
149        let count = ps.policies().count();
150        let policy_ids: Vec<String> = ps.policies().map(|p| p.id().to_string()).collect();
151        *self.policies.write() = Arc::new(ps);
152        info!(count, policies = ?policy_ids, "Cedar policies loaded");
153        counter!("chronix_authz_policy_load", "operation" => "load_all").increment(1);
154        Ok(count)
155    }
156
157    /// Add one policy, keeping the rest.
158    ///
159    /// Clone-and-swap: the candidate is built and validated without a lock
160    /// held, and the write lock is taken only for the swap, so a rejected
161    /// policy leaves the active set exactly as it was.
162    ///
163    /// # Errors
164    ///
165    /// As [`check_policies`](Self::check_policies), or
166    /// [`AuthzError::Internal`] if `id` is already taken.
167    pub fn add_policy(&self, id: &str, policy_src: &str) -> Result<()> {
168        let policy = cedar_policy::Policy::parse(Some(cedar_policy::PolicyId::new(id)), policy_src)
169            .map_err(|e| AuthzError::PolicyParse(format!("{e}")))?;
170
171        let mut candidate = {
172            let ps = self.policies.read();
173            let mut candidate = PolicySet::new();
174            for p in ps.policies() {
175                candidate
176                    .add(p.clone())
177                    .map_err(|e| AuthzError::Internal(format!("Duplicate policy: {e}")))?;
178            }
179            candidate
180        };
181        candidate
182            .add(policy)
183            .map_err(|e| AuthzError::Internal(format!("Failed to add policy: {e}")))?;
184        Self::validate(&candidate)?;
185
186        let total = candidate.policies().count();
187        *self.policies.write() = Arc::new(candidate);
188        info!(policy_id = id, total_policies = total, "Cedar policy added");
189        counter!("chronix_authz_policy_load", "operation" => "add").increment(1);
190        Ok(())
191    }
192
193    /// Remove a policy by id. Returns whether it was there.
194    pub fn remove_policy(&self, id: &str) -> bool {
195        let mut guard = self.policies.write();
196        let mut ps = PolicySet::new();
197        for p in guard.policies() {
198            if let Err(e) = ps.add(p.clone()) {
199                tracing::error!(error = %e, "failed to re-add policy while removing another");
200            }
201        }
202        match ps.remove_static(cedar_policy::PolicyId::new(id)) {
203            Ok(_) => {
204                let remaining = ps.policies().count();
205                *guard = Arc::new(ps);
206                info!(
207                    policy_id = id,
208                    remaining_policies = remaining,
209                    "Cedar policy removed"
210                );
211                counter!("chronix_authz_policy_load", "operation" => "remove").increment(1);
212                true
213            }
214            Err(_) => {
215                warn!(id, "Policy not found for removal");
216                false
217            }
218        }
219    }
220
221    /// Load every `.cedar` file in `dir` as one policy set.
222    ///
223    /// # Errors
224    ///
225    /// The directory cannot be read, or the combined text does not parse or
226    /// validate.
227    pub fn load_policies_from_dir(&self, dir: &Path) -> Result<usize> {
228        let mut combined = String::new();
229        let mut file_count = 0;
230
231        // Sorted, so a policy id collision between two files is reported the
232        // same way on every machine. `read_dir` order is the filesystem's.
233        let mut paths: Vec<_> = std::fs::read_dir(dir)?
234            .filter_map(std::result::Result::ok)
235            .map(|e| e.path())
236            .filter(|p| p.extension().is_some_and(|e| e == "cedar"))
237            .collect();
238        paths.sort();
239
240        for path in paths {
241            combined.push_str(&std::fs::read_to_string(&path)?);
242            combined.push('\n');
243            file_count += 1;
244            debug!(path = %path.display(), "Loaded policy file");
245        }
246
247        if file_count == 0 {
248            info!(dir = %dir.display(), "No .cedar files found");
249            return Ok(0);
250        }
251        self.load_policies(&combined)
252    }
253
254    /// Number of active policies.
255    #[must_use]
256    pub fn policy_count(&self) -> usize {
257        self.policies.read().policies().count()
258    }
259
260    /// The ids of the active policies.
261    #[must_use]
262    pub fn policy_ids(&self) -> Vec<String> {
263        self.policies
264            .read()
265            .policies()
266            .map(|p| p.id().to_string())
267            .collect()
268    }
269
270    /// May `principal` perform `action` in `namespace`?
271    ///
272    /// `action` must be a data action; an administrative one cannot reach a
273    /// namespace resource under the schema, so asking would be a decision
274    /// nobody can write a policy for. Debug builds assert it.
275    #[must_use]
276    pub fn authorize_namespace(
277        &self,
278        principal: &ChronixPrincipal,
279        action: ChronixAction,
280        namespace: &ChronixNamespace,
281    ) -> Decision {
282        debug_assert!(
283            !action.is_administrative(),
284            "{action} applies to the system, not to a namespace"
285        );
286        match namespace.to_entity() {
287            Ok(resource) => self.evaluate(
288                principal,
289                action,
290                resource,
291                namespace.to_entity_uid(),
292                "namespace",
293            ),
294            Err(e) => Self::internal_deny(&e),
295        }
296    }
297
298    /// May `principal` perform the administrative `action`?
299    #[must_use]
300    pub fn authorize_system(
301        &self,
302        principal: &ChronixPrincipal,
303        action: ChronixAction,
304    ) -> Decision {
305        debug_assert!(
306            action.is_administrative(),
307            "{action} applies to a namespace, not to the system"
308        );
309        match ChronixSystem::to_entity() {
310            Ok(resource) => self.evaluate(
311                principal,
312                action,
313                resource,
314                ChronixSystem::to_entity_uid(),
315                "system",
316            ),
317            Err(e) => Self::internal_deny(&e),
318        }
319    }
320
321    /// An entity the authorizer could not build is a denial, never a pass.
322    fn internal_deny(e: &AuthzError) -> Decision {
323        tracing::error!(error = %e, "Cedar entity construction failed");
324        Decision::Deny {
325            reasons: vec!["internal authorization error".into()],
326        }
327    }
328
329    fn evaluate(
330        &self,
331        principal: &ChronixPrincipal,
332        action: ChronixAction,
333        resource: Entity,
334        resource_uid: EntityUid,
335        resource_label: &'static str,
336    ) -> Decision {
337        let principal_entity = match principal.to_entity() {
338            Ok(e) => e,
339            Err(e) => return Self::internal_deny(&e),
340        };
341        let role_entities = match principal.role_entities() {
342            Ok(v) => v,
343            Err(e) => return Self::internal_deny(&e),
344        };
345
346        let mut all = vec![principal_entity, resource];
347        all.extend(role_entities);
348        let entities = match Entities::from_entities(all, Some(&SCHEMA)) {
349            Ok(e) => e,
350            Err(e) => {
351                return Self::internal_deny(&AuthzError::Internal(format!("entity set: {e}")));
352            }
353        };
354
355        let request = match Request::new(
356            principal.to_entity_uid(),
357            action.to_entity_uid(),
358            resource_uid,
359            Context::empty(),
360            Some(&SCHEMA),
361        ) {
362            Ok(r) => r,
363            Err(e) => return Self::internal_deny(&AuthzError::Internal(format!("request: {e}"))),
364        };
365
366        let policies = Arc::clone(&self.policies.read());
367        let response = self
368            .authorizer
369            .is_authorized(&request, &policies, &entities);
370
371        let decision_label = match response.decision() {
372            cedar_policy::Decision::Allow => "allow",
373            cedar_policy::Decision::Deny => "deny",
374        };
375        counter!(
376            "chronix_authz_decisions_total",
377            "action" => action.to_string(),
378            "resource_type" => resource_label,
379            "decision" => decision_label,
380        )
381        .increment(1);
382
383        match response.decision() {
384            cedar_policy::Decision::Allow => Decision::Allow,
385            cedar_policy::Decision::Deny => {
386                // Diagnostics name policy ids and evaluation internals, so
387                // they go to the log and the caller gets "access denied".
388                let eval_errors: Vec<String> = response
389                    .diagnostics()
390                    .errors()
391                    .map(std::string::ToString::to_string)
392                    .collect();
393                let policy_reasons: Vec<String> = response
394                    .diagnostics()
395                    .reason()
396                    .map(std::string::ToString::to_string)
397                    .collect();
398                if !policy_reasons.is_empty() || !eval_errors.is_empty() {
399                    debug!(
400                        ?policy_reasons,
401                        ?eval_errors,
402                        %action,
403                        principal = %principal.id,
404                        "authorization denied with diagnostics"
405                    );
406                }
407                Decision::Deny {
408                    reasons: vec!["access denied".into()],
409                }
410            }
411        }
412    }
413}
414
415impl Default for AuthzEngine {
416    fn default() -> Self {
417        Self::new()
418    }
419}
420
421impl std::fmt::Debug for AuthzEngine {
422    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
423        f.debug_struct("AuthzEngine")
424            .field("policy_count", &self.policy_count())
425            .finish()
426    }
427}
428
429/// The action names the schema declares, group included.
430///
431/// Read out of the parsed schema rather than out of the text: the point of
432/// the inventory check is that the two lists cannot drift, and a regex over
433/// the source would drift with it.
434///
435/// # Errors
436///
437/// Returns [`AuthzError::Internal`] if Cedar cannot enumerate the actions.
438pub fn schema_action_names() -> Result<HashSet<String>> {
439    let entities = SCHEMA
440        .action_entities()
441        .map_err(|e| AuthzError::Internal(format!("schema actions: {e}")))?;
442    Ok(entities
443        .iter()
444        .map(|e| e.uid().id().escaped().trim_matches('"').to_string())
445        .collect())
446}
447
448#[cfg(test)]
449mod tests {
450    use super::*;
451
452    fn alice(role: &str) -> ChronixPrincipal {
453        ChronixPrincipal::new("alice").with_role(role)
454    }
455
456    fn prod() -> ChronixNamespace {
457        ChronixNamespace::new("prod")
458    }
459
460    #[test]
461    fn the_builtin_schema_parses() {
462        // `SCHEMA` is a `LazyLock` that panics on a malformed schema, so
463        // forcing it is the assertion.
464        let _ = &*SCHEMA;
465        assert!(!schema_action_names().unwrap().is_empty());
466    }
467
468    #[test]
469    fn the_schema_describes_exactly_the_actions_we_issue() {
470        // The two inventories drift in both directions and both are silent:
471        // an action in the schema that nothing requests is a policy clause
472        // that never fires, and an action requested but absent from the
473        // schema fails validation on every policy that names it.
474        let mut declared = schema_action_names().unwrap();
475        // `Admin` is a group, deliberately never requested.
476        assert!(declared.remove("Admin"), "the Admin group is gone");
477        let issued: HashSet<String> = ChronixAction::ALL
478            .iter()
479            .map(|a| a.name().to_string())
480            .collect();
481        assert_eq!(declared, issued);
482    }
483
484    #[test]
485    fn a_policy_naming_an_unknown_action_is_refused() {
486        // The exact shape the security guide shipped: Prometheus-flavoured
487        // action names, unqualified entity types, a `Group` that is a `Role`.
488        for bad in [
489            r#"permit(principal, action == Chronix::Action::"Query", resource);"#,
490            r#"permit(principal, action == Action::"Write", resource);"#,
491            r#"permit(principal in Group::"platform-team", action, resource);"#,
492            r#"permit(principal, action, resource in Namespace::"team-platform");"#,
493            r#"permit(principal, action == Chronix::Action::"CreateMeasurement", resource);"#,
494        ] {
495            assert!(
496                AuthzEngine::check_policies(bad).is_err(),
497                "should have been refused: {bad}"
498            );
499        }
500    }
501
502    #[test]
503    fn an_admin_action_aimed_at_a_namespace_is_refused() {
504        let bad = r#"
505            permit(
506              principal,
507              action == Chronix::Action::"ManageKeys",
508              resource == Chronix::Namespace::"prod"
509            );
510        "#;
511        assert!(AuthzEngine::check_policies(bad).is_err());
512    }
513
514    #[test]
515    fn default_deny() {
516        let engine = AuthzEngine::new();
517        assert!(
518            engine
519                .authorize_namespace(&alice("reader"), ChronixAction::Read, &prod())
520                .is_denied()
521        );
522        assert!(
523            engine
524                .authorize_system(&alice("reader"), ChronixAction::ManageKeys)
525                .is_denied()
526        );
527    }
528
529    #[test]
530    fn a_role_grants_within_one_namespace_only() {
531        let engine = AuthzEngine::new();
532        engine
533            .load_policies(
534                r#"
535                permit(
536                  principal in Chronix::Role::"prod_reader",
537                  action == Chronix::Action::"Read",
538                  resource == Chronix::Namespace::"prod"
539                );
540                "#,
541            )
542            .unwrap();
543        let p = alice("prod_reader");
544        assert!(
545            engine
546                .authorize_namespace(&p, ChronixAction::Read, &prod())
547                .is_allowed()
548        );
549        assert!(
550            engine
551                .authorize_namespace(&p, ChronixAction::Read, &ChronixNamespace::new("staging"))
552                .is_denied()
553        );
554        assert!(
555            engine
556                .authorize_namespace(&p, ChronixAction::Write, &prod())
557                .is_denied()
558        );
559    }
560
561    #[test]
562    fn a_principal_without_the_role_is_denied() {
563        let engine = AuthzEngine::new();
564        engine
565            .load_policies(
566                r#"permit(principal in Chronix::Role::"writer",
567                          action == Chronix::Action::"Write", resource);"#,
568            )
569            .unwrap();
570        assert!(
571            engine
572                .authorize_namespace(&alice("reader"), ChronixAction::Write, &prod())
573                .is_denied()
574        );
575        assert!(
576            engine
577                .authorize_namespace(&alice("writer"), ChronixAction::Write, &prod())
578                .is_allowed()
579        );
580    }
581
582    #[test]
583    fn the_admin_group_grants_every_capability_and_one_action_grants_one() {
584        let engine = AuthzEngine::new();
585        engine
586            .load_policies(
587                r#"
588                permit(
589                  principal in Chronix::Role::"root",
590                  action in Chronix::Action::"Admin",
591                  resource
592                );
593                permit(
594                  principal in Chronix::Role::"backup_operator",
595                  action == Chronix::Action::"ManageBackups",
596                  resource
597                );
598                "#,
599            )
600            .unwrap();
601
602        let root = alice("root");
603        for action in ChronixAction::ALL
604            .iter()
605            .copied()
606            .filter(|a| a.is_administrative())
607        {
608            assert!(
609                engine.authorize_system(&root, action).is_allowed(),
610                "the Admin group should cover {action}"
611            );
612        }
613
614        // Least privilege is the point: the capability that was granted, and
615        // nothing else. Every administrative route asked for `Admin` before
616        // this, so a backup credential could mint API keys.
617        let backup = alice("backup_operator");
618        assert!(
619            engine
620                .authorize_system(&backup, ChronixAction::ManageBackups)
621                .is_allowed()
622        );
623        assert!(
624            engine
625                .authorize_system(&backup, ChronixAction::ManageKeys)
626                .is_denied()
627        );
628        assert!(
629            engine
630                .authorize_system(&backup, ChronixAction::ManageNamespaces)
631                .is_denied()
632        );
633    }
634
635    #[test]
636    fn a_forbid_overrides_a_permit() {
637        let engine = AuthzEngine::new();
638        engine
639            .load_policies(
640                r#"
641                permit(principal, action == Chronix::Action::"Read", resource);
642                forbid(principal, action, resource == Chronix::Namespace::"secret");
643                "#,
644            )
645            .unwrap();
646        assert!(
647            engine
648                .authorize_namespace(&alice("any"), ChronixAction::Read, &prod())
649                .is_allowed()
650        );
651        assert!(
652            engine
653                .authorize_namespace(
654                    &alice("any"),
655                    ChronixAction::Read,
656                    &ChronixNamespace::new("secret")
657                )
658                .is_denied()
659        );
660    }
661
662    #[test]
663    fn a_principal_named_directly_needs_no_role() {
664        let engine = AuthzEngine::new();
665        engine
666            .load_policies(
667                r#"permit(principal == Chronix::User::"ingest-key",
668                          action == Chronix::Action::"Write", resource);"#,
669            )
670            .unwrap();
671        assert!(
672            engine
673                .authorize_namespace(
674                    &ChronixPrincipal::new("ingest-key"),
675                    ChronixAction::Write,
676                    &prod()
677                )
678                .is_allowed()
679        );
680        assert!(
681            engine
682                .authorize_namespace(
683                    &ChronixPrincipal::new("other-key"),
684                    ChronixAction::Write,
685                    &prod()
686                )
687                .is_denied()
688        );
689    }
690
691    #[test]
692    fn a_rejected_policy_leaves_the_active_set_alone() {
693        let engine = AuthzEngine::new();
694        engine
695            .load_policies(r#"permit(principal, action == Chronix::Action::"Read", resource);"#)
696            .unwrap();
697        assert_eq!(engine.policy_count(), 1);
698        assert!(
699            engine
700                .add_policy(
701                    "bad",
702                    r#"permit(principal, action == Chronix::Action::"Query", resource);"#
703                )
704                .is_err()
705        );
706        assert_eq!(engine.policy_count(), 1);
707        assert!(
708            engine
709                .authorize_namespace(&alice("any"), ChronixAction::Read, &prod())
710                .is_allowed()
711        );
712    }
713
714    #[test]
715    fn add_and_remove_a_policy() {
716        let engine = AuthzEngine::new();
717        engine
718            .add_policy(
719                "readers",
720                r#"permit(principal, action == Chronix::Action::"Read", resource);"#,
721            )
722            .unwrap();
723        assert_eq!(engine.policy_ids(), vec!["readers".to_string()]);
724        assert!(engine.remove_policy("readers"));
725        assert!(!engine.remove_policy("readers"));
726        assert_eq!(engine.policy_count(), 0);
727    }
728
729    #[test]
730    fn policies_load_from_a_directory_in_a_stable_order() {
731        let dir = tempfile::tempdir().unwrap();
732        std::fs::write(
733            dir.path().join("b.cedar"),
734            r#"permit(principal, action == Chronix::Action::"Write", resource);"#,
735        )
736        .unwrap();
737        std::fs::write(
738            dir.path().join("a.cedar"),
739            r#"permit(principal, action == Chronix::Action::"Read", resource);"#,
740        )
741        .unwrap();
742        std::fs::write(dir.path().join("notes.txt"), "ignored").unwrap();
743
744        let engine = AuthzEngine::new();
745        assert_eq!(engine.load_policies_from_dir(dir.path()).unwrap(), 2);
746        assert!(
747            engine
748                .authorize_namespace(&alice("any"), ChronixAction::Read, &prod())
749                .is_allowed()
750        );
751    }
752
753    #[test]
754    fn an_empty_directory_loads_nothing_and_denies_everything() {
755        let dir = tempfile::tempdir().unwrap();
756        let engine = AuthzEngine::new();
757        assert_eq!(engine.load_policies_from_dir(dir.path()).unwrap(), 0);
758        assert!(
759            engine
760                .authorize_namespace(&alice("any"), ChronixAction::Read, &prod())
761                .is_denied()
762        );
763    }
764}