Skip to main content

reddb_server/auth/
policies.rs

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