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