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::Number(n) if *n >= 0.0 => Ok(*n as u128),
772        Value::String(s) => parse_rfc3339_ms(s),
773        _ => Err(PolicyError::InvalidCondition(format!(
774            "timestamp expected (rfc3339 or ms epoch), got {v:?}"
775        ))),
776    }
777}
778
779/// Parse a tiny RFC 3339 grammar — `YYYY-MM-DDTHH:MM:SS[.fff]Z` or with
780/// `+HH:MM` / `-HH:MM` offsets. Pure stdlib: we convert to days-since-epoch
781/// using the civil-from-days algorithm and then to milliseconds.
782fn parse_rfc3339_ms(s: &str) -> Result<u128, PolicyError> {
783    let bad = || PolicyError::InvalidCondition(format!("not rfc3339: {s}"));
784    if s.len() < 20 {
785        return Err(bad());
786    }
787    let bytes = s.as_bytes();
788    if bytes[4] != b'-' || bytes[7] != b'-' || bytes[10] != b'T' {
789        return Err(bad());
790    }
791    let year: i64 = s[0..4].parse().map_err(|_| bad())?;
792    let month: u32 = s[5..7].parse().map_err(|_| bad())?;
793    let day: u32 = s[8..10].parse().map_err(|_| bad())?;
794    if bytes[13] != b':' || bytes[16] != b':' {
795        return Err(bad());
796    }
797    let hour: u64 = s[11..13].parse().map_err(|_| bad())?;
798    let minute: u64 = s[14..16].parse().map_err(|_| bad())?;
799    let second: u64 = s[17..19].parse().map_err(|_| bad())?;
800
801    // Optional fractional seconds.
802    let mut idx = 19;
803    let mut millis: u64 = 0;
804    if idx < bytes.len() && bytes[idx] == b'.' {
805        idx += 1;
806        let start = idx;
807        while idx < bytes.len() && bytes[idx].is_ascii_digit() {
808            idx += 1;
809        }
810        let frac = &s[start..idx];
811        if !frac.is_empty() {
812            // Only the first three digits contribute to milliseconds.
813            let take = frac.len().min(3);
814            let pad = "0".repeat(3 - take);
815            let combined = format!("{}{}", &frac[..take], pad);
816            millis = combined.parse().map_err(|_| bad())?;
817        }
818    }
819
820    // Trailing offset: `Z` or `±HH:MM`.
821    let mut offset_secs: i64 = 0;
822    if idx < bytes.len() {
823        match bytes[idx] {
824            b'Z' | b'z' => {
825                idx += 1;
826            }
827            b'+' | b'-' => {
828                if bytes.len() < idx + 6 || bytes[idx + 3] != b':' {
829                    return Err(bad());
830                }
831                let sign: i64 = if bytes[idx] == b'+' { 1 } else { -1 };
832                let oh: i64 = s[idx + 1..idx + 3].parse().map_err(|_| bad())?;
833                let om: i64 = s[idx + 4..idx + 6].parse().map_err(|_| bad())?;
834                offset_secs = sign * (oh * 3600 + om * 60);
835                idx += 6;
836            }
837            _ => return Err(bad()),
838        }
839    }
840    if idx != bytes.len() {
841        return Err(bad());
842    }
843
844    let days = days_from_civil(year, month as i64, day as i64);
845    let total_secs =
846        days * 86_400 + (hour as i64) * 3600 + (minute as i64) * 60 + second as i64 - offset_secs;
847    if total_secs < 0 {
848        return Err(bad());
849    }
850    Ok((total_secs as u128) * 1000 + millis as u128)
851}
852
853/// Howard Hinnant's `days_from_civil` — converts a proleptic Gregorian
854/// (Y, M, D) to days since 1970-01-01.
855fn days_from_civil(y: i64, m: i64, d: i64) -> i64 {
856    let y = if m <= 2 { y - 1 } else { y };
857    let era = if y >= 0 { y } else { y - 399 } / 400;
858    let yoe = y - era * 400; // [0, 399]
859    let doy = (153 * (if m > 2 { m - 3 } else { m + 9 }) + 2) / 5 + d - 1; // [0, 365]
860    let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy; // [0, 146096]
861    era * 146_097 + doe - 719_468
862}
863
864fn parse_hhmm(s: &str) -> Result<u16, PolicyError> {
865    let bad = || PolicyError::InvalidCondition(format!("HH:MM expected, got {s}"));
866    if s.len() != 5 || s.as_bytes()[2] != b':' {
867        return Err(bad());
868    }
869    let h: u16 = s[0..2].parse().map_err(|_| bad())?;
870    let m: u16 = s[3..5].parse().map_err(|_| bad())?;
871    if h >= 24 || m >= 60 {
872        return Err(bad());
873    }
874    Ok(h * 60 + m)
875}
876
877fn format_hhmm(min: u16) -> String {
878    format!("{:02}:{:02}", min / 60, min % 60)
879}
880
881fn parse_tz_offset(s: &str) -> Result<i32, PolicyError> {
882    if s == "UTC" || s == "Z" {
883        return Ok(0);
884    }
885    let bytes = s.as_bytes();
886    if bytes.len() == 6 && (bytes[0] == b'+' || bytes[0] == b'-') && bytes[3] == b':' {
887        let sign: i32 = if bytes[0] == b'+' { 1 } else { -1 };
888        let h: i32 = s[1..3]
889            .parse()
890            .map_err(|_| PolicyError::InvalidCondition(format!("bad tz: {s}")))?;
891        let m: i32 = s[4..6]
892            .parse()
893            .map_err(|_| PolicyError::InvalidCondition(format!("bad tz: {s}")))?;
894        return Ok(sign * (h * 3600 + m * 60));
895    }
896    Err(PolicyError::InvalidCondition(format!(
897        "tz must be UTC or +HH:MM/-HH:MM (got {s})"
898    )))
899}
900
901fn format_tz(secs: i32) -> String {
902    if secs == 0 {
903        return "UTC".into();
904    }
905    let sign = if secs >= 0 { '+' } else { '-' };
906    let abs = secs.abs();
907    format!("{}{:02}:{:02}", sign, abs / 3600, (abs % 3600) / 60)
908}
909
910// ---------------------------------------------------------------------------
911// CIDR helpers
912// ---------------------------------------------------------------------------
913
914fn parse_cidr(s: &str) -> Result<IpCidr, PolicyError> {
915    let (addr_s, prefix_s) = match s.split_once('/') {
916        Some(parts) => parts,
917        None => {
918            let addr =
919                IpAddr::from_str(s).map_err(|e| PolicyError::InvalidCidr(format!("{s}: {e}")))?;
920            let prefix_len = match addr {
921                IpAddr::V4(_) => 32,
922                IpAddr::V6(_) => 128,
923            };
924            return Ok(IpCidr { addr, prefix_len });
925        }
926    };
927    let addr =
928        IpAddr::from_str(addr_s).map_err(|e| PolicyError::InvalidCidr(format!("{s}: {e}")))?;
929    let prefix_len: u8 = prefix_s
930        .parse()
931        .map_err(|_| PolicyError::InvalidCidr(format!("bad prefix in {s}")))?;
932    let max = match addr {
933        IpAddr::V4(_) => 32,
934        IpAddr::V6(_) => 128,
935    };
936    if prefix_len > max {
937        return Err(PolicyError::InvalidCidr(format!("prefix > {max} in {s}")));
938    }
939    Ok(IpCidr { addr, prefix_len })
940}
941
942fn cidr_contains(cidr: &IpCidr, ip: IpAddr) -> bool {
943    match (cidr.addr, ip) {
944        (IpAddr::V4(net), IpAddr::V4(ip)) => {
945            let n = u32::from_be_bytes(net.octets());
946            let i = u32::from_be_bytes(ip.octets());
947            let mask = if cidr.prefix_len == 0 {
948                0u32
949            } else {
950                u32::MAX << (32 - cidr.prefix_len)
951            };
952            (n & mask) == (i & mask)
953        }
954        (IpAddr::V6(net), IpAddr::V6(ip)) => {
955            let n = u128::from_be_bytes(net.octets());
956            let i = u128::from_be_bytes(ip.octets());
957            let mask = if cidr.prefix_len == 0 {
958                0u128
959            } else {
960                u128::MAX << (128 - cidr.prefix_len)
961            };
962            (n & mask) == (i & mask)
963        }
964        _ => false, // v4 vs v6 never match
965    }
966}
967
968// ---------------------------------------------------------------------------
969// Action / resource matching
970// ---------------------------------------------------------------------------
971
972fn action_matches(pat: &ActionPattern, action: &str) -> bool {
973    match pat {
974        ActionPattern::Wildcard => true,
975        ActionPattern::Exact(s) => s == action,
976        ActionPattern::Prefix(p) => {
977            // `admin:*` matches `admin:foo` but not `admin` and not `administer`.
978            action.len() > p.len() + 1
979                && action.starts_with(p.as_str())
980                && action.as_bytes()[p.len()] == b':'
981        }
982    }
983}
984
985/// Match a resource pattern against a concrete resource. Patterns that
986/// don't include a tenant prefix (`tenant/...`) are implicitly scoped to
987/// `ctx.current_tenant` so a policy author can write `table:public.foo`
988/// without manually qualifying the tenant.
989fn resource_matches(pat: &ResourcePattern, resource: &ResourceRef, ctx: &EvalContext) -> bool {
990    let target = qualified_name(&resource.kind, &resource.name, resource.tenant.as_deref());
991    match pat {
992        ResourcePattern::Wildcard => true,
993        ResourcePattern::Exact { kind, name } => {
994            if kind != &resource.kind {
995                return false;
996            }
997            let qualified = if name.starts_with("tenant/") {
998                format!("{kind}:{name}")
999            } else {
1000                qualified_name(kind, name, ctx.current_tenant.as_deref())
1001            };
1002            qualified == target
1003        }
1004        ResourcePattern::Glob(raw) => {
1005            let (pkind, pname) = match raw.split_once(':') {
1006                Some(parts) => parts,
1007                None => return false,
1008            };
1009            if !pkind.is_empty() && pkind != "*" && pkind != resource.kind {
1010                return false;
1011            }
1012            let qualified_pat = if pname.starts_with("tenant/") || pname == "*" {
1013                format!("{pkind}:{pname}")
1014            } else {
1015                let scoped = match ctx.current_tenant.as_deref() {
1016                    Some(t) => format!("tenant/{t}/{pname}"),
1017                    None => pname.to_string(),
1018                };
1019                format!("{pkind}:{scoped}")
1020            };
1021            let compiled = compile_glob(&qualified_pat);
1022            glob_matches(&compiled, &target)
1023        }
1024    }
1025}
1026
1027/// Build the canonical fully-qualified resource name. `tenant/<t>/...` is
1028/// prepended when a tenant is in scope; platform resources stay bare.
1029fn qualified_name(kind: &str, name: &str, tenant: Option<&str>) -> String {
1030    if name.starts_with("tenant/") {
1031        return format!("{kind}:{name}");
1032    }
1033    match tenant {
1034        Some(t) => format!("{kind}:tenant/{t}/{name}"),
1035        None => format!("{kind}:{name}"),
1036    }
1037}
1038
1039// ---------------------------------------------------------------------------
1040// Condition evaluator
1041// ---------------------------------------------------------------------------
1042
1043fn condition_holds(cond: Option<&Condition>, resource: &ResourceRef, ctx: &EvalContext) -> bool {
1044    let Some(c) = cond else { return true };
1045    if let Some(exp) = c.expires_at {
1046        if ctx.now_ms >= exp {
1047            return false;
1048        }
1049    }
1050    if let Some(vf) = c.valid_from {
1051        if ctx.now_ms < vf {
1052            return false;
1053        }
1054    }
1055    if let Some(true) = c.tenant_match {
1056        if resource.tenant.as_deref() != ctx.current_tenant.as_deref() {
1057            return false;
1058        }
1059    }
1060    if let Some(true) = c.mfa {
1061        if !ctx.mfa_present {
1062            return false;
1063        }
1064    }
1065    if let Some(want) = c.platform_scoped {
1066        if ctx.principal_is_platform_scoped != want {
1067            return false;
1068        }
1069    }
1070    if let Some(cidrs) = &c.source_ip {
1071        let Some(ip) = ctx.peer_ip else {
1072            return false;
1073        };
1074        if !cidrs.iter().any(|c| cidr_contains(c, ip)) {
1075            return false;
1076        }
1077    }
1078    if let Some(tw) = &c.time_window {
1079        if !time_window_contains(tw, ctx.now_ms) {
1080            return false;
1081        }
1082    }
1083    true
1084}
1085
1086fn time_window_contains(tw: &TimeWindow, now_ms: u128) -> bool {
1087    // Convert ms-since-epoch to local minute-of-day.
1088    let now_secs = (now_ms / 1000) as i128 + tw.tz_offset_secs as i128;
1089    let day_secs = now_secs.rem_euclid(86_400);
1090    let minute = (day_secs / 60) as u16;
1091    if tw.from_minute <= tw.to_minute {
1092        minute >= tw.from_minute && minute <= tw.to_minute
1093    } else {
1094        // Wrap-around window: e.g. 22:00 .. 06:00
1095        minute >= tw.from_minute || minute <= tw.to_minute
1096    }
1097}
1098
1099// ---------------------------------------------------------------------------
1100// Evaluator + simulator
1101// ---------------------------------------------------------------------------
1102
1103/// Evaluate a request against an ordered list of policies. See the
1104/// module-level docs for the algorithm.
1105pub fn evaluate(
1106    policies: &[&Policy],
1107    action: &str,
1108    resource: &ResourceRef,
1109    ctx: &EvalContext,
1110) -> Decision {
1111    let mut allow_hit: Option<(String, Option<String>)> = None;
1112
1113    for p in policies {
1114        for st in &p.statements {
1115            if !condition_holds(st.condition.as_ref(), resource, ctx) {
1116                continue;
1117            }
1118            if !st.actions.iter().any(|a| action_matches(a, action)) {
1119                continue;
1120            }
1121            if !st
1122                .resources
1123                .iter()
1124                .any(|r| resource_matches(r, resource, ctx))
1125            {
1126                continue;
1127            }
1128            match st.effect {
1129                // Explicit Deny wins over everything, including admin
1130                // authority. This is what makes managed guardrails work.
1131                Effect::Deny => {
1132                    return Decision::Deny {
1133                        matched_policy_id: p.id.clone(),
1134                        matched_sid: st.sid.clone(),
1135                    };
1136                }
1137                Effect::Allow => {
1138                    if allow_hit.is_none() {
1139                        allow_hit = Some((p.id.clone(), st.sid.clone()));
1140                    }
1141                }
1142            }
1143        }
1144    }
1145
1146    // No explicit Deny matched. Admin authority is no longer an evaluator-level
1147    // bypass: admin principals fall through to the same allow/default-deny arm
1148    // as every other principal. The operational backstop is the bootstrap
1149    // preset attaching an allow-all policy to the first admin user.
1150    let _ = ctx.principal_is_admin_role;
1151
1152    match allow_hit {
1153        Some((pid, sid)) => Decision::Allow {
1154            matched_policy_id: pid,
1155            matched_sid: sid,
1156        },
1157        None => Decision::DefaultDeny,
1158    }
1159}
1160
1161/// One row of a simulator trail.
1162#[derive(Debug, Clone, PartialEq)]
1163pub struct TrailEntry {
1164    pub policy_id: String,
1165    pub sid: Option<String>,
1166    pub matched: bool,
1167    pub effect: Effect,
1168    pub why_skipped: Option<&'static str>,
1169}
1170
1171/// Simulator output — a `Decision` plus a human-readable trail.
1172#[derive(Debug, Clone, PartialEq)]
1173pub struct SimulationOutcome {
1174    pub decision: Decision,
1175    pub reason: String,
1176    pub trail: Vec<TrailEntry>,
1177}
1178
1179/// Like `evaluate` but records every visited statement and produces a
1180/// human-readable explanation. Returns the same decision the evaluator
1181/// would have returned.
1182pub fn simulate(
1183    policies: &[&Policy],
1184    action: &str,
1185    resource: &ResourceRef,
1186    ctx: &EvalContext,
1187) -> SimulationOutcome {
1188    let mut trail = Vec::new();
1189    let mut allow_hit: Option<(String, Option<String>, usize)> = None;
1190    let mut deny_hit: Option<(String, Option<String>, usize)> = None;
1191
1192    'outer: for p in policies {
1193        for (idx, st) in p.statements.iter().enumerate() {
1194            let mut why: Option<&'static str> = None;
1195            let mut matched = false;
1196
1197            if !condition_holds(st.condition.as_ref(), resource, ctx) {
1198                why = Some("condition not met");
1199            } else if !st.actions.iter().any(|a| action_matches(a, action)) {
1200                why = Some("no action match");
1201            } else if !st
1202                .resources
1203                .iter()
1204                .any(|r| resource_matches(r, resource, ctx))
1205            {
1206                why = Some("no resource match");
1207            } else {
1208                matched = true;
1209            }
1210
1211            trail.push(TrailEntry {
1212                policy_id: p.id.clone(),
1213                sid: st.sid.clone(),
1214                matched,
1215                effect: st.effect,
1216                why_skipped: why,
1217            });
1218
1219            if matched {
1220                match st.effect {
1221                    Effect::Deny => {
1222                        deny_hit = Some((p.id.clone(), st.sid.clone(), idx));
1223                        break 'outer;
1224                    }
1225                    Effect::Allow => {
1226                        if allow_hit.is_none() {
1227                            allow_hit = Some((p.id.clone(), st.sid.clone(), idx));
1228                        }
1229                    }
1230                }
1231            }
1232        }
1233    }
1234
1235    if let Some((pid, sid, idx)) = deny_hit {
1236        let reason = format!(
1237            "deny at {}.statement[{}]{}",
1238            pid,
1239            idx,
1240            sid.as_ref()
1241                .map(|s| format!(" (sid={s})"))
1242                .unwrap_or_default()
1243        );
1244        return SimulationOutcome {
1245            decision: Decision::Deny {
1246                matched_policy_id: pid,
1247                matched_sid: sid,
1248            },
1249            reason,
1250            trail,
1251        };
1252    }
1253    // No explicit Deny matched. Admin principals no longer get an
1254    // evaluator-level bypass — they fall through to the same allow/default-deny
1255    // arm as every other principal (see the parallel comment in `evaluate`).
1256    let _ = ctx.principal_is_admin_role;
1257    if let Some((pid, sid, idx)) = allow_hit {
1258        let reason = format!(
1259            "allow at {}.statement[{}]{}",
1260            pid,
1261            idx,
1262            sid.as_ref()
1263                .map(|s| format!(" (sid={s})"))
1264                .unwrap_or_default()
1265        );
1266        return SimulationOutcome {
1267            decision: Decision::Allow {
1268                matched_policy_id: pid,
1269                matched_sid: sid,
1270            },
1271            reason,
1272            trail,
1273        };
1274    }
1275    SimulationOutcome {
1276        decision: Decision::DefaultDeny,
1277        reason: "no statement matched (default deny)".into(),
1278        trail,
1279    }
1280}
1281
1282// ---------------------------------------------------------------------------
1283// JSON helpers
1284// ---------------------------------------------------------------------------
1285
1286fn string_field(obj: &Map<String, Value>, key: &str) -> Result<String, PolicyError> {
1287    obj.get(key)
1288        .and_then(|v| v.as_str())
1289        .map(|s| s.to_string())
1290        .ok_or_else(|| PolicyError::InvalidJson(format!("policy.{key} required string")))
1291}
1292
1293// ---------------------------------------------------------------------------
1294// Tests
1295// ---------------------------------------------------------------------------
1296
1297#[cfg(test)]
1298mod tests {
1299    use super::*;
1300
1301    fn minimal_policy_json() -> &'static str {
1302        r#"{
1303            "id": "p-min",
1304            "version": 1,
1305            "statements": [
1306                { "effect": "allow", "actions": ["select"], "resources": ["table:public.x"] }
1307            ]
1308        }"#
1309    }
1310
1311    fn full_policy_json() -> &'static str {
1312        r#"{
1313            "id": "p-full",
1314            "version": 1,
1315            "tenant": "acme",
1316            "created_at": 1700000000000,
1317            "updated_at": 1700000001000,
1318            "statements": [
1319                {
1320                    "sid": "s1",
1321                    "effect": "allow",
1322                    "actions": ["select", "insert"],
1323                    "resources": ["table:public.orders", "table:public.*"]
1324                },
1325                {
1326                    "sid": "s2",
1327                    "effect": "deny",
1328                    "actions": ["delete"],
1329                    "resources": ["*"]
1330                }
1331            ]
1332        }"#
1333    }
1334
1335    fn cond_policy_json() -> &'static str {
1336        r#"{
1337            "id": "p-cond",
1338            "version": 1,
1339            "statements": [
1340                {
1341                    "sid": "biz-hours",
1342                    "effect": "allow",
1343                    "actions": ["select"],
1344                    "resources": ["table:public.orders"],
1345                    "condition": {
1346                        "expires_at": "2099-12-31T23:59:59Z",
1347                        "valid_from": 1700000000000,
1348                        "tenant_match": true,
1349                        "source_ip": ["10.0.0.0/8"],
1350                        "mfa": true,
1351                        "time_window": { "from": "09:00", "to": "17:00", "tz": "UTC" }
1352                    }
1353                }
1354            ]
1355        }"#
1356    }
1357
1358    fn ctx_now(now_ms: u128) -> EvalContext {
1359        EvalContext {
1360            now_ms,
1361            ..Default::default()
1362        }
1363    }
1364
1365    // -----------------------------------------------------------------
1366    // JSON roundtrip
1367    // -----------------------------------------------------------------
1368
1369    #[test]
1370    fn roundtrip_minimal() {
1371        let p = Policy::from_json_str(minimal_policy_json()).unwrap();
1372        let s = p.to_json_string();
1373        let p2 = Policy::from_json_str(&s).unwrap();
1374        assert_eq!(p, p2);
1375        assert_eq!(p.id, "p-min");
1376        assert_eq!(p.statements.len(), 1);
1377    }
1378
1379    #[test]
1380    fn roundtrip_full() {
1381        let p = Policy::from_json_str(full_policy_json()).unwrap();
1382        let s = p.to_json_string();
1383        let p2 = Policy::from_json_str(&s).unwrap();
1384        assert_eq!(p, p2);
1385        assert_eq!(p.tenant.as_deref(), Some("acme"));
1386        assert_eq!(p.statements.len(), 2);
1387    }
1388
1389    #[test]
1390    fn roundtrip_platform_scoped_condition() {
1391        let p = Policy::from_json_str(
1392            r#"{
1393                "id": "p-attrs",
1394                "version": 1,
1395                "statements": [{
1396                    "effect": "allow",
1397                    "actions": ["admin:reload"],
1398                    "resources": ["*"],
1399                    "condition": { "platform_scoped": false }
1400                }]
1401            }"#,
1402        )
1403        .unwrap();
1404        let c = p.statements[0].condition.as_ref().unwrap();
1405        assert_eq!(c.platform_scoped, Some(false));
1406        let p2 = Policy::from_json_str(&p.to_json_string()).unwrap();
1407        assert_eq!(p, p2);
1408    }
1409
1410    #[test]
1411    fn system_owned_condition_is_rejected() {
1412        let err = Policy::from_json_str(
1413            r#"{
1414                "id": "p-sys",
1415                "version": 1,
1416                "statements": [{
1417                    "effect": "allow",
1418                    "actions": ["admin:reload"],
1419                    "resources": ["*"],
1420                    "condition": { "system_owned": true }
1421                }]
1422            }"#,
1423        )
1424        .unwrap_err();
1425        assert!(matches!(err, PolicyError::InvalidCondition(_)));
1426    }
1427
1428    #[test]
1429    fn roundtrip_with_conditions() {
1430        let p = Policy::from_json_str(cond_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        let c = p.statements[0].condition.as_ref().unwrap();
1435        assert!(c.expires_at.is_some());
1436        assert!(c.valid_from.is_some());
1437        assert_eq!(c.tenant_match, Some(true));
1438        assert_eq!(c.mfa, Some(true));
1439        let cidrs = c.source_ip.as_ref().unwrap();
1440        assert_eq!(cidrs.len(), 1);
1441        assert_eq!(cidrs[0].prefix_len, 8);
1442    }
1443
1444    // -----------------------------------------------------------------
1445    // Validator rejection classes
1446    // -----------------------------------------------------------------
1447
1448    #[test]
1449    fn validator_rejects_invalid_json() {
1450        let err = Policy::from_json_str("{ not json").unwrap_err();
1451        matches!(err, PolicyError::InvalidJson(_));
1452    }
1453
1454    #[test]
1455    fn validator_rejects_invalid_action() {
1456        let bad = r#"{
1457            "id":"p","version":1,"statements":[
1458                {"effect":"allow","actions":["bogus"],"resources":["table:public.x"]}
1459            ]}"#;
1460        let err = Policy::from_json_str(bad).unwrap_err();
1461        assert!(matches!(err, PolicyError::InvalidAction(_)));
1462    }
1463
1464    #[test]
1465    fn validator_rejects_per_verb_kv_actions_except_invalidate() {
1466        for action in [
1467            "kv:get",
1468            "kv:put",
1469            "kv:delete",
1470            "kv:incr",
1471            "kv:cas",
1472            "kv:watch",
1473        ] {
1474            let bad = format!(
1475                r#"{{
1476                    "id":"p","version":1,"statements":[
1477                        {{"effect":"allow","actions":["{action}"],"resources":["kv:sessions"]}}
1478                    ]}}"#
1479            );
1480            let err = Policy::from_json_str(&bad).unwrap_err();
1481            assert!(
1482                matches!(err, PolicyError::InvalidAction(ref invalid) if invalid == action),
1483                "expected {action} to be rejected, got {err:?}"
1484            );
1485        }
1486
1487        let allowed = r#"{
1488            "id":"p","version":1,"statements":[
1489                {"effect":"allow","actions":["kv:invalidate"],"resources":["kv:sessions"]}
1490            ]}"#;
1491        Policy::from_json_str(allowed).expect("kv:invalidate is the only per-KV verb action");
1492    }
1493
1494    #[test]
1495    fn validator_rejects_invalid_resource() {
1496        let bad = r#"{
1497            "id":"p","version":1,"statements":[
1498                {"effect":"allow","actions":["select"],"resources":["nokind"]}
1499            ]}"#;
1500        let err = Policy::from_json_str(bad).unwrap_err();
1501        assert!(matches!(err, PolicyError::InvalidResource(_)));
1502    }
1503
1504    #[test]
1505    fn validator_rejects_invalid_condition() {
1506        let bad = r#"{
1507            "id":"p","version":1,"statements":[
1508                {"effect":"allow","actions":["select"],"resources":["table:public.x"],
1509                 "condition":{"expires_at":{}}}
1510            ]}"#;
1511        let err = Policy::from_json_str(bad).unwrap_err();
1512        assert!(matches!(err, PolicyError::InvalidCondition(_)));
1513    }
1514
1515    #[test]
1516    fn validator_rejects_invalid_cidr() {
1517        let bad = r#"{
1518            "id":"p","version":1,"statements":[
1519                {"effect":"allow","actions":["select"],"resources":["table:public.x"],
1520                 "condition":{"source_ip":["10.0.0.0/99"]}}
1521            ]}"#;
1522        let err = Policy::from_json_str(bad).unwrap_err();
1523        assert!(matches!(err, PolicyError::InvalidCidr(_)));
1524    }
1525
1526    #[test]
1527    fn validator_rejects_duplicate_sid() {
1528        let bad = r#"{
1529            "id":"p","version":1,"statements":[
1530                {"sid":"x","effect":"allow","actions":["select"],"resources":["table:public.x"]},
1531                {"sid":"x","effect":"deny","actions":["delete"],"resources":["table:public.y"]}
1532            ]}"#;
1533        let err = Policy::from_json_str(bad).unwrap_err();
1534        assert!(matches!(err, PolicyError::DuplicateSid(_)));
1535    }
1536
1537    #[test]
1538    fn validator_rejects_empty_statements() {
1539        let bad = r#"{"id":"p","version":1,"statements":[]}"#;
1540        let err = Policy::from_json_str(bad).unwrap_err();
1541        assert!(matches!(err, PolicyError::EmptyStatements));
1542    }
1543
1544    #[test]
1545    fn validator_rejects_empty_actions() {
1546        let bad = r#"{
1547            "id":"p","version":1,"statements":[
1548                {"effect":"allow","actions":[],"resources":["table:public.x"]}
1549            ]}"#;
1550        let err = Policy::from_json_str(bad).unwrap_err();
1551        assert!(matches!(err, PolicyError::EmptyActions));
1552    }
1553
1554    #[test]
1555    fn validator_rejects_empty_resources() {
1556        let bad = r#"{
1557            "id":"p","version":1,"statements":[
1558                {"effect":"allow","actions":["select"],"resources":[]}
1559            ]}"#;
1560        let err = Policy::from_json_str(bad).unwrap_err();
1561        assert!(matches!(err, PolicyError::EmptyResources));
1562    }
1563
1564    #[test]
1565    fn validator_rejects_too_many_statements() {
1566        let mut p = Policy::from_json_str(minimal_policy_json()).unwrap();
1567        let st = p.statements[0].clone();
1568        for _ in 0..MAX_STATEMENTS {
1569            p.statements.push(st.clone());
1570        }
1571        let err = p.validate().unwrap_err();
1572        assert!(matches!(err, PolicyError::TooManyStatements(_)));
1573    }
1574
1575    #[test]
1576    fn validator_rejects_too_many_actions() {
1577        let mut p = Policy::from_json_str(minimal_policy_json()).unwrap();
1578        for _ in 0..MAX_ACTIONS {
1579            p.statements[0].actions.push(ActionPattern::Wildcard);
1580        }
1581        let err = p.validate().unwrap_err();
1582        assert!(matches!(err, PolicyError::TooManyActions(_)));
1583    }
1584
1585    #[test]
1586    fn validator_rejects_too_many_resources() {
1587        let mut p = Policy::from_json_str(minimal_policy_json()).unwrap();
1588        for _ in 0..MAX_RESOURCES {
1589            p.statements[0].resources.push(ResourcePattern::Wildcard);
1590        }
1591        let err = p.validate().unwrap_err();
1592        assert!(matches!(err, PolicyError::TooManyResources(_)));
1593    }
1594
1595    #[test]
1596    fn validator_rejects_oversize_json() {
1597        let big = "x".repeat(MAX_POLICY_BYTES + 1);
1598        let err = Policy::from_json_str(&big).unwrap_err();
1599        assert!(matches!(err, PolicyError::PolicyTooLarge(_)));
1600    }
1601
1602    // -----------------------------------------------------------------
1603    // Glob + action match
1604    // -----------------------------------------------------------------
1605
1606    #[test]
1607    fn glob_matches_table_public_star() {
1608        let pat = compile_glob("table:public.*");
1609        assert!(glob_matches(&pat, "table:public.orders"));
1610        assert!(glob_matches(&pat, "table:public."));
1611        assert!(!glob_matches(&pat, "table:other.x"));
1612    }
1613
1614    #[test]
1615    fn glob_matches_tenant_star() {
1616        let pat = compile_glob("tenant:acme/*");
1617        assert!(glob_matches(&pat, "tenant:acme/whatever"));
1618        assert!(glob_matches(&pat, "tenant:acme/a/b/c"));
1619        assert!(!glob_matches(&pat, "tenant:other/whatever"));
1620    }
1621
1622    #[test]
1623    fn action_match_exact() {
1624        assert!(action_matches(&compile_action("select"), "select"));
1625        assert!(!action_matches(&compile_action("select"), "selectall"));
1626        assert!(!action_matches(&compile_action("select"), "insert"));
1627    }
1628
1629    #[test]
1630    fn action_match_prefix() {
1631        let p = compile_action("admin:*");
1632        assert!(action_matches(&p, "admin:bootstrap"));
1633        assert!(action_matches(&p, "admin:reload"));
1634        assert!(!action_matches(&p, "admin"));
1635        assert!(!action_matches(&p, "select"));
1636    }
1637
1638    #[test]
1639    fn action_match_wildcard() {
1640        let p = compile_action("*");
1641        assert!(action_matches(&p, "select"));
1642        assert!(action_matches(&p, "admin:bootstrap"));
1643        assert!(action_matches(&p, "policy:put"));
1644    }
1645
1646    // -----------------------------------------------------------------
1647    // Conditions
1648    // -----------------------------------------------------------------
1649
1650    #[test]
1651    fn condition_expires_at() {
1652        let c = Condition {
1653            expires_at: Some(2_000),
1654            valid_from: None,
1655            tenant_match: None,
1656            source_ip: None,
1657            mfa: None,
1658            time_window: None,
1659            platform_scoped: None,
1660        };
1661        let r = ResourceRef::new("table", "x");
1662        assert!(condition_holds(Some(&c), &r, &ctx_now(1_000)));
1663        assert!(!condition_holds(Some(&c), &r, &ctx_now(2_000)));
1664        assert!(!condition_holds(Some(&c), &r, &ctx_now(2_500)));
1665    }
1666
1667    #[test]
1668    fn condition_valid_from() {
1669        let c = Condition {
1670            expires_at: None,
1671            valid_from: Some(2_000),
1672            tenant_match: None,
1673            source_ip: None,
1674            mfa: None,
1675            time_window: None,
1676            platform_scoped: None,
1677        };
1678        let r = ResourceRef::new("table", "x");
1679        assert!(!condition_holds(Some(&c), &r, &ctx_now(1_999)));
1680        assert!(condition_holds(Some(&c), &r, &ctx_now(2_000)));
1681        assert!(condition_holds(Some(&c), &r, &ctx_now(3_000)));
1682    }
1683
1684    #[test]
1685    fn condition_source_ip_v4() {
1686        let c = Condition {
1687            expires_at: None,
1688            valid_from: None,
1689            tenant_match: None,
1690            source_ip: Some(vec![parse_cidr("10.0.0.0/8").unwrap()]),
1691            mfa: None,
1692            time_window: None,
1693            platform_scoped: None,
1694        };
1695        let r = ResourceRef::new("table", "x");
1696        let mut ctx = ctx_now(1);
1697        ctx.peer_ip = Some(IpAddr::from_str("10.0.0.1").unwrap());
1698        assert!(condition_holds(Some(&c), &r, &ctx));
1699        ctx.peer_ip = Some(IpAddr::from_str("11.0.0.1").unwrap());
1700        assert!(!condition_holds(Some(&c), &r, &ctx));
1701        ctx.peer_ip = None;
1702        assert!(!condition_holds(Some(&c), &r, &ctx));
1703    }
1704
1705    #[test]
1706    fn condition_source_ip_accepts_single_ip() {
1707        let cidr = parse_cidr("192.168.1.5").unwrap();
1708        assert_eq!(cidr.prefix_len, 32);
1709
1710        let c = Condition {
1711            expires_at: None,
1712            valid_from: None,
1713            tenant_match: None,
1714            source_ip: Some(vec![cidr]),
1715            mfa: None,
1716            time_window: None,
1717            platform_scoped: None,
1718        };
1719        let r = ResourceRef::new("table", "public.x");
1720        let mut ctx = ctx_now(1);
1721        ctx.peer_ip = Some(IpAddr::from_str("192.168.1.5").unwrap());
1722        assert!(condition_holds(Some(&c), &r, &ctx));
1723        ctx.peer_ip = Some(IpAddr::from_str("192.168.1.6").unwrap());
1724        assert!(!condition_holds(Some(&c), &r, &ctx));
1725    }
1726
1727    #[test]
1728    fn condition_tenant_match() {
1729        let c = Condition {
1730            expires_at: None,
1731            valid_from: None,
1732            tenant_match: Some(true),
1733            source_ip: None,
1734            mfa: None,
1735            time_window: None,
1736            platform_scoped: None,
1737        };
1738        let r = ResourceRef::new("table", "x").with_tenant("acme");
1739        let mut ctx = ctx_now(1);
1740        ctx.current_tenant = Some("acme".into());
1741        assert!(condition_holds(Some(&c), &r, &ctx));
1742        ctx.current_tenant = Some("globex".into());
1743        assert!(!condition_holds(Some(&c), &r, &ctx));
1744    }
1745
1746    #[test]
1747    fn condition_mfa() {
1748        let c = Condition {
1749            expires_at: None,
1750            valid_from: None,
1751            tenant_match: None,
1752            source_ip: None,
1753            mfa: Some(true),
1754            time_window: None,
1755            platform_scoped: None,
1756        };
1757        let r = ResourceRef::new("table", "x");
1758        let mut ctx = ctx_now(1);
1759        ctx.mfa_present = true;
1760        assert!(condition_holds(Some(&c), &r, &ctx));
1761        ctx.mfa_present = false;
1762        assert!(!condition_holds(Some(&c), &r, &ctx));
1763    }
1764
1765    #[test]
1766    fn condition_time_window_normal() {
1767        // 09:00 .. 17:00 UTC. now = 1970-01-01T12:00:00Z = 12 * 3600 * 1000 ms.
1768        let tw = TimeWindow {
1769            from_minute: 9 * 60,
1770            to_minute: 17 * 60,
1771            tz_offset_secs: 0,
1772        };
1773        assert!(time_window_contains(&tw, 12 * 3_600_000));
1774        assert!(time_window_contains(&tw, 9 * 3_600_000));
1775        assert!(time_window_contains(&tw, 17 * 3_600_000));
1776        // 18:00 outside.
1777        assert!(!time_window_contains(&tw, 18 * 3_600_000));
1778        // 06:00 outside.
1779        assert!(!time_window_contains(&tw, 6 * 3_600_000));
1780    }
1781
1782    #[test]
1783    fn condition_time_window_wraparound() {
1784        // 22:00 .. 06:00 UTC.
1785        let tw = TimeWindow {
1786            from_minute: 22 * 60,
1787            to_minute: 6 * 60,
1788            tz_offset_secs: 0,
1789        };
1790        assert!(time_window_contains(&tw, 23 * 3_600_000));
1791        assert!(time_window_contains(&tw, 1 * 3_600_000));
1792        assert!(time_window_contains(&tw, 6 * 3_600_000));
1793        assert!(!time_window_contains(&tw, 12 * 3_600_000));
1794        assert!(!time_window_contains(&tw, 21 * 3_600_000));
1795    }
1796
1797    // -----------------------------------------------------------------
1798    // Evaluator
1799    // -----------------------------------------------------------------
1800
1801    fn analyst_policy() -> Policy {
1802        Policy::from_json_str(
1803            r#"{
1804                "id":"analyst","version":1,"statements":[
1805                    {"sid":"reads","effect":"allow",
1806                     "actions":["select"],"resources":["table:public.orders"]}
1807                ]}"#,
1808        )
1809        .unwrap()
1810    }
1811
1812    fn no_deletes_policy() -> Policy {
1813        Policy::from_json_str(
1814            r#"{
1815                "id":"no-deletes","version":1,"statements":[
1816                    {"sid":"hard-stop","effect":"deny",
1817                     "actions":["delete"],"resources":["*"]}
1818                ]}"#,
1819        )
1820        .unwrap()
1821    }
1822
1823    #[test]
1824    fn evaluator_pure_allow() {
1825        let p = analyst_policy();
1826        let r = ResourceRef::new("table", "public.orders");
1827        let d = evaluate(&[&p], "select", &r, &EvalContext::default());
1828        match d {
1829            Decision::Allow {
1830                matched_policy_id,
1831                matched_sid,
1832            } => {
1833                assert_eq!(matched_policy_id, "analyst");
1834                assert_eq!(matched_sid.as_deref(), Some("reads"));
1835            }
1836            other => panic!("expected Allow, got {other:?}"),
1837        }
1838    }
1839
1840    #[test]
1841    fn evaluator_deny_overrides_allow() {
1842        let allow = analyst_policy();
1843        let deny = no_deletes_policy();
1844        let r = ResourceRef::new("table", "public.orders");
1845        // Allow says nothing about delete; deny matches.
1846        let d = evaluate(&[&allow, &deny], "delete", &r, &EvalContext::default());
1847        match d {
1848            Decision::Deny {
1849                matched_policy_id, ..
1850            } => {
1851                assert_eq!(matched_policy_id, "no-deletes");
1852            }
1853            other => panic!("expected Deny, got {other:?}"),
1854        }
1855    }
1856
1857    #[test]
1858    fn evaluator_default_deny() {
1859        let p = analyst_policy();
1860        let r = ResourceRef::new("table", "public.invoices");
1861        let d = evaluate(&[&p], "select", &r, &EvalContext::default());
1862        assert_eq!(d, Decision::DefaultDeny);
1863    }
1864
1865    #[test]
1866    fn evaluator_admin_gets_default_deny_without_allow_policy() {
1867        // After retiring the AdminBypass shortcut, an admin principal with
1868        // no attached allow policy falls through to DefaultDeny just like
1869        // any other principal — operational admin access comes from the
1870        // bootstrap preset attaching an allow-all policy, not the evaluator.
1871        let p = analyst_policy();
1872        let r = ResourceRef::new("table", "anything");
1873        let mut ctx = EvalContext::default();
1874        ctx.principal_is_admin_role = true;
1875        let d = evaluate(&[&p], "delete", &r, &ctx);
1876        assert_eq!(d, Decision::DefaultDeny);
1877    }
1878
1879    #[test]
1880    fn evaluator_admin_does_not_bypass_explicit_deny() {
1881        // An explicit Deny is a managed guardrail and must win even for an
1882        // admin principal.
1883        let allow = analyst_policy();
1884        let deny = no_deletes_policy();
1885        let r = ResourceRef::new("table", "public.orders");
1886        let mut ctx = EvalContext::default();
1887        ctx.principal_is_admin_role = true;
1888        let d = evaluate(&[&allow, &deny], "delete", &r, &ctx);
1889        match d {
1890            Decision::Deny {
1891                matched_policy_id, ..
1892            } => assert_eq!(matched_policy_id, "no-deletes"),
1893            other => panic!("expected Deny for admin, got {other:?}"),
1894        }
1895    }
1896
1897    #[test]
1898    fn evaluator_implicit_tenant_scoping() {
1899        // Pattern `table:public.x` written without tenant prefix should
1900        // implicitly bind to ctx.current_tenant — so a request against
1901        // tenant `acme` matches but a request against tenant `globex`
1902        // does not (when the policy is evaluated with current_tenant=acme).
1903        let p = Policy::from_json_str(
1904            r#"{
1905                "id":"impl","version":1,"statements":[
1906                    {"sid":"s","effect":"allow",
1907                     "actions":["select"],"resources":["table:public.x"]}
1908                ]}"#,
1909        )
1910        .unwrap();
1911        let r_acme = ResourceRef::new("table", "public.x").with_tenant("acme");
1912        let r_globex = ResourceRef::new("table", "public.x").with_tenant("globex");
1913        let mut ctx = EvalContext::default();
1914        ctx.current_tenant = Some("acme".into());
1915        assert!(matches!(
1916            evaluate(&[&p], "select", &r_acme, &ctx),
1917            Decision::Allow { .. }
1918        ));
1919        assert_eq!(
1920            evaluate(&[&p], "select", &r_globex, &ctx),
1921            Decision::DefaultDeny
1922        );
1923    }
1924
1925    // -----------------------------------------------------------------
1926    // Simulator
1927    // -----------------------------------------------------------------
1928
1929    #[test]
1930    fn simulator_produces_trail() {
1931        let allow = analyst_policy();
1932        let deny = no_deletes_policy();
1933        let r = ResourceRef::new("table", "public.orders");
1934        let out = simulate(&[&allow, &deny], "delete", &r, &EvalContext::default());
1935        // Two policies, each with one statement → at least one trail
1936        // entry per statement.
1937        assert!(out.trail.len() >= 2);
1938        assert!(matches!(out.decision, Decision::Deny { .. }));
1939        assert!(out.reason.contains("deny"));
1940    }
1941
1942    // -----------------------------------------------------------------
1943    // Misc helpers
1944    // -----------------------------------------------------------------
1945
1946    #[test]
1947    fn rfc3339_parses_to_ms() {
1948        let ms = parse_rfc3339_ms("1970-01-01T00:00:00Z").unwrap();
1949        assert_eq!(ms, 0);
1950        let ms = parse_rfc3339_ms("1970-01-01T00:00:01.500Z").unwrap();
1951        assert_eq!(ms, 1_500);
1952        let ms = parse_rfc3339_ms("2024-01-01T00:00:00+00:00").unwrap();
1953        // 2024-01-01 = 19723 days after epoch.
1954        assert_eq!(ms, 19_723u128 * 86_400_000);
1955    }
1956
1957    #[test]
1958    fn rfc3339_handles_negative_offset() {
1959        // 2024-01-01T01:00:00+01:00 == 2024-01-01T00:00:00Z
1960        let a = parse_rfc3339_ms("2024-01-01T01:00:00+01:00").unwrap();
1961        let b = parse_rfc3339_ms("2024-01-01T00:00:00Z").unwrap();
1962        assert_eq!(a, b);
1963    }
1964
1965    #[test]
1966    fn cidr_v6_basic() {
1967        let c = parse_cidr("::1/128").unwrap();
1968        assert_eq!(c.prefix_len, 128);
1969        assert!(cidr_contains(&c, IpAddr::from_str("::1").unwrap()));
1970        assert!(!cidr_contains(&c, IpAddr::from_str("::2").unwrap()));
1971    }
1972}