use std::collections::BTreeMap;
use serde::{Deserialize, Serialize};
use crate::Claims;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum Action {
Read,
Write,
Delete,
Destructive,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum ActionPattern {
Read,
Write,
Delete,
Destructive,
#[serde(rename = "*")]
Any,
}
impl ActionPattern {
#[must_use]
pub fn matches(self, action: Action) -> bool {
match self {
Self::Any => true,
Self::Read => action == Action::Read,
Self::Write => action == Action::Write,
Self::Delete => action == Action::Delete,
Self::Destructive => matches!(action, Action::Delete | Action::Destructive),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum Effect {
Allow,
Deny,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Rule {
pub effect: Effect,
pub actions: Vec<ActionPattern>,
#[serde(default)]
pub when: BTreeMap<String, Vec<String>>,
#[serde(default)]
pub obligations: Vec<String>,
}
impl Rule {
#[must_use]
fn matches(
&self,
claims: &Claims,
action: Action,
entity: &str,
resource: &BTreeMap<String, Vec<String>>,
env: &BTreeMap<String, Vec<String>>,
) -> bool {
if !self.actions.iter().any(|pattern| pattern.matches(action)) {
return false;
}
self.when.iter().all(|(key, wanted)| {
let have = values_for(claims, entity, resource, env, key);
let namespaced_absent =
have.is_empty() && (key.starts_with("resource.") || key.starts_with("env."));
wanted.iter().any(|want| {
let (negate, raw) = match want.strip_prefix('!') {
Some(rest) => (true, rest),
None => (false, want.as_str()),
};
let resolved = resolve_template(claims, raw);
if negate && namespaced_absent {
return self.effect == Effect::Deny;
}
have.contains(&resolved) != negate
})
})
}
}
fn resolve_template<'a>(claims: &'a Claims, want: &'a str) -> &'a str {
match want {
"$sub" => claims.sub.as_str(),
"$email" => claims.email.as_str(),
other => other,
}
}
fn values_for<'a>(
claims: &'a Claims,
entity: &'a str,
resource: &'a BTreeMap<String, Vec<String>>,
env: &'a BTreeMap<String, Vec<String>>,
key: &str,
) -> Vec<&'a str> {
if let Some(name) = key.strip_prefix("resource.") {
return resource
.get(name)
.map(|values| values.iter().map(String::as_str).collect())
.unwrap_or_default();
}
if let Some(name) = key.strip_prefix("env.") {
return env
.get(name)
.map(|values| values.iter().map(String::as_str).collect())
.unwrap_or_default();
}
match key {
"sub" => vec![claims.sub.as_str()],
"email" => vec![claims.email.as_str()],
"entity" => vec![entity],
_ => claims
.attrs
.get(key)
.map(|values| values.iter().map(String::as_str).collect())
.unwrap_or_default(),
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Policy {
pub rules: Vec<Rule>,
}
impl Policy {
pub fn from_json(json: &str) -> Result<Self, serde_json::Error> {
serde_json::from_str(json)
}
#[must_use]
pub fn default_policy() -> Self {
let when = |key: &str, values: &[&str]| {
let mut map = BTreeMap::new();
map.insert(
key.to_string(),
values.iter().map(ToString::to_string).collect(),
);
map
};
Self {
rules: vec![
Rule {
effect: Effect::Allow,
actions: vec![ActionPattern::Write, ActionPattern::Destructive],
when: when("svc", &["true"]),
obligations: Vec::new(),
},
Rule {
effect: Effect::Allow,
actions: vec![ActionPattern::Destructive],
when: when("access", &["admin"]),
obligations: Vec::new(),
},
Rule {
effect: Effect::Allow,
actions: vec![ActionPattern::Write],
when: when("access", &["write", "admin"]),
obligations: Vec::new(),
},
],
}
}
#[must_use]
pub fn evaluate(&self, claims: &Claims, action: Action, entity: &str) -> Decision {
self.evaluate_with_resource(claims, action, entity, &BTreeMap::new())
}
#[must_use]
pub fn evaluate_with_resource(
&self,
claims: &Claims,
action: Action,
entity: &str,
resource: &BTreeMap<String, Vec<String>>,
) -> Decision {
self.evaluate_with_context(claims, action, entity, resource, &BTreeMap::new())
}
#[must_use]
pub fn evaluate_with_context(
&self,
claims: &Claims,
action: Action,
entity: &str,
resource: &BTreeMap<String, Vec<String>>,
env: &BTreeMap<String, Vec<String>>,
) -> Decision {
for (index, rule) in self.rules.iter().enumerate() {
if rule.matches(claims, action, entity, resource, env) {
return match rule.effect {
Effect::Allow => Decision {
allowed: true,
reason: format!("allow (rule {index})"),
obligations: rule.obligations.clone(),
},
Effect::Deny => Decision {
allowed: false,
reason: format!("deny (rule {index})"),
obligations: Vec::new(),
},
};
}
}
if action == Action::Read {
Decision {
allowed: true,
reason: "default allow (read)".to_string(),
obligations: Vec::new(),
}
} else {
Decision {
allowed: false,
reason: "default deny".to_string(),
obligations: Vec::new(),
}
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Decision {
pub allowed: bool,
pub reason: String,
#[serde(default)]
pub obligations: Vec<String>,
}
impl Decision {
#[must_use]
pub fn requires(&self, obligation: &str) -> bool {
self.obligations.iter().any(|o| o == obligation)
}
}
#[derive(Debug)]
pub struct ReloadablePolicy {
inner: std::sync::RwLock<std::sync::Arc<Policy>>,
}
impl ReloadablePolicy {
#[must_use]
pub fn new(policy: Policy) -> Self {
Self {
inner: std::sync::RwLock::new(std::sync::Arc::new(policy)),
}
}
#[must_use]
pub fn current(&self) -> std::sync::Arc<Policy> {
self.inner
.read()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.clone()
}
pub fn store(&self, policy: Policy) {
*self
.inner
.write()
.unwrap_or_else(std::sync::PoisonError::into_inner) = std::sync::Arc::new(policy);
}
}
#[cfg(test)]
mod tests {
use super::*;
fn claims_with_attrs(attrs: &[(&str, &[&str])]) -> Claims {
Claims {
sub: "11111111-1111-1111-1111-111111111111".to_string(),
email: "alice@example.com".to_string(),
name: "Alice".to_string(),
iss: "authentication-service".to_string(),
aud: "main-x-service".to_string(),
exp: 2_000_000_000,
iat: 1_900_000_000,
nbf: None,
sid: "22222222-2222-2222-2222-222222222222".to_string(),
scope: vec![],
roles: vec![],
attrs: attrs
.iter()
.map(|(key, values)| {
(
(*key).to_string(),
values.iter().map(ToString::to_string).collect(),
)
})
.collect(),
}
}
#[test]
fn empty_attrs_default_policy_allows_read_denies_everything_else() {
let policy = Policy::default_policy();
let claims = claims_with_attrs(&[]);
let read = policy.evaluate(&claims, Action::Read, "place");
assert!(read.allowed);
assert_eq!(read.reason, "default allow (read)");
for action in [Action::Write, Action::Delete, Action::Destructive] {
let decision = policy.evaluate(&claims, action, "place");
assert!(!decision.allowed, "{action:?} must default-deny");
assert_eq!(decision.reason, "default deny");
}
}
#[test]
fn access_write_allows_write_but_not_delete_or_destructive() {
let policy = Policy::default_policy();
let claims = claims_with_attrs(&[("access", &["write"])]);
assert!(policy.evaluate(&claims, Action::Read, "place").allowed);
let write = policy.evaluate(&claims, Action::Write, "place");
assert!(write.allowed);
assert_eq!(write.reason, "allow (rule 2)");
assert!(!policy.evaluate(&claims, Action::Delete, "place").allowed);
assert!(
!policy
.evaluate(&claims, Action::Destructive, "place")
.allowed
);
}
#[test]
fn access_admin_allows_destructive_and_delete_and_write() {
let policy = Policy::default_policy();
let claims = claims_with_attrs(&[("access", &["admin"])]);
let destructive = policy.evaluate(&claims, Action::Destructive, "place");
assert!(destructive.allowed);
assert_eq!(destructive.reason, "allow (rule 1)");
assert!(policy.evaluate(&claims, Action::Delete, "place").allowed);
assert!(policy.evaluate(&claims, Action::Write, "place").allowed);
assert!(policy.evaluate(&claims, Action::Read, "place").allowed);
}
#[test]
fn svc_true_allows_everything() {
let policy = Policy::default_policy();
let claims = claims_with_attrs(&[("svc", &["true"])]);
for action in [
Action::Read,
Action::Write,
Action::Delete,
Action::Destructive,
] {
assert!(
policy.evaluate(&claims, action, "place").allowed,
"{action:?} must be allowed for svc=true"
);
}
}
#[test]
fn first_match_wins_deny_before_allow() {
let policy = Policy::from_json(
r#"{ "rules": [
{ "effect": "deny", "actions": ["write"], "when": { "dept": ["cardiology"] } },
{ "effect": "allow", "actions": ["write"], "when": { "access": ["write"] } }
] }"#,
)
.expect("policy parses");
let denied = claims_with_attrs(&[("access", &["write"]), ("dept", &["cardiology"])]);
let decision = policy.evaluate(&denied, Action::Write, "case");
assert!(!decision.allowed);
assert_eq!(decision.reason, "deny (rule 0)");
let allowed = claims_with_attrs(&[("access", &["write"])]);
let decision = policy.evaluate(&allowed, Action::Write, "case");
assert!(decision.allowed);
assert_eq!(decision.reason, "allow (rule 1)");
}
#[test]
fn negated_value_matches_absence_of_the_value() {
let policy = Policy::from_json(
r#"{ "rules": [
{ "effect": "deny", "actions": ["read"], "when": { "dept": ["!cardiology"] } }
] }"#,
)
.expect("policy parses");
let outsider = claims_with_attrs(&[("dept", &["oncology"])]);
assert!(!policy.evaluate(&outsider, Action::Read, "case").allowed);
let no_dept = claims_with_attrs(&[]);
assert!(!policy.evaluate(&no_dept, Action::Read, "case").allowed);
let insider = claims_with_attrs(&[("dept", &["cardiology"])]);
assert!(policy.evaluate(&insider, Action::Read, "case").allowed);
}
#[test]
fn wildcard_actions_cover_every_action() {
let policy = Policy::from_json(
r#"{ "rules": [
{ "effect": "allow", "actions": ["*"], "when": { "svc": ["true"] } }
] }"#,
)
.expect("policy parses");
let claims = claims_with_attrs(&[("svc", &["true"])]);
for action in [
Action::Read,
Action::Write,
Action::Delete,
Action::Destructive,
] {
let decision = policy.evaluate(&claims, action, "thing");
assert!(decision.allowed);
assert_eq!(decision.reason, "allow (rule 0)");
}
}
#[test]
fn value_list_means_any_of_these_values() {
let policy = Policy::default_policy();
for tier in ["write", "admin"] {
let claims = claims_with_attrs(&[("access", &[tier])]);
assert!(
policy.evaluate(&claims, Action::Write, "place").allowed,
"access={tier} must allow write"
);
}
let claims = claims_with_attrs(&[("access", &["other"])]);
assert!(!policy.evaluate(&claims, Action::Write, "place").allowed);
}
#[test]
fn empty_when_matches_every_subject_and_empty_value_list_matches_none() {
let policy = Policy::from_json(
r#"{ "rules": [
{ "effect": "allow", "actions": ["write"] },
{ "effect": "allow", "actions": ["delete"], "when": { "access": [] } }
] }"#,
)
.expect("policy parses");
let claims = claims_with_attrs(&[("access", &["admin"])]);
assert!(policy.evaluate(&claims, Action::Write, "event").allowed);
assert!(!policy.evaluate(&claims, Action::Delete, "event").allowed);
}
#[test]
fn pseudo_attributes_sub_email_and_entity_are_matchable() {
let policy = Policy::from_json(
r#"{ "rules": [
{ "effect": "allow", "actions": ["write"],
"when": { "sub": ["11111111-1111-1111-1111-111111111111"],
"email": ["alice@example.com"],
"entity": ["place"] } }
] }"#,
)
.expect("policy parses");
let claims = claims_with_attrs(&[]);
assert!(policy.evaluate(&claims, Action::Write, "place").allowed);
assert!(!policy.evaluate(&claims, Action::Write, "person").allowed);
}
#[test]
fn pseudo_attributes_cannot_be_shadowed_by_attrs() {
let policy = Policy::from_json(
r#"{ "rules": [
{ "effect": "allow", "actions": ["write"], "when": { "sub": ["spoofed"] } }
] }"#,
)
.expect("policy parses");
let claims = claims_with_attrs(&[("sub", &["spoofed"])]);
assert!(!policy.evaluate(&claims, Action::Write, "place").allowed);
}
#[test]
fn bad_policy_json_is_an_error_not_a_panic() {
assert!(Policy::from_json("not json at all").is_err());
assert!(Policy::from_json(r#"{ "rules": "not-a-list" }"#).is_err());
assert!(
Policy::from_json(r#"{ "rules": [ { "effect": "shrug", "actions": ["read"] } ] }"#)
.is_err()
);
assert!(
Policy::from_json(r#"{ "rules": [ { "effect": "allow", "actions": ["frob"] } ] }"#)
.is_err()
);
}
#[test]
fn unknown_rule_fields_are_ignored() {
let policy = Policy::from_json(
r#"{ "rules": [
{ "effect": "allow", "actions": ["write"], "when": { "svc": ["true"] },
"comment": "future field", "advice": ["someday"] }
] }"#,
)
.expect("unknown fields must be ignored");
let claims = claims_with_attrs(&[("svc", &["true"])]);
assert!(policy.evaluate(&claims, Action::Write, "worker").allowed);
}
#[test]
fn allow_rule_carries_its_obligations_to_the_decision() {
let policy = Policy::from_json(
r#"{ "rules": [
{ "effect": "allow", "actions": ["read"],
"when": { "access": ["read-masked"] },
"obligations": ["mask", "audit"] },
{ "effect": "deny", "actions": ["read"],
"when": { "resource.sensitivity": ["secret"] },
"obligations": ["mask"] }
] }"#,
)
.expect("policy parses");
let masked = claims_with_attrs(&[("access", &["read-masked"])]);
let decision = policy.evaluate(&masked, Action::Read, "case");
assert!(decision.allowed);
assert!(decision.requires("mask"));
assert!(decision.requires("audit"));
assert!(!decision.requires("delete"));
let onto_secret = policy.evaluate_with_resource(
&claims_with_attrs(&[("access", &["read-masked"]), ("other", &["x"])]),
Action::Read,
"case",
&resource(&[("sensitivity", &["secret"])]),
);
assert!(onto_secret.allowed);
assert!(onto_secret.requires("mask"));
let plain = policy.evaluate(&claims_with_attrs(&[]), Action::Read, "case");
assert!(plain.allowed); assert!(plain.obligations.is_empty());
}
#[test]
fn default_policy_allows_carry_no_obligations() {
let policy = Policy::default_policy();
let admin = claims_with_attrs(&[("access", &["admin"])]);
let d = policy.evaluate(&admin, Action::Write, "place");
assert!(d.allowed);
assert!(d.obligations.is_empty());
}
#[test]
fn reloadable_policy_swaps_the_active_policy() {
let holder = ReloadablePolicy::new(Policy { rules: vec![] });
let writer = claims_with_attrs(&[("access", &["write"])]);
assert!(
!holder
.current()
.evaluate(&writer, Action::Write, "case")
.allowed,
"before reload: default deny-mutation"
);
holder.store(
Policy::from_json(
r#"{ "rules": [
{ "effect": "allow", "actions": ["write"], "when": { "access": ["write"] } }
] }"#,
)
.expect("policy parses"),
);
assert!(
holder
.current()
.evaluate(&writer, Action::Write, "case")
.allowed,
"after reload: the new policy allows the writer"
);
let snapshot = holder.current();
holder.store(Policy { rules: vec![] });
assert!(
snapshot.evaluate(&writer, Action::Write, "case").allowed,
"an in-flight snapshot keeps the policy it captured"
);
assert!(
!holder
.current()
.evaluate(&writer, Action::Write, "case")
.allowed,
"new readers see the latest (deny) policy"
);
}
#[test]
fn default_policy_round_trips_through_its_json_form() {
let from_json = Policy::from_json(
r#"{ "rules": [
{ "effect": "allow", "actions": ["write", "destructive"], "when": { "svc": ["true"] } },
{ "effect": "allow", "actions": ["destructive"], "when": { "access": ["admin"] } },
{ "effect": "allow", "actions": ["write"], "when": { "access": ["write", "admin"] } }
] }"#,
)
.expect("policy parses");
assert_eq!(from_json, Policy::default_policy());
}
#[test]
fn delete_pattern_does_not_match_destructive_posts() {
let policy = Policy::from_json(
r#"{ "rules": [
{ "effect": "allow", "actions": ["delete"], "when": { "access": ["ops"] } }
] }"#,
)
.expect("policy parses");
let claims = claims_with_attrs(&[("access", &["ops"])]);
assert!(policy.evaluate(&claims, Action::Delete, "case").allowed);
assert!(
!policy
.evaluate(&claims, Action::Destructive, "case")
.allowed
);
}
fn resource(pairs: &[(&str, &[&str])]) -> BTreeMap<String, Vec<String>> {
pairs
.iter()
.map(|(key, values)| {
(
(*key).to_string(),
values.iter().map(ToString::to_string).collect(),
)
})
.collect()
}
#[test]
fn resource_attribute_gates_a_deny_on_record_sensitivity() {
let policy = Policy::from_json(
r#"{ "rules": [
{ "effect": "allow", "actions": ["write"], "when": { "access": ["admin"] } },
{ "effect": "deny", "actions": ["write"], "when": { "resource.sensitivity": ["high"] } },
{ "effect": "allow", "actions": ["write"], "when": { "access": ["write"] } }
] }"#,
)
.expect("policy parses");
let writer = claims_with_attrs(&[("access", &["write"])]);
let admin = claims_with_attrs(&[("access", &["admin"])]);
let high = resource(&[("sensitivity", &["high"])]);
let low = resource(&[("sensitivity", &["low"])]);
let d = policy.evaluate_with_resource(&writer, Action::Write, "case", &high);
assert!(!d.allowed);
assert_eq!(d.reason, "deny (rule 1)");
assert!(
policy
.evaluate_with_resource(&writer, Action::Write, "case", &low)
.allowed
);
let a = policy.evaluate_with_resource(&admin, Action::Write, "case", &high);
assert!(a.allowed);
assert_eq!(a.reason, "allow (rule 0)");
}
#[test]
fn resource_keys_resolve_empty_without_resource_attrs() {
let policy = Policy::from_json(
r#"{ "rules": [
{ "effect": "deny", "actions": ["write"], "when": { "resource.sensitivity": ["high"] } },
{ "effect": "allow", "actions": ["write"], "when": { "access": ["write"] } }
] }"#,
)
.expect("policy parses");
let writer = claims_with_attrs(&[("access", &["write"])]);
let d = policy.evaluate(&writer, Action::Write, "case");
assert!(d.allowed);
assert_eq!(d.reason, "allow (rule 1)");
assert_eq!(
d,
policy.evaluate_with_resource(&writer, Action::Write, "case", &BTreeMap::new())
);
}
#[test]
fn negated_resource_value_matches_when_absent_or_empty() {
let policy = Policy::from_json(
r#"{ "rules": [
{ "effect": "deny", "actions": ["read"], "when": { "resource.sensitivity": ["!public"] } }
] }"#,
)
.expect("policy parses");
let anyone = claims_with_attrs(&[]);
assert!(
policy
.evaluate_with_resource(
&anyone,
Action::Read,
"case",
&resource(&[("sensitivity", &["public"])])
)
.allowed
);
assert!(
!policy
.evaluate_with_resource(
&anyone,
Action::Read,
"case",
&resource(&[("sensitivity", &["restricted"])])
)
.allowed
);
assert!(
!policy
.evaluate_with_resource(&anyone, Action::Read, "case", &BTreeMap::new())
.allowed
);
}
#[test]
fn negated_allow_does_not_match_vacuously_when_namespace_absent() {
let policy = Policy::from_json(
r#"{ "rules": [
{ "effect": "allow", "actions": ["write"], "when": { "env.network": ["!untrusted"] } }
] }"#,
)
.expect("policy parses");
let anyone = claims_with_attrs(&[]);
assert!(!policy.evaluate(&anyone, Action::Write, "case").allowed);
assert!(
!policy
.evaluate_with_context(
&anyone,
Action::Write,
"case",
&BTreeMap::new(),
&BTreeMap::new()
)
.allowed
);
assert!(
policy
.evaluate_with_context(
&anyone,
Action::Write,
"case",
&BTreeMap::new(),
&resource(&[("network", &["trusted"])]),
)
.allowed
);
}
#[test]
fn resource_namespace_is_disjoint_from_subject_attrs() {
let policy = Policy::from_json(
r#"{ "rules": [
{ "effect": "allow", "actions": ["write"], "when": { "resource.sensitivity": ["high"] } }
] }"#,
)
.expect("policy parses");
let spoofer = claims_with_attrs(&[("resource.sensitivity", &["high"])]);
assert!(!policy.evaluate(&spoofer, Action::Write, "case").allowed);
}
const SUBJECT_PID: &str = "11111111-1111-1111-1111-111111111111";
#[test]
fn value_template_sub_expresses_ownership() {
let policy = Policy::from_json(
r#"{ "rules": [
{ "effect": "allow", "actions": ["write"],
"when": { "access": ["write"], "resource.owner": ["$sub"] } }
] }"#,
)
.expect("policy parses");
let writer = claims_with_attrs(&[("access", &["write"])]);
assert!(
policy
.evaluate_with_resource(
&writer,
Action::Write,
"case",
&resource(&[("owner", &[SUBJECT_PID])])
)
.allowed
);
assert!(
!policy
.evaluate_with_resource(
&writer,
Action::Write,
"case",
&resource(&[("owner", &["99999999-9999-9999-9999-999999999999"])])
)
.allowed
);
}
#[test]
fn literal_dollar_value_is_not_a_template() {
let policy = Policy::from_json(
r#"{ "rules": [
{ "effect": "allow", "actions": ["write"], "when": { "tier": ["$gold"] } }
] }"#,
)
.expect("policy parses");
let claims = claims_with_attrs(&[("tier", &["$gold"])]);
assert!(policy.evaluate(&claims, Action::Write, "thing").allowed);
}
#[test]
fn env_attribute_gates_a_time_window_deny() {
let policy = Policy::from_json(
r#"{ "rules": [
{ "effect": "allow", "actions": ["write"], "when": { "access": ["admin"] } },
{ "effect": "deny", "actions": ["write"], "when": { "env.after_hours": ["true"] } },
{ "effect": "allow", "actions": ["write"], "when": { "access": ["write"] } }
] }"#,
)
.expect("policy parses");
let writer = claims_with_attrs(&[("access", &["write"])]);
let admin = claims_with_attrs(&[("access", &["admin"])]);
let after_hours = resource(&[("after_hours", &["true"])]);
let during_hours = resource(&[("after_hours", &["false"])]);
let denied = policy.evaluate_with_context(
&writer,
Action::Write,
"case",
&BTreeMap::new(),
&after_hours,
);
assert!(!denied.allowed);
assert_eq!(denied.reason, "deny (rule 1)");
assert!(
policy
.evaluate_with_context(
&writer,
Action::Write,
"case",
&BTreeMap::new(),
&during_hours
)
.allowed
);
assert!(
policy
.evaluate_with_context(
&admin,
Action::Write,
"case",
&BTreeMap::new(),
&after_hours
)
.allowed
);
}
#[test]
fn env_namespace_empty_without_context() {
let policy = Policy::from_json(
r#"{ "rules": [
{ "effect": "deny", "actions": ["write"], "when": { "env.after_hours": ["true"] } },
{ "effect": "allow", "actions": ["write"], "when": { "access": ["write"] } }
] }"#,
)
.expect("policy parses");
let writer = claims_with_attrs(&[("access", &["write"])]);
let d = policy.evaluate(&writer, Action::Write, "case");
assert!(d.allowed);
assert_eq!(d.reason, "allow (rule 1)");
assert_eq!(
d,
policy.evaluate_with_resource(&writer, Action::Write, "case", &BTreeMap::new())
);
}
#[test]
fn every_example_policy_parses() {
let dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../examples/policies");
let mut checked = 0;
for entry in
std::fs::read_dir(&dir).unwrap_or_else(|e| panic!("read_dir({}): {e}", dir.display()))
{
let entry = entry.expect("dir entry");
let path = entry.path();
if path.extension().and_then(|e| e.to_str()) != Some("json") {
continue;
}
let json = std::fs::read_to_string(&path)
.unwrap_or_else(|e| panic!("read {}: {e}", path.display()));
Policy::from_json(&json)
.unwrap_or_else(|e| panic!("{} failed to parse as a Policy: {e}", path.display()));
checked += 1;
}
assert!(
checked >= 6,
"expected at least 6 example policies in {}, found {checked}",
dir.display()
);
}
}