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