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