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