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}