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