Skip to main content

reddb_server/auth/
policies.rs

1//! IAM-style policy kernel: data model, JSON codec, validator, evaluator,
2//! and simulator.
3//!
4//! This module is intentionally self-contained — it owns the *policy object*
5//! and the *decision algorithm* but knows nothing about how policies are
6//! stored, attached to principals, or fronted by HTTP. A separate
7//! integration layer plumbs `Policy` through the auth store and surfaces
8//! the simulator on the admin API.
9//!
10//! # Decision algorithm
11//! `evaluate(policies, action, resource, ctx)` walks the supplied policy
12//! list in order. The list is expected to be ordered "least specific
13//! first": platform-level group attachments come first, tenant attachments
14//! next, user attachments last. Within each policy, statements are
15//! evaluated left-to-right.
16//!
17//! 1. For each statement, check the condition first (cheap), then the
18//!    action set, then the resource set.
19//! 2. Any matching `Deny` short-circuits to `Decision::Deny` — this wins
20//!    over everything, including admin authority. Explicit Deny is a
21//!    managed guardrail, so it is *never* bypassed.
22//! 3. The first matching `Allow` is recorded but evaluation continues so
23//!    that a later `Deny` can still override it.
24//! 4. If no `Deny` matched and `ctx.principal_is_admin_role` is true,
25//!    return `AdminBypass`. Admin authority is policy-first: it grants a
26//!    broad allow, but it does not override an explicit Deny.
27//! 5. Otherwise return the recorded `Allow`, or `DefaultDeny` if nothing
28//!    matched at all.
29//!
30//! # Glob semantics
31//! Globs are *split-on-`*`*: a pattern is broken into a prefix, a suffix,
32//! and an ordered list of "contains" segments. Matching walks the input
33//! checking that the prefix is at the start, the suffix is at the end,
34//! and each contains segment appears in order. There is no regex engine
35//! and no character classes — keep the matcher boring on purpose.
36//!
37//! # Time windows
38//! `TimeWindow.tz_offset_secs` is a fixed signed offset from UTC. This
39//! kernel intentionally does NOT depend on `chrono-tz` or any IANA tz
40//! database (none is currently a dependency). The integration agent can
41//! extend `TimeWindow` to accept IANA names later; until then, callers
42//! must pass an explicit offset (`+HH:MM`/`-HH:MM`) or 0 for UTC.
43//!
44//! # Limits
45//! - 100 statements per policy
46//! - 50 actions per statement
47//! - 50 resources per statement
48//! - 32 KiB serialized JSON per policy
49
50use std::error::Error;
51use std::fmt;
52use std::net::IpAddr;
53use std::str::FromStr;
54
55use crate::serde_json::{self, JsonDecode, JsonEncode, Map, Value};
56
57// ---------------------------------------------------------------------------
58// Limits
59// ---------------------------------------------------------------------------
60
61/// Maximum statements per policy.
62pub const MAX_STATEMENTS: usize = 100;
63/// Maximum actions per statement.
64pub const MAX_ACTIONS: usize = 50;
65/// Maximum resources per statement.
66pub const MAX_RESOURCES: usize = 50;
67/// Maximum serialized JSON size in bytes.
68pub const MAX_POLICY_BYTES: usize = 32 * 1024;
69
70/// Recognised action verbs. Anything outside this allowlist is rejected
71/// at validation time so a typo in a policy can never silently widen
72/// access.
73const ACTION_ALLOWLIST: &[&str] = &[
74    "select",
75    "write",
76    "insert",
77    "update",
78    "delete",
79    "truncate",
80    "references",
81    "execute",
82    "usage",
83    "grant",
84    "revoke",
85    "create",
86    "drop",
87    "alter",
88    "policy:put",
89    "policy:drop",
90    "policy:attach",
91    "policy:detach",
92    "policy:simulate",
93    "kv:invalidate",
94    "admin:bootstrap",
95    "admin:audit-read",
96    "admin:reload",
97    "admin:lease-promote",
98    "config:read",
99    "config:write",
100    "config:*",
101    "vault:read_metadata",
102    "vault:write",
103    "vault:unseal",
104    "vault:unseal_history",
105    "vault:purge",
106    "red.registry:register",
107    "red.registry:supersede",
108    "red.registry:*",
109    "*",
110    "admin:*",
111    "vault:*",
112    "kv:*",
113    "policy:*",
114];
115
116// ---------------------------------------------------------------------------
117// Policy / Statement
118// ---------------------------------------------------------------------------
119
120/// A single IAM-style policy document.
121#[derive(Debug, Clone, PartialEq)]
122pub struct Policy {
123    /// Unique policy id within a tenant.
124    pub id: String,
125    /// Schema version. Currently `1`.
126    pub version: u8,
127    pub statements: Vec<Statement>,
128    /// `None` = platform-wide policy, `Some(t)` = tenant-scoped.
129    pub tenant: Option<String>,
130    /// Creation timestamp (unix ms).
131    pub created_at: u128,
132    /// Last-update timestamp (unix ms).
133    pub updated_at: u128,
134}
135
136/// One Allow/Deny rule inside a policy.
137#[derive(Debug, Clone, PartialEq)]
138pub struct Statement {
139    /// Optional human-readable id, unique within the policy.
140    pub sid: Option<String>,
141    pub effect: Effect,
142    pub actions: Vec<ActionPattern>,
143    pub resources: Vec<ResourcePattern>,
144    pub condition: Option<Condition>,
145}
146
147#[derive(Debug, Clone, Copy, PartialEq, Eq)]
148pub enum Effect {
149    Allow,
150    Deny,
151}
152
153/// Action match pattern.
154///
155/// `Prefix(s)` is stored *without* the trailing `:*` — `"admin:*"` parses
156/// to `Prefix("admin")` so the matcher can compare against `admin:foo`
157/// with a single `starts_with` + colon check.
158#[derive(Debug, Clone, PartialEq, Eq)]
159pub enum ActionPattern {
160    Exact(String),
161    Wildcard,
162    Prefix(String),
163}
164
165/// Resource match pattern.
166#[derive(Debug, Clone, PartialEq, Eq)]
167pub enum ResourcePattern {
168    Exact { kind: String, name: String },
169    Glob(String),
170    Wildcard,
171}
172
173/// Conditions that must hold for a statement to match. All present keys
174/// are AND-combined; an absent key is "no constraint".
175#[derive(Debug, Clone, PartialEq)]
176pub struct Condition {
177    pub expires_at: Option<u128>,
178    pub valid_from: Option<u128>,
179    pub tenant_match: Option<bool>,
180    pub source_ip: Option<Vec<IpCidr>>,
181    pub mfa: Option<bool>,
182    pub time_window: Option<TimeWindow>,
183    /// Require the principal to be (or not be) system-owned.
184    pub system_owned: Option<bool>,
185    /// Require the principal to be (or not be) platform-scoped.
186    pub platform_scoped: Option<bool>,
187}
188
189/// CIDR block for `source_ip` matches.
190#[derive(Debug, Clone, PartialEq, Eq)]
191pub struct IpCidr {
192    pub addr: IpAddr,
193    pub prefix_len: u8,
194}
195
196/// Daily time window. Minutes are `HH * 60 + MM` in the local time zone
197/// represented by `tz_offset_secs`. `from_minute > to_minute` means the
198/// window wraps midnight.
199#[derive(Debug, Clone, PartialEq, Eq)]
200pub struct TimeWindow {
201    pub from_minute: u16,
202    pub to_minute: u16,
203    pub tz_offset_secs: i32,
204}
205
206/// The resource being authorized — kind plus fully-qualified name.
207#[derive(Debug, Clone, PartialEq)]
208pub struct ResourceRef {
209    pub kind: String,
210    pub name: String,
211    pub tenant: Option<String>,
212}
213
214impl ResourceRef {
215    pub fn new(kind: impl Into<String>, name: impl Into<String>) -> Self {
216        Self {
217            kind: kind.into(),
218            name: name.into(),
219            tenant: None,
220        }
221    }
222
223    pub fn with_tenant(mut self, tenant: impl Into<String>) -> Self {
224        self.tenant = Some(tenant.into());
225        self
226    }
227}
228
229/// Per-request evaluation context.
230#[derive(Debug, Clone, Default)]
231pub struct EvalContext {
232    /// Tenant of the authenticated principal.
233    pub principal_tenant: Option<String>,
234    /// Tenant the request is currently operating in (`SET TENANT`).
235    pub current_tenant: Option<String>,
236    /// Source IP of the connection (for `source_ip` conditions).
237    pub peer_ip: Option<IpAddr>,
238    pub mfa_present: bool,
239    /// Wall clock at decision time (unix ms).
240    pub now_ms: u128,
241    /// Set when the principal has the classic `Role::Admin`. Grants a
242    /// policy-derived broad allow (`AdminBypass`) when no statement
243    /// matched and no explicit `Deny` applies — it does **not** override
244    /// an explicit Deny.
245    pub principal_is_admin_role: bool,
246    /// Set when the principal's user record is system-owned (operator-owned,
247    /// immutable through the normal user-management API). Policies can match
248    /// on this via the `system_owned` condition key to distinguish operator
249    /// principals from ordinary users without a separate login type.
250    pub principal_is_system_owned: bool,
251    /// Set when the principal is platform-scoped (no tenant — `tenant_id`
252    /// is `None`). Matched via the `platform_scoped` condition key.
253    pub principal_is_platform_scoped: bool,
254}
255
256/// Outcome of `evaluate` / `simulate`.
257#[derive(Debug, Clone, PartialEq)]
258pub enum Decision {
259    Allow {
260        matched_policy_id: String,
261        matched_sid: Option<String>,
262    },
263    Deny {
264        matched_policy_id: String,
265        matched_sid: Option<String>,
266    },
267    DefaultDeny,
268    AdminBypass,
269}
270
271// ---------------------------------------------------------------------------
272// PolicyError
273// ---------------------------------------------------------------------------
274
275#[derive(Debug, Clone)]
276pub enum PolicyError {
277    InvalidJson(String),
278    InvalidAction(String),
279    InvalidResource(String),
280    InvalidCondition(String),
281    InvalidCidr(String),
282    DuplicateSid(String),
283    EmptyStatements,
284    EmptyActions,
285    EmptyResources,
286    TooManyStatements(usize),
287    TooManyActions(usize),
288    TooManyResources(usize),
289    PolicyTooLarge(usize),
290}
291
292impl fmt::Display for PolicyError {
293    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
294        match self {
295            Self::InvalidJson(m) => write!(f, "invalid policy json: {m}"),
296            Self::InvalidAction(m) => write!(f, "invalid action: {m}"),
297            Self::InvalidResource(m) => write!(f, "invalid resource: {m}"),
298            Self::InvalidCondition(m) => write!(f, "invalid condition: {m}"),
299            Self::InvalidCidr(m) => write!(f, "invalid cidr: {m}"),
300            Self::DuplicateSid(s) => write!(f, "duplicate sid in policy: {s}"),
301            Self::EmptyStatements => write!(f, "policy has no statements"),
302            Self::EmptyActions => write!(f, "statement has no actions"),
303            Self::EmptyResources => write!(f, "statement has no resources"),
304            Self::TooManyStatements(n) => {
305                write!(f, "policy has {n} statements (max {MAX_STATEMENTS})")
306            }
307            Self::TooManyActions(n) => {
308                write!(f, "statement has {n} actions (max {MAX_ACTIONS})")
309            }
310            Self::TooManyResources(n) => {
311                write!(f, "statement has {n} resources (max {MAX_RESOURCES})")
312            }
313            Self::PolicyTooLarge(n) => {
314                write!(f, "policy json is {n} bytes (max {MAX_POLICY_BYTES})")
315            }
316        }
317    }
318}
319
320impl Error for PolicyError {}
321
322// ---------------------------------------------------------------------------
323// Policy: parse + validate + serialize
324// ---------------------------------------------------------------------------
325
326impl Policy {
327    /// Parse and validate a policy from a JSON string. Enforces the 32 KiB
328    /// size cap on the *raw* input before parsing.
329    pub fn from_json_str(s: &str) -> Result<Policy, PolicyError> {
330        if s.len() > MAX_POLICY_BYTES {
331            return Err(PolicyError::PolicyTooLarge(s.len()));
332        }
333        let value: Value = serde_json::from_str(s).map_err(PolicyError::InvalidJson)?;
334        let policy = Policy::from_json_value(&value)?;
335        policy.validate()?;
336        Ok(policy)
337    }
338
339    /// Serialize this policy to a compact JSON string. Round-trips with
340    /// `from_json_str` modulo whitespace.
341    pub fn to_json_string(&self) -> String {
342        self.to_json_value().to_string_compact()
343    }
344
345    /// Validate structural invariants. Called automatically by
346    /// `from_json_str` but also exposed for in-memory constructions.
347    pub fn validate(&self) -> Result<(), PolicyError> {
348        if self.statements.is_empty() {
349            return Err(PolicyError::EmptyStatements);
350        }
351        if self.statements.len() > MAX_STATEMENTS {
352            return Err(PolicyError::TooManyStatements(self.statements.len()));
353        }
354
355        let mut seen_sids: Vec<&str> = Vec::new();
356        for st in &self.statements {
357            if let Some(sid) = st.sid.as_deref() {
358                if seen_sids.contains(&sid) {
359                    return Err(PolicyError::DuplicateSid(sid.to_string()));
360                }
361                seen_sids.push(sid);
362            }
363            if st.actions.is_empty() {
364                return Err(PolicyError::EmptyActions);
365            }
366            if st.actions.len() > MAX_ACTIONS {
367                return Err(PolicyError::TooManyActions(st.actions.len()));
368            }
369            if st.resources.is_empty() {
370                return Err(PolicyError::EmptyResources);
371            }
372            if st.resources.len() > MAX_RESOURCES {
373                return Err(PolicyError::TooManyResources(st.resources.len()));
374            }
375            for a in &st.actions {
376                validate_action(a)?;
377            }
378        }
379        Ok(())
380    }
381
382    fn from_json_value(v: &Value) -> Result<Policy, PolicyError> {
383        let obj = v
384            .as_object()
385            .ok_or_else(|| PolicyError::InvalidJson("policy must be an object".into()))?;
386        let id = string_field(obj, "id")?;
387        let version = obj
388            .get("version")
389            .and_then(|n| n.as_u64())
390            .map(|n| n as u8)
391            .unwrap_or(1);
392        let tenant = obj
393            .get("tenant")
394            .and_then(|t| match t {
395                Value::Null => None,
396                Value::String(s) => Some(Some(s.clone())),
397                _ => Some(None),
398            })
399            .flatten();
400        let created_at = parse_ts_field(obj, "created_at").unwrap_or(0);
401        let updated_at = parse_ts_field(obj, "updated_at").unwrap_or(created_at);
402
403        let statements_v =
404            obj.get("statements")
405                .and_then(|v| v.as_array())
406                .ok_or(PolicyError::InvalidJson(
407                    "policy.statements must be an array".into(),
408                ))?;
409        let mut statements = Vec::with_capacity(statements_v.len());
410        for sv in statements_v {
411            statements.push(Statement::from_json_value(sv)?);
412        }
413
414        Ok(Policy {
415            id,
416            version,
417            statements,
418            tenant,
419            created_at,
420            updated_at,
421        })
422    }
423
424    fn to_json_value(&self) -> Value {
425        let mut obj = Map::new();
426        obj.insert("id".into(), Value::String(self.id.clone()));
427        obj.insert("version".into(), Value::Number(self.version as f64));
428        if let Some(t) = &self.tenant {
429            obj.insert("tenant".into(), Value::String(t.clone()));
430        } else {
431            obj.insert("tenant".into(), Value::Null);
432        }
433        obj.insert("created_at".into(), Value::Number(self.created_at as f64));
434        obj.insert("updated_at".into(), Value::Number(self.updated_at as f64));
435        obj.insert(
436            "statements".into(),
437            Value::Array(self.statements.iter().map(|s| s.to_json_value()).collect()),
438        );
439        Value::Object(obj)
440    }
441}
442
443impl JsonEncode for Policy {
444    fn to_json_value(&self) -> Value {
445        self.to_json_value()
446    }
447}
448
449impl JsonDecode for Policy {
450    fn from_json_value(value: Value) -> Result<Self, String> {
451        Policy::from_json_value(&value).map_err(|e| e.to_string())
452    }
453}
454
455// ---------------------------------------------------------------------------
456// Statement parsing
457// ---------------------------------------------------------------------------
458
459impl Statement {
460    fn from_json_value(v: &Value) -> Result<Statement, PolicyError> {
461        let obj = v
462            .as_object()
463            .ok_or_else(|| PolicyError::InvalidJson("statement must be an object".into()))?;
464        let sid = obj
465            .get("sid")
466            .and_then(|s| s.as_str())
467            .map(|s| s.to_string());
468        let effect_s = obj
469            .get("effect")
470            .and_then(|e| e.as_str())
471            .ok_or_else(|| PolicyError::InvalidJson("statement.effect required".into()))?;
472        let effect = match effect_s.to_ascii_lowercase().as_str() {
473            "allow" => Effect::Allow,
474            "deny" => Effect::Deny,
475            other => return Err(PolicyError::InvalidJson(format!("unknown effect: {other}"))),
476        };
477
478        let actions = obj
479            .get("actions")
480            .and_then(|a| a.as_array())
481            .ok_or_else(|| PolicyError::InvalidJson("statement.actions must be array".into()))?
482            .iter()
483            .map(|v| {
484                v.as_str()
485                    .ok_or_else(|| PolicyError::InvalidJson("action must be string".into()))
486                    .map(compile_action)
487            })
488            .collect::<Result<Vec<_>, _>>()?;
489
490        let resources = obj
491            .get("resources")
492            .and_then(|r| r.as_array())
493            .ok_or_else(|| PolicyError::InvalidJson("statement.resources must be array".into()))?
494            .iter()
495            .map(|v| {
496                v.as_str()
497                    .ok_or_else(|| PolicyError::InvalidJson("resource must be string".into()))
498                    .and_then(compile_resource)
499            })
500            .collect::<Result<Vec<_>, _>>()?;
501
502        let condition = match obj.get("condition") {
503            None | Some(Value::Null) => None,
504            Some(c) => Some(Condition::from_json_value(c)?),
505        };
506
507        Ok(Statement {
508            sid,
509            effect,
510            actions,
511            resources,
512            condition,
513        })
514    }
515
516    fn to_json_value(&self) -> Value {
517        let mut obj = Map::new();
518        if let Some(sid) = &self.sid {
519            obj.insert("sid".into(), Value::String(sid.clone()));
520        }
521        obj.insert(
522            "effect".into(),
523            Value::String(
524                match self.effect {
525                    Effect::Allow => "allow",
526                    Effect::Deny => "deny",
527                }
528                .into(),
529            ),
530        );
531        obj.insert(
532            "actions".into(),
533            Value::Array(
534                self.actions
535                    .iter()
536                    .map(|a| Value::String(action_to_string(a)))
537                    .collect(),
538            ),
539        );
540        obj.insert(
541            "resources".into(),
542            Value::Array(
543                self.resources
544                    .iter()
545                    .map(|r| Value::String(resource_to_string(r)))
546                    .collect(),
547            ),
548        );
549        if let Some(c) = &self.condition {
550            obj.insert("condition".into(), c.to_json_value());
551        }
552        Value::Object(obj)
553    }
554}
555
556// ---------------------------------------------------------------------------
557// Condition parsing
558// ---------------------------------------------------------------------------
559
560impl Condition {
561    fn from_json_value(v: &Value) -> Result<Condition, PolicyError> {
562        let obj = v
563            .as_object()
564            .ok_or_else(|| PolicyError::InvalidCondition("condition must be object".into()))?;
565
566        let expires_at = match obj.get("expires_at") {
567            None | Some(Value::Null) => None,
568            Some(x) => Some(parse_ts_value(x)?),
569        };
570        let valid_from = match obj.get("valid_from") {
571            None | Some(Value::Null) => None,
572            Some(x) => Some(parse_ts_value(x)?),
573        };
574        let tenant_match = obj.get("tenant_match").and_then(|v| v.as_bool());
575        let mfa = obj.get("mfa").and_then(|v| v.as_bool());
576        let system_owned = obj.get("system_owned").and_then(|v| v.as_bool());
577        let platform_scoped = obj.get("platform_scoped").and_then(|v| v.as_bool());
578
579        let source_ip = match obj.get("source_ip") {
580            None | Some(Value::Null) => None,
581            Some(arr) => {
582                let xs = arr.as_array().ok_or_else(|| {
583                    PolicyError::InvalidCondition("source_ip must be array".into())
584                })?;
585                let mut out = Vec::with_capacity(xs.len());
586                for v in xs {
587                    let s = v.as_str().ok_or_else(|| {
588                        PolicyError::InvalidCidr("source_ip entry must be string".into())
589                    })?;
590                    out.push(parse_cidr(s)?);
591                }
592                Some(out)
593            }
594        };
595
596        let time_window = match obj.get("time_window") {
597            None | Some(Value::Null) => None,
598            Some(tw) => Some(TimeWindow::from_json_value(tw)?),
599        };
600
601        Ok(Condition {
602            expires_at,
603            valid_from,
604            tenant_match,
605            source_ip,
606            mfa,
607            time_window,
608            system_owned,
609            platform_scoped,
610        })
611    }
612
613    fn to_json_value(&self) -> Value {
614        let mut obj = Map::new();
615        if let Some(t) = self.expires_at {
616            obj.insert("expires_at".into(), Value::Number(t as f64));
617        }
618        if let Some(t) = self.valid_from {
619            obj.insert("valid_from".into(), Value::Number(t as f64));
620        }
621        if let Some(b) = self.tenant_match {
622            obj.insert("tenant_match".into(), Value::Bool(b));
623        }
624        if let Some(b) = self.mfa {
625            obj.insert("mfa".into(), Value::Bool(b));
626        }
627        if let Some(b) = self.system_owned {
628            obj.insert("system_owned".into(), Value::Bool(b));
629        }
630        if let Some(b) = self.platform_scoped {
631            obj.insert("platform_scoped".into(), Value::Bool(b));
632        }
633        if let Some(cidrs) = &self.source_ip {
634            obj.insert(
635                "source_ip".into(),
636                Value::Array(
637                    cidrs
638                        .iter()
639                        .map(|c| Value::String(format!("{}/{}", c.addr, c.prefix_len)))
640                        .collect(),
641                ),
642            );
643        }
644        if let Some(tw) = &self.time_window {
645            obj.insert("time_window".into(), tw.to_json_value());
646        }
647        Value::Object(obj)
648    }
649}
650
651impl TimeWindow {
652    fn from_json_value(v: &Value) -> Result<TimeWindow, PolicyError> {
653        let obj = v
654            .as_object()
655            .ok_or_else(|| PolicyError::InvalidCondition("time_window must be object".into()))?;
656        let from_minute =
657            parse_hhmm(obj.get("from").and_then(|s| s.as_str()).ok_or_else(|| {
658                PolicyError::InvalidCondition("time_window.from required".into())
659            })?)?;
660        let to_minute = parse_hhmm(
661            obj.get("to")
662                .and_then(|s| s.as_str())
663                .ok_or_else(|| PolicyError::InvalidCondition("time_window.to required".into()))?,
664        )?;
665        let tz_str = obj.get("tz").and_then(|s| s.as_str()).unwrap_or("UTC");
666        let tz_offset_secs = parse_tz_offset(tz_str)?;
667        Ok(TimeWindow {
668            from_minute,
669            to_minute,
670            tz_offset_secs,
671        })
672    }
673
674    fn to_json_value(&self) -> Value {
675        let mut obj = Map::new();
676        obj.insert("from".into(), Value::String(format_hhmm(self.from_minute)));
677        obj.insert("to".into(), Value::String(format_hhmm(self.to_minute)));
678        obj.insert("tz".into(), Value::String(format_tz(self.tz_offset_secs)));
679        Value::Object(obj)
680    }
681}
682
683// ---------------------------------------------------------------------------
684// Action / Resource helpers
685// ---------------------------------------------------------------------------
686
687/// Compile a string action into a pattern. `"*"` → wildcard, `"foo:*"` →
688/// prefix-match on `foo`, anything else → exact match.
689pub fn compile_action(s: &str) -> ActionPattern {
690    if s == "*" {
691        ActionPattern::Wildcard
692    } else if let Some(p) = s.strip_suffix(":*") {
693        ActionPattern::Prefix(p.to_string())
694    } else {
695        ActionPattern::Exact(s.to_string())
696    }
697}
698
699fn action_to_string(a: &ActionPattern) -> String {
700    match a {
701        ActionPattern::Wildcard => "*".into(),
702        ActionPattern::Prefix(p) => format!("{p}:*"),
703        ActionPattern::Exact(s) => s.clone(),
704    }
705}
706
707fn validate_action(a: &ActionPattern) -> Result<(), PolicyError> {
708    let s = action_to_string(a);
709    if ACTION_ALLOWLIST.iter().any(|w| *w == s) {
710        Ok(())
711    } else {
712        Err(PolicyError::InvalidAction(s))
713    }
714}
715
716fn compile_resource(s: &str) -> Result<ResourcePattern, PolicyError> {
717    if s == "*" {
718        return Ok(ResourcePattern::Wildcard);
719    }
720    if s.contains('*') {
721        return Ok(ResourcePattern::Glob(s.to_string()));
722    }
723    let (kind, name) = s
724        .split_once(':')
725        .ok_or_else(|| PolicyError::InvalidResource(format!("expected `kind:name`, got `{s}`")))?;
726    if kind.is_empty() || name.is_empty() {
727        return Err(PolicyError::InvalidResource(s.to_string()));
728    }
729    Ok(ResourcePattern::Exact {
730        kind: kind.to_string(),
731        name: name.to_string(),
732    })
733}
734
735fn resource_to_string(r: &ResourcePattern) -> String {
736    match r {
737        ResourcePattern::Wildcard => "*".into(),
738        ResourcePattern::Exact { kind, name } => format!("{kind}:{name}"),
739        ResourcePattern::Glob(s) => s.clone(),
740    }
741}
742
743/// Compiled glob pattern: prefix + suffix + ordered "must contain"
744/// segments (between consecutive `*` markers).
745#[derive(Debug, Clone, PartialEq, Eq)]
746pub struct CompiledPattern {
747    pub prefix: String,
748    pub suffix: String,
749    pub contains_segments: Vec<String>,
750}
751
752/// Split a `*`-glob into its compiled form. No regex involved.
753pub fn compile_glob(pattern: &str) -> CompiledPattern {
754    let parts: Vec<&str> = pattern.split('*').collect();
755    if parts.len() == 1 {
756        // No `*` at all — treat the whole pattern as a literal prefix
757        // *and* suffix so plain equality still works through this matcher.
758        return CompiledPattern {
759            prefix: parts[0].to_string(),
760            suffix: String::new(),
761            contains_segments: Vec::new(),
762        };
763    }
764    let prefix = parts[0].to_string();
765    let suffix = parts[parts.len() - 1].to_string();
766    let contains_segments = parts[1..parts.len() - 1]
767        .iter()
768        .filter(|s| !s.is_empty())
769        .map(|s| s.to_string())
770        .collect();
771    CompiledPattern {
772        prefix,
773        suffix,
774        contains_segments,
775    }
776}
777
778fn glob_matches(pat: &CompiledPattern, input: &str) -> bool {
779    if !input.starts_with(&pat.prefix) {
780        return false;
781    }
782    if !input.ends_with(&pat.suffix) {
783        return false;
784    }
785    if pat.prefix.len() + pat.suffix.len() > input.len() {
786        return false;
787    }
788    let mut cursor = pat.prefix.len();
789    let inner_end = input.len() - pat.suffix.len();
790    for seg in &pat.contains_segments {
791        let hay = &input[cursor..inner_end];
792        match hay.find(seg.as_str()) {
793            Some(i) => cursor += i + seg.len(),
794            None => return false,
795        }
796    }
797    true
798}
799
800// ---------------------------------------------------------------------------
801// Timestamp + tz helpers
802// ---------------------------------------------------------------------------
803
804fn parse_ts_field(obj: &Map<String, Value>, key: &str) -> Option<u128> {
805    obj.get(key).and_then(|v| parse_ts_value(v).ok())
806}
807
808fn parse_ts_value(v: &Value) -> Result<u128, PolicyError> {
809    match v {
810        Value::Number(n) if *n >= 0.0 => Ok(*n as u128),
811        Value::String(s) => parse_rfc3339_ms(s),
812        _ => Err(PolicyError::InvalidCondition(format!(
813            "timestamp expected (rfc3339 or ms epoch), got {v:?}"
814        ))),
815    }
816}
817
818/// Parse a tiny RFC 3339 grammar — `YYYY-MM-DDTHH:MM:SS[.fff]Z` or with
819/// `+HH:MM` / `-HH:MM` offsets. Pure stdlib: we convert to days-since-epoch
820/// using the civil-from-days algorithm and then to milliseconds.
821fn parse_rfc3339_ms(s: &str) -> Result<u128, PolicyError> {
822    let bad = || PolicyError::InvalidCondition(format!("not rfc3339: {s}"));
823    if s.len() < 20 {
824        return Err(bad());
825    }
826    let bytes = s.as_bytes();
827    if bytes[4] != b'-' || bytes[7] != b'-' || bytes[10] != b'T' {
828        return Err(bad());
829    }
830    let year: i64 = s[0..4].parse().map_err(|_| bad())?;
831    let month: u32 = s[5..7].parse().map_err(|_| bad())?;
832    let day: u32 = s[8..10].parse().map_err(|_| bad())?;
833    if bytes[13] != b':' || bytes[16] != b':' {
834        return Err(bad());
835    }
836    let hour: u64 = s[11..13].parse().map_err(|_| bad())?;
837    let minute: u64 = s[14..16].parse().map_err(|_| bad())?;
838    let second: u64 = s[17..19].parse().map_err(|_| bad())?;
839
840    // Optional fractional seconds.
841    let mut idx = 19;
842    let mut millis: u64 = 0;
843    if idx < bytes.len() && bytes[idx] == b'.' {
844        idx += 1;
845        let start = idx;
846        while idx < bytes.len() && bytes[idx].is_ascii_digit() {
847            idx += 1;
848        }
849        let frac = &s[start..idx];
850        if !frac.is_empty() {
851            // Only the first three digits contribute to milliseconds.
852            let take = frac.len().min(3);
853            let pad = "0".repeat(3 - take);
854            let combined = format!("{}{}", &frac[..take], pad);
855            millis = combined.parse().map_err(|_| bad())?;
856        }
857    }
858
859    // Trailing offset: `Z` or `±HH:MM`.
860    let mut offset_secs: i64 = 0;
861    if idx < bytes.len() {
862        match bytes[idx] {
863            b'Z' | b'z' => {
864                idx += 1;
865            }
866            b'+' | b'-' => {
867                if bytes.len() < idx + 6 || bytes[idx + 3] != b':' {
868                    return Err(bad());
869                }
870                let sign: i64 = if bytes[idx] == b'+' { 1 } else { -1 };
871                let oh: i64 = s[idx + 1..idx + 3].parse().map_err(|_| bad())?;
872                let om: i64 = s[idx + 4..idx + 6].parse().map_err(|_| bad())?;
873                offset_secs = sign * (oh * 3600 + om * 60);
874                idx += 6;
875            }
876            _ => return Err(bad()),
877        }
878    }
879    if idx != bytes.len() {
880        return Err(bad());
881    }
882
883    let days = days_from_civil(year, month as i64, day as i64);
884    let total_secs =
885        days * 86_400 + (hour as i64) * 3600 + (minute as i64) * 60 + second as i64 - offset_secs;
886    if total_secs < 0 {
887        return Err(bad());
888    }
889    Ok((total_secs as u128) * 1000 + millis as u128)
890}
891
892/// Howard Hinnant's `days_from_civil` — converts a proleptic Gregorian
893/// (Y, M, D) to days since 1970-01-01.
894fn days_from_civil(y: i64, m: i64, d: i64) -> i64 {
895    let y = if m <= 2 { y - 1 } else { y };
896    let era = if y >= 0 { y } else { y - 399 } / 400;
897    let yoe = y - era * 400; // [0, 399]
898    let doy = (153 * (if m > 2 { m - 3 } else { m + 9 }) + 2) / 5 + d - 1; // [0, 365]
899    let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy; // [0, 146096]
900    era * 146_097 + doe - 719_468
901}
902
903fn parse_hhmm(s: &str) -> Result<u16, PolicyError> {
904    let bad = || PolicyError::InvalidCondition(format!("HH:MM expected, got {s}"));
905    if s.len() != 5 || s.as_bytes()[2] != b':' {
906        return Err(bad());
907    }
908    let h: u16 = s[0..2].parse().map_err(|_| bad())?;
909    let m: u16 = s[3..5].parse().map_err(|_| bad())?;
910    if h >= 24 || m >= 60 {
911        return Err(bad());
912    }
913    Ok(h * 60 + m)
914}
915
916fn format_hhmm(min: u16) -> String {
917    format!("{:02}:{:02}", min / 60, min % 60)
918}
919
920fn parse_tz_offset(s: &str) -> Result<i32, PolicyError> {
921    if s == "UTC" || s == "Z" {
922        return Ok(0);
923    }
924    let bytes = s.as_bytes();
925    if bytes.len() == 6 && (bytes[0] == b'+' || bytes[0] == b'-') && bytes[3] == b':' {
926        let sign: i32 = if bytes[0] == b'+' { 1 } else { -1 };
927        let h: i32 = s[1..3]
928            .parse()
929            .map_err(|_| PolicyError::InvalidCondition(format!("bad tz: {s}")))?;
930        let m: i32 = s[4..6]
931            .parse()
932            .map_err(|_| PolicyError::InvalidCondition(format!("bad tz: {s}")))?;
933        return Ok(sign * (h * 3600 + m * 60));
934    }
935    Err(PolicyError::InvalidCondition(format!(
936        "tz must be UTC or +HH:MM/-HH:MM (got {s})"
937    )))
938}
939
940fn format_tz(secs: i32) -> String {
941    if secs == 0 {
942        return "UTC".into();
943    }
944    let sign = if secs >= 0 { '+' } else { '-' };
945    let abs = secs.abs();
946    format!("{}{:02}:{:02}", sign, abs / 3600, (abs % 3600) / 60)
947}
948
949// ---------------------------------------------------------------------------
950// CIDR helpers
951// ---------------------------------------------------------------------------
952
953fn parse_cidr(s: &str) -> Result<IpCidr, PolicyError> {
954    let (addr_s, prefix_s) = match s.split_once('/') {
955        Some(parts) => parts,
956        None => {
957            let addr =
958                IpAddr::from_str(s).map_err(|e| PolicyError::InvalidCidr(format!("{s}: {e}")))?;
959            let prefix_len = match addr {
960                IpAddr::V4(_) => 32,
961                IpAddr::V6(_) => 128,
962            };
963            return Ok(IpCidr { addr, prefix_len });
964        }
965    };
966    let addr =
967        IpAddr::from_str(addr_s).map_err(|e| PolicyError::InvalidCidr(format!("{s}: {e}")))?;
968    let prefix_len: u8 = prefix_s
969        .parse()
970        .map_err(|_| PolicyError::InvalidCidr(format!("bad prefix in {s}")))?;
971    let max = match addr {
972        IpAddr::V4(_) => 32,
973        IpAddr::V6(_) => 128,
974    };
975    if prefix_len > max {
976        return Err(PolicyError::InvalidCidr(format!("prefix > {max} in {s}")));
977    }
978    Ok(IpCidr { addr, prefix_len })
979}
980
981fn cidr_contains(cidr: &IpCidr, ip: IpAddr) -> bool {
982    match (cidr.addr, ip) {
983        (IpAddr::V4(net), IpAddr::V4(ip)) => {
984            let n = u32::from_be_bytes(net.octets());
985            let i = u32::from_be_bytes(ip.octets());
986            let mask = if cidr.prefix_len == 0 {
987                0u32
988            } else {
989                u32::MAX << (32 - cidr.prefix_len)
990            };
991            (n & mask) == (i & mask)
992        }
993        (IpAddr::V6(net), IpAddr::V6(ip)) => {
994            let n = u128::from_be_bytes(net.octets());
995            let i = u128::from_be_bytes(ip.octets());
996            let mask = if cidr.prefix_len == 0 {
997                0u128
998            } else {
999                u128::MAX << (128 - cidr.prefix_len)
1000            };
1001            (n & mask) == (i & mask)
1002        }
1003        _ => false, // v4 vs v6 never match
1004    }
1005}
1006
1007// ---------------------------------------------------------------------------
1008// Action / resource matching
1009// ---------------------------------------------------------------------------
1010
1011fn action_matches(pat: &ActionPattern, action: &str) -> bool {
1012    match pat {
1013        ActionPattern::Wildcard => true,
1014        ActionPattern::Exact(s) => s == action,
1015        ActionPattern::Prefix(p) => {
1016            // `admin:*` matches `admin:foo` but not `admin` and not `administer`.
1017            action.len() > p.len() + 1
1018                && action.starts_with(p.as_str())
1019                && action.as_bytes()[p.len()] == b':'
1020        }
1021    }
1022}
1023
1024/// Match a resource pattern against a concrete resource. Patterns that
1025/// don't include a tenant prefix (`tenant/...`) are implicitly scoped to
1026/// `ctx.current_tenant` so a policy author can write `table:public.foo`
1027/// without manually qualifying the tenant.
1028fn resource_matches(pat: &ResourcePattern, resource: &ResourceRef, ctx: &EvalContext) -> bool {
1029    let target = qualified_name(&resource.kind, &resource.name, resource.tenant.as_deref());
1030    match pat {
1031        ResourcePattern::Wildcard => true,
1032        ResourcePattern::Exact { kind, name } => {
1033            if kind != &resource.kind {
1034                return false;
1035            }
1036            let qualified = if name.starts_with("tenant/") {
1037                format!("{kind}:{name}")
1038            } else {
1039                qualified_name(kind, name, ctx.current_tenant.as_deref())
1040            };
1041            qualified == target
1042        }
1043        ResourcePattern::Glob(raw) => {
1044            let (pkind, pname) = match raw.split_once(':') {
1045                Some(parts) => parts,
1046                None => return false,
1047            };
1048            if !pkind.is_empty() && pkind != "*" && pkind != resource.kind {
1049                return false;
1050            }
1051            let qualified_pat = if pname.starts_with("tenant/") || pname == "*" {
1052                format!("{pkind}:{pname}")
1053            } else {
1054                let scoped = match ctx.current_tenant.as_deref() {
1055                    Some(t) => format!("tenant/{t}/{pname}"),
1056                    None => pname.to_string(),
1057                };
1058                format!("{pkind}:{scoped}")
1059            };
1060            let compiled = compile_glob(&qualified_pat);
1061            glob_matches(&compiled, &target)
1062        }
1063    }
1064}
1065
1066/// Build the canonical fully-qualified resource name. `tenant/<t>/...` is
1067/// prepended when a tenant is in scope; platform resources stay bare.
1068fn qualified_name(kind: &str, name: &str, tenant: Option<&str>) -> String {
1069    if name.starts_with("tenant/") {
1070        return format!("{kind}:{name}");
1071    }
1072    match tenant {
1073        Some(t) => format!("{kind}:tenant/{t}/{name}"),
1074        None => format!("{kind}:{name}"),
1075    }
1076}
1077
1078// ---------------------------------------------------------------------------
1079// Condition evaluator
1080// ---------------------------------------------------------------------------
1081
1082fn condition_holds(cond: Option<&Condition>, resource: &ResourceRef, ctx: &EvalContext) -> bool {
1083    let Some(c) = cond else { return true };
1084    if let Some(exp) = c.expires_at {
1085        if ctx.now_ms >= exp {
1086            return false;
1087        }
1088    }
1089    if let Some(vf) = c.valid_from {
1090        if ctx.now_ms < vf {
1091            return false;
1092        }
1093    }
1094    if let Some(true) = c.tenant_match {
1095        if resource.tenant.as_deref() != ctx.current_tenant.as_deref() {
1096            return false;
1097        }
1098    }
1099    if let Some(true) = c.mfa {
1100        if !ctx.mfa_present {
1101            return false;
1102        }
1103    }
1104    if let Some(want) = c.system_owned {
1105        if ctx.principal_is_system_owned != want {
1106            return false;
1107        }
1108    }
1109    if let Some(want) = c.platform_scoped {
1110        if ctx.principal_is_platform_scoped != want {
1111            return false;
1112        }
1113    }
1114    if let Some(cidrs) = &c.source_ip {
1115        let Some(ip) = ctx.peer_ip else {
1116            return false;
1117        };
1118        if !cidrs.iter().any(|c| cidr_contains(c, ip)) {
1119            return false;
1120        }
1121    }
1122    if let Some(tw) = &c.time_window {
1123        if !time_window_contains(tw, ctx.now_ms) {
1124            return false;
1125        }
1126    }
1127    true
1128}
1129
1130fn time_window_contains(tw: &TimeWindow, now_ms: u128) -> bool {
1131    // Convert ms-since-epoch to local minute-of-day.
1132    let now_secs = (now_ms / 1000) as i128 + tw.tz_offset_secs as i128;
1133    let day_secs = now_secs.rem_euclid(86_400);
1134    let minute = (day_secs / 60) as u16;
1135    if tw.from_minute <= tw.to_minute {
1136        minute >= tw.from_minute && minute <= tw.to_minute
1137    } else {
1138        // Wrap-around window: e.g. 22:00 .. 06:00
1139        minute >= tw.from_minute || minute <= tw.to_minute
1140    }
1141}
1142
1143// ---------------------------------------------------------------------------
1144// Evaluator + simulator
1145// ---------------------------------------------------------------------------
1146
1147/// Evaluate a request against an ordered list of policies. See the
1148/// module-level docs for the algorithm.
1149pub fn evaluate(
1150    policies: &[&Policy],
1151    action: &str,
1152    resource: &ResourceRef,
1153    ctx: &EvalContext,
1154) -> Decision {
1155    let mut allow_hit: Option<(String, Option<String>)> = None;
1156
1157    for p in policies {
1158        for st in &p.statements {
1159            if !condition_holds(st.condition.as_ref(), resource, ctx) {
1160                continue;
1161            }
1162            if !st.actions.iter().any(|a| action_matches(a, action)) {
1163                continue;
1164            }
1165            if !st
1166                .resources
1167                .iter()
1168                .any(|r| resource_matches(r, resource, ctx))
1169            {
1170                continue;
1171            }
1172            match st.effect {
1173                // Explicit Deny wins over everything, including admin
1174                // authority. This is what makes managed guardrails work.
1175                Effect::Deny => {
1176                    return Decision::Deny {
1177                        matched_policy_id: p.id.clone(),
1178                        matched_sid: st.sid.clone(),
1179                    };
1180                }
1181                Effect::Allow => {
1182                    if allow_hit.is_none() {
1183                        allow_hit = Some((p.id.clone(), st.sid.clone()));
1184                    }
1185                }
1186            }
1187        }
1188    }
1189
1190    // No explicit Deny matched. Admin authority is a policy-derived broad
1191    // allow that applies here, but it could never have overridden a Deny.
1192    if ctx.principal_is_admin_role {
1193        return Decision::AdminBypass;
1194    }
1195
1196    match allow_hit {
1197        Some((pid, sid)) => Decision::Allow {
1198            matched_policy_id: pid,
1199            matched_sid: sid,
1200        },
1201        None => Decision::DefaultDeny,
1202    }
1203}
1204
1205/// One row of a simulator trail.
1206#[derive(Debug, Clone, PartialEq)]
1207pub struct TrailEntry {
1208    pub policy_id: String,
1209    pub sid: Option<String>,
1210    pub matched: bool,
1211    pub effect: Effect,
1212    pub why_skipped: Option<&'static str>,
1213}
1214
1215/// Simulator output — a `Decision` plus a human-readable trail.
1216#[derive(Debug, Clone, PartialEq)]
1217pub struct SimulationOutcome {
1218    pub decision: Decision,
1219    pub reason: String,
1220    pub trail: Vec<TrailEntry>,
1221}
1222
1223/// Like `evaluate` but records every visited statement and produces a
1224/// human-readable explanation. Returns the same decision the evaluator
1225/// would have returned.
1226pub fn simulate(
1227    policies: &[&Policy],
1228    action: &str,
1229    resource: &ResourceRef,
1230    ctx: &EvalContext,
1231) -> SimulationOutcome {
1232    let mut trail = Vec::new();
1233    let mut allow_hit: Option<(String, Option<String>, usize)> = None;
1234    let mut deny_hit: Option<(String, Option<String>, usize)> = None;
1235
1236    'outer: for p in policies {
1237        for (idx, st) in p.statements.iter().enumerate() {
1238            let mut why: Option<&'static str> = None;
1239            let mut matched = false;
1240
1241            if !condition_holds(st.condition.as_ref(), resource, ctx) {
1242                why = Some("condition not met");
1243            } else if !st.actions.iter().any(|a| action_matches(a, action)) {
1244                why = Some("no action match");
1245            } else if !st
1246                .resources
1247                .iter()
1248                .any(|r| resource_matches(r, resource, ctx))
1249            {
1250                why = Some("no resource match");
1251            } else {
1252                matched = true;
1253            }
1254
1255            trail.push(TrailEntry {
1256                policy_id: p.id.clone(),
1257                sid: st.sid.clone(),
1258                matched,
1259                effect: st.effect,
1260                why_skipped: why,
1261            });
1262
1263            if matched {
1264                match st.effect {
1265                    Effect::Deny => {
1266                        deny_hit = Some((p.id.clone(), st.sid.clone(), idx));
1267                        break 'outer;
1268                    }
1269                    Effect::Allow => {
1270                        if allow_hit.is_none() {
1271                            allow_hit = Some((p.id.clone(), st.sid.clone(), idx));
1272                        }
1273                    }
1274                }
1275            }
1276        }
1277    }
1278
1279    if let Some((pid, sid, idx)) = deny_hit {
1280        let reason = format!(
1281            "deny at {}.statement[{}]{}",
1282            pid,
1283            idx,
1284            sid.as_ref()
1285                .map(|s| format!(" (sid={s})"))
1286                .unwrap_or_default()
1287        );
1288        return SimulationOutcome {
1289            decision: Decision::Deny {
1290                matched_policy_id: pid,
1291                matched_sid: sid,
1292            },
1293            reason,
1294            trail,
1295        };
1296    }
1297    // No explicit Deny matched. Admin authority is policy-first: it grants
1298    // a broad allow once we know no managed guardrail (Deny) applies.
1299    if ctx.principal_is_admin_role {
1300        return SimulationOutcome {
1301            decision: Decision::AdminBypass,
1302            reason: "admin allow: principal has Role::Admin (no explicit deny matched)".into(),
1303            trail,
1304        };
1305    }
1306    if let Some((pid, sid, idx)) = allow_hit {
1307        let reason = format!(
1308            "allow at {}.statement[{}]{}",
1309            pid,
1310            idx,
1311            sid.as_ref()
1312                .map(|s| format!(" (sid={s})"))
1313                .unwrap_or_default()
1314        );
1315        return SimulationOutcome {
1316            decision: Decision::Allow {
1317                matched_policy_id: pid,
1318                matched_sid: sid,
1319            },
1320            reason,
1321            trail,
1322        };
1323    }
1324    SimulationOutcome {
1325        decision: Decision::DefaultDeny,
1326        reason: "no statement matched (default deny)".into(),
1327        trail,
1328    }
1329}
1330
1331// ---------------------------------------------------------------------------
1332// JSON helpers
1333// ---------------------------------------------------------------------------
1334
1335fn string_field(obj: &Map<String, Value>, key: &str) -> Result<String, PolicyError> {
1336    obj.get(key)
1337        .and_then(|v| v.as_str())
1338        .map(|s| s.to_string())
1339        .ok_or_else(|| PolicyError::InvalidJson(format!("policy.{key} required string")))
1340}
1341
1342// ---------------------------------------------------------------------------
1343// Tests
1344// ---------------------------------------------------------------------------
1345
1346#[cfg(test)]
1347mod tests {
1348    use super::*;
1349
1350    fn minimal_policy_json() -> &'static str {
1351        r#"{
1352            "id": "p-min",
1353            "version": 1,
1354            "statements": [
1355                { "effect": "allow", "actions": ["select"], "resources": ["table:public.x"] }
1356            ]
1357        }"#
1358    }
1359
1360    fn full_policy_json() -> &'static str {
1361        r#"{
1362            "id": "p-full",
1363            "version": 1,
1364            "tenant": "acme",
1365            "created_at": 1700000000000,
1366            "updated_at": 1700000001000,
1367            "statements": [
1368                {
1369                    "sid": "s1",
1370                    "effect": "allow",
1371                    "actions": ["select", "insert"],
1372                    "resources": ["table:public.orders", "table:public.*"]
1373                },
1374                {
1375                    "sid": "s2",
1376                    "effect": "deny",
1377                    "actions": ["delete"],
1378                    "resources": ["*"]
1379                }
1380            ]
1381        }"#
1382    }
1383
1384    fn cond_policy_json() -> &'static str {
1385        r#"{
1386            "id": "p-cond",
1387            "version": 1,
1388            "statements": [
1389                {
1390                    "sid": "biz-hours",
1391                    "effect": "allow",
1392                    "actions": ["select"],
1393                    "resources": ["table:public.orders"],
1394                    "condition": {
1395                        "expires_at": "2099-12-31T23:59:59Z",
1396                        "valid_from": 1700000000000,
1397                        "tenant_match": true,
1398                        "source_ip": ["10.0.0.0/8"],
1399                        "mfa": true,
1400                        "time_window": { "from": "09:00", "to": "17:00", "tz": "UTC" }
1401                    }
1402                }
1403            ]
1404        }"#
1405    }
1406
1407    fn ctx_now(now_ms: u128) -> EvalContext {
1408        EvalContext {
1409            now_ms,
1410            ..Default::default()
1411        }
1412    }
1413
1414    // -----------------------------------------------------------------
1415    // JSON roundtrip
1416    // -----------------------------------------------------------------
1417
1418    #[test]
1419    fn roundtrip_minimal() {
1420        let p = Policy::from_json_str(minimal_policy_json()).unwrap();
1421        let s = p.to_json_string();
1422        let p2 = Policy::from_json_str(&s).unwrap();
1423        assert_eq!(p, p2);
1424        assert_eq!(p.id, "p-min");
1425        assert_eq!(p.statements.len(), 1);
1426    }
1427
1428    #[test]
1429    fn roundtrip_full() {
1430        let p = Policy::from_json_str(full_policy_json()).unwrap();
1431        let s = p.to_json_string();
1432        let p2 = Policy::from_json_str(&s).unwrap();
1433        assert_eq!(p, p2);
1434        assert_eq!(p.tenant.as_deref(), Some("acme"));
1435        assert_eq!(p.statements.len(), 2);
1436    }
1437
1438    #[test]
1439    fn roundtrip_principal_attribute_conditions() {
1440        let p = Policy::from_json_str(
1441            r#"{
1442                "id": "p-attrs",
1443                "version": 1,
1444                "statements": [{
1445                    "effect": "allow",
1446                    "actions": ["admin:reload"],
1447                    "resources": ["*"],
1448                    "condition": { "system_owned": true, "platform_scoped": false }
1449                }]
1450            }"#,
1451        )
1452        .unwrap();
1453        let c = p.statements[0].condition.as_ref().unwrap();
1454        assert_eq!(c.system_owned, Some(true));
1455        assert_eq!(c.platform_scoped, Some(false));
1456        let p2 = Policy::from_json_str(&p.to_json_string()).unwrap();
1457        assert_eq!(p, p2);
1458    }
1459
1460    #[test]
1461    fn condition_principal_attributes_gate_evaluation() {
1462        let p = Policy::from_json_str(
1463            r#"{
1464                "id": "p-sys",
1465                "version": 1,
1466                "statements": [{
1467                    "effect": "allow",
1468                    "actions": ["admin:reload"],
1469                    "resources": ["*"],
1470                    "condition": { "system_owned": true }
1471                }]
1472            }"#,
1473        )
1474        .unwrap();
1475        let r = ResourceRef::new("config", "global");
1476        let mut ctx = ctx_now(1_700_000_000_000);
1477        ctx.principal_is_system_owned = false;
1478        assert!(matches!(
1479            evaluate(&[&p], "admin:reload", &r, &ctx),
1480            Decision::DefaultDeny
1481        ));
1482        ctx.principal_is_system_owned = true;
1483        assert!(matches!(
1484            evaluate(&[&p], "admin:reload", &r, &ctx),
1485            Decision::Allow { .. }
1486        ));
1487    }
1488
1489    #[test]
1490    fn roundtrip_with_conditions() {
1491        let p = Policy::from_json_str(cond_policy_json()).unwrap();
1492        let s = p.to_json_string();
1493        let p2 = Policy::from_json_str(&s).unwrap();
1494        assert_eq!(p, p2);
1495        let c = p.statements[0].condition.as_ref().unwrap();
1496        assert!(c.expires_at.is_some());
1497        assert!(c.valid_from.is_some());
1498        assert_eq!(c.tenant_match, Some(true));
1499        assert_eq!(c.mfa, Some(true));
1500        let cidrs = c.source_ip.as_ref().unwrap();
1501        assert_eq!(cidrs.len(), 1);
1502        assert_eq!(cidrs[0].prefix_len, 8);
1503    }
1504
1505    // -----------------------------------------------------------------
1506    // Validator rejection classes
1507    // -----------------------------------------------------------------
1508
1509    #[test]
1510    fn validator_rejects_invalid_json() {
1511        let err = Policy::from_json_str("{ not json").unwrap_err();
1512        matches!(err, PolicyError::InvalidJson(_));
1513    }
1514
1515    #[test]
1516    fn validator_rejects_invalid_action() {
1517        let bad = r#"{
1518            "id":"p","version":1,"statements":[
1519                {"effect":"allow","actions":["bogus"],"resources":["table:public.x"]}
1520            ]}"#;
1521        let err = Policy::from_json_str(bad).unwrap_err();
1522        assert!(matches!(err, PolicyError::InvalidAction(_)));
1523    }
1524
1525    #[test]
1526    fn validator_rejects_per_verb_kv_actions_except_invalidate() {
1527        for action in [
1528            "kv:get",
1529            "kv:put",
1530            "kv:delete",
1531            "kv:incr",
1532            "kv:cas",
1533            "kv:watch",
1534        ] {
1535            let bad = format!(
1536                r#"{{
1537                    "id":"p","version":1,"statements":[
1538                        {{"effect":"allow","actions":["{action}"],"resources":["kv:sessions"]}}
1539                    ]}}"#
1540            );
1541            let err = Policy::from_json_str(&bad).unwrap_err();
1542            assert!(
1543                matches!(err, PolicyError::InvalidAction(ref invalid) if invalid == action),
1544                "expected {action} to be rejected, got {err:?}"
1545            );
1546        }
1547
1548        let allowed = r#"{
1549            "id":"p","version":1,"statements":[
1550                {"effect":"allow","actions":["kv:invalidate"],"resources":["kv:sessions"]}
1551            ]}"#;
1552        Policy::from_json_str(allowed).expect("kv:invalidate is the only per-KV verb action");
1553    }
1554
1555    #[test]
1556    fn validator_rejects_invalid_resource() {
1557        let bad = r#"{
1558            "id":"p","version":1,"statements":[
1559                {"effect":"allow","actions":["select"],"resources":["nokind"]}
1560            ]}"#;
1561        let err = Policy::from_json_str(bad).unwrap_err();
1562        assert!(matches!(err, PolicyError::InvalidResource(_)));
1563    }
1564
1565    #[test]
1566    fn validator_rejects_invalid_condition() {
1567        let bad = r#"{
1568            "id":"p","version":1,"statements":[
1569                {"effect":"allow","actions":["select"],"resources":["table:public.x"],
1570                 "condition":{"expires_at":{}}}
1571            ]}"#;
1572        let err = Policy::from_json_str(bad).unwrap_err();
1573        assert!(matches!(err, PolicyError::InvalidCondition(_)));
1574    }
1575
1576    #[test]
1577    fn validator_rejects_invalid_cidr() {
1578        let bad = r#"{
1579            "id":"p","version":1,"statements":[
1580                {"effect":"allow","actions":["select"],"resources":["table:public.x"],
1581                 "condition":{"source_ip":["10.0.0.0/99"]}}
1582            ]}"#;
1583        let err = Policy::from_json_str(bad).unwrap_err();
1584        assert!(matches!(err, PolicyError::InvalidCidr(_)));
1585    }
1586
1587    #[test]
1588    fn validator_rejects_duplicate_sid() {
1589        let bad = r#"{
1590            "id":"p","version":1,"statements":[
1591                {"sid":"x","effect":"allow","actions":["select"],"resources":["table:public.x"]},
1592                {"sid":"x","effect":"deny","actions":["delete"],"resources":["table:public.y"]}
1593            ]}"#;
1594        let err = Policy::from_json_str(bad).unwrap_err();
1595        assert!(matches!(err, PolicyError::DuplicateSid(_)));
1596    }
1597
1598    #[test]
1599    fn validator_rejects_empty_statements() {
1600        let bad = r#"{"id":"p","version":1,"statements":[]}"#;
1601        let err = Policy::from_json_str(bad).unwrap_err();
1602        assert!(matches!(err, PolicyError::EmptyStatements));
1603    }
1604
1605    #[test]
1606    fn validator_rejects_empty_actions() {
1607        let bad = r#"{
1608            "id":"p","version":1,"statements":[
1609                {"effect":"allow","actions":[],"resources":["table:public.x"]}
1610            ]}"#;
1611        let err = Policy::from_json_str(bad).unwrap_err();
1612        assert!(matches!(err, PolicyError::EmptyActions));
1613    }
1614
1615    #[test]
1616    fn validator_rejects_empty_resources() {
1617        let bad = r#"{
1618            "id":"p","version":1,"statements":[
1619                {"effect":"allow","actions":["select"],"resources":[]}
1620            ]}"#;
1621        let err = Policy::from_json_str(bad).unwrap_err();
1622        assert!(matches!(err, PolicyError::EmptyResources));
1623    }
1624
1625    #[test]
1626    fn validator_rejects_too_many_statements() {
1627        let mut p = Policy::from_json_str(minimal_policy_json()).unwrap();
1628        let st = p.statements[0].clone();
1629        for _ in 0..MAX_STATEMENTS {
1630            p.statements.push(st.clone());
1631        }
1632        let err = p.validate().unwrap_err();
1633        assert!(matches!(err, PolicyError::TooManyStatements(_)));
1634    }
1635
1636    #[test]
1637    fn validator_rejects_too_many_actions() {
1638        let mut p = Policy::from_json_str(minimal_policy_json()).unwrap();
1639        for _ in 0..MAX_ACTIONS {
1640            p.statements[0].actions.push(ActionPattern::Wildcard);
1641        }
1642        let err = p.validate().unwrap_err();
1643        assert!(matches!(err, PolicyError::TooManyActions(_)));
1644    }
1645
1646    #[test]
1647    fn validator_rejects_too_many_resources() {
1648        let mut p = Policy::from_json_str(minimal_policy_json()).unwrap();
1649        for _ in 0..MAX_RESOURCES {
1650            p.statements[0].resources.push(ResourcePattern::Wildcard);
1651        }
1652        let err = p.validate().unwrap_err();
1653        assert!(matches!(err, PolicyError::TooManyResources(_)));
1654    }
1655
1656    #[test]
1657    fn validator_rejects_oversize_json() {
1658        let big = "x".repeat(MAX_POLICY_BYTES + 1);
1659        let err = Policy::from_json_str(&big).unwrap_err();
1660        assert!(matches!(err, PolicyError::PolicyTooLarge(_)));
1661    }
1662
1663    // -----------------------------------------------------------------
1664    // Glob + action match
1665    // -----------------------------------------------------------------
1666
1667    #[test]
1668    fn glob_matches_table_public_star() {
1669        let pat = compile_glob("table:public.*");
1670        assert!(glob_matches(&pat, "table:public.orders"));
1671        assert!(glob_matches(&pat, "table:public."));
1672        assert!(!glob_matches(&pat, "table:other.x"));
1673    }
1674
1675    #[test]
1676    fn glob_matches_tenant_star() {
1677        let pat = compile_glob("tenant:acme/*");
1678        assert!(glob_matches(&pat, "tenant:acme/whatever"));
1679        assert!(glob_matches(&pat, "tenant:acme/a/b/c"));
1680        assert!(!glob_matches(&pat, "tenant:other/whatever"));
1681    }
1682
1683    #[test]
1684    fn action_match_exact() {
1685        assert!(action_matches(&compile_action("select"), "select"));
1686        assert!(!action_matches(&compile_action("select"), "selectall"));
1687        assert!(!action_matches(&compile_action("select"), "insert"));
1688    }
1689
1690    #[test]
1691    fn action_match_prefix() {
1692        let p = compile_action("admin:*");
1693        assert!(action_matches(&p, "admin:bootstrap"));
1694        assert!(action_matches(&p, "admin:reload"));
1695        assert!(!action_matches(&p, "admin"));
1696        assert!(!action_matches(&p, "select"));
1697    }
1698
1699    #[test]
1700    fn action_match_wildcard() {
1701        let p = compile_action("*");
1702        assert!(action_matches(&p, "select"));
1703        assert!(action_matches(&p, "admin:bootstrap"));
1704        assert!(action_matches(&p, "policy:put"));
1705    }
1706
1707    // -----------------------------------------------------------------
1708    // Conditions
1709    // -----------------------------------------------------------------
1710
1711    #[test]
1712    fn condition_expires_at() {
1713        let c = Condition {
1714            expires_at: Some(2_000),
1715            valid_from: None,
1716            tenant_match: None,
1717            source_ip: None,
1718            mfa: None,
1719            time_window: None,
1720            system_owned: None,
1721            platform_scoped: None,
1722        };
1723        let r = ResourceRef::new("table", "x");
1724        assert!(condition_holds(Some(&c), &r, &ctx_now(1_000)));
1725        assert!(!condition_holds(Some(&c), &r, &ctx_now(2_000)));
1726        assert!(!condition_holds(Some(&c), &r, &ctx_now(2_500)));
1727    }
1728
1729    #[test]
1730    fn condition_valid_from() {
1731        let c = Condition {
1732            expires_at: None,
1733            valid_from: Some(2_000),
1734            tenant_match: None,
1735            source_ip: None,
1736            mfa: None,
1737            time_window: None,
1738            system_owned: None,
1739            platform_scoped: None,
1740        };
1741        let r = ResourceRef::new("table", "x");
1742        assert!(!condition_holds(Some(&c), &r, &ctx_now(1_999)));
1743        assert!(condition_holds(Some(&c), &r, &ctx_now(2_000)));
1744        assert!(condition_holds(Some(&c), &r, &ctx_now(3_000)));
1745    }
1746
1747    #[test]
1748    fn condition_source_ip_v4() {
1749        let c = Condition {
1750            expires_at: None,
1751            valid_from: None,
1752            tenant_match: None,
1753            source_ip: Some(vec![parse_cidr("10.0.0.0/8").unwrap()]),
1754            mfa: None,
1755            time_window: None,
1756            system_owned: None,
1757            platform_scoped: None,
1758        };
1759        let r = ResourceRef::new("table", "x");
1760        let mut ctx = ctx_now(1);
1761        ctx.peer_ip = Some(IpAddr::from_str("10.0.0.1").unwrap());
1762        assert!(condition_holds(Some(&c), &r, &ctx));
1763        ctx.peer_ip = Some(IpAddr::from_str("11.0.0.1").unwrap());
1764        assert!(!condition_holds(Some(&c), &r, &ctx));
1765        ctx.peer_ip = None;
1766        assert!(!condition_holds(Some(&c), &r, &ctx));
1767    }
1768
1769    #[test]
1770    fn condition_source_ip_accepts_single_ip() {
1771        let cidr = parse_cidr("192.168.1.5").unwrap();
1772        assert_eq!(cidr.prefix_len, 32);
1773
1774        let c = Condition {
1775            expires_at: None,
1776            valid_from: None,
1777            tenant_match: None,
1778            source_ip: Some(vec![cidr]),
1779            mfa: None,
1780            time_window: None,
1781            system_owned: None,
1782            platform_scoped: None,
1783        };
1784        let r = ResourceRef::new("table", "public.x");
1785        let mut ctx = ctx_now(1);
1786        ctx.peer_ip = Some(IpAddr::from_str("192.168.1.5").unwrap());
1787        assert!(condition_holds(Some(&c), &r, &ctx));
1788        ctx.peer_ip = Some(IpAddr::from_str("192.168.1.6").unwrap());
1789        assert!(!condition_holds(Some(&c), &r, &ctx));
1790    }
1791
1792    #[test]
1793    fn condition_tenant_match() {
1794        let c = Condition {
1795            expires_at: None,
1796            valid_from: None,
1797            tenant_match: Some(true),
1798            source_ip: None,
1799            mfa: None,
1800            time_window: None,
1801            system_owned: None,
1802            platform_scoped: None,
1803        };
1804        let r = ResourceRef::new("table", "x").with_tenant("acme");
1805        let mut ctx = ctx_now(1);
1806        ctx.current_tenant = Some("acme".into());
1807        assert!(condition_holds(Some(&c), &r, &ctx));
1808        ctx.current_tenant = Some("globex".into());
1809        assert!(!condition_holds(Some(&c), &r, &ctx));
1810    }
1811
1812    #[test]
1813    fn condition_mfa() {
1814        let c = Condition {
1815            expires_at: None,
1816            valid_from: None,
1817            tenant_match: None,
1818            source_ip: None,
1819            mfa: Some(true),
1820            time_window: None,
1821            system_owned: None,
1822            platform_scoped: None,
1823        };
1824        let r = ResourceRef::new("table", "x");
1825        let mut ctx = ctx_now(1);
1826        ctx.mfa_present = true;
1827        assert!(condition_holds(Some(&c), &r, &ctx));
1828        ctx.mfa_present = false;
1829        assert!(!condition_holds(Some(&c), &r, &ctx));
1830    }
1831
1832    #[test]
1833    fn condition_time_window_normal() {
1834        // 09:00 .. 17:00 UTC. now = 1970-01-01T12:00:00Z = 12 * 3600 * 1000 ms.
1835        let tw = TimeWindow {
1836            from_minute: 9 * 60,
1837            to_minute: 17 * 60,
1838            tz_offset_secs: 0,
1839        };
1840        assert!(time_window_contains(&tw, 12 * 3_600_000));
1841        assert!(time_window_contains(&tw, 9 * 3_600_000));
1842        assert!(time_window_contains(&tw, 17 * 3_600_000));
1843        // 18:00 outside.
1844        assert!(!time_window_contains(&tw, 18 * 3_600_000));
1845        // 06:00 outside.
1846        assert!(!time_window_contains(&tw, 6 * 3_600_000));
1847    }
1848
1849    #[test]
1850    fn condition_time_window_wraparound() {
1851        // 22:00 .. 06:00 UTC.
1852        let tw = TimeWindow {
1853            from_minute: 22 * 60,
1854            to_minute: 6 * 60,
1855            tz_offset_secs: 0,
1856        };
1857        assert!(time_window_contains(&tw, 23 * 3_600_000));
1858        assert!(time_window_contains(&tw, 1 * 3_600_000));
1859        assert!(time_window_contains(&tw, 6 * 3_600_000));
1860        assert!(!time_window_contains(&tw, 12 * 3_600_000));
1861        assert!(!time_window_contains(&tw, 21 * 3_600_000));
1862    }
1863
1864    // -----------------------------------------------------------------
1865    // Evaluator
1866    // -----------------------------------------------------------------
1867
1868    fn analyst_policy() -> Policy {
1869        Policy::from_json_str(
1870            r#"{
1871                "id":"analyst","version":1,"statements":[
1872                    {"sid":"reads","effect":"allow",
1873                     "actions":["select"],"resources":["table:public.orders"]}
1874                ]}"#,
1875        )
1876        .unwrap()
1877    }
1878
1879    fn no_deletes_policy() -> Policy {
1880        Policy::from_json_str(
1881            r#"{
1882                "id":"no-deletes","version":1,"statements":[
1883                    {"sid":"hard-stop","effect":"deny",
1884                     "actions":["delete"],"resources":["*"]}
1885                ]}"#,
1886        )
1887        .unwrap()
1888    }
1889
1890    #[test]
1891    fn evaluator_pure_allow() {
1892        let p = analyst_policy();
1893        let r = ResourceRef::new("table", "public.orders");
1894        let d = evaluate(&[&p], "select", &r, &EvalContext::default());
1895        match d {
1896            Decision::Allow {
1897                matched_policy_id,
1898                matched_sid,
1899            } => {
1900                assert_eq!(matched_policy_id, "analyst");
1901                assert_eq!(matched_sid.as_deref(), Some("reads"));
1902            }
1903            other => panic!("expected Allow, got {other:?}"),
1904        }
1905    }
1906
1907    #[test]
1908    fn evaluator_deny_overrides_allow() {
1909        let allow = analyst_policy();
1910        let deny = no_deletes_policy();
1911        let r = ResourceRef::new("table", "public.orders");
1912        // Allow says nothing about delete; deny matches.
1913        let d = evaluate(&[&allow, &deny], "delete", &r, &EvalContext::default());
1914        match d {
1915            Decision::Deny {
1916                matched_policy_id, ..
1917            } => {
1918                assert_eq!(matched_policy_id, "no-deletes");
1919            }
1920            other => panic!("expected Deny, got {other:?}"),
1921        }
1922    }
1923
1924    #[test]
1925    fn evaluator_default_deny() {
1926        let p = analyst_policy();
1927        let r = ResourceRef::new("table", "public.invoices");
1928        let d = evaluate(&[&p], "select", &r, &EvalContext::default());
1929        assert_eq!(d, Decision::DefaultDeny);
1930    }
1931
1932    #[test]
1933    fn evaluator_admin_allow_when_no_deny() {
1934        // Admin authority is a policy-derived broad allow: with no
1935        // matching statement and no explicit Deny, the admin is allowed.
1936        let p = analyst_policy();
1937        let r = ResourceRef::new("table", "anything");
1938        let mut ctx = EvalContext::default();
1939        ctx.principal_is_admin_role = true;
1940        let d = evaluate(&[&p], "delete", &r, &ctx);
1941        assert_eq!(d, Decision::AdminBypass);
1942    }
1943
1944    #[test]
1945    fn evaluator_admin_does_not_bypass_explicit_deny() {
1946        // An explicit Deny is a managed guardrail and must win even for an
1947        // admin principal.
1948        let allow = analyst_policy();
1949        let deny = no_deletes_policy();
1950        let r = ResourceRef::new("table", "public.orders");
1951        let mut ctx = EvalContext::default();
1952        ctx.principal_is_admin_role = true;
1953        let d = evaluate(&[&allow, &deny], "delete", &r, &ctx);
1954        match d {
1955            Decision::Deny {
1956                matched_policy_id, ..
1957            } => assert_eq!(matched_policy_id, "no-deletes"),
1958            other => panic!("expected Deny for admin, got {other:?}"),
1959        }
1960    }
1961
1962    #[test]
1963    fn evaluator_implicit_tenant_scoping() {
1964        // Pattern `table:public.x` written without tenant prefix should
1965        // implicitly bind to ctx.current_tenant — so a request against
1966        // tenant `acme` matches but a request against tenant `globex`
1967        // does not (when the policy is evaluated with current_tenant=acme).
1968        let p = Policy::from_json_str(
1969            r#"{
1970                "id":"impl","version":1,"statements":[
1971                    {"sid":"s","effect":"allow",
1972                     "actions":["select"],"resources":["table:public.x"]}
1973                ]}"#,
1974        )
1975        .unwrap();
1976        let r_acme = ResourceRef::new("table", "public.x").with_tenant("acme");
1977        let r_globex = ResourceRef::new("table", "public.x").with_tenant("globex");
1978        let mut ctx = EvalContext::default();
1979        ctx.current_tenant = Some("acme".into());
1980        assert!(matches!(
1981            evaluate(&[&p], "select", &r_acme, &ctx),
1982            Decision::Allow { .. }
1983        ));
1984        assert_eq!(
1985            evaluate(&[&p], "select", &r_globex, &ctx),
1986            Decision::DefaultDeny
1987        );
1988    }
1989
1990    // -----------------------------------------------------------------
1991    // Simulator
1992    // -----------------------------------------------------------------
1993
1994    #[test]
1995    fn simulator_produces_trail() {
1996        let allow = analyst_policy();
1997        let deny = no_deletes_policy();
1998        let r = ResourceRef::new("table", "public.orders");
1999        let out = simulate(&[&allow, &deny], "delete", &r, &EvalContext::default());
2000        // Two policies, each with one statement → at least one trail
2001        // entry per statement.
2002        assert!(out.trail.len() >= 2);
2003        assert!(matches!(out.decision, Decision::Deny { .. }));
2004        assert!(out.reason.contains("deny"));
2005    }
2006
2007    // -----------------------------------------------------------------
2008    // Misc helpers
2009    // -----------------------------------------------------------------
2010
2011    #[test]
2012    fn rfc3339_parses_to_ms() {
2013        let ms = parse_rfc3339_ms("1970-01-01T00:00:00Z").unwrap();
2014        assert_eq!(ms, 0);
2015        let ms = parse_rfc3339_ms("1970-01-01T00:00:01.500Z").unwrap();
2016        assert_eq!(ms, 1_500);
2017        let ms = parse_rfc3339_ms("2024-01-01T00:00:00+00:00").unwrap();
2018        // 2024-01-01 = 19723 days after epoch.
2019        assert_eq!(ms, 19_723u128 * 86_400_000);
2020    }
2021
2022    #[test]
2023    fn rfc3339_handles_negative_offset() {
2024        // 2024-01-01T01:00:00+01:00 == 2024-01-01T00:00:00Z
2025        let a = parse_rfc3339_ms("2024-01-01T01:00:00+01:00").unwrap();
2026        let b = parse_rfc3339_ms("2024-01-01T00:00:00Z").unwrap();
2027        assert_eq!(a, b);
2028    }
2029
2030    #[test]
2031    fn cidr_v6_basic() {
2032        let c = parse_cidr("::1/128").unwrap();
2033        assert_eq!(c.prefix_len, 128);
2034        assert!(cidr_contains(&c, IpAddr::from_str("::1").unwrap()));
2035        assert!(!cidr_contains(&c, IpAddr::from_str("::2").unwrap()));
2036    }
2037}