Skip to main content

authentication_verifier/
abac.rs

1//! Attribute-based access control (ABAC) — the family's shared
2//! authorization engine.
3//!
4//! Implements the policy model fixed by
5//! [`agents/share/authorization-attributes.md`] (§2–§5): a decision is a
6//! pure evaluation of an ordered rule list over the **subject
7//! attributes** carried in the verified [`Claims::attrs`] map, the
8//! derived **action** ([`Action`]), the coarse **resource** (the entity
9//! name), and — for services that load the target record or supply
10//! request context — optional **record-level resource attributes**
11//! (`resource.*`) and **environment attributes** (`env.*`) matched by
12//! prefixed `when` keys (§9–§10; see [`Policy::evaluate_with_context`]).
13//! A `when` value of `$sub` / `$email` is a template resolving to the
14//! caller's identity, so a rule can express ownership. First match wins;
15//! when no rule matches, the default decision is **allow read, deny
16//! everything else**. An allow rule may attach **obligations** (e.g.
17//! `"mask"`, `"audit"`) that the [`Decision`] carries for the
18//! enforcement point to honour — the engine does not interpret them.
19//!
20//! The engine is pure data + pure evaluation: no I/O, no clock, no
21//! panics on any input. Nine entity services embed it inside their
22//! blanket `/api/*` guards so the family shares one tested
23//! implementation instead of nine copies.
24//!
25//! # Example
26//!
27//! ```
28//! use authentication_verifier::{Action, Claims, Policy};
29//! use std::collections::BTreeMap;
30//!
31//! // Subject attributes as minted by the auth-service into the token.
32//! let mut attrs = BTreeMap::new();
33//! attrs.insert("access".to_string(), vec!["write".to_string()]);
34//! let claims = Claims {
35//!     sub: "11111111-1111-1111-1111-111111111111".to_string(),
36//!     email: "alice@example.com".to_string(),
37//!     name: "Alice".to_string(),
38//!     iss: "authentication-service".to_string(),
39//!     aud: "main-x-service".to_string(),
40//!     exp: 2_000_000_000,
41//!     iat: 1_900_000_000,
42//!     nbf: None,
43//!     sid: "22222222-2222-2222-2222-222222222222".to_string(),
44//!     scope: vec![],
45//!     roles: vec![],
46//!     attrs,
47//! };
48//!
49//! let policy = Policy::default_policy();
50//! assert!(policy.evaluate(&claims, Action::Read, "place").allowed);
51//! assert!(policy.evaluate(&claims, Action::Write, "place").allowed);
52//! assert!(!policy.evaluate(&claims, Action::Delete, "place").allowed);
53//! ```
54//!
55//! [`agents/share/authorization-attributes.md`]:
56//!     https://github.com/sixarm/main-x-service
57
58use std::collections::BTreeMap;
59
60use serde::{Deserialize, Serialize};
61
62use crate::Claims;
63
64/// The derived action attribute of a request, fixed family-wide.
65///
66/// Each service's guard derives one `Action` per request from the HTTP
67/// method plus its documented destructive named POSTs:
68///
69/// | Action | Derivation |
70/// |---|---|
71/// | `Read` | GET / HEAD / OPTIONS |
72/// | `Write` | POST / PUT / PATCH — **except** destructive named POSTs |
73/// | `Delete` | DELETE |
74/// | `Destructive` | the crate's destructive named POSTs (record merge, batch deduplicate, bulk import) |
75///
76/// **`Delete` implies `Destructive` for rule matching**: a rule listing
77/// the `destructive` action matches both `Delete` and `Destructive`
78/// requests, while a rule listing `delete` matches only `Delete`.
79/// Destructive named POSTs are deliberately *not* `Write`.
80#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
81#[serde(rename_all = "lowercase")]
82pub enum Action {
83    /// GET / HEAD / OPTIONS — default-allowed when no rule matches.
84    Read,
85    /// POST / PUT / PATCH, excluding destructive named POSTs.
86    Write,
87    /// DELETE. Matched by both `delete` and `destructive` rule actions.
88    Delete,
89    /// A destructive named POST (merge / deduplicate / import).
90    Destructive,
91}
92
93/// One entry of a rule's `actions` list: a concrete [`Action`] name or
94/// the `"*"` wildcard covering every action.
95///
96/// Deserializes from the lowercase action names (`"read"`, `"write"`,
97/// `"delete"`, `"destructive"`) or `"*"`. The `Destructive` pattern
98/// matches both [`Action::Delete`] and [`Action::Destructive`] (delete
99/// implies destructive); every other named pattern matches exactly its
100/// own action.
101#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
102#[serde(rename_all = "lowercase")]
103pub enum ActionPattern {
104    /// Matches [`Action::Read`] only.
105    Read,
106    /// Matches [`Action::Write`] only.
107    Write,
108    /// Matches [`Action::Delete`] only.
109    Delete,
110    /// Matches [`Action::Delete`] and [`Action::Destructive`].
111    Destructive,
112    /// `"*"` — matches every action.
113    #[serde(rename = "*")]
114    Any,
115}
116
117impl ActionPattern {
118    /// Whether this pattern covers the given derived action.
119    #[must_use]
120    pub fn matches(self, action: Action) -> bool {
121        match self {
122            Self::Any => true,
123            Self::Read => action == Action::Read,
124            Self::Write => action == Action::Write,
125            Self::Delete => action == Action::Delete,
126            Self::Destructive => matches!(action, Action::Delete | Action::Destructive),
127        }
128    }
129}
130
131/// A rule's effect: grant or refuse the covered actions.
132///
133/// Deny rules make exceptions expressible; combined with
134/// first-match-wins ordering (see [`Policy::evaluate`]) evaluation stays
135/// O(rules) and auditable.
136#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
137#[serde(rename_all = "lowercase")]
138pub enum Effect {
139    /// The matching request is allowed.
140    Allow,
141    /// The matching request is denied (surfaced as HTTP 403 + reason).
142    Deny,
143}
144
145/// One ordered policy rule: an [`Effect`], the actions it covers, and a
146/// conjunction of subject-attribute conditions.
147///
148/// Deserializes from the JSON shape fixed in
149/// `authorization-attributes.md` §4, e.g.
150/// `{ "effect": "allow", "actions": ["write"], "when": { "access": ["write", "admin"] } }`.
151/// Unknown JSON fields are **ignored** (forward-compatible: a newer
152/// policy vocabulary still parses here; unrecognised extensions are
153/// inert rather than a boot failure).
154///
155/// `when` semantics (§4):
156///
157/// - The map is a **conjunction**: every listed key must match.
158/// - A value list means the subject has **any** of these values
159///   (`["write", "admin"]` = write OR admin). An **empty** value list
160///   never matches.
161/// - A `!`-prefixed value negates: it matches when the subject does
162///   **not** have that value (including when the subject lacks the
163///   attribute entirely).
164/// - An empty `when` map matches every authenticated subject.
165/// - Keys resolve against [`Claims::attrs`], except the reserved
166///   pseudo-attributes `sub` and `email` (from the verified claims) and
167///   `entity` (the resource entity passed to [`Policy::evaluate`]),
168///   which always resolve from those sources and cannot be shadowed by
169///   an identically-named `attrs` entry.
170/// - A key prefixed **`resource.`** resolves against the **resource
171///   attributes** passed to [`Policy::evaluate_with_resource`] (record-
172///   level attributes, e.g. `resource.sensitivity`), and a key prefixed
173///   **`env.`** against the **environment attributes** passed to
174///   [`Policy::evaluate_with_context`] (request-time / network context,
175///   e.g. `env.hour`), each with the prefix stripped — so a deployment
176///   can gate on properties of the specific record or the request
177///   context. Under a call that does not supply them every such key
178///   resolves empty, so the rule never matches a positive value (and a
179///   `!`-negated value always matches). Both namespaces are disjoint
180///   from subject attributes, so a subject can never spoof either
181///   through its token.
182/// - A `when` **value** of `$sub` or `$email` is a **template**: it
183///   resolves to the caller's `sub` / `email` before comparison, so a
184///   rule can compare an attribute to the caller's own identity — e.g.
185///   `{ "resource.owner": ["$sub"] }` matches when the record's owner
186///   is the caller (the ownership pattern). Any other value (including
187///   one merely containing `$`) is a literal.
188#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
189pub struct Rule {
190    /// Whether a match allows or denies the request.
191    pub effect: Effect,
192    /// The derived actions this rule covers (`"*"` = all).
193    pub actions: Vec<ActionPattern>,
194    /// Conjunction over subject attributes; empty matches everyone.
195    #[serde(default)]
196    pub when: BTreeMap<String, Vec<String>>,
197    /// **Obligations** the enforcement point must honour when this rule
198    /// **allows** — advisory instructions the engine carries but does
199    /// not interpret (e.g. `"mask"` ⇒ return the masked view,
200    /// `"audit"` ⇒ write an audit record). Short lowercase tokens, like
201    /// attribute values. Empty by default; ignored on a `deny` rule
202    /// (a denial is a 403, not a conditional allow). Surfaced on the
203    /// [`Decision`] of the deciding rule so the caller can act on them.
204    #[serde(default)]
205    pub obligations: Vec<String>,
206}
207
208impl Rule {
209    /// Whether this rule matches the given subject, action, entity,
210    /// resource attributes, and environment attributes.
211    #[must_use]
212    fn matches(
213        &self,
214        claims: &Claims,
215        action: Action,
216        entity: &str,
217        resource: &BTreeMap<String, Vec<String>>,
218        env: &BTreeMap<String, Vec<String>>,
219    ) -> bool {
220        if !self.actions.iter().any(|pattern| pattern.matches(action)) {
221            return false;
222        }
223        self.when.iter().all(|(key, wanted)| {
224            let have = values_for(claims, entity, resource, env, key);
225            // A `resource.` / `env.` key whose value set is ABSENT — e.g. on
226            // the coarse guard path where no record / environment was loaded.
227            let namespaced_absent =
228                have.is_empty() && (key.starts_with("resource.") || key.starts_with("env."));
229            wanted.iter().any(|want| {
230                let (negate, raw) = match want.strip_prefix('!') {
231                    Some(rest) => (true, rest),
232                    None => (false, want.as_str()),
233                };
234                // Resolve a `$sub` / `$email` template on the wanted
235                // value against the subject before comparing, so a rule
236                // can compare an attribute to the caller's identity
237                // (e.g. `resource.owner: ["$sub"]` = owned by the caller).
238                let resolved = resolve_template(claims, raw);
239                if negate && namespaced_absent {
240                    // SEC-V2: a **negated** `resource.`/`env.` condition must
241                    // not match *vacuously* when the namespace is absent (an
242                    // attacker's token has no resource/env, so `!untrusted`
243                    // is not evidence). Bias by effect to the SAFE outcome:
244                    // an `allow` rule must NOT match (no silent grant on the
245                    // coarse path), a `deny` rule MUST match (fail-closed).
246                    return self.effect == Effect::Deny;
247                }
248                have.contains(&resolved) != negate
249            })
250        })
251    }
252}
253
254/// Resolve a `when` **value** template against the subject: `$sub` →
255/// the caller's `sub`, `$email` → the caller's `email`; any other value
256/// (including one that merely contains `$`) is returned unchanged. This
257/// lets a rule compare an attribute to the caller's own identity rather
258/// than to a fixed literal — the ownership pattern
259/// (`authorization-attributes.md` §4).
260fn resolve_template<'a>(claims: &'a Claims, want: &'a str) -> &'a str {
261    match want {
262        "$sub" => claims.sub.as_str(),
263        "$email" => claims.email.as_str(),
264        other => other,
265    }
266}
267
268/// The values for one `when` key. A `resource.<name>` key resolves from
269/// the request's **resource attributes** (record-level) and an
270/// `env.<name>` key from the **environment attributes** (request-time /
271/// network / …), each with the prefix stripped; every other key
272/// resolves the **subject** side — the reserved pseudo-attributes
273/// `sub` / `email` / `entity` first, then the token's [`Claims::attrs`]
274/// map, else empty. The `resource.` / `env.` namespaces are disjoint
275/// from subject attributes, so a subject can never spoof either through
276/// its token.
277fn values_for<'a>(
278    claims: &'a Claims,
279    entity: &'a str,
280    resource: &'a BTreeMap<String, Vec<String>>,
281    env: &'a BTreeMap<String, Vec<String>>,
282    key: &str,
283) -> Vec<&'a str> {
284    if let Some(name) = key.strip_prefix("resource.") {
285        return resource
286            .get(name)
287            .map(|values| values.iter().map(String::as_str).collect())
288            .unwrap_or_default();
289    }
290    if let Some(name) = key.strip_prefix("env.") {
291        return env
292            .get(name)
293            .map(|values| values.iter().map(String::as_str).collect())
294            .unwrap_or_default();
295    }
296    match key {
297        "sub" => vec![claims.sub.as_str()],
298        "email" => vec![claims.email.as_str()],
299        "entity" => vec![entity],
300        _ => claims
301            .attrs
302            .get(key)
303            .map(|values| values.iter().map(String::as_str).collect())
304            .unwrap_or_default(),
305    }
306}
307
308/// An ordered list of [`Rule`]s, evaluated top-down with
309/// **first match wins**; when nothing matches, the default decision is
310/// allow-read / deny-everything-else (`authorization-attributes.md` §5).
311///
312/// Pure data: load once at boot (from `<ENTITY>_ABAC_POLICY` /
313/// `<ENTITY>_ABAC_POLICY_FILE` via [`Policy::from_json`], falling back
314/// to [`Policy::default_policy`] on parse failure), then call
315/// [`Policy::evaluate`] per request. Unknown JSON fields are ignored
316/// (see [`Rule`]).
317#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
318pub struct Policy {
319    /// The ordered rules; index order is evaluation order.
320    pub rules: Vec<Rule>,
321}
322
323impl Policy {
324    /// Parse a policy from its JSON document form
325    /// (`{ "rules": [ { "effect": ..., "actions": [...], "when": {...} }, ... ] }`).
326    ///
327    /// # Errors
328    ///
329    /// Returns the underlying [`serde_json::Error`] when the document is
330    /// not valid JSON or does not fit the policy shape (unknown `effect`
331    /// / action names, non-list `when` values, ...). Callers fall back
332    /// to [`Policy::default_policy`] — a bad policy must never take a
333    /// service down at boot.
334    pub fn from_json(json: &str) -> Result<Self, serde_json::Error> {
335        serde_json::from_str(json)
336    }
337
338    /// The built-in default policy (`authorization-attributes.md` §5),
339    /// used when no policy is configured:
340    ///
341    /// 1. `svc=true` ⇒ allow `write` + `destructive` (machine peers may
342    ///    do everything; `read` is default-allowed anyway),
343    /// 2. `access=admin` ⇒ allow `destructive` (which covers `delete`),
344    /// 3. `access=write` or `access=admin` ⇒ allow `write`.
345    ///
346    /// Everything else falls through to the default decision: read
347    /// allowed, mutation denied.
348    #[must_use]
349    pub fn default_policy() -> Self {
350        let when = |key: &str, values: &[&str]| {
351            let mut map = BTreeMap::new();
352            map.insert(
353                key.to_string(),
354                values.iter().map(ToString::to_string).collect(),
355            );
356            map
357        };
358        Self {
359            rules: vec![
360                Rule {
361                    effect: Effect::Allow,
362                    actions: vec![ActionPattern::Write, ActionPattern::Destructive],
363                    when: when("svc", &["true"]),
364                    obligations: Vec::new(),
365                },
366                Rule {
367                    effect: Effect::Allow,
368                    actions: vec![ActionPattern::Destructive],
369                    when: when("access", &["admin"]),
370                    obligations: Vec::new(),
371                },
372                Rule {
373                    effect: Effect::Allow,
374                    actions: vec![ActionPattern::Write],
375                    when: when("access", &["write", "admin"]),
376                    obligations: Vec::new(),
377                },
378            ],
379        }
380    }
381
382    /// Evaluate the policy for one request: the verified subject
383    /// ([`Claims`], whose [`attrs`](Claims::attrs) carry the subject
384    /// attributes), the derived [`Action`], and the resource `entity`
385    /// (the crate's entity type, e.g. `"place"`; reserved for resource
386    /// rules — a `when` key `entity` matches against it).
387    ///
388    /// Considers **no record-level resource attributes** — every
389    /// `resource.*` `when` key resolves empty. This is the coarse,
390    /// no-record-load path the blanket guards use; a service that has
391    /// loaded the target record calls [`Policy::evaluate_with_resource`]
392    /// instead (handler-level record checks).
393    ///
394    /// Rules are checked top-down; the **first** matching rule decides.
395    /// When no rule matches, the default decision applies: [`Action::Read`]
396    /// is allowed, every other action is denied.
397    ///
398    /// Pure and total: no I/O, and no input — claims, attrs, policy, or
399    /// entity — can make it panic.
400    #[must_use]
401    pub fn evaluate(&self, claims: &Claims, action: Action, entity: &str) -> Decision {
402        self.evaluate_with_resource(claims, action, entity, &BTreeMap::new())
403    }
404
405    /// Evaluate the policy with **record-level resource attributes** —
406    /// a string→strings map describing the specific target record (e.g.
407    /// `{"sensitivity": ["high"]}`), matched by `when` keys prefixed
408    /// `resource.` (`resource.sensitivity`). Otherwise identical to
409    /// [`Policy::evaluate`]: same subject attributes, same action, same
410    /// entity, same first-match-wins ordering and default decision.
411    ///
412    /// A service derives the resource attributes from the record it just
413    /// loaded (handler-level, after fetch) and passes them here, so a
414    /// deployment can express e.g. "deny write on a high-sensitivity
415    /// record unless `access=admin`" as ordered policy rules
416    /// (`authorization-attributes.md` §9). The `resource.` namespace is
417    /// disjoint from subject attributes, so a caller can never spoof a
418    /// resource attribute through its token. Considers **no environment
419    /// attributes** (every `env.*` key resolves empty) — call
420    /// [`Policy::evaluate_with_context`] to pass those too.
421    ///
422    /// Pure and total: no I/O, no panics on any input.
423    #[must_use]
424    pub fn evaluate_with_resource(
425        &self,
426        claims: &Claims,
427        action: Action,
428        entity: &str,
429        resource: &BTreeMap<String, Vec<String>>,
430    ) -> Decision {
431        self.evaluate_with_context(claims, action, entity, resource, &BTreeMap::new())
432    }
433
434    /// Evaluate the policy with both **record-level resource attributes**
435    /// (matched by `resource.*` `when` keys) and **environment
436    /// attributes** (matched by `env.*` keys) — a string→strings map of
437    /// request-time / network context, e.g.
438    /// `{"hour": ["22"], "network": ["office"]}`. Otherwise identical to
439    /// [`Policy::evaluate`].
440    ///
441    /// A service derives the environment attributes per request (clock,
442    /// source IP, …) and passes them here so a deployment can express
443    /// e.g. "deny write outside working hours" or geofencing as ordered
444    /// policy rules (`authorization-attributes.md` §10). Like
445    /// `resource.*`, the `env.*` namespace is disjoint from subject
446    /// attributes, so a caller cannot spoof it through its token.
447    ///
448    /// Pure and total: no I/O (the caller supplies the clock/network,
449    /// keeping the engine deterministic), no panics on any input.
450    #[must_use]
451    pub fn evaluate_with_context(
452        &self,
453        claims: &Claims,
454        action: Action,
455        entity: &str,
456        resource: &BTreeMap<String, Vec<String>>,
457        env: &BTreeMap<String, Vec<String>>,
458    ) -> Decision {
459        for (index, rule) in self.rules.iter().enumerate() {
460            if rule.matches(claims, action, entity, resource, env) {
461                return match rule.effect {
462                    // An allow carries the rule's obligations for the
463                    // enforcement point to honour (e.g. "mask"); a deny
464                    // carries none (it is a 403, not a conditional allow).
465                    Effect::Allow => Decision {
466                        allowed: true,
467                        reason: format!("allow (rule {index})"),
468                        obligations: rule.obligations.clone(),
469                    },
470                    Effect::Deny => Decision {
471                        allowed: false,
472                        reason: format!("deny (rule {index})"),
473                        obligations: Vec::new(),
474                    },
475                };
476            }
477        }
478        if action == Action::Read {
479            Decision {
480                allowed: true,
481                reason: "default allow (read)".to_string(),
482                obligations: Vec::new(),
483            }
484        } else {
485            Decision {
486                allowed: false,
487                reason: "default deny".to_string(),
488                obligations: Vec::new(),
489            }
490        }
491    }
492}
493
494/// The outcome of one [`Policy::evaluate`] call.
495///
496/// `reason` names the deciding rule by index (`"allow (rule 0)"` /
497/// `"deny (rule 2)"`) or the default decision (`"default allow (read)"`
498/// / `"default deny"`), so a 403 response body and the audit trail can
499/// state exactly why.
500#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
501pub struct Decision {
502    /// Whether the request is allowed.
503    pub allowed: bool,
504    /// The deciding rule index or the default decision.
505    pub reason: String,
506    /// **Obligations** the enforcement point must honour on an allow —
507    /// the deciding allow rule's [`Rule::obligations`] (e.g. `"mask"`,
508    /// `"audit"`). Empty on a deny or the default decision. Advisory: the
509    /// engine carries them; the caller (PEP) interprets and acts on them
510    /// (e.g. `"mask"` ⇒ return the masked view). `#[serde(default)]` so
511    /// a decision serialized by an older peer still deserializes.
512    #[serde(default)]
513    pub obligations: Vec<String>,
514}
515
516impl Decision {
517    /// Whether the decision carries the named obligation (a convenience
518    /// for the enforcement point, e.g. `decision.requires("mask")`).
519    #[must_use]
520    pub fn requires(&self, obligation: &str) -> bool {
521        self.obligations.iter().any(|o| o == obligation)
522    }
523}
524
525/// A **hot-reloadable** [`Policy`] holder for the enforcement point: the
526/// active policy can be swapped at runtime (e.g. when the policy file
527/// changes) **without a restart**, while readers keep the coarse
528/// `evaluate` path lock-light.
529///
530/// It wraps an `Arc<Policy>` behind an `RwLock`. Per request the guard
531/// calls [`current`](Self::current) — a brief read-lock returning a
532/// cheap `Arc` clone it then evaluates against; a reload calls
533/// [`store`](Self::store) — a brief write-lock swapping the `Arc`. A
534/// request in flight during a reload finishes against the snapshot it
535/// took, so a swap is never observed mid-evaluation. Poison-safe: a
536/// panic elsewhere never makes `current`/`store` panic (verifier rule:
537/// no panics in the API).
538///
539/// The **trigger** (a file-mtime watch, a signal, an admin endpoint) is
540/// the service's concern — this type only holds and swaps the value.
541#[derive(Debug)]
542pub struct ReloadablePolicy {
543    inner: std::sync::RwLock<std::sync::Arc<Policy>>,
544}
545
546impl ReloadablePolicy {
547    /// Wrap an initial policy (e.g. the one loaded at boot).
548    #[must_use]
549    pub fn new(policy: Policy) -> Self {
550        Self {
551            inner: std::sync::RwLock::new(std::sync::Arc::new(policy)),
552        }
553    }
554
555    /// The currently active policy — a cheap `Arc` clone taken under a
556    /// brief read-lock. Evaluate against the returned snapshot; a
557    /// concurrent [`store`](Self::store) does not affect it.
558    #[must_use]
559    pub fn current(&self) -> std::sync::Arc<Policy> {
560        self.inner
561            .read()
562            .unwrap_or_else(std::sync::PoisonError::into_inner)
563            .clone()
564    }
565
566    /// Atomically replace the active policy (a brief write-lock). New
567    /// requests see the new policy; in-flight requests finish against
568    /// the snapshot they already took.
569    pub fn store(&self, policy: Policy) {
570        *self
571            .inner
572            .write()
573            .unwrap_or_else(std::sync::PoisonError::into_inner) = std::sync::Arc::new(policy);
574    }
575}
576
577/// Engine unit tests — pure, offline, per
578/// `authorization-attributes.md` §7.
579#[cfg(test)]
580mod tests {
581    use super::*;
582
583    /// Claims with the given subject attributes; everything else fixed.
584    fn claims_with_attrs(attrs: &[(&str, &[&str])]) -> Claims {
585        Claims {
586            sub: "11111111-1111-1111-1111-111111111111".to_string(),
587            email: "alice@example.com".to_string(),
588            name: "Alice".to_string(),
589            iss: "authentication-service".to_string(),
590            aud: "main-x-service".to_string(),
591            exp: 2_000_000_000,
592            iat: 1_900_000_000,
593            nbf: None,
594            sid: "22222222-2222-2222-2222-222222222222".to_string(),
595            scope: vec![],
596            roles: vec![],
597            attrs: attrs
598                .iter()
599                .map(|(key, values)| {
600                    (
601                        (*key).to_string(),
602                        values.iter().map(ToString::to_string).collect(),
603                    )
604                })
605                .collect(),
606        }
607    }
608
609    #[test]
610    fn empty_attrs_default_policy_allows_read_denies_everything_else() {
611        let policy = Policy::default_policy();
612        let claims = claims_with_attrs(&[]);
613        let read = policy.evaluate(&claims, Action::Read, "place");
614        assert!(read.allowed);
615        assert_eq!(read.reason, "default allow (read)");
616        for action in [Action::Write, Action::Delete, Action::Destructive] {
617            let decision = policy.evaluate(&claims, action, "place");
618            assert!(!decision.allowed, "{action:?} must default-deny");
619            assert_eq!(decision.reason, "default deny");
620        }
621    }
622
623    #[test]
624    fn access_write_allows_write_but_not_delete_or_destructive() {
625        let policy = Policy::default_policy();
626        let claims = claims_with_attrs(&[("access", &["write"])]);
627        assert!(policy.evaluate(&claims, Action::Read, "place").allowed);
628        let write = policy.evaluate(&claims, Action::Write, "place");
629        assert!(write.allowed);
630        assert_eq!(write.reason, "allow (rule 2)");
631        assert!(!policy.evaluate(&claims, Action::Delete, "place").allowed);
632        assert!(
633            !policy
634                .evaluate(&claims, Action::Destructive, "place")
635                .allowed
636        );
637    }
638
639    #[test]
640    fn access_admin_allows_destructive_and_delete_and_write() {
641        let policy = Policy::default_policy();
642        let claims = claims_with_attrs(&[("access", &["admin"])]);
643        let destructive = policy.evaluate(&claims, Action::Destructive, "place");
644        assert!(destructive.allowed);
645        assert_eq!(destructive.reason, "allow (rule 1)");
646        // Delete implies destructive: the `destructive` rule covers it.
647        assert!(policy.evaluate(&claims, Action::Delete, "place").allowed);
648        assert!(policy.evaluate(&claims, Action::Write, "place").allowed);
649        assert!(policy.evaluate(&claims, Action::Read, "place").allowed);
650    }
651
652    #[test]
653    fn svc_true_allows_everything() {
654        let policy = Policy::default_policy();
655        let claims = claims_with_attrs(&[("svc", &["true"])]);
656        for action in [
657            Action::Read,
658            Action::Write,
659            Action::Delete,
660            Action::Destructive,
661        ] {
662            assert!(
663                policy.evaluate(&claims, action, "place").allowed,
664                "{action:?} must be allowed for svc=true"
665            );
666        }
667    }
668
669    #[test]
670    fn first_match_wins_deny_before_allow() {
671        // A deny rule ahead of a matching allow rule must pin the deny.
672        let policy = Policy::from_json(
673            r#"{ "rules": [
674                { "effect": "deny",  "actions": ["write"], "when": { "dept": ["cardiology"] } },
675                { "effect": "allow", "actions": ["write"], "when": { "access": ["write"] } }
676            ] }"#,
677        )
678        .expect("policy parses");
679        let denied = claims_with_attrs(&[("access", &["write"]), ("dept", &["cardiology"])]);
680        let decision = policy.evaluate(&denied, Action::Write, "case");
681        assert!(!decision.allowed);
682        assert_eq!(decision.reason, "deny (rule 0)");
683        // Without the denied attribute, the later allow rule matches.
684        let allowed = claims_with_attrs(&[("access", &["write"])]);
685        let decision = policy.evaluate(&allowed, Action::Write, "case");
686        assert!(decision.allowed);
687        assert_eq!(decision.reason, "allow (rule 1)");
688    }
689
690    #[test]
691    fn negated_value_matches_absence_of_the_value() {
692        // §4 example: deny read outside cardiology.
693        let policy = Policy::from_json(
694            r#"{ "rules": [
695                { "effect": "deny", "actions": ["read"], "when": { "dept": ["!cardiology"] } }
696            ] }"#,
697        )
698        .expect("policy parses");
699        // Has another dept ⇒ "does not have cardiology" ⇒ deny matches.
700        let outsider = claims_with_attrs(&[("dept", &["oncology"])]);
701        assert!(!policy.evaluate(&outsider, Action::Read, "case").allowed);
702        // Lacks the attribute entirely ⇒ also does not have it ⇒ deny.
703        let no_dept = claims_with_attrs(&[]);
704        assert!(!policy.evaluate(&no_dept, Action::Read, "case").allowed);
705        // Has cardiology ⇒ the negation does not match ⇒ default allow read.
706        let insider = claims_with_attrs(&[("dept", &["cardiology"])]);
707        assert!(policy.evaluate(&insider, Action::Read, "case").allowed);
708    }
709
710    #[test]
711    fn wildcard_actions_cover_every_action() {
712        let policy = Policy::from_json(
713            r#"{ "rules": [
714                { "effect": "allow", "actions": ["*"], "when": { "svc": ["true"] } }
715            ] }"#,
716        )
717        .expect("policy parses");
718        let claims = claims_with_attrs(&[("svc", &["true"])]);
719        for action in [
720            Action::Read,
721            Action::Write,
722            Action::Delete,
723            Action::Destructive,
724        ] {
725            let decision = policy.evaluate(&claims, action, "thing");
726            assert!(decision.allowed);
727            assert_eq!(decision.reason, "allow (rule 0)");
728        }
729    }
730
731    #[test]
732    fn value_list_means_any_of_these_values() {
733        // §4: ["write", "admin"] = write OR admin.
734        let policy = Policy::default_policy();
735        for tier in ["write", "admin"] {
736            let claims = claims_with_attrs(&[("access", &[tier])]);
737            assert!(
738                policy.evaluate(&claims, Action::Write, "place").allowed,
739                "access={tier} must allow write"
740            );
741        }
742        let claims = claims_with_attrs(&[("access", &["other"])]);
743        assert!(!policy.evaluate(&claims, Action::Write, "place").allowed);
744    }
745
746    #[test]
747    fn empty_when_matches_every_subject_and_empty_value_list_matches_none() {
748        let policy = Policy::from_json(
749            r#"{ "rules": [
750                { "effect": "allow", "actions": ["write"] },
751                { "effect": "allow", "actions": ["delete"], "when": { "access": [] } }
752            ] }"#,
753        )
754        .expect("policy parses");
755        let claims = claims_with_attrs(&[("access", &["admin"])]);
756        // Rule 0 has no `when` ⇒ matches everyone.
757        assert!(policy.evaluate(&claims, Action::Write, "event").allowed);
758        // Rule 1's empty value list matches nothing ⇒ default deny.
759        assert!(!policy.evaluate(&claims, Action::Delete, "event").allowed);
760    }
761
762    #[test]
763    fn pseudo_attributes_sub_email_and_entity_are_matchable() {
764        let policy = Policy::from_json(
765            r#"{ "rules": [
766                { "effect": "allow", "actions": ["write"],
767                  "when": { "sub": ["11111111-1111-1111-1111-111111111111"],
768                            "email": ["alice@example.com"],
769                            "entity": ["place"] } }
770            ] }"#,
771        )
772        .expect("policy parses");
773        let claims = claims_with_attrs(&[]);
774        assert!(policy.evaluate(&claims, Action::Write, "place").allowed);
775        // Same subject, different entity ⇒ the rule no longer matches.
776        assert!(!policy.evaluate(&claims, Action::Write, "person").allowed);
777    }
778
779    #[test]
780    fn pseudo_attributes_cannot_be_shadowed_by_attrs() {
781        let policy = Policy::from_json(
782            r#"{ "rules": [
783                { "effect": "allow", "actions": ["write"], "when": { "sub": ["spoofed"] } }
784            ] }"#,
785        )
786        .expect("policy parses");
787        // An `attrs` entry named "sub" must not override the claims `sub`.
788        let claims = claims_with_attrs(&[("sub", &["spoofed"])]);
789        assert!(!policy.evaluate(&claims, Action::Write, "place").allowed);
790    }
791
792    #[test]
793    fn bad_policy_json_is_an_error_not_a_panic() {
794        assert!(Policy::from_json("not json at all").is_err());
795        assert!(Policy::from_json(r#"{ "rules": "not-a-list" }"#).is_err());
796        assert!(
797            Policy::from_json(r#"{ "rules": [ { "effect": "shrug", "actions": ["read"] } ] }"#)
798                .is_err()
799        );
800        assert!(
801            Policy::from_json(r#"{ "rules": [ { "effect": "allow", "actions": ["frob"] } ] }"#)
802                .is_err()
803        );
804    }
805
806    #[test]
807    fn unknown_rule_fields_are_ignored() {
808        let policy = Policy::from_json(
809            r#"{ "rules": [
810                { "effect": "allow", "actions": ["write"], "when": { "svc": ["true"] },
811                  "comment": "future field", "advice": ["someday"] }
812            ] }"#,
813        )
814        .expect("unknown fields must be ignored");
815        let claims = claims_with_attrs(&[("svc", &["true"])]);
816        assert!(policy.evaluate(&claims, Action::Write, "worker").allowed);
817    }
818
819    #[test]
820    fn allow_rule_carries_its_obligations_to_the_decision() {
821        // A rule can attach obligations (e.g. "mask") the enforcement
822        // point must honour on an allow; a deny / default carries none.
823        let policy = Policy::from_json(
824            r#"{ "rules": [
825                { "effect": "allow", "actions": ["read"],
826                  "when": { "access": ["read-masked"] },
827                  "obligations": ["mask", "audit"] },
828                { "effect": "deny", "actions": ["read"],
829                  "when": { "resource.sensitivity": ["secret"] },
830                  "obligations": ["mask"] }
831            ] }"#,
832        )
833        .expect("policy parses");
834
835        // The allow rule surfaces its obligations.
836        let masked = claims_with_attrs(&[("access", &["read-masked"])]);
837        let decision = policy.evaluate(&masked, Action::Read, "case");
838        assert!(decision.allowed);
839        assert!(decision.requires("mask"));
840        assert!(decision.requires("audit"));
841        assert!(!decision.requires("delete"));
842
843        // A deny carries no obligations (it's a 403, not a conditional
844        // allow) even though the rule lists one.
845        let onto_secret = policy.evaluate_with_resource(
846            &claims_with_attrs(&[("access", &["read-masked"]), ("other", &["x"])]),
847            Action::Read,
848            "case",
849            &resource(&[("sensitivity", &["secret"])]),
850        );
851        // rule 0 matches first (access=read-masked) → allow+mask; the
852        // deny never reached. Confirm first-match precedence holds.
853        assert!(onto_secret.allowed);
854        assert!(onto_secret.requires("mask"));
855
856        // A subject with no matching allow rule: default decision, no
857        // obligations.
858        let plain = policy.evaluate(&claims_with_attrs(&[]), Action::Read, "case");
859        assert!(plain.allowed); // default allow-read
860        assert!(plain.obligations.is_empty());
861    }
862
863    #[test]
864    fn default_policy_allows_carry_no_obligations() {
865        let policy = Policy::default_policy();
866        let admin = claims_with_attrs(&[("access", &["admin"])]);
867        let d = policy.evaluate(&admin, Action::Write, "place");
868        assert!(d.allowed);
869        assert!(d.obligations.is_empty());
870    }
871
872    #[test]
873    fn reloadable_policy_swaps_the_active_policy() {
874        // Start with a policy that denies all writes (empty rules ⇒
875        // default deny-mutation), then hot-swap to one that allows a
876        // writer — without recreating the holder.
877        let holder = ReloadablePolicy::new(Policy { rules: vec![] });
878        let writer = claims_with_attrs(&[("access", &["write"])]);
879        assert!(
880            !holder
881                .current()
882                .evaluate(&writer, Action::Write, "case")
883                .allowed,
884            "before reload: default deny-mutation"
885        );
886
887        holder.store(
888            Policy::from_json(
889                r#"{ "rules": [
890                    { "effect": "allow", "actions": ["write"], "when": { "access": ["write"] } }
891                ] }"#,
892            )
893            .expect("policy parses"),
894        );
895        assert!(
896            holder
897                .current()
898                .evaluate(&writer, Action::Write, "case")
899                .allowed,
900            "after reload: the new policy allows the writer"
901        );
902
903        // A snapshot taken before a reload is unaffected by it.
904        let snapshot = holder.current();
905        holder.store(Policy { rules: vec![] });
906        assert!(
907            snapshot.evaluate(&writer, Action::Write, "case").allowed,
908            "an in-flight snapshot keeps the policy it captured"
909        );
910        assert!(
911            !holder
912                .current()
913                .evaluate(&writer, Action::Write, "case")
914                .allowed,
915            "new readers see the latest (deny) policy"
916        );
917    }
918
919    #[test]
920    fn default_policy_round_trips_through_its_json_form() {
921        // The built-in policy is exactly the §5 subset of the §4 example.
922        let from_json = Policy::from_json(
923            r#"{ "rules": [
924                { "effect": "allow", "actions": ["write", "destructive"], "when": { "svc": ["true"] } },
925                { "effect": "allow", "actions": ["destructive"], "when": { "access": ["admin"] } },
926                { "effect": "allow", "actions": ["write"], "when": { "access": ["write", "admin"] } }
927            ] }"#,
928        )
929        .expect("policy parses");
930        assert_eq!(from_json, Policy::default_policy());
931    }
932
933    #[test]
934    fn delete_pattern_does_not_match_destructive_posts() {
935        // `write` excludes destructive POSTs and `delete` covers only
936        // DELETE; only `destructive` (or `*`) covers a merge-POST.
937        let policy = Policy::from_json(
938            r#"{ "rules": [
939                { "effect": "allow", "actions": ["delete"], "when": { "access": ["ops"] } }
940            ] }"#,
941        )
942        .expect("policy parses");
943        let claims = claims_with_attrs(&[("access", &["ops"])]);
944        assert!(policy.evaluate(&claims, Action::Delete, "case").allowed);
945        assert!(
946            !policy
947                .evaluate(&claims, Action::Destructive, "case")
948                .allowed
949        );
950    }
951
952    /// Build a resource-attribute map from `(key, values)` pairs.
953    fn resource(pairs: &[(&str, &[&str])]) -> BTreeMap<String, Vec<String>> {
954        pairs
955            .iter()
956            .map(|(key, values)| {
957                (
958                    (*key).to_string(),
959                    values.iter().map(ToString::to_string).collect(),
960                )
961            })
962            .collect()
963    }
964
965    #[test]
966    fn resource_attribute_gates_a_deny_on_record_sensitivity() {
967        // §9 motivating example: deny write on a high-sensitivity record
968        // unless the subject is an admin. Order matters — the admin
969        // allow sits above the sensitivity deny.
970        let policy = Policy::from_json(
971            r#"{ "rules": [
972                { "effect": "allow", "actions": ["write"], "when": { "access": ["admin"] } },
973                { "effect": "deny",  "actions": ["write"], "when": { "resource.sensitivity": ["high"] } },
974                { "effect": "allow", "actions": ["write"], "when": { "access": ["write"] } }
975            ] }"#,
976        )
977        .expect("policy parses");
978
979        let writer = claims_with_attrs(&[("access", &["write"])]);
980        let admin = claims_with_attrs(&[("access", &["admin"])]);
981        let high = resource(&[("sensitivity", &["high"])]);
982        let low = resource(&[("sensitivity", &["low"])]);
983
984        // Writer on a high-sensitivity record → denied by rule 1.
985        let d = policy.evaluate_with_resource(&writer, Action::Write, "case", &high);
986        assert!(!d.allowed);
987        assert_eq!(d.reason, "deny (rule 1)");
988        // Writer on a low-sensitivity record → allowed by rule 2.
989        assert!(
990            policy
991                .evaluate_with_resource(&writer, Action::Write, "case", &low)
992                .allowed
993        );
994        // Admin on a high-sensitivity record → allowed by rule 0 (the
995        // admin allow precedes the sensitivity deny).
996        let a = policy.evaluate_with_resource(&admin, Action::Write, "case", &high);
997        assert!(a.allowed);
998        assert_eq!(a.reason, "allow (rule 0)");
999    }
1000
1001    #[test]
1002    fn resource_keys_resolve_empty_without_resource_attrs() {
1003        // Under plain `evaluate` (no record loaded), a `resource.*`
1004        // positive match never fires; the writer falls through to the
1005        // later allow. This is why the coarse blanket guard stays sound.
1006        let policy = Policy::from_json(
1007            r#"{ "rules": [
1008                { "effect": "deny",  "actions": ["write"], "when": { "resource.sensitivity": ["high"] } },
1009                { "effect": "allow", "actions": ["write"], "when": { "access": ["write"] } }
1010            ] }"#,
1011        )
1012        .expect("policy parses");
1013        let writer = claims_with_attrs(&[("access", &["write"])]);
1014        // No resource attrs ⇒ the deny cannot match ⇒ later allow wins.
1015        let d = policy.evaluate(&writer, Action::Write, "case");
1016        assert!(d.allowed);
1017        assert_eq!(d.reason, "allow (rule 1)");
1018        // The same call via evaluate_with_resource + an empty map is
1019        // identical (evaluate delegates to it).
1020        assert_eq!(
1021            d,
1022            policy.evaluate_with_resource(&writer, Action::Write, "case", &BTreeMap::new())
1023        );
1024    }
1025
1026    #[test]
1027    fn negated_resource_value_matches_when_absent_or_empty() {
1028        // "allow read only on records the caller's dept owns": deny read
1029        // when the record's owning dept is NOT the caller's dept. Here we
1030        // pin the simpler negation semantics against a literal.
1031        let policy = Policy::from_json(
1032            r#"{ "rules": [
1033                { "effect": "deny", "actions": ["read"], "when": { "resource.sensitivity": ["!public"] } }
1034            ] }"#,
1035        )
1036        .expect("policy parses");
1037        let anyone = claims_with_attrs(&[]);
1038        // Record is public ⇒ negation does not match ⇒ default allow read.
1039        assert!(
1040            policy
1041                .evaluate_with_resource(
1042                    &anyone,
1043                    Action::Read,
1044                    "case",
1045                    &resource(&[("sensitivity", &["public"])])
1046                )
1047                .allowed
1048        );
1049        // Record is restricted ⇒ "does not have public" ⇒ deny matches.
1050        assert!(
1051            !policy
1052                .evaluate_with_resource(
1053                    &anyone,
1054                    Action::Read,
1055                    "case",
1056                    &resource(&[("sensitivity", &["restricted"])])
1057                )
1058                .allowed
1059        );
1060        // No resource attr at all ⇒ also "does not have public" ⇒ deny.
1061        // (A `deny` rule still fires when the namespaced attr is absent —
1062        // fail-closed, SEC-V2.)
1063        assert!(
1064            !policy
1065                .evaluate_with_resource(&anyone, Action::Read, "case", &BTreeMap::new())
1066                .allowed
1067        );
1068    }
1069
1070    /// SEC-V2: a **negated** `resource.`/`env.` condition on an **allow**
1071    /// rule must NOT match vacuously when the namespace is absent (the
1072    /// coarse guard path), or any authenticated caller would silently gain
1073    /// the grant. Mirror of the deny-rule fail-closed case above.
1074    #[test]
1075    fn negated_allow_does_not_match_vacuously_when_namespace_absent() {
1076        let policy = Policy::from_json(
1077            r#"{ "rules": [
1078                { "effect": "allow", "actions": ["write"], "when": { "env.network": ["!untrusted"] } }
1079            ] }"#,
1080        )
1081        .expect("policy parses");
1082        let anyone = claims_with_attrs(&[]);
1083        // Coarse path: no env supplied ⇒ the allow must NOT fire ⇒ the
1084        // default (deny-mutation) governs. Before the fix this ALLOWED.
1085        assert!(!policy.evaluate(&anyone, Action::Write, "case").allowed);
1086        assert!(
1087            !policy
1088                .evaluate_with_context(
1089                    &anyone,
1090                    Action::Write,
1091                    "case",
1092                    &BTreeMap::new(),
1093                    &BTreeMap::new()
1094                )
1095                .allowed
1096        );
1097        // But when env IS supplied and is trusted (network=trusted, not
1098        // "untrusted"), the negated condition legitimately matches ⇒ allow.
1099        assert!(
1100            policy
1101                .evaluate_with_context(
1102                    &anyone,
1103                    Action::Write,
1104                    "case",
1105                    &BTreeMap::new(),
1106                    &resource(&[("network", &["trusted"])]),
1107                )
1108                .allowed
1109        );
1110    }
1111
1112    #[test]
1113    fn resource_namespace_is_disjoint_from_subject_attrs() {
1114        // A subject cannot spoof a resource attribute through its token:
1115        // an `attrs` entry literally named "resource.sensitivity" is a
1116        // plain subject key, not the resource value.
1117        let policy = Policy::from_json(
1118            r#"{ "rules": [
1119                { "effect": "allow", "actions": ["write"], "when": { "resource.sensitivity": ["high"] } }
1120            ] }"#,
1121        )
1122        .expect("policy parses");
1123        // Subject carries a same-named attr, but no resource attrs are
1124        // passed ⇒ the resource key resolves empty ⇒ no match ⇒ default
1125        // deny for write.
1126        let spoofer = claims_with_attrs(&[("resource.sensitivity", &["high"])]);
1127        assert!(!policy.evaluate(&spoofer, Action::Write, "case").allowed);
1128    }
1129
1130    /// The subject's fixed `sub` in [`claims_with_attrs`], reused by the
1131    /// ownership-template test as the record's `owner`.
1132    const SUBJECT_PID: &str = "11111111-1111-1111-1111-111111111111";
1133
1134    #[test]
1135    fn value_template_sub_expresses_ownership() {
1136        // "a writer may write a record they own": allow write when the
1137        // record's owner equals the caller's sub (`$sub`).
1138        let policy = Policy::from_json(
1139            r#"{ "rules": [
1140                { "effect": "allow", "actions": ["write"],
1141                  "when": { "access": ["write"], "resource.owner": ["$sub"] } }
1142            ] }"#,
1143        )
1144        .expect("policy parses");
1145        let writer = claims_with_attrs(&[("access", &["write"])]);
1146
1147        // Owner == caller ⇒ allowed.
1148        assert!(
1149            policy
1150                .evaluate_with_resource(
1151                    &writer,
1152                    Action::Write,
1153                    "case",
1154                    &resource(&[("owner", &[SUBJECT_PID])])
1155                )
1156                .allowed
1157        );
1158        // Owner is someone else ⇒ the `$sub` template does not match ⇒
1159        // default deny for write.
1160        assert!(
1161            !policy
1162                .evaluate_with_resource(
1163                    &writer,
1164                    Action::Write,
1165                    "case",
1166                    &resource(&[("owner", &["99999999-9999-9999-9999-999999999999"])])
1167                )
1168                .allowed
1169        );
1170    }
1171
1172    #[test]
1173    fn literal_dollar_value_is_not_a_template() {
1174        // Only exactly `$sub` / `$email` are templates; any other value
1175        // (incl. one containing `$`) is a literal.
1176        let policy = Policy::from_json(
1177            r#"{ "rules": [
1178                { "effect": "allow", "actions": ["write"], "when": { "tier": ["$gold"] } }
1179            ] }"#,
1180        )
1181        .expect("policy parses");
1182        // A subject whose `tier` is literally "$gold" matches.
1183        let claims = claims_with_attrs(&[("tier", &["$gold"])]);
1184        assert!(policy.evaluate(&claims, Action::Write, "thing").allowed);
1185    }
1186
1187    #[test]
1188    fn env_attribute_gates_a_time_window_deny() {
1189        // §10 example: deny write outside working hours (here, hour 22).
1190        // The service supplies the clock as an env attribute — the engine
1191        // stays deterministic.
1192        let policy = Policy::from_json(
1193            r#"{ "rules": [
1194                { "effect": "allow", "actions": ["write"], "when": { "access": ["admin"] } },
1195                { "effect": "deny",  "actions": ["write"], "when": { "env.after_hours": ["true"] } },
1196                { "effect": "allow", "actions": ["write"], "when": { "access": ["write"] } }
1197            ] }"#,
1198        )
1199        .expect("policy parses");
1200        let writer = claims_with_attrs(&[("access", &["write"])]);
1201        let admin = claims_with_attrs(&[("access", &["admin"])]);
1202        let after_hours = resource(&[("after_hours", &["true"])]);
1203        let during_hours = resource(&[("after_hours", &["false"])]);
1204
1205        // Writer after hours ⇒ denied by rule 1.
1206        let denied = policy.evaluate_with_context(
1207            &writer,
1208            Action::Write,
1209            "case",
1210            &BTreeMap::new(),
1211            &after_hours,
1212        );
1213        assert!(!denied.allowed);
1214        assert_eq!(denied.reason, "deny (rule 1)");
1215        // Writer during hours ⇒ allowed by rule 2.
1216        assert!(
1217            policy
1218                .evaluate_with_context(
1219                    &writer,
1220                    Action::Write,
1221                    "case",
1222                    &BTreeMap::new(),
1223                    &during_hours
1224                )
1225                .allowed
1226        );
1227        // Admin overrides the after-hours deny (rule 0 precedes it).
1228        assert!(
1229            policy
1230                .evaluate_with_context(
1231                    &admin,
1232                    Action::Write,
1233                    "case",
1234                    &BTreeMap::new(),
1235                    &after_hours
1236                )
1237                .allowed
1238        );
1239    }
1240
1241    #[test]
1242    fn env_namespace_empty_without_context() {
1243        // Under evaluate / evaluate_with_resource (no env supplied), an
1244        // `env.*` positive match never fires; the later allow wins.
1245        let policy = Policy::from_json(
1246            r#"{ "rules": [
1247                { "effect": "deny",  "actions": ["write"], "when": { "env.after_hours": ["true"] } },
1248                { "effect": "allow", "actions": ["write"], "when": { "access": ["write"] } }
1249            ] }"#,
1250        )
1251        .expect("policy parses");
1252        let writer = claims_with_attrs(&[("access", &["write"])]);
1253        let d = policy.evaluate(&writer, Action::Write, "case");
1254        assert!(d.allowed);
1255        assert_eq!(d.reason, "allow (rule 1)");
1256        // evaluate_with_resource (resource but no env) is identical.
1257        assert_eq!(
1258            d,
1259            policy.evaluate_with_resource(&writer, Action::Write, "case", &BTreeMap::new())
1260        );
1261    }
1262
1263    /// Every policy in the repo-root `examples/policies/` cookbook must be
1264    /// valid, parseable `Policy` JSON — the cookbook is documentation that
1265    /// nothing else re-checks, so a typo there would otherwise ship silently.
1266    #[test]
1267    fn every_example_policy_parses() {
1268        let dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../examples/policies");
1269        let mut checked = 0;
1270        for entry in
1271            std::fs::read_dir(&dir).unwrap_or_else(|e| panic!("read_dir({}): {e}", dir.display()))
1272        {
1273            let entry = entry.expect("dir entry");
1274            let path = entry.path();
1275            if path.extension().and_then(|e| e.to_str()) != Some("json") {
1276                continue;
1277            }
1278            let json = std::fs::read_to_string(&path)
1279                .unwrap_or_else(|e| panic!("read {}: {e}", path.display()));
1280            Policy::from_json(&json)
1281                .unwrap_or_else(|e| panic!("{} failed to parse as a Policy: {e}", path.display()));
1282            checked += 1;
1283        }
1284        assert!(
1285            checked >= 6,
1286            "expected at least 6 example policies in {}, found {checked}",
1287            dir.display()
1288        );
1289    }
1290}