Skip to main content

acme_proxy/filter/
policy.rs

1//! The policy engine: named checks, boolean rules over them, and the evaluator.
2//!
3//! A [`Check`] is one named question about a request — "is this address in the
4//! management network?", "does the inventory say this address owns this name?".
5//! A [`Rule`] is a boolean expression over check names plus what to do when it
6//! matches. [`FilterPolicy`] holds both and answers one request.
7//!
8//! ## Three-valued, on purpose
9//!
10//! A check answers [`Verdict::Pass`], [`Verdict::Fail`] or
11//! [`Verdict::Undecided`]. The third is what makes composition possible at all:
12//! a two-valued check cannot distinguish "the inventory says no" from "the
13//! inventory is down", so `mgmt-net or inventory` would either refuse every
14//! request during an inventory outage or quietly admit every request during
15//! one. Combination follows **Kleene three-valued logic** ([`kleene_and`],
16//! [`kleene_or`], [`kleene_not`]): an unknown propagates only when it could
17//! change the answer.
18//!
19//! The same principle applies one level up, at the rule loop — see
20//! [`FilterPolicy::evaluate`], where a rule whose condition could not be
21//! evaluated poisons the result only if it would have decided differently from
22//! the rule that did.
23//!
24//! ## Two stages, evaluated independently
25//!
26//! Some checks decide from the connection alone; others need the names being
27//! requested, which only the handlers know. A rule's [`StageSet`] is the
28//! **intersection** of its checks' — never the union, because evaluating a rule
29//! at a stage where one of its checks cannot run would silently substitute
30//! `Pass` for that check and change the boolean answer.
31//!
32//! Each stage evaluates its own applicable subset of the rules and both must
33//! allow. `IdentifierStage::NewOrder` and `IdentifierStage::Csr` are *sub*-stages
34//! of [`Stage::Identifiers`] and evaluate the same rules; a check that wants to
35//! tell them apart reads
36//! [`IdentifierContext::stage`](crate::filter::IdentifierContext::stage).
37//!
38//! **A stage with no applicable rules allows.** [`FilterPolicy::default_effect`]
39//! is consulted only when at least one rule was applicable and none matched —
40//! otherwise a policy made entirely of identifier-stage rules would refuse every
41//! connection before a name was ever mentioned.
42
43use std::collections::BTreeMap;
44use std::fmt;
45use std::future::Future;
46use std::net::IpAddr;
47use std::pin::Pin;
48use std::sync::Arc;
49
50use async_trait::async_trait;
51use tracing::warn;
52
53use super::client_ip::ProxyPolicy;
54use super::expr::Condition;
55use super::{ConnectionContext, IdentifierContext};
56
57/// What one check decided about one request.
58#[derive(Debug, Clone, PartialEq, Eq)]
59pub enum Verdict {
60    /// The check is satisfied.
61    Pass,
62    /// The check is not satisfied. The detail may reach the client.
63    Fail(String),
64    /// The check could not decide — a DNS timeout, an inventory outage. Never
65    /// a refusal: a check that cannot reach its authority knows nothing, and
66    /// treating that as either answer is a bug.
67    Undecided(String),
68}
69
70/// Where in a request's life a check is being asked.
71#[derive(Debug, Clone, Copy, PartialEq, Eq)]
72pub enum Stage {
73    /// Before the handler, from the connection alone.
74    Connection,
75    /// At `newOrder` and again at `finalize`, with the requested names in hand.
76    Identifiers,
77}
78
79impl Stage {
80    /// Short label for logs and `explain` output.
81    #[must_use]
82    pub fn as_str(self) -> &'static str {
83        match self {
84            Self::Connection => "connection",
85            Self::Identifiers => "identifiers",
86        }
87    }
88}
89
90/// The stages a check can decide at, or a rule is evaluated at.
91#[derive(Debug, Clone, Copy, PartialEq, Eq)]
92pub struct StageSet {
93    pub connection: bool,
94    pub identifiers: bool,
95}
96
97impl StageSet {
98    /// A check needing only the client address, which both contexts carry.
99    #[must_use]
100    pub const fn both() -> Self {
101        Self {
102            connection: true,
103            identifiers: true,
104        }
105    }
106
107    #[must_use]
108    pub const fn connection_only() -> Self {
109        Self {
110            connection: true,
111            identifiers: false,
112        }
113    }
114
115    #[must_use]
116    pub const fn identifiers_only() -> Self {
117        Self {
118            connection: false,
119            identifiers: true,
120        }
121    }
122
123    /// The empty set — a rule with this never runs.
124    #[must_use]
125    pub const fn none() -> Self {
126        Self {
127            connection: false,
128            identifiers: false,
129        }
130    }
131
132    #[must_use]
133    pub const fn contains(self, stage: Stage) -> bool {
134        match stage {
135            Stage::Connection => self.connection,
136            Stage::Identifiers => self.identifiers,
137        }
138    }
139
140    #[must_use]
141    pub const fn intersect(self, other: Self) -> Self {
142        Self {
143            connection: self.connection && other.connection,
144            identifiers: self.identifiers && other.identifiers,
145        }
146    }
147
148    #[must_use]
149    pub const fn is_empty(self) -> bool {
150        !self.connection && !self.identifiers
151    }
152}
153
154impl fmt::Display for StageSet {
155    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
156        match (self.connection, self.identifiers) {
157            (true, true) => formatter.write_str("connection and identifiers"),
158            (true, false) => formatter.write_str("connection only"),
159            (false, true) => formatter.write_str("identifiers only"),
160            (false, false) => formatter.write_str("no stage"),
161        }
162    }
163}
164
165/// One named question a policy can ask about a request.
166///
167/// Both hooks default to [`Verdict::Pass`] so an implementation writes only the
168/// one it serves — but [`Check::stages`] has **no** default, because a check
169/// that claims a stage it cannot decide at would silently pass there, which is
170/// exactly the failure the stage intersection exists to prevent.
171#[async_trait]
172pub trait Check: Send + Sync {
173    /// The check *type* (`allowed_ip`, `ipam`, …), not the instance name — the
174    /// instance name is the policy's key for it.
175    fn kind(&self) -> &'static str;
176
177    /// Where this instance can decide.
178    fn stages(&self) -> StageSet;
179
180    async fn check_connection(&self, _context: &ConnectionContext<'_>) -> Verdict {
181        Verdict::Pass
182    }
183
184    async fn check_identifiers(&self, _context: &IdentifierContext<'_>) -> Verdict {
185        Verdict::Pass
186    }
187}
188
189/// `Fail and Undecided` is `Fail`: the conjunction is already false whatever
190/// the unknown turns out to be.
191#[must_use]
192pub fn kleene_and(left: &Verdict, right: &Verdict) -> Verdict {
193    match (left, right) {
194        (Verdict::Fail(detail), _) | (_, Verdict::Fail(detail)) => Verdict::Fail(detail.clone()),
195        (Verdict::Undecided(detail), _) | (_, Verdict::Undecided(detail)) => {
196            Verdict::Undecided(detail.clone())
197        }
198        (Verdict::Pass, Verdict::Pass) => Verdict::Pass,
199    }
200}
201
202/// `Pass or Undecided` is `Pass`: the disjunction is already true whatever the
203/// unknown turns out to be. This is the property that lets an operator write
204/// `mgmt-net or inventory` and survive an inventory outage.
205#[must_use]
206pub fn kleene_or(left: &Verdict, right: &Verdict) -> Verdict {
207    match (left, right) {
208        (Verdict::Pass, _) | (_, Verdict::Pass) => Verdict::Pass,
209        (Verdict::Undecided(detail), _) | (_, Verdict::Undecided(detail)) => {
210            Verdict::Undecided(detail.clone())
211        }
212        (Verdict::Fail(detail), Verdict::Fail(_)) => Verdict::Fail(detail.clone()),
213    }
214}
215
216/// Negation swaps the two decisive answers and leaves the unknown alone.
217#[must_use]
218pub fn kleene_not(verdict: &Verdict) -> Verdict {
219    match verdict {
220        Verdict::Pass => Verdict::Fail("the condition was negated".to_string()),
221        Verdict::Fail(_) => Verdict::Pass,
222        Verdict::Undecided(detail) => Verdict::Undecided(detail.clone()),
223    }
224}
225
226/// What a matching rule does.
227#[derive(Debug, Clone, Copy, PartialEq, Eq)]
228pub enum Effect {
229    Allow,
230    Deny,
231}
232
233impl Effect {
234    #[must_use]
235    pub fn as_str(self) -> &'static str {
236        match self {
237            Self::Allow => "allow",
238            Self::Deny => "deny",
239        }
240    }
241}
242
243/// Whether a rule decides or only reports what it would have decided.
244#[derive(Debug, Clone, Copy, PartialEq, Eq)]
245pub enum Mode {
246    Enforce,
247    /// Dry run: a match is logged as `filter_rule_warned` and evaluation
248    /// continues, so a policy of nothing but `warn` rules falls through to
249    /// [`FilterPolicy::default_effect`].
250    Warn,
251}
252
253/// One authored rule, before its stages are derived.
254#[derive(Debug, Clone)]
255pub struct Rule {
256    pub name: String,
257    pub when: Condition,
258    pub then: Effect,
259    /// The operator's own words for the refusal, shown to the client verbatim.
260    pub message: Option<String>,
261    pub mode: Mode,
262}
263
264/// A rule plus the stages its checks agree on.
265#[derive(Debug, Clone)]
266struct CompiledRule {
267    rule: Rule,
268    stages: StageSet,
269}
270
271struct CheckSlot {
272    kind: &'static str,
273    stages: StageSet,
274    check: Arc<dyn Check>,
275}
276
277/// What one check answered, in the order the evaluator reached it.
278#[derive(Debug, Clone)]
279pub struct CheckOutcome {
280    pub name: String,
281    pub kind: &'static str,
282    pub verdict: Verdict,
283}
284
285/// A `warn`-mode rule that matched without deciding.
286#[derive(Debug, Clone)]
287pub struct WarnedRule {
288    pub name: String,
289    pub then: Effect,
290}
291
292/// What a policy decided about one request at one stage.
293#[derive(Debug, Clone, PartialEq, Eq)]
294pub enum Outcome {
295    Allow,
296    /// Refused. The detail is shown to the client.
297    Deny(String),
298    /// The policy could not be evaluated. Maps to a 500 so the client retries,
299    /// rather than to a refusal it would believe.
300    Undecided(String),
301}
302
303/// Everything the evaluator learned, for logs and `acme-proxy filter explain`.
304#[derive(Debug, Clone)]
305pub struct Evaluation {
306    pub outcome: Outcome,
307    /// The rule that decided, or `None` when the default did (or when no rule
308    /// was applicable at this stage).
309    pub matched: Option<String>,
310    /// Every check actually evaluated, in order. A check skipped by
311    /// short-circuit is absent — which is what `explain` reports as *skipped*.
312    pub checks: Vec<CheckOutcome>,
313    pub warned: Vec<WarnedRule>,
314}
315
316/// One configured check, as `filter show` and `filter explain` describe it.
317#[derive(Debug, Clone, Copy)]
318pub struct CheckSummary<'a> {
319    pub name: &'a str,
320    pub kind: &'static str,
321    pub stages: StageSet,
322}
323
324/// One configured rule, as `filter show` and `filter explain` describe it.
325#[derive(Debug, Clone, Copy)]
326pub struct RuleSummary<'a> {
327    pub name: &'a str,
328    pub when: &'a Condition,
329    pub then: Effect,
330    pub mode: Mode,
331    pub stages: StageSet,
332}
333
334/// A rule that could not be evaluated, remembered until the answer is known.
335struct PendingUnknown {
336    rule: String,
337    then: Effect,
338    detail: String,
339}
340
341/// The configured checks, the rules over them, and how the middleware turns a
342/// peer address into a client address.
343///
344/// Cheap to clone behind the `Arc` it is always held in.
345pub struct FilterPolicy {
346    checks: BTreeMap<String, CheckSlot>,
347    rules: Vec<CompiledRule>,
348    default_effect: Effect,
349    proxy: ProxyPolicy,
350}
351
352impl fmt::Debug for FilterPolicy {
353    /// `dyn Check` is not `Debug`, so show the names and conditions — which is
354    /// the only part worth reading anyway.
355    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
356        formatter
357            .debug_struct("FilterPolicy")
358            .field(
359                "checks",
360                &self
361                    .checks
362                    .iter()
363                    .map(|(name, slot)| format!("{name}: {}", slot.kind))
364                    .collect::<Vec<_>>(),
365            )
366            .field(
367                "rules",
368                &self
369                    .rules
370                    .iter()
371                    .map(|compiled| {
372                        format!(
373                            "{}: {} -> {}",
374                            compiled.rule.name,
375                            compiled.rule.when,
376                            compiled.rule.then.as_str()
377                        )
378                    })
379                    .collect::<Vec<_>>(),
380            )
381            .field("default", &self.default_effect.as_str())
382            .field("proxy", &self.proxy)
383            .finish()
384    }
385}
386
387impl Default for FilterPolicy {
388    /// No checks and no rules: every stage has an empty applicable set, so
389    /// everything is allowed. This is what a server with no `[filter]` section
390    /// and every test that does not care about filtering uses.
391    fn default() -> Self {
392        Self {
393            checks: BTreeMap::new(),
394            rules: Vec::new(),
395            default_effect: Effect::Deny,
396            proxy: ProxyPolicy::default(),
397        }
398    }
399}
400
401impl FilterPolicy {
402    /// Assembles a policy, deriving each rule's stages from its checks.
403    ///
404    /// The stage intersection is computed here rather than passed in, so the
405    /// one place that knows the rule is what fills it in. A rule naming a check
406    /// that does not exist gets [`StageSet::none`] and therefore never runs —
407    /// the builder refuses that configuration at startup, and this is only the
408    /// net under it.
409    #[must_use]
410    pub fn new(
411        checks: Vec<(String, Arc<dyn Check>)>,
412        rules: Vec<Rule>,
413        default_effect: Effect,
414        proxy: ProxyPolicy,
415    ) -> Self {
416        let checks: BTreeMap<String, CheckSlot> = checks
417            .into_iter()
418            .map(|(name, check)| {
419                let slot = CheckSlot {
420                    kind: check.kind(),
421                    stages: check.stages(),
422                    check,
423                };
424                (name, slot)
425            })
426            .collect();
427
428        let rules = rules
429            .into_iter()
430            .map(|rule| {
431                let stages = stages_for(&rule.when, &checks);
432                CompiledRule { rule, stages }
433            })
434            .collect();
435
436        Self {
437            checks,
438            rules,
439            default_effect,
440            proxy,
441        }
442    }
443
444    /// How the middleware turns a peer address plus headers into a client IP.
445    pub fn proxy(&self) -> &ProxyPolicy {
446        &self.proxy
447    }
448
449    /// What happens when a rule was applicable at a stage and none matched.
450    #[must_use]
451    pub fn default_effect(&self) -> Effect {
452        self.default_effect
453    }
454
455    /// Whether any rule is evaluated at `stage`.
456    ///
457    /// `post_finalize` asks about [`Stage::Identifiers`] to skip re-parsing the
458    /// CSR when nothing would look at the result.
459    #[must_use]
460    pub fn has_rules_at(&self, stage: Stage) -> bool {
461        self.rules
462            .iter()
463            .any(|compiled| compiled.stages.contains(stage))
464    }
465
466    /// Whether the policy would decide anything at all.
467    #[must_use]
468    pub fn is_active(&self) -> bool {
469        !self.rules.is_empty()
470    }
471
472    /// Every configured check, for `acme-proxy filter show` and for working
473    /// out which checks an evaluation short-circuited past.
474    pub fn checks(&self) -> Vec<CheckSummary<'_>> {
475        self.checks
476            .iter()
477            .map(|(name, slot)| CheckSummary {
478                name,
479                kind: slot.kind,
480                stages: slot.stages,
481            })
482            .collect()
483    }
484
485    /// Every rule, in evaluation order.
486    pub fn rules(&self) -> Vec<RuleSummary<'_>> {
487        self.rules
488            .iter()
489            .map(|compiled| RuleSummary {
490                name: &compiled.rule.name,
491                when: &compiled.rule.when,
492                then: compiled.rule.then,
493                mode: compiled.rule.mode,
494                stages: compiled.stages,
495            })
496            .collect()
497    }
498
499    /// Whether any check asks about the requesting account's EAB credential.
500    ///
501    /// The handlers gate the two database reads that resolve one on this, so a
502    /// policy with no `eab` check pays nothing for the field's existence.
503    #[must_use]
504    pub fn needs_eab(&self) -> bool {
505        self.checks.values().any(|slot| slot.kind == "eab")
506    }
507
508    /// Evaluates the connection stage, keeping the whole trace.
509    ///
510    /// The trace is what `acme-proxy filter explain` renders; a request path
511    /// wants [`FilterPolicy::check_connection`], which logs the decision.
512    pub async fn evaluate_connection(&self, context: &ConnectionContext<'_>) -> Evaluation {
513        self.evaluate(Hook::Connection(context)).await
514    }
515
516    /// Evaluates the identifier stage, keeping the whole trace.
517    pub async fn evaluate_identifiers(&self, context: &IdentifierContext<'_>) -> Evaluation {
518        self.evaluate(Hook::Identifiers(context)).await
519    }
520
521    /// The connection stage's answer, with the decision logged.
522    pub async fn check_connection(&self, context: &ConnectionContext<'_>) -> Outcome {
523        let evaluation = self.evaluate_connection(context).await;
524        log_decision(&evaluation, Stage::Connection.as_str(), context.client_ip);
525        evaluation.outcome
526    }
527
528    /// The identifier stage's answer, with the decision logged.
529    ///
530    /// The hook is logged as `newOrder` or `CSR` rather than `identifiers`,
531    /// because which of the two refused is the first thing an operator reading
532    /// the line wants to know.
533    pub async fn check_identifiers(&self, context: &IdentifierContext<'_>) -> Outcome {
534        let evaluation = self.evaluate_identifiers(context).await;
535        log_decision(&evaluation, context.stage.as_str(), context.client_ip);
536        evaluation.outcome
537    }
538
539    /// Runs the applicable rules in order and reduces them to one answer.
540    ///
541    /// The loop is first-match-wins with two wrinkles.
542    ///
543    /// A `warn`-mode rule that matches is recorded and **does not decide**, so
544    /// a policy can be tightened in production and watched before it bites.
545    ///
546    /// A rule whose condition came back [`Verdict::Undecided`] is remembered
547    /// rather than skipped. It might have matched, so once the answer is known
548    /// the loop asks whether that would have mattered: if the unknown rule's
549    /// effect differs from the effect actually reached, the whole stage is
550    /// [`Outcome::Undecided`] — a retryable 500 — because there is no honest
551    /// answer to give. If it agrees, the answer stands whichever way the
552    /// unknown would have gone. This is [`kleene_or`]'s principle at the rule
553    /// level, and it is what keeps rule *order* from deciding whether an
554    /// inventory outage is survivable.
555    async fn evaluate(&self, hook: Hook<'_>) -> Evaluation {
556        let stage = hook.stage();
557        let applicable: Vec<&CompiledRule> = self
558            .rules
559            .iter()
560            .filter(|compiled| compiled.stages.contains(stage))
561            .collect();
562
563        // Law: a stage nobody wrote a rule for is not a stage that refuses.
564        if applicable.is_empty() {
565            return Evaluation {
566                outcome: Outcome::Allow,
567                matched: None,
568                checks: Vec::new(),
569                warned: Vec::new(),
570            };
571        }
572
573        let mut run = Run {
574            policy: self,
575            hook,
576            stage,
577            memo: BTreeMap::new(),
578            trace: Vec::new(),
579        };
580        let mut decision: Option<&Rule> = None;
581        let mut warned = Vec::new();
582        let mut pending: Vec<PendingUnknown> = Vec::new();
583        // Where the last rule's own checks begin in the trace — see
584        // `denial_detail` for why the *last* rule is the one whose refusal is
585        // worth quoting.
586        let mut last_rule_start = 0;
587
588        for compiled in applicable {
589            last_rule_start = run.trace.len();
590            match run.eval(&compiled.rule.when).await {
591                Verdict::Pass => {
592                    if compiled.rule.mode == Mode::Warn {
593                        warn!(
594                            event = "filter_rule_warned",
595                            outcome = "advisory",
596                            rule = %compiled.rule.name,
597                            then = compiled.rule.then.as_str(),
598                            stage = stage.as_str(),
599                            "rule matched in warn mode and did not decide",
600                        );
601                        warned.push(WarnedRule {
602                            name: compiled.rule.name.clone(),
603                            then: compiled.rule.then,
604                        });
605                        continue;
606                    }
607                    decision = Some(&compiled.rule);
608                    break;
609                }
610                Verdict::Fail(_) => continue,
611                Verdict::Undecided(detail) => {
612                    if compiled.rule.mode == Mode::Enforce {
613                        pending.push(PendingUnknown {
614                            rule: compiled.rule.name.clone(),
615                            then: compiled.rule.then,
616                            detail,
617                        });
618                    }
619                }
620            }
621        }
622
623        let effect = decision.map_or(self.default_effect, |rule| rule.then);
624
625        if let Some(unknown) = pending.into_iter().find(|entry| entry.then != effect) {
626            return Evaluation {
627                outcome: Outcome::Undecided(format!(
628                    "rule `{}` could not be evaluated ({}), and it would have \
629                     decided differently from the rule that did",
630                    unknown.rule, unknown.detail
631                )),
632                matched: decision.map(|rule| rule.name.clone()),
633                checks: run.trace,
634                warned,
635            };
636        }
637
638        let outcome = match effect {
639            Effect::Allow => Outcome::Allow,
640            Effect::Deny => Outcome::Deny(denial_detail(decision, &run.trace, last_rule_start)),
641        };
642
643        Evaluation {
644            outcome,
645            matched: decision.map(|rule| rule.name.clone()),
646            checks: run.trace,
647            warned,
648        }
649    }
650}
651
652/// One log line per refusal, at the level the reason deserves: a refusal is
653/// routine operation, an unknown is something an operator should see.
654///
655/// Only the *decision* logs. A check whose `Undecided` was absorbed by an `or`
656/// must not produce an `error!` — that would be one line per check per request
657/// on a policy that is working exactly as written.
658///
659/// `check` is the instance name and `filter` its type, so a family grep still
660/// finds every `allowed_ip` refusal while three `custom` scripts are finally
661/// distinguishable from one another.
662fn log_decision(evaluation: &Evaluation, hook: &str, client_ip: Option<IpAddr>) {
663    let rule = evaluation.matched.as_deref().unwrap_or("default");
664    let source = evaluation
665        .checks
666        .iter()
667        .find(|outcome| matches!(outcome.verdict, Verdict::Fail(_) | Verdict::Undecided(_)));
668    let check = source.map(|outcome| outcome.name.as_str());
669    let kind = source.map(|outcome| outcome.kind);
670
671    match &evaluation.outcome {
672        Outcome::Allow => {}
673        Outcome::Deny(detail) => tracing::warn!(
674            event = "filter_denied",
675            outcome = "failure",
676            check = ?check,
677            filter = ?kind,
678            rule,
679            hook,
680            client_ip = ?client_ip,
681            detail = %detail,
682        ),
683        Outcome::Undecided(detail) => tracing::error!(
684            event = "filter_failed",
685            outcome = "failure",
686            check = ?check,
687            filter = ?kind,
688            rule,
689            hook,
690            client_ip = ?client_ip,
691            detail = %detail,
692        ),
693    }
694}
695
696/// The intersection of the stages every check the condition names can serve.
697fn stages_for(condition: &Condition, checks: &BTreeMap<String, CheckSlot>) -> StageSet {
698    condition
699        .check_names()
700        .into_iter()
701        .fold(StageSet::both(), |accumulated, name| {
702            let stages = checks
703                .get(name)
704                .map_or_else(StageSet::none, |slot| slot.stages);
705            accumulated.intersect(stages)
706        })
707}
708
709/// What the client is told when a stage refuses.
710///
711/// A matching rule speaks for itself — the operator's `message` if they wrote
712/// one, otherwise its own name, which is at least greppable.
713///
714/// Falling through to the default is different: nothing *decided* to refuse, so
715/// the most useful thing to hand back is a check that actually said no. Which
716/// one matters. Rules are first-match-wins, so the ones an operator writes
717/// first are the narrow bypasses and the last is the general case — and the
718/// general case is the one a refused client was expected to satisfy. Quoting
719/// the first failure across the whole stage instead would answer a policy of
720///
721/// ```text
722/// rules = ["public-paths", "mgmt-net", "corp-names-from-inventory"]
723/// ```
724///
725/// with "path /newOrder is not allowed", which is true of the bypass and
726/// actively misleading about the request: the path is fine, the address is not.
727/// So the search starts at the last rule evaluated and only widens if that rule
728/// left no refusal behind.
729fn denial_detail(
730    decision: Option<&Rule>,
731    trace: &[CheckOutcome],
732    last_rule_start: usize,
733) -> String {
734    if let Some(rule) = decision {
735        return rule
736            .message
737            .clone()
738            .unwrap_or_else(|| format!("refused by policy rule `{}`", rule.name));
739    }
740
741    let first_failure = |slice: &[CheckOutcome]| {
742        slice.iter().find_map(|outcome| match &outcome.verdict {
743            Verdict::Fail(detail) => Some(detail.clone()),
744            _ => None,
745        })
746    };
747
748    first_failure(trace.get(last_rule_start..).unwrap_or_default())
749        .or_else(|| first_failure(trace))
750        .unwrap_or_else(|| "no policy rule permits this request".to_string())
751}
752
753/// Which hook is being evaluated, so one recursion serves both.
754#[derive(Clone, Copy)]
755enum Hook<'a> {
756    Connection(&'a ConnectionContext<'a>),
757    Identifiers(&'a IdentifierContext<'a>),
758}
759
760impl Hook<'_> {
761    fn stage(self) -> Stage {
762        match self {
763            Self::Connection(_) => Stage::Connection,
764            Self::Identifiers(_) => Stage::Identifiers,
765        }
766    }
767}
768
769/// One evaluation of one stage.
770struct Run<'a> {
771    policy: &'a FilterPolicy,
772    hook: Hook<'a>,
773    stage: Stage,
774    /// A check named twice in one expression runs once. This is not an
775    /// optimisation: `custom` forks a process and `ipam` makes four HTTP
776    /// requests, so a second evaluation would be a second side effect.
777    memo: BTreeMap<String, Verdict>,
778    trace: Vec<CheckOutcome>,
779}
780
781type VerdictFuture<'a> = Pin<Box<dyn Future<Output = Verdict> + Send + 'a>>;
782
783impl Run<'_> {
784    /// Evaluates a condition, skipping operands that cannot change the answer.
785    ///
786    /// Short-circuiting lives here rather than in [`kleene_and`]/[`kleene_or`]
787    /// because the hooks are `async`: a lazy combinator would need to take a
788    /// future, and the truth tables are worth keeping pure and table-testable.
789    ///
790    /// Note which cases actually skip. `Fail and _` and `Pass or _` are
791    /// decided by the left operand alone. An **`Undecided` left operand skips
792    /// nothing** — the right operand is exactly what might rescue it.
793    fn eval<'s>(&'s mut self, condition: &'s Condition) -> VerdictFuture<'s> {
794        Box::pin(async move {
795            match condition {
796                Condition::Check(name) => self.eval_check(name).await,
797                Condition::Not(inner) => kleene_not(&self.eval(inner).await),
798                Condition::And(left, right) => {
799                    let left = self.eval(left).await;
800                    if matches!(left, Verdict::Fail(_)) {
801                        return left;
802                    }
803                    let right = self.eval(right).await;
804                    kleene_and(&left, &right)
805                }
806                Condition::Or(left, right) => {
807                    let left = self.eval(left).await;
808                    if matches!(left, Verdict::Pass) {
809                        return left;
810                    }
811                    let right = self.eval(right).await;
812                    kleene_or(&left, &right)
813                }
814            }
815        })
816    }
817
818    async fn eval_check(&mut self, name: &str) -> Verdict {
819        if let Some(cached) = self.memo.get(name) {
820            return cached.clone();
821        }
822
823        let Some(slot) = self.policy.checks.get(name) else {
824            // The builder resolves every name in every condition, so reaching
825            // this is a bug in the builder rather than in the configuration —
826            // hence an unknown rather than a refusal the operator would chase.
827            return Verdict::Undecided(format!("check `{name}` is not configured"));
828        };
829
830        // Belt and braces for the stage intersection: a check asked at a stage
831        // it does not serve would otherwise fall through to the trait's
832        // default `Pass`, which is precisely the silent substitution the
833        // intersection exists to prevent.
834        if !slot.stages.contains(self.stage) {
835            return Verdict::Undecided(format!(
836                "check `{name}` cannot decide at the {} stage",
837                self.stage.as_str()
838            ));
839        }
840
841        let kind = slot.kind;
842        let check = Arc::clone(&slot.check);
843
844        let verdict = match self.hook {
845            Hook::Connection(context) => check.check_connection(context).await,
846            Hook::Identifiers(context) => check.check_identifiers(context).await,
847        };
848
849        self.memo.insert(name.to_string(), verdict.clone());
850        self.trace.push(CheckOutcome {
851            name: name.to_string(),
852            kind,
853            verdict: verdict.clone(),
854        });
855        verdict
856    }
857}
858
859#[cfg(test)]
860mod tests {
861    use std::sync::atomic::{AtomicUsize, Ordering};
862
863    use axum::http::Method;
864
865    use super::*;
866
867    /// A check with a fixed answer that counts how often it was asked.
868    ///
869    /// The counter is the only thing that can prove a *skip*: a short-circuited
870    /// operand and an evaluated one that happens not to matter produce the same
871    /// verdict, so the assertion has to be about the call, not the result.
872    struct StubCheck {
873        verdict: Verdict,
874        stages: StageSet,
875        calls: Arc<AtomicUsize>,
876    }
877
878    impl StubCheck {
879        fn with(verdict: Verdict, stages: StageSet) -> (Arc<dyn Check>, Arc<AtomicUsize>) {
880            let calls = Arc::new(AtomicUsize::new(0));
881            let check = Arc::new(Self {
882                verdict,
883                stages,
884                calls: Arc::clone(&calls),
885            });
886            (check, calls)
887        }
888
889        fn passing() -> (Arc<dyn Check>, Arc<AtomicUsize>) {
890            Self::with(Verdict::Pass, StageSet::both())
891        }
892
893        fn failing() -> (Arc<dyn Check>, Arc<AtomicUsize>) {
894            Self::with(Verdict::Fail("stub refused".to_string()), StageSet::both())
895        }
896
897        fn undecided() -> (Arc<dyn Check>, Arc<AtomicUsize>) {
898            Self::with(
899                Verdict::Undecided("stub is down".to_string()),
900                StageSet::both(),
901            )
902        }
903    }
904
905    #[async_trait]
906    impl Check for StubCheck {
907        fn kind(&self) -> &'static str {
908            "stub"
909        }
910
911        fn stages(&self) -> StageSet {
912            self.stages
913        }
914
915        async fn check_connection(&self, _context: &ConnectionContext<'_>) -> Verdict {
916            self.calls.fetch_add(1, Ordering::SeqCst);
917            self.verdict.clone()
918        }
919
920        async fn check_identifiers(&self, _context: &IdentifierContext<'_>) -> Verdict {
921            self.calls.fetch_add(1, Ordering::SeqCst);
922            self.verdict.clone()
923        }
924    }
925
926    fn rule(name: &str, when: &str, then: Effect) -> Rule {
927        Rule {
928            name: name.to_string(),
929            when: Condition::parse(when).expect("test condition should parse"),
930            then,
931            message: None,
932            mode: Mode::Enforce,
933        }
934    }
935
936    fn connection_context() -> ConnectionContext<'static> {
937        ConnectionContext {
938            client_ip: Some("10.0.0.5".parse().expect("literal address")),
939            method: &Method::POST,
940            path: "/newOrder",
941        }
942    }
943
944    async fn decide(policy: &FilterPolicy) -> Outcome {
945        policy
946            .evaluate_connection(&connection_context())
947            .await
948            .outcome
949    }
950
951    // ---- the truth tables -------------------------------------------------
952
953    /// The whole design rests on these twenty-one rows, so they are asserted
954    /// directly rather than inferred from the evaluator's behaviour.
955    #[test]
956    fn kleene_and_is_complete() {
957        let pass = Verdict::Pass;
958        let fail = Verdict::Fail("no".to_string());
959        let unknown = Verdict::Undecided("down".to_string());
960
961        let cases = [
962            (&pass, &pass, &pass),
963            (&pass, &fail, &fail),
964            (&pass, &unknown, &unknown),
965            (&fail, &pass, &fail),
966            (&fail, &fail, &fail),
967            // The row that matters: already false, so the unknown is irrelevant.
968            (&fail, &unknown, &fail),
969            (&unknown, &pass, &unknown),
970            (&unknown, &fail, &fail),
971            (&unknown, &unknown, &unknown),
972        ];
973
974        for (left, right, expected) in cases {
975            assert!(
976                same_kind(&kleene_and(left, right), expected),
977                "{left:?} and {right:?} should be {expected:?}"
978            );
979        }
980    }
981
982    #[test]
983    fn kleene_or_is_complete() {
984        let pass = Verdict::Pass;
985        let fail = Verdict::Fail("no".to_string());
986        let unknown = Verdict::Undecided("down".to_string());
987
988        let cases = [
989            (&pass, &pass, &pass),
990            (&pass, &fail, &pass),
991            // The row the whole feature exists for: an inventory outage does
992            // not defeat an address that already matched.
993            (&pass, &unknown, &pass),
994            (&fail, &pass, &pass),
995            (&fail, &fail, &fail),
996            (&fail, &unknown, &unknown),
997            (&unknown, &pass, &pass),
998            (&unknown, &fail, &unknown),
999            (&unknown, &unknown, &unknown),
1000        ];
1001
1002        for (left, right, expected) in cases {
1003            assert!(
1004                same_kind(&kleene_or(left, right), expected),
1005                "{left:?} or {right:?} should be {expected:?}"
1006            );
1007        }
1008    }
1009
1010    #[test]
1011    fn kleene_not_leaves_the_unknown_alone() {
1012        assert!(same_kind(
1013            &kleene_not(&Verdict::Pass),
1014            &Verdict::Fail(String::new())
1015        ));
1016        assert!(same_kind(
1017            &kleene_not(&Verdict::Fail("no".to_string())),
1018            &Verdict::Pass
1019        ));
1020        assert!(same_kind(
1021            &kleene_not(&Verdict::Undecided("down".to_string())),
1022            &Verdict::Undecided(String::new())
1023        ));
1024    }
1025
1026    /// Compares which of the three a verdict is, ignoring its detail.
1027    fn same_kind(left: &Verdict, right: &Verdict) -> bool {
1028        matches!(
1029            (left, right),
1030            (Verdict::Pass, Verdict::Pass)
1031                | (Verdict::Fail(_), Verdict::Fail(_))
1032                | (Verdict::Undecided(_), Verdict::Undecided(_))
1033        )
1034    }
1035
1036    // ---- short-circuiting and memoisation ---------------------------------
1037
1038    #[tokio::test]
1039    async fn a_failing_left_operand_skips_the_right_of_an_and() {
1040        let (left, left_calls) = StubCheck::failing();
1041        let (right, right_calls) = StubCheck::passing();
1042        let policy = FilterPolicy::new(
1043            vec![("left".to_string(), left), ("right".to_string(), right)],
1044            vec![rule("r", "left and right", Effect::Allow)],
1045            Effect::Deny,
1046            ProxyPolicy::default(),
1047        );
1048
1049        assert!(matches!(decide(&policy).await, Outcome::Deny(_)));
1050        assert_eq!(left_calls.load(Ordering::SeqCst), 1);
1051        assert_eq!(right_calls.load(Ordering::SeqCst), 0, "right was evaluated");
1052    }
1053
1054    #[tokio::test]
1055    async fn a_passing_left_operand_skips_the_right_of_an_or() {
1056        let (left, left_calls) = StubCheck::passing();
1057        let (right, right_calls) = StubCheck::failing();
1058        let policy = FilterPolicy::new(
1059            vec![("left".to_string(), left), ("right".to_string(), right)],
1060            vec![rule("r", "left or right", Effect::Allow)],
1061            Effect::Deny,
1062            ProxyPolicy::default(),
1063        );
1064
1065        assert_eq!(decide(&policy).await, Outcome::Allow);
1066        assert_eq!(left_calls.load(Ordering::SeqCst), 1);
1067        assert_eq!(right_calls.load(Ordering::SeqCst), 0, "right was evaluated");
1068    }
1069
1070    /// The converse, and the reason short-circuiting cannot simply be "stop at
1071    /// the first non-`Pass`": an unknown is exactly the case where the other
1072    /// operand still matters.
1073    #[tokio::test]
1074    async fn an_undecided_left_operand_still_evaluates_the_right() {
1075        let (left, left_calls) = StubCheck::undecided();
1076        let (right, right_calls) = StubCheck::passing();
1077        let policy = FilterPolicy::new(
1078            vec![("left".to_string(), left), ("right".to_string(), right)],
1079            vec![rule("r", "left or right", Effect::Allow)],
1080            Effect::Deny,
1081            ProxyPolicy::default(),
1082        );
1083
1084        assert_eq!(decide(&policy).await, Outcome::Allow);
1085        assert_eq!(left_calls.load(Ordering::SeqCst), 1);
1086        assert_eq!(right_calls.load(Ordering::SeqCst), 1);
1087    }
1088
1089    #[tokio::test]
1090    async fn a_check_named_twice_runs_once() {
1091        let (check, calls) = StubCheck::passing();
1092        let policy = FilterPolicy::new(
1093            vec![("only".to_string(), check)],
1094            vec![rule("r", "only and (only or only)", Effect::Allow)],
1095            Effect::Deny,
1096            ProxyPolicy::default(),
1097        );
1098
1099        assert_eq!(decide(&policy).await, Outcome::Allow);
1100        assert_eq!(calls.load(Ordering::SeqCst), 1);
1101    }
1102
1103    #[tokio::test]
1104    async fn memoisation_spans_rules_within_one_stage() {
1105        let (check, calls) = StubCheck::failing();
1106        let policy = FilterPolicy::new(
1107            vec![("only".to_string(), check)],
1108            vec![
1109                rule("first", "only", Effect::Allow),
1110                rule("second", "only", Effect::Allow),
1111            ],
1112            Effect::Deny,
1113            ProxyPolicy::default(),
1114        );
1115
1116        assert!(matches!(decide(&policy).await, Outcome::Deny(_)));
1117        assert_eq!(calls.load(Ordering::SeqCst), 1);
1118    }
1119
1120    // ---- the rule loop ----------------------------------------------------
1121
1122    #[tokio::test]
1123    async fn the_first_matching_rule_decides_and_later_rules_never_run() {
1124        let (first, _) = StubCheck::passing();
1125        let (second, second_calls) = StubCheck::passing();
1126        let policy = FilterPolicy::new(
1127            vec![("first".to_string(), first), ("second".to_string(), second)],
1128            vec![
1129                rule("allow-it", "first", Effect::Allow),
1130                rule("deny-it", "second", Effect::Deny),
1131            ],
1132            Effect::Deny,
1133            ProxyPolicy::default(),
1134        );
1135
1136        assert_eq!(decide(&policy).await, Outcome::Allow);
1137        assert_eq!(second_calls.load(Ordering::SeqCst), 0);
1138    }
1139
1140    #[tokio::test]
1141    async fn a_stage_with_no_applicable_rules_allows() {
1142        let (check, calls) = StubCheck::with(
1143            Verdict::Fail("no".to_string()),
1144            StageSet::identifiers_only(),
1145        );
1146        let policy = FilterPolicy::new(
1147            vec![("names".to_string(), check)],
1148            vec![rule("names-only", "names", Effect::Allow)],
1149            Effect::Deny,
1150            ProxyPolicy::default(),
1151        );
1152
1153        // Nothing is applicable at the connection stage, so the default deny
1154        // must not reach it — otherwise an identifier-only policy would lock
1155        // out every request before a name was ever mentioned.
1156        assert_eq!(decide(&policy).await, Outcome::Allow);
1157        assert_eq!(calls.load(Ordering::SeqCst), 0);
1158        assert!(!policy.has_rules_at(Stage::Connection));
1159        assert!(policy.has_rules_at(Stage::Identifiers));
1160    }
1161
1162    #[tokio::test]
1163    async fn the_default_applies_only_once_a_rule_was_applicable() {
1164        let (check, _) = StubCheck::failing();
1165        let policy = FilterPolicy::new(
1166            vec![("no".to_string(), check)],
1167            vec![rule("never", "no", Effect::Allow)],
1168            Effect::Allow,
1169            ProxyPolicy::default(),
1170        );
1171
1172        assert_eq!(decide(&policy).await, Outcome::Allow);
1173    }
1174
1175    #[tokio::test]
1176    async fn a_warn_rule_matches_without_deciding() {
1177        let (check, _) = StubCheck::passing();
1178        let mut warned = rule("would-deny", "yes", Effect::Deny);
1179        warned.mode = Mode::Warn;
1180
1181        let policy = FilterPolicy::new(
1182            vec![("yes".to_string(), check)],
1183            vec![warned],
1184            Effect::Allow,
1185            ProxyPolicy::default(),
1186        );
1187
1188        let evaluation = policy.evaluate_connection(&connection_context()).await;
1189        assert_eq!(evaluation.outcome, Outcome::Allow);
1190        assert_eq!(evaluation.matched, None);
1191        assert_eq!(evaluation.warned.len(), 1);
1192        assert_eq!(evaluation.warned[0].name, "would-deny");
1193        assert_eq!(evaluation.warned[0].then, Effect::Deny);
1194    }
1195
1196    #[tokio::test]
1197    async fn the_enforcing_twin_of_a_warn_rule_denies() {
1198        let (check, _) = StubCheck::passing();
1199        let policy = FilterPolicy::new(
1200            vec![("yes".to_string(), check)],
1201            vec![rule("deny-it", "yes", Effect::Deny)],
1202            Effect::Allow,
1203            ProxyPolicy::default(),
1204        );
1205
1206        assert!(matches!(decide(&policy).await, Outcome::Deny(_)));
1207    }
1208
1209    // ---- unknowns at the rule level ---------------------------------------
1210
1211    #[tokio::test]
1212    async fn an_unknown_rule_poisons_a_differing_answer() {
1213        let (down, _) = StubCheck::undecided();
1214        let policy = FilterPolicy::new(
1215            vec![("inventory".to_string(), down)],
1216            vec![rule("inventory-owned", "inventory", Effect::Allow)],
1217            Effect::Deny,
1218            ProxyPolicy::default(),
1219        );
1220
1221        // The rule would have allowed; the default denies. There is no honest
1222        // answer, so this is a retryable 500 rather than a refusal.
1223        assert!(matches!(decide(&policy).await, Outcome::Undecided(_)));
1224    }
1225
1226    /// The rule that makes order stop mattering: a later rule reaching the same
1227    /// effect the unknown rule would have reached is a decision either way.
1228    #[tokio::test]
1229    async fn an_unknown_rule_is_harmless_when_the_answer_agrees() {
1230        let (down, _) = StubCheck::undecided();
1231        let (up, _) = StubCheck::passing();
1232        let policy = FilterPolicy::new(
1233            vec![("inventory".to_string(), down), ("mgmt".to_string(), up)],
1234            vec![
1235                rule("inventory-owned", "inventory", Effect::Allow),
1236                rule("mgmt-bypass", "mgmt", Effect::Allow),
1237            ],
1238            Effect::Deny,
1239            ProxyPolicy::default(),
1240        );
1241
1242        assert_eq!(decide(&policy).await, Outcome::Allow);
1243    }
1244
1245    /// Written as one condition instead of two rules, the same outage is
1246    /// absorbed by `or` before the rule loop ever sees it.
1247    #[tokio::test]
1248    async fn an_or_absorbs_the_outage_within_a_single_rule() {
1249        let (down, _) = StubCheck::undecided();
1250        let (up, _) = StubCheck::passing();
1251        let policy = FilterPolicy::new(
1252            vec![("inventory".to_string(), down), ("mgmt".to_string(), up)],
1253            vec![rule("reachable", "mgmt or inventory", Effect::Allow)],
1254            Effect::Deny,
1255            ProxyPolicy::default(),
1256        );
1257
1258        assert_eq!(decide(&policy).await, Outcome::Allow);
1259    }
1260
1261    /// ...and the same policy without the disjunct is still a 500, which is
1262    /// what stops the `or` from reading as "outages are ignored".
1263    #[tokio::test]
1264    async fn the_inventory_alone_is_still_an_outage() {
1265        let (down, _) = StubCheck::undecided();
1266        let policy = FilterPolicy::new(
1267            vec![("inventory".to_string(), down)],
1268            vec![rule("reachable", "inventory", Effect::Allow)],
1269            Effect::Deny,
1270            ProxyPolicy::default(),
1271        );
1272
1273        assert!(matches!(decide(&policy).await, Outcome::Undecided(_)));
1274    }
1275
1276    #[tokio::test]
1277    async fn a_warn_rule_that_cannot_be_evaluated_poisons_nothing() {
1278        let (down, _) = StubCheck::undecided();
1279        let mut dry_run = rule("inventory-owned", "inventory", Effect::Allow);
1280        dry_run.mode = Mode::Warn;
1281
1282        let policy = FilterPolicy::new(
1283            vec![("inventory".to_string(), down)],
1284            vec![dry_run],
1285            Effect::Deny,
1286            ProxyPolicy::default(),
1287        );
1288
1289        assert!(matches!(decide(&policy).await, Outcome::Deny(_)));
1290    }
1291
1292    // ---- what the client is told ------------------------------------------
1293
1294    #[tokio::test]
1295    async fn a_rule_message_is_shown_verbatim() {
1296        let (check, _) = StubCheck::passing();
1297        let mut denying = rule("no-tenants", "yes", Effect::Deny);
1298        denying.message = Some("this address owns no such name".to_string());
1299
1300        let policy = FilterPolicy::new(
1301            vec![("yes".to_string(), check)],
1302            vec![denying],
1303            Effect::Allow,
1304            ProxyPolicy::default(),
1305        );
1306
1307        assert_eq!(
1308            decide(&policy).await,
1309            Outcome::Deny("this address owns no such name".to_string())
1310        );
1311    }
1312
1313    #[tokio::test]
1314    async fn a_denying_rule_without_a_message_names_itself() {
1315        let (check, _) = StubCheck::passing();
1316        let policy = FilterPolicy::new(
1317            vec![("yes".to_string(), check)],
1318            vec![rule("no-tenants", "yes", Effect::Deny)],
1319            Effect::Allow,
1320            ProxyPolicy::default(),
1321        );
1322
1323        assert_eq!(
1324            decide(&policy).await,
1325            Outcome::Deny("refused by policy rule `no-tenants`".to_string())
1326        );
1327    }
1328
1329    #[tokio::test]
1330    async fn falling_through_to_the_default_reports_the_first_refusing_check() {
1331        let (check, _) = StubCheck::failing();
1332        let policy = FilterPolicy::new(
1333            vec![("addr".to_string(), check)],
1334            vec![rule("permitted", "addr", Effect::Allow)],
1335            Effect::Deny,
1336            ProxyPolicy::default(),
1337        );
1338
1339        assert_eq!(
1340            decide(&policy).await,
1341            Outcome::Deny("stub refused".to_string())
1342        );
1343    }
1344
1345    /// The regression: with rules ordered bypass-first, quoting the first
1346    /// failure anywhere in the stage tells a refused client about the bypass
1347    /// it was never going to match, not about the rule it actually failed.
1348    #[tokio::test]
1349    async fn a_default_deny_quotes_the_last_rule_not_the_first_bypass() {
1350        let (bypass, _) = StubCheck::with(
1351            Verdict::Fail("path /newOrder is not allowed".to_string()),
1352            StageSet::both(),
1353        );
1354        let (main, _) = StubCheck::with(
1355            Verdict::Fail("address 203.0.113.9 is not allowed".to_string()),
1356            StageSet::both(),
1357        );
1358
1359        let policy = FilterPolicy::new(
1360            vec![
1361                ("public-paths".to_string(), bypass),
1362                ("mgmt-net".to_string(), main),
1363            ],
1364            vec![
1365                rule("public", "public-paths", Effect::Allow),
1366                rule("mgmt-bypass", "mgmt-net", Effect::Allow),
1367            ],
1368            Effect::Deny,
1369            ProxyPolicy::default(),
1370        );
1371
1372        assert_eq!(
1373            decide(&policy).await,
1374            Outcome::Deny("address 203.0.113.9 is not allowed".to_string())
1375        );
1376    }
1377
1378    /// ...and it widens to the whole stage when the last rule left no refusal
1379    /// of its own, so the fallback never loses information it had before.
1380    #[tokio::test]
1381    async fn a_default_deny_widens_when_the_last_rule_refused_nothing() {
1382        let (failing, _) = StubCheck::failing();
1383        let (passing, _) = StubCheck::passing();
1384
1385        let policy = FilterPolicy::new(
1386            vec![
1387                ("first".to_string(), failing),
1388                ("second".to_string(), passing),
1389            ],
1390            vec![
1391                rule("early", "first", Effect::Allow),
1392                // Passes its check, so `not` refuses the rule without any
1393                // check having failed.
1394                rule("late", "not second", Effect::Allow),
1395            ],
1396            Effect::Deny,
1397            ProxyPolicy::default(),
1398        );
1399
1400        assert_eq!(
1401            decide(&policy).await,
1402            Outcome::Deny("stub refused".to_string())
1403        );
1404    }
1405
1406    #[tokio::test]
1407    async fn a_default_deny_with_nothing_to_report_says_so() {
1408        let (check, _) = StubCheck::passing();
1409        // The trace records what each *check* answered, not what the condition
1410        // built out of them came to. `not yes` therefore fails the rule while
1411        // leaving a single `Pass` behind, so there is genuinely no refusal to
1412        // quote and the generic sentence is the honest answer.
1413        let policy = FilterPolicy::new(
1414            vec![("yes".to_string(), check)],
1415            vec![rule("permitted", "not yes", Effect::Allow)],
1416            Effect::Deny,
1417            ProxyPolicy::default(),
1418        );
1419
1420        let evaluation = policy.evaluate_connection(&connection_context()).await;
1421        assert_eq!(
1422            evaluation.outcome,
1423            Outcome::Deny("no policy rule permits this request".to_string())
1424        );
1425        assert_eq!(evaluation.checks.len(), 1);
1426        assert_eq!(evaluation.checks[0].verdict, Verdict::Pass);
1427    }
1428
1429    // ---- the trace --------------------------------------------------------
1430
1431    #[tokio::test]
1432    async fn the_trace_records_every_evaluated_check_in_order() {
1433        let (first, _) = StubCheck::passing();
1434        let (second, _) = StubCheck::failing();
1435        let policy = FilterPolicy::new(
1436            vec![("first".to_string(), first), ("second".to_string(), second)],
1437            vec![rule("r", "first and second", Effect::Allow)],
1438            Effect::Deny,
1439            ProxyPolicy::default(),
1440        );
1441
1442        let evaluation = policy.evaluate_connection(&connection_context()).await;
1443        let names: Vec<&str> = evaluation
1444            .checks
1445            .iter()
1446            .map(|outcome| outcome.name.as_str())
1447            .collect();
1448        assert_eq!(names, vec!["first", "second"]);
1449        assert_eq!(evaluation.checks[0].kind, "stub");
1450    }
1451
1452    // ---- stage derivation -------------------------------------------------
1453
1454    #[test]
1455    fn a_rule_takes_the_intersection_of_its_checks_stages() {
1456        let (anywhere, _) = StubCheck::with(Verdict::Pass, StageSet::both());
1457        let (names_only, _) = StubCheck::with(Verdict::Pass, StageSet::identifiers_only());
1458        let policy = FilterPolicy::new(
1459            vec![
1460                ("anywhere".to_string(), anywhere),
1461                ("names".to_string(), names_only),
1462            ],
1463            vec![rule("mixed", "anywhere or names", Effect::Allow)],
1464            Effect::Deny,
1465            ProxyPolicy::default(),
1466        );
1467
1468        assert!(!policy.has_rules_at(Stage::Connection));
1469        assert!(policy.has_rules_at(Stage::Identifiers));
1470    }
1471
1472    #[test]
1473    fn a_rule_naming_an_unknown_check_is_never_applicable() {
1474        let policy = FilterPolicy::new(
1475            Vec::new(),
1476            vec![rule("broken", "nonexistent", Effect::Allow)],
1477            Effect::Deny,
1478            ProxyPolicy::default(),
1479        );
1480
1481        assert!(!policy.has_rules_at(Stage::Connection));
1482        assert!(!policy.has_rules_at(Stage::Identifiers));
1483        assert!(policy.is_active());
1484    }
1485
1486    #[test]
1487    fn stage_sets_describe_themselves() {
1488        assert_eq!(StageSet::both().to_string(), "connection and identifiers");
1489        assert_eq!(StageSet::connection_only().to_string(), "connection only");
1490        assert_eq!(StageSet::identifiers_only().to_string(), "identifiers only");
1491        assert_eq!(StageSet::none().to_string(), "no stage");
1492        assert!(StageSet::none().is_empty());
1493        assert!(
1494            StageSet::connection_only()
1495                .intersect(StageSet::identifiers_only())
1496                .is_empty()
1497        );
1498        assert_eq!(Stage::Connection.as_str(), "connection");
1499        assert_eq!(Stage::Identifiers.as_str(), "identifiers");
1500        assert_eq!(Effect::Allow.as_str(), "allow");
1501        assert_eq!(Effect::Deny.as_str(), "deny");
1502    }
1503
1504    #[test]
1505    fn the_default_policy_decides_nothing() {
1506        let policy = FilterPolicy::default();
1507        assert!(!policy.is_active());
1508        assert!(!policy.has_rules_at(Stage::Connection));
1509        assert_eq!(policy.default_effect(), Effect::Deny);
1510        assert!(format!("{policy:?}").contains("FilterPolicy"));
1511    }
1512
1513    #[test]
1514    fn the_debug_rendering_names_checks_and_rules() {
1515        let (check, _) = StubCheck::passing();
1516        let policy = FilterPolicy::new(
1517            vec![("mgmt".to_string(), check)],
1518            vec![rule("bypass", "mgmt", Effect::Allow)],
1519            Effect::Deny,
1520            ProxyPolicy::default(),
1521        );
1522
1523        let rendered = format!("{policy:?}");
1524        assert!(rendered.contains("mgmt: stub"), "{rendered}");
1525        assert!(rendered.contains("bypass: mgmt -> allow"), "{rendered}");
1526    }
1527}