1use std::error::Error;
51use std::fmt;
52use std::net::IpAddr;
53use std::str::FromStr;
54
55use crate::serde_json::{self, JsonDecode, JsonEncode, Map, Value};
56
57pub const MAX_STATEMENTS: usize = 100;
63pub const MAX_ACTIONS: usize = 50;
65pub const MAX_RESOURCES: usize = 50;
67pub const MAX_POLICY_BYTES: usize = 32 * 1024;
69
70const ACTION_ALLOWLIST: &[&str] = &[
74 "select",
75 "write",
76 "insert",
77 "update",
78 "delete",
79 "truncate",
80 "references",
81 "execute",
82 "usage",
83 "grant",
84 "revoke",
85 "create",
86 "drop",
87 "alter",
88 "policy:put",
89 "policy:drop",
90 "policy:attach",
91 "policy:detach",
92 "policy:simulate",
93 "kv:invalidate",
94 "admin:bootstrap",
95 "admin:audit-read",
96 "admin:reload",
97 "admin:lease-promote",
98 "config:read",
99 "config:write",
100 "config:*",
101 "vault:read_metadata",
102 "vault:write",
103 "vault:unseal",
104 "vault:unseal_history",
105 "vault:purge",
106 "red.registry:register",
107 "red.registry:supersede",
108 "red.registry:*",
109 "*",
110 "admin:*",
111 "vault:*",
112 "kv:*",
113 "policy:*",
114];
115
116#[derive(Debug, Clone, PartialEq)]
122pub struct Policy {
123 pub id: String,
125 pub version: u8,
127 pub statements: Vec<Statement>,
128 pub tenant: Option<String>,
130 pub created_at: u128,
132 pub updated_at: u128,
134}
135
136#[derive(Debug, Clone, PartialEq)]
138pub struct Statement {
139 pub sid: Option<String>,
141 pub effect: Effect,
142 pub actions: Vec<ActionPattern>,
143 pub resources: Vec<ResourcePattern>,
144 pub condition: Option<Condition>,
145}
146
147#[derive(Debug, Clone, Copy, PartialEq, Eq)]
148pub enum Effect {
149 Allow,
150 Deny,
151}
152
153#[derive(Debug, Clone, PartialEq, Eq)]
159pub enum ActionPattern {
160 Exact(String),
161 Wildcard,
162 Prefix(String),
163}
164
165#[derive(Debug, Clone, PartialEq, Eq)]
167pub enum ResourcePattern {
168 Exact { kind: String, name: String },
169 Glob(String),
170 Wildcard,
171}
172
173#[derive(Debug, Clone, PartialEq)]
176pub struct Condition {
177 pub expires_at: Option<u128>,
178 pub valid_from: Option<u128>,
179 pub tenant_match: Option<bool>,
180 pub source_ip: Option<Vec<IpCidr>>,
181 pub mfa: Option<bool>,
182 pub time_window: Option<TimeWindow>,
183 pub system_owned: Option<bool>,
185 pub platform_scoped: Option<bool>,
187}
188
189#[derive(Debug, Clone, PartialEq, Eq)]
191pub struct IpCidr {
192 pub addr: IpAddr,
193 pub prefix_len: u8,
194}
195
196#[derive(Debug, Clone, PartialEq, Eq)]
200pub struct TimeWindow {
201 pub from_minute: u16,
202 pub to_minute: u16,
203 pub tz_offset_secs: i32,
204}
205
206#[derive(Debug, Clone, PartialEq)]
208pub struct ResourceRef {
209 pub kind: String,
210 pub name: String,
211 pub tenant: Option<String>,
212}
213
214impl ResourceRef {
215 pub fn new(kind: impl Into<String>, name: impl Into<String>) -> Self {
216 Self {
217 kind: kind.into(),
218 name: name.into(),
219 tenant: None,
220 }
221 }
222
223 pub fn with_tenant(mut self, tenant: impl Into<String>) -> Self {
224 self.tenant = Some(tenant.into());
225 self
226 }
227}
228
229#[derive(Debug, Clone, Default)]
231pub struct EvalContext {
232 pub principal_tenant: Option<String>,
234 pub current_tenant: Option<String>,
236 pub peer_ip: Option<IpAddr>,
238 pub mfa_present: bool,
239 pub now_ms: u128,
241 pub principal_is_admin_role: bool,
246 pub principal_is_system_owned: bool,
251 pub principal_is_platform_scoped: bool,
254}
255
256#[derive(Debug, Clone, PartialEq)]
258pub enum Decision {
259 Allow {
260 matched_policy_id: String,
261 matched_sid: Option<String>,
262 },
263 Deny {
264 matched_policy_id: String,
265 matched_sid: Option<String>,
266 },
267 DefaultDeny,
268 AdminBypass,
269}
270
271#[derive(Debug, Clone)]
276pub enum PolicyError {
277 InvalidJson(String),
278 InvalidAction(String),
279 InvalidResource(String),
280 InvalidCondition(String),
281 InvalidCidr(String),
282 DuplicateSid(String),
283 EmptyStatements,
284 EmptyActions,
285 EmptyResources,
286 TooManyStatements(usize),
287 TooManyActions(usize),
288 TooManyResources(usize),
289 PolicyTooLarge(usize),
290}
291
292impl fmt::Display for PolicyError {
293 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
294 match self {
295 Self::InvalidJson(m) => write!(f, "invalid policy json: {m}"),
296 Self::InvalidAction(m) => write!(f, "invalid action: {m}"),
297 Self::InvalidResource(m) => write!(f, "invalid resource: {m}"),
298 Self::InvalidCondition(m) => write!(f, "invalid condition: {m}"),
299 Self::InvalidCidr(m) => write!(f, "invalid cidr: {m}"),
300 Self::DuplicateSid(s) => write!(f, "duplicate sid in policy: {s}"),
301 Self::EmptyStatements => write!(f, "policy has no statements"),
302 Self::EmptyActions => write!(f, "statement has no actions"),
303 Self::EmptyResources => write!(f, "statement has no resources"),
304 Self::TooManyStatements(n) => {
305 write!(f, "policy has {n} statements (max {MAX_STATEMENTS})")
306 }
307 Self::TooManyActions(n) => {
308 write!(f, "statement has {n} actions (max {MAX_ACTIONS})")
309 }
310 Self::TooManyResources(n) => {
311 write!(f, "statement has {n} resources (max {MAX_RESOURCES})")
312 }
313 Self::PolicyTooLarge(n) => {
314 write!(f, "policy json is {n} bytes (max {MAX_POLICY_BYTES})")
315 }
316 }
317 }
318}
319
320impl Error for PolicyError {}
321
322impl Policy {
327 pub fn from_json_str(s: &str) -> Result<Policy, PolicyError> {
330 if s.len() > MAX_POLICY_BYTES {
331 return Err(PolicyError::PolicyTooLarge(s.len()));
332 }
333 let value: Value = serde_json::from_str(s).map_err(PolicyError::InvalidJson)?;
334 let policy = Policy::from_json_value(&value)?;
335 policy.validate()?;
336 Ok(policy)
337 }
338
339 pub fn to_json_string(&self) -> String {
342 self.to_json_value().to_string_compact()
343 }
344
345 pub fn validate(&self) -> Result<(), PolicyError> {
348 if self.statements.is_empty() {
349 return Err(PolicyError::EmptyStatements);
350 }
351 if self.statements.len() > MAX_STATEMENTS {
352 return Err(PolicyError::TooManyStatements(self.statements.len()));
353 }
354
355 let mut seen_sids: Vec<&str> = Vec::new();
356 for st in &self.statements {
357 if let Some(sid) = st.sid.as_deref() {
358 if seen_sids.contains(&sid) {
359 return Err(PolicyError::DuplicateSid(sid.to_string()));
360 }
361 seen_sids.push(sid);
362 }
363 if st.actions.is_empty() {
364 return Err(PolicyError::EmptyActions);
365 }
366 if st.actions.len() > MAX_ACTIONS {
367 return Err(PolicyError::TooManyActions(st.actions.len()));
368 }
369 if st.resources.is_empty() {
370 return Err(PolicyError::EmptyResources);
371 }
372 if st.resources.len() > MAX_RESOURCES {
373 return Err(PolicyError::TooManyResources(st.resources.len()));
374 }
375 for a in &st.actions {
376 validate_action(a)?;
377 }
378 }
379 Ok(())
380 }
381
382 fn from_json_value(v: &Value) -> Result<Policy, PolicyError> {
383 let obj = v
384 .as_object()
385 .ok_or_else(|| PolicyError::InvalidJson("policy must be an object".into()))?;
386 let id = string_field(obj, "id")?;
387 let version = obj
388 .get("version")
389 .and_then(|n| n.as_u64())
390 .map(|n| n as u8)
391 .unwrap_or(1);
392 let tenant = obj
393 .get("tenant")
394 .and_then(|t| match t {
395 Value::Null => None,
396 Value::String(s) => Some(Some(s.clone())),
397 _ => Some(None),
398 })
399 .flatten();
400 let created_at = parse_ts_field(obj, "created_at").unwrap_or(0);
401 let updated_at = parse_ts_field(obj, "updated_at").unwrap_or(created_at);
402
403 let statements_v =
404 obj.get("statements")
405 .and_then(|v| v.as_array())
406 .ok_or(PolicyError::InvalidJson(
407 "policy.statements must be an array".into(),
408 ))?;
409 let mut statements = Vec::with_capacity(statements_v.len());
410 for sv in statements_v {
411 statements.push(Statement::from_json_value(sv)?);
412 }
413
414 Ok(Policy {
415 id,
416 version,
417 statements,
418 tenant,
419 created_at,
420 updated_at,
421 })
422 }
423
424 fn to_json_value(&self) -> Value {
425 let mut obj = Map::new();
426 obj.insert("id".into(), Value::String(self.id.clone()));
427 obj.insert("version".into(), Value::Number(self.version as f64));
428 if let Some(t) = &self.tenant {
429 obj.insert("tenant".into(), Value::String(t.clone()));
430 } else {
431 obj.insert("tenant".into(), Value::Null);
432 }
433 obj.insert("created_at".into(), Value::Number(self.created_at as f64));
434 obj.insert("updated_at".into(), Value::Number(self.updated_at as f64));
435 obj.insert(
436 "statements".into(),
437 Value::Array(self.statements.iter().map(|s| s.to_json_value()).collect()),
438 );
439 Value::Object(obj)
440 }
441}
442
443impl JsonEncode for Policy {
444 fn to_json_value(&self) -> Value {
445 self.to_json_value()
446 }
447}
448
449impl JsonDecode for Policy {
450 fn from_json_value(value: Value) -> Result<Self, String> {
451 Policy::from_json_value(&value).map_err(|e| e.to_string())
452 }
453}
454
455impl Statement {
460 fn from_json_value(v: &Value) -> Result<Statement, PolicyError> {
461 let obj = v
462 .as_object()
463 .ok_or_else(|| PolicyError::InvalidJson("statement must be an object".into()))?;
464 let sid = obj
465 .get("sid")
466 .and_then(|s| s.as_str())
467 .map(|s| s.to_string());
468 let effect_s = obj
469 .get("effect")
470 .and_then(|e| e.as_str())
471 .ok_or_else(|| PolicyError::InvalidJson("statement.effect required".into()))?;
472 let effect = match effect_s.to_ascii_lowercase().as_str() {
473 "allow" => Effect::Allow,
474 "deny" => Effect::Deny,
475 other => return Err(PolicyError::InvalidJson(format!("unknown effect: {other}"))),
476 };
477
478 let actions = obj
479 .get("actions")
480 .and_then(|a| a.as_array())
481 .ok_or_else(|| PolicyError::InvalidJson("statement.actions must be array".into()))?
482 .iter()
483 .map(|v| {
484 v.as_str()
485 .ok_or_else(|| PolicyError::InvalidJson("action must be string".into()))
486 .map(compile_action)
487 })
488 .collect::<Result<Vec<_>, _>>()?;
489
490 let resources = obj
491 .get("resources")
492 .and_then(|r| r.as_array())
493 .ok_or_else(|| PolicyError::InvalidJson("statement.resources must be array".into()))?
494 .iter()
495 .map(|v| {
496 v.as_str()
497 .ok_or_else(|| PolicyError::InvalidJson("resource must be string".into()))
498 .and_then(compile_resource)
499 })
500 .collect::<Result<Vec<_>, _>>()?;
501
502 let condition = match obj.get("condition") {
503 None | Some(Value::Null) => None,
504 Some(c) => Some(Condition::from_json_value(c)?),
505 };
506
507 Ok(Statement {
508 sid,
509 effect,
510 actions,
511 resources,
512 condition,
513 })
514 }
515
516 fn to_json_value(&self) -> Value {
517 let mut obj = Map::new();
518 if let Some(sid) = &self.sid {
519 obj.insert("sid".into(), Value::String(sid.clone()));
520 }
521 obj.insert(
522 "effect".into(),
523 Value::String(
524 match self.effect {
525 Effect::Allow => "allow",
526 Effect::Deny => "deny",
527 }
528 .into(),
529 ),
530 );
531 obj.insert(
532 "actions".into(),
533 Value::Array(
534 self.actions
535 .iter()
536 .map(|a| Value::String(action_to_string(a)))
537 .collect(),
538 ),
539 );
540 obj.insert(
541 "resources".into(),
542 Value::Array(
543 self.resources
544 .iter()
545 .map(|r| Value::String(resource_to_string(r)))
546 .collect(),
547 ),
548 );
549 if let Some(c) = &self.condition {
550 obj.insert("condition".into(), c.to_json_value());
551 }
552 Value::Object(obj)
553 }
554}
555
556impl Condition {
561 fn from_json_value(v: &Value) -> Result<Condition, PolicyError> {
562 let obj = v
563 .as_object()
564 .ok_or_else(|| PolicyError::InvalidCondition("condition must be object".into()))?;
565
566 let expires_at = match obj.get("expires_at") {
567 None | Some(Value::Null) => None,
568 Some(x) => Some(parse_ts_value(x)?),
569 };
570 let valid_from = match obj.get("valid_from") {
571 None | Some(Value::Null) => None,
572 Some(x) => Some(parse_ts_value(x)?),
573 };
574 let tenant_match = obj.get("tenant_match").and_then(|v| v.as_bool());
575 let mfa = obj.get("mfa").and_then(|v| v.as_bool());
576 let system_owned = obj.get("system_owned").and_then(|v| v.as_bool());
577 let platform_scoped = obj.get("platform_scoped").and_then(|v| v.as_bool());
578
579 let source_ip = match obj.get("source_ip") {
580 None | Some(Value::Null) => None,
581 Some(arr) => {
582 let xs = arr.as_array().ok_or_else(|| {
583 PolicyError::InvalidCondition("source_ip must be array".into())
584 })?;
585 let mut out = Vec::with_capacity(xs.len());
586 for v in xs {
587 let s = v.as_str().ok_or_else(|| {
588 PolicyError::InvalidCidr("source_ip entry must be string".into())
589 })?;
590 out.push(parse_cidr(s)?);
591 }
592 Some(out)
593 }
594 };
595
596 let time_window = match obj.get("time_window") {
597 None | Some(Value::Null) => None,
598 Some(tw) => Some(TimeWindow::from_json_value(tw)?),
599 };
600
601 Ok(Condition {
602 expires_at,
603 valid_from,
604 tenant_match,
605 source_ip,
606 mfa,
607 time_window,
608 system_owned,
609 platform_scoped,
610 })
611 }
612
613 fn to_json_value(&self) -> Value {
614 let mut obj = Map::new();
615 if let Some(t) = self.expires_at {
616 obj.insert("expires_at".into(), Value::Number(t as f64));
617 }
618 if let Some(t) = self.valid_from {
619 obj.insert("valid_from".into(), Value::Number(t as f64));
620 }
621 if let Some(b) = self.tenant_match {
622 obj.insert("tenant_match".into(), Value::Bool(b));
623 }
624 if let Some(b) = self.mfa {
625 obj.insert("mfa".into(), Value::Bool(b));
626 }
627 if let Some(b) = self.system_owned {
628 obj.insert("system_owned".into(), Value::Bool(b));
629 }
630 if let Some(b) = self.platform_scoped {
631 obj.insert("platform_scoped".into(), Value::Bool(b));
632 }
633 if let Some(cidrs) = &self.source_ip {
634 obj.insert(
635 "source_ip".into(),
636 Value::Array(
637 cidrs
638 .iter()
639 .map(|c| Value::String(format!("{}/{}", c.addr, c.prefix_len)))
640 .collect(),
641 ),
642 );
643 }
644 if let Some(tw) = &self.time_window {
645 obj.insert("time_window".into(), tw.to_json_value());
646 }
647 Value::Object(obj)
648 }
649}
650
651impl TimeWindow {
652 fn from_json_value(v: &Value) -> Result<TimeWindow, PolicyError> {
653 let obj = v
654 .as_object()
655 .ok_or_else(|| PolicyError::InvalidCondition("time_window must be object".into()))?;
656 let from_minute =
657 parse_hhmm(obj.get("from").and_then(|s| s.as_str()).ok_or_else(|| {
658 PolicyError::InvalidCondition("time_window.from required".into())
659 })?)?;
660 let to_minute = parse_hhmm(
661 obj.get("to")
662 .and_then(|s| s.as_str())
663 .ok_or_else(|| PolicyError::InvalidCondition("time_window.to required".into()))?,
664 )?;
665 let tz_str = obj.get("tz").and_then(|s| s.as_str()).unwrap_or("UTC");
666 let tz_offset_secs = parse_tz_offset(tz_str)?;
667 Ok(TimeWindow {
668 from_minute,
669 to_minute,
670 tz_offset_secs,
671 })
672 }
673
674 fn to_json_value(&self) -> Value {
675 let mut obj = Map::new();
676 obj.insert("from".into(), Value::String(format_hhmm(self.from_minute)));
677 obj.insert("to".into(), Value::String(format_hhmm(self.to_minute)));
678 obj.insert("tz".into(), Value::String(format_tz(self.tz_offset_secs)));
679 Value::Object(obj)
680 }
681}
682
683pub fn compile_action(s: &str) -> ActionPattern {
690 if s == "*" {
691 ActionPattern::Wildcard
692 } else if let Some(p) = s.strip_suffix(":*") {
693 ActionPattern::Prefix(p.to_string())
694 } else {
695 ActionPattern::Exact(s.to_string())
696 }
697}
698
699fn action_to_string(a: &ActionPattern) -> String {
700 match a {
701 ActionPattern::Wildcard => "*".into(),
702 ActionPattern::Prefix(p) => format!("{p}:*"),
703 ActionPattern::Exact(s) => s.clone(),
704 }
705}
706
707fn validate_action(a: &ActionPattern) -> Result<(), PolicyError> {
708 let s = action_to_string(a);
709 if ACTION_ALLOWLIST.iter().any(|w| *w == s) {
710 Ok(())
711 } else {
712 Err(PolicyError::InvalidAction(s))
713 }
714}
715
716fn compile_resource(s: &str) -> Result<ResourcePattern, PolicyError> {
717 if s == "*" {
718 return Ok(ResourcePattern::Wildcard);
719 }
720 if s.contains('*') {
721 return Ok(ResourcePattern::Glob(s.to_string()));
722 }
723 let (kind, name) = s
724 .split_once(':')
725 .ok_or_else(|| PolicyError::InvalidResource(format!("expected `kind:name`, got `{s}`")))?;
726 if kind.is_empty() || name.is_empty() {
727 return Err(PolicyError::InvalidResource(s.to_string()));
728 }
729 Ok(ResourcePattern::Exact {
730 kind: kind.to_string(),
731 name: name.to_string(),
732 })
733}
734
735fn resource_to_string(r: &ResourcePattern) -> String {
736 match r {
737 ResourcePattern::Wildcard => "*".into(),
738 ResourcePattern::Exact { kind, name } => format!("{kind}:{name}"),
739 ResourcePattern::Glob(s) => s.clone(),
740 }
741}
742
743#[derive(Debug, Clone, PartialEq, Eq)]
746pub struct CompiledPattern {
747 pub prefix: String,
748 pub suffix: String,
749 pub contains_segments: Vec<String>,
750}
751
752pub fn compile_glob(pattern: &str) -> CompiledPattern {
754 let parts: Vec<&str> = pattern.split('*').collect();
755 if parts.len() == 1 {
756 return CompiledPattern {
759 prefix: parts[0].to_string(),
760 suffix: String::new(),
761 contains_segments: Vec::new(),
762 };
763 }
764 let prefix = parts[0].to_string();
765 let suffix = parts[parts.len() - 1].to_string();
766 let contains_segments = parts[1..parts.len() - 1]
767 .iter()
768 .filter(|s| !s.is_empty())
769 .map(|s| s.to_string())
770 .collect();
771 CompiledPattern {
772 prefix,
773 suffix,
774 contains_segments,
775 }
776}
777
778fn glob_matches(pat: &CompiledPattern, input: &str) -> bool {
779 if !input.starts_with(&pat.prefix) {
780 return false;
781 }
782 if !input.ends_with(&pat.suffix) {
783 return false;
784 }
785 if pat.prefix.len() + pat.suffix.len() > input.len() {
786 return false;
787 }
788 let mut cursor = pat.prefix.len();
789 let inner_end = input.len() - pat.suffix.len();
790 for seg in &pat.contains_segments {
791 let hay = &input[cursor..inner_end];
792 match hay.find(seg.as_str()) {
793 Some(i) => cursor += i + seg.len(),
794 None => return false,
795 }
796 }
797 true
798}
799
800fn parse_ts_field(obj: &Map<String, Value>, key: &str) -> Option<u128> {
805 obj.get(key).and_then(|v| parse_ts_value(v).ok())
806}
807
808fn parse_ts_value(v: &Value) -> Result<u128, PolicyError> {
809 match v {
810 Value::Number(n) if *n >= 0.0 => Ok(*n as u128),
811 Value::String(s) => parse_rfc3339_ms(s),
812 _ => Err(PolicyError::InvalidCondition(format!(
813 "timestamp expected (rfc3339 or ms epoch), got {v:?}"
814 ))),
815 }
816}
817
818fn parse_rfc3339_ms(s: &str) -> Result<u128, PolicyError> {
822 let bad = || PolicyError::InvalidCondition(format!("not rfc3339: {s}"));
823 if s.len() < 20 {
824 return Err(bad());
825 }
826 let bytes = s.as_bytes();
827 if bytes[4] != b'-' || bytes[7] != b'-' || bytes[10] != b'T' {
828 return Err(bad());
829 }
830 let year: i64 = s[0..4].parse().map_err(|_| bad())?;
831 let month: u32 = s[5..7].parse().map_err(|_| bad())?;
832 let day: u32 = s[8..10].parse().map_err(|_| bad())?;
833 if bytes[13] != b':' || bytes[16] != b':' {
834 return Err(bad());
835 }
836 let hour: u64 = s[11..13].parse().map_err(|_| bad())?;
837 let minute: u64 = s[14..16].parse().map_err(|_| bad())?;
838 let second: u64 = s[17..19].parse().map_err(|_| bad())?;
839
840 let mut idx = 19;
842 let mut millis: u64 = 0;
843 if idx < bytes.len() && bytes[idx] == b'.' {
844 idx += 1;
845 let start = idx;
846 while idx < bytes.len() && bytes[idx].is_ascii_digit() {
847 idx += 1;
848 }
849 let frac = &s[start..idx];
850 if !frac.is_empty() {
851 let take = frac.len().min(3);
853 let pad = "0".repeat(3 - take);
854 let combined = format!("{}{}", &frac[..take], pad);
855 millis = combined.parse().map_err(|_| bad())?;
856 }
857 }
858
859 let mut offset_secs: i64 = 0;
861 if idx < bytes.len() {
862 match bytes[idx] {
863 b'Z' | b'z' => {
864 idx += 1;
865 }
866 b'+' | b'-' => {
867 if bytes.len() < idx + 6 || bytes[idx + 3] != b':' {
868 return Err(bad());
869 }
870 let sign: i64 = if bytes[idx] == b'+' { 1 } else { -1 };
871 let oh: i64 = s[idx + 1..idx + 3].parse().map_err(|_| bad())?;
872 let om: i64 = s[idx + 4..idx + 6].parse().map_err(|_| bad())?;
873 offset_secs = sign * (oh * 3600 + om * 60);
874 idx += 6;
875 }
876 _ => return Err(bad()),
877 }
878 }
879 if idx != bytes.len() {
880 return Err(bad());
881 }
882
883 let days = days_from_civil(year, month as i64, day as i64);
884 let total_secs =
885 days * 86_400 + (hour as i64) * 3600 + (minute as i64) * 60 + second as i64 - offset_secs;
886 if total_secs < 0 {
887 return Err(bad());
888 }
889 Ok((total_secs as u128) * 1000 + millis as u128)
890}
891
892fn days_from_civil(y: i64, m: i64, d: i64) -> i64 {
895 let y = if m <= 2 { y - 1 } else { y };
896 let era = if y >= 0 { y } else { y - 399 } / 400;
897 let yoe = y - era * 400; let doy = (153 * (if m > 2 { m - 3 } else { m + 9 }) + 2) / 5 + d - 1; let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy; era * 146_097 + doe - 719_468
901}
902
903fn parse_hhmm(s: &str) -> Result<u16, PolicyError> {
904 let bad = || PolicyError::InvalidCondition(format!("HH:MM expected, got {s}"));
905 if s.len() != 5 || s.as_bytes()[2] != b':' {
906 return Err(bad());
907 }
908 let h: u16 = s[0..2].parse().map_err(|_| bad())?;
909 let m: u16 = s[3..5].parse().map_err(|_| bad())?;
910 if h >= 24 || m >= 60 {
911 return Err(bad());
912 }
913 Ok(h * 60 + m)
914}
915
916fn format_hhmm(min: u16) -> String {
917 format!("{:02}:{:02}", min / 60, min % 60)
918}
919
920fn parse_tz_offset(s: &str) -> Result<i32, PolicyError> {
921 if s == "UTC" || s == "Z" {
922 return Ok(0);
923 }
924 let bytes = s.as_bytes();
925 if bytes.len() == 6 && (bytes[0] == b'+' || bytes[0] == b'-') && bytes[3] == b':' {
926 let sign: i32 = if bytes[0] == b'+' { 1 } else { -1 };
927 let h: i32 = s[1..3]
928 .parse()
929 .map_err(|_| PolicyError::InvalidCondition(format!("bad tz: {s}")))?;
930 let m: i32 = s[4..6]
931 .parse()
932 .map_err(|_| PolicyError::InvalidCondition(format!("bad tz: {s}")))?;
933 return Ok(sign * (h * 3600 + m * 60));
934 }
935 Err(PolicyError::InvalidCondition(format!(
936 "tz must be UTC or +HH:MM/-HH:MM (got {s})"
937 )))
938}
939
940fn format_tz(secs: i32) -> String {
941 if secs == 0 {
942 return "UTC".into();
943 }
944 let sign = if secs >= 0 { '+' } else { '-' };
945 let abs = secs.abs();
946 format!("{}{:02}:{:02}", sign, abs / 3600, (abs % 3600) / 60)
947}
948
949fn parse_cidr(s: &str) -> Result<IpCidr, PolicyError> {
954 let (addr_s, prefix_s) = match s.split_once('/') {
955 Some(parts) => parts,
956 None => {
957 let addr =
958 IpAddr::from_str(s).map_err(|e| PolicyError::InvalidCidr(format!("{s}: {e}")))?;
959 let prefix_len = match addr {
960 IpAddr::V4(_) => 32,
961 IpAddr::V6(_) => 128,
962 };
963 return Ok(IpCidr { addr, prefix_len });
964 }
965 };
966 let addr =
967 IpAddr::from_str(addr_s).map_err(|e| PolicyError::InvalidCidr(format!("{s}: {e}")))?;
968 let prefix_len: u8 = prefix_s
969 .parse()
970 .map_err(|_| PolicyError::InvalidCidr(format!("bad prefix in {s}")))?;
971 let max = match addr {
972 IpAddr::V4(_) => 32,
973 IpAddr::V6(_) => 128,
974 };
975 if prefix_len > max {
976 return Err(PolicyError::InvalidCidr(format!("prefix > {max} in {s}")));
977 }
978 Ok(IpCidr { addr, prefix_len })
979}
980
981fn cidr_contains(cidr: &IpCidr, ip: IpAddr) -> bool {
982 match (cidr.addr, ip) {
983 (IpAddr::V4(net), IpAddr::V4(ip)) => {
984 let n = u32::from_be_bytes(net.octets());
985 let i = u32::from_be_bytes(ip.octets());
986 let mask = if cidr.prefix_len == 0 {
987 0u32
988 } else {
989 u32::MAX << (32 - cidr.prefix_len)
990 };
991 (n & mask) == (i & mask)
992 }
993 (IpAddr::V6(net), IpAddr::V6(ip)) => {
994 let n = u128::from_be_bytes(net.octets());
995 let i = u128::from_be_bytes(ip.octets());
996 let mask = if cidr.prefix_len == 0 {
997 0u128
998 } else {
999 u128::MAX << (128 - cidr.prefix_len)
1000 };
1001 (n & mask) == (i & mask)
1002 }
1003 _ => false, }
1005}
1006
1007fn action_matches(pat: &ActionPattern, action: &str) -> bool {
1012 match pat {
1013 ActionPattern::Wildcard => true,
1014 ActionPattern::Exact(s) => s == action,
1015 ActionPattern::Prefix(p) => {
1016 action.len() > p.len() + 1
1018 && action.starts_with(p.as_str())
1019 && action.as_bytes()[p.len()] == b':'
1020 }
1021 }
1022}
1023
1024fn resource_matches(pat: &ResourcePattern, resource: &ResourceRef, ctx: &EvalContext) -> bool {
1029 let target = qualified_name(&resource.kind, &resource.name, resource.tenant.as_deref());
1030 match pat {
1031 ResourcePattern::Wildcard => true,
1032 ResourcePattern::Exact { kind, name } => {
1033 if kind != &resource.kind {
1034 return false;
1035 }
1036 let qualified = if name.starts_with("tenant/") {
1037 format!("{kind}:{name}")
1038 } else {
1039 qualified_name(kind, name, ctx.current_tenant.as_deref())
1040 };
1041 qualified == target
1042 }
1043 ResourcePattern::Glob(raw) => {
1044 let (pkind, pname) = match raw.split_once(':') {
1045 Some(parts) => parts,
1046 None => return false,
1047 };
1048 if !pkind.is_empty() && pkind != "*" && pkind != resource.kind {
1049 return false;
1050 }
1051 let qualified_pat = if pname.starts_with("tenant/") || pname == "*" {
1052 format!("{pkind}:{pname}")
1053 } else {
1054 let scoped = match ctx.current_tenant.as_deref() {
1055 Some(t) => format!("tenant/{t}/{pname}"),
1056 None => pname.to_string(),
1057 };
1058 format!("{pkind}:{scoped}")
1059 };
1060 let compiled = compile_glob(&qualified_pat);
1061 glob_matches(&compiled, &target)
1062 }
1063 }
1064}
1065
1066fn qualified_name(kind: &str, name: &str, tenant: Option<&str>) -> String {
1069 if name.starts_with("tenant/") {
1070 return format!("{kind}:{name}");
1071 }
1072 match tenant {
1073 Some(t) => format!("{kind}:tenant/{t}/{name}"),
1074 None => format!("{kind}:{name}"),
1075 }
1076}
1077
1078fn condition_holds(cond: Option<&Condition>, resource: &ResourceRef, ctx: &EvalContext) -> bool {
1083 let Some(c) = cond else { return true };
1084 if let Some(exp) = c.expires_at {
1085 if ctx.now_ms >= exp {
1086 return false;
1087 }
1088 }
1089 if let Some(vf) = c.valid_from {
1090 if ctx.now_ms < vf {
1091 return false;
1092 }
1093 }
1094 if let Some(true) = c.tenant_match {
1095 if resource.tenant.as_deref() != ctx.current_tenant.as_deref() {
1096 return false;
1097 }
1098 }
1099 if let Some(true) = c.mfa {
1100 if !ctx.mfa_present {
1101 return false;
1102 }
1103 }
1104 if let Some(want) = c.system_owned {
1105 if ctx.principal_is_system_owned != want {
1106 return false;
1107 }
1108 }
1109 if let Some(want) = c.platform_scoped {
1110 if ctx.principal_is_platform_scoped != want {
1111 return false;
1112 }
1113 }
1114 if let Some(cidrs) = &c.source_ip {
1115 let Some(ip) = ctx.peer_ip else {
1116 return false;
1117 };
1118 if !cidrs.iter().any(|c| cidr_contains(c, ip)) {
1119 return false;
1120 }
1121 }
1122 if let Some(tw) = &c.time_window {
1123 if !time_window_contains(tw, ctx.now_ms) {
1124 return false;
1125 }
1126 }
1127 true
1128}
1129
1130fn time_window_contains(tw: &TimeWindow, now_ms: u128) -> bool {
1131 let now_secs = (now_ms / 1000) as i128 + tw.tz_offset_secs as i128;
1133 let day_secs = now_secs.rem_euclid(86_400);
1134 let minute = (day_secs / 60) as u16;
1135 if tw.from_minute <= tw.to_minute {
1136 minute >= tw.from_minute && minute <= tw.to_minute
1137 } else {
1138 minute >= tw.from_minute || minute <= tw.to_minute
1140 }
1141}
1142
1143pub fn evaluate(
1150 policies: &[&Policy],
1151 action: &str,
1152 resource: &ResourceRef,
1153 ctx: &EvalContext,
1154) -> Decision {
1155 let mut allow_hit: Option<(String, Option<String>)> = None;
1156
1157 for p in policies {
1158 for st in &p.statements {
1159 if !condition_holds(st.condition.as_ref(), resource, ctx) {
1160 continue;
1161 }
1162 if !st.actions.iter().any(|a| action_matches(a, action)) {
1163 continue;
1164 }
1165 if !st
1166 .resources
1167 .iter()
1168 .any(|r| resource_matches(r, resource, ctx))
1169 {
1170 continue;
1171 }
1172 match st.effect {
1173 Effect::Deny => {
1176 return Decision::Deny {
1177 matched_policy_id: p.id.clone(),
1178 matched_sid: st.sid.clone(),
1179 };
1180 }
1181 Effect::Allow => {
1182 if allow_hit.is_none() {
1183 allow_hit = Some((p.id.clone(), st.sid.clone()));
1184 }
1185 }
1186 }
1187 }
1188 }
1189
1190 if ctx.principal_is_admin_role {
1193 return Decision::AdminBypass;
1194 }
1195
1196 match allow_hit {
1197 Some((pid, sid)) => Decision::Allow {
1198 matched_policy_id: pid,
1199 matched_sid: sid,
1200 },
1201 None => Decision::DefaultDeny,
1202 }
1203}
1204
1205#[derive(Debug, Clone, PartialEq)]
1207pub struct TrailEntry {
1208 pub policy_id: String,
1209 pub sid: Option<String>,
1210 pub matched: bool,
1211 pub effect: Effect,
1212 pub why_skipped: Option<&'static str>,
1213}
1214
1215#[derive(Debug, Clone, PartialEq)]
1217pub struct SimulationOutcome {
1218 pub decision: Decision,
1219 pub reason: String,
1220 pub trail: Vec<TrailEntry>,
1221}
1222
1223pub fn simulate(
1227 policies: &[&Policy],
1228 action: &str,
1229 resource: &ResourceRef,
1230 ctx: &EvalContext,
1231) -> SimulationOutcome {
1232 let mut trail = Vec::new();
1233 let mut allow_hit: Option<(String, Option<String>, usize)> = None;
1234 let mut deny_hit: Option<(String, Option<String>, usize)> = None;
1235
1236 'outer: for p in policies {
1237 for (idx, st) in p.statements.iter().enumerate() {
1238 let mut why: Option<&'static str> = None;
1239 let mut matched = false;
1240
1241 if !condition_holds(st.condition.as_ref(), resource, ctx) {
1242 why = Some("condition not met");
1243 } else if !st.actions.iter().any(|a| action_matches(a, action)) {
1244 why = Some("no action match");
1245 } else if !st
1246 .resources
1247 .iter()
1248 .any(|r| resource_matches(r, resource, ctx))
1249 {
1250 why = Some("no resource match");
1251 } else {
1252 matched = true;
1253 }
1254
1255 trail.push(TrailEntry {
1256 policy_id: p.id.clone(),
1257 sid: st.sid.clone(),
1258 matched,
1259 effect: st.effect,
1260 why_skipped: why,
1261 });
1262
1263 if matched {
1264 match st.effect {
1265 Effect::Deny => {
1266 deny_hit = Some((p.id.clone(), st.sid.clone(), idx));
1267 break 'outer;
1268 }
1269 Effect::Allow => {
1270 if allow_hit.is_none() {
1271 allow_hit = Some((p.id.clone(), st.sid.clone(), idx));
1272 }
1273 }
1274 }
1275 }
1276 }
1277 }
1278
1279 if let Some((pid, sid, idx)) = deny_hit {
1280 let reason = format!(
1281 "deny at {}.statement[{}]{}",
1282 pid,
1283 idx,
1284 sid.as_ref()
1285 .map(|s| format!(" (sid={s})"))
1286 .unwrap_or_default()
1287 );
1288 return SimulationOutcome {
1289 decision: Decision::Deny {
1290 matched_policy_id: pid,
1291 matched_sid: sid,
1292 },
1293 reason,
1294 trail,
1295 };
1296 }
1297 if ctx.principal_is_admin_role {
1300 return SimulationOutcome {
1301 decision: Decision::AdminBypass,
1302 reason: "admin allow: principal has Role::Admin (no explicit deny matched)".into(),
1303 trail,
1304 };
1305 }
1306 if let Some((pid, sid, idx)) = allow_hit {
1307 let reason = format!(
1308 "allow at {}.statement[{}]{}",
1309 pid,
1310 idx,
1311 sid.as_ref()
1312 .map(|s| format!(" (sid={s})"))
1313 .unwrap_or_default()
1314 );
1315 return SimulationOutcome {
1316 decision: Decision::Allow {
1317 matched_policy_id: pid,
1318 matched_sid: sid,
1319 },
1320 reason,
1321 trail,
1322 };
1323 }
1324 SimulationOutcome {
1325 decision: Decision::DefaultDeny,
1326 reason: "no statement matched (default deny)".into(),
1327 trail,
1328 }
1329}
1330
1331fn string_field(obj: &Map<String, Value>, key: &str) -> Result<String, PolicyError> {
1336 obj.get(key)
1337 .and_then(|v| v.as_str())
1338 .map(|s| s.to_string())
1339 .ok_or_else(|| PolicyError::InvalidJson(format!("policy.{key} required string")))
1340}
1341
1342#[cfg(test)]
1347mod tests {
1348 use super::*;
1349
1350 fn minimal_policy_json() -> &'static str {
1351 r#"{
1352 "id": "p-min",
1353 "version": 1,
1354 "statements": [
1355 { "effect": "allow", "actions": ["select"], "resources": ["table:public.x"] }
1356 ]
1357 }"#
1358 }
1359
1360 fn full_policy_json() -> &'static str {
1361 r#"{
1362 "id": "p-full",
1363 "version": 1,
1364 "tenant": "acme",
1365 "created_at": 1700000000000,
1366 "updated_at": 1700000001000,
1367 "statements": [
1368 {
1369 "sid": "s1",
1370 "effect": "allow",
1371 "actions": ["select", "insert"],
1372 "resources": ["table:public.orders", "table:public.*"]
1373 },
1374 {
1375 "sid": "s2",
1376 "effect": "deny",
1377 "actions": ["delete"],
1378 "resources": ["*"]
1379 }
1380 ]
1381 }"#
1382 }
1383
1384 fn cond_policy_json() -> &'static str {
1385 r#"{
1386 "id": "p-cond",
1387 "version": 1,
1388 "statements": [
1389 {
1390 "sid": "biz-hours",
1391 "effect": "allow",
1392 "actions": ["select"],
1393 "resources": ["table:public.orders"],
1394 "condition": {
1395 "expires_at": "2099-12-31T23:59:59Z",
1396 "valid_from": 1700000000000,
1397 "tenant_match": true,
1398 "source_ip": ["10.0.0.0/8"],
1399 "mfa": true,
1400 "time_window": { "from": "09:00", "to": "17:00", "tz": "UTC" }
1401 }
1402 }
1403 ]
1404 }"#
1405 }
1406
1407 fn ctx_now(now_ms: u128) -> EvalContext {
1408 EvalContext {
1409 now_ms,
1410 ..Default::default()
1411 }
1412 }
1413
1414 #[test]
1419 fn roundtrip_minimal() {
1420 let p = Policy::from_json_str(minimal_policy_json()).unwrap();
1421 let s = p.to_json_string();
1422 let p2 = Policy::from_json_str(&s).unwrap();
1423 assert_eq!(p, p2);
1424 assert_eq!(p.id, "p-min");
1425 assert_eq!(p.statements.len(), 1);
1426 }
1427
1428 #[test]
1429 fn roundtrip_full() {
1430 let p = Policy::from_json_str(full_policy_json()).unwrap();
1431 let s = p.to_json_string();
1432 let p2 = Policy::from_json_str(&s).unwrap();
1433 assert_eq!(p, p2);
1434 assert_eq!(p.tenant.as_deref(), Some("acme"));
1435 assert_eq!(p.statements.len(), 2);
1436 }
1437
1438 #[test]
1439 fn roundtrip_principal_attribute_conditions() {
1440 let p = Policy::from_json_str(
1441 r#"{
1442 "id": "p-attrs",
1443 "version": 1,
1444 "statements": [{
1445 "effect": "allow",
1446 "actions": ["admin:reload"],
1447 "resources": ["*"],
1448 "condition": { "system_owned": true, "platform_scoped": false }
1449 }]
1450 }"#,
1451 )
1452 .unwrap();
1453 let c = p.statements[0].condition.as_ref().unwrap();
1454 assert_eq!(c.system_owned, Some(true));
1455 assert_eq!(c.platform_scoped, Some(false));
1456 let p2 = Policy::from_json_str(&p.to_json_string()).unwrap();
1457 assert_eq!(p, p2);
1458 }
1459
1460 #[test]
1461 fn condition_principal_attributes_gate_evaluation() {
1462 let p = Policy::from_json_str(
1463 r#"{
1464 "id": "p-sys",
1465 "version": 1,
1466 "statements": [{
1467 "effect": "allow",
1468 "actions": ["admin:reload"],
1469 "resources": ["*"],
1470 "condition": { "system_owned": true }
1471 }]
1472 }"#,
1473 )
1474 .unwrap();
1475 let r = ResourceRef::new("config", "global");
1476 let mut ctx = ctx_now(1_700_000_000_000);
1477 ctx.principal_is_system_owned = false;
1478 assert!(matches!(
1479 evaluate(&[&p], "admin:reload", &r, &ctx),
1480 Decision::DefaultDeny
1481 ));
1482 ctx.principal_is_system_owned = true;
1483 assert!(matches!(
1484 evaluate(&[&p], "admin:reload", &r, &ctx),
1485 Decision::Allow { .. }
1486 ));
1487 }
1488
1489 #[test]
1490 fn roundtrip_with_conditions() {
1491 let p = Policy::from_json_str(cond_policy_json()).unwrap();
1492 let s = p.to_json_string();
1493 let p2 = Policy::from_json_str(&s).unwrap();
1494 assert_eq!(p, p2);
1495 let c = p.statements[0].condition.as_ref().unwrap();
1496 assert!(c.expires_at.is_some());
1497 assert!(c.valid_from.is_some());
1498 assert_eq!(c.tenant_match, Some(true));
1499 assert_eq!(c.mfa, Some(true));
1500 let cidrs = c.source_ip.as_ref().unwrap();
1501 assert_eq!(cidrs.len(), 1);
1502 assert_eq!(cidrs[0].prefix_len, 8);
1503 }
1504
1505 #[test]
1510 fn validator_rejects_invalid_json() {
1511 let err = Policy::from_json_str("{ not json").unwrap_err();
1512 matches!(err, PolicyError::InvalidJson(_));
1513 }
1514
1515 #[test]
1516 fn validator_rejects_invalid_action() {
1517 let bad = r#"{
1518 "id":"p","version":1,"statements":[
1519 {"effect":"allow","actions":["bogus"],"resources":["table:public.x"]}
1520 ]}"#;
1521 let err = Policy::from_json_str(bad).unwrap_err();
1522 assert!(matches!(err, PolicyError::InvalidAction(_)));
1523 }
1524
1525 #[test]
1526 fn validator_rejects_per_verb_kv_actions_except_invalidate() {
1527 for action in [
1528 "kv:get",
1529 "kv:put",
1530 "kv:delete",
1531 "kv:incr",
1532 "kv:cas",
1533 "kv:watch",
1534 ] {
1535 let bad = format!(
1536 r#"{{
1537 "id":"p","version":1,"statements":[
1538 {{"effect":"allow","actions":["{action}"],"resources":["kv:sessions"]}}
1539 ]}}"#
1540 );
1541 let err = Policy::from_json_str(&bad).unwrap_err();
1542 assert!(
1543 matches!(err, PolicyError::InvalidAction(ref invalid) if invalid == action),
1544 "expected {action} to be rejected, got {err:?}"
1545 );
1546 }
1547
1548 let allowed = r#"{
1549 "id":"p","version":1,"statements":[
1550 {"effect":"allow","actions":["kv:invalidate"],"resources":["kv:sessions"]}
1551 ]}"#;
1552 Policy::from_json_str(allowed).expect("kv:invalidate is the only per-KV verb action");
1553 }
1554
1555 #[test]
1556 fn validator_rejects_invalid_resource() {
1557 let bad = r#"{
1558 "id":"p","version":1,"statements":[
1559 {"effect":"allow","actions":["select"],"resources":["nokind"]}
1560 ]}"#;
1561 let err = Policy::from_json_str(bad).unwrap_err();
1562 assert!(matches!(err, PolicyError::InvalidResource(_)));
1563 }
1564
1565 #[test]
1566 fn validator_rejects_invalid_condition() {
1567 let bad = r#"{
1568 "id":"p","version":1,"statements":[
1569 {"effect":"allow","actions":["select"],"resources":["table:public.x"],
1570 "condition":{"expires_at":{}}}
1571 ]}"#;
1572 let err = Policy::from_json_str(bad).unwrap_err();
1573 assert!(matches!(err, PolicyError::InvalidCondition(_)));
1574 }
1575
1576 #[test]
1577 fn validator_rejects_invalid_cidr() {
1578 let bad = r#"{
1579 "id":"p","version":1,"statements":[
1580 {"effect":"allow","actions":["select"],"resources":["table:public.x"],
1581 "condition":{"source_ip":["10.0.0.0/99"]}}
1582 ]}"#;
1583 let err = Policy::from_json_str(bad).unwrap_err();
1584 assert!(matches!(err, PolicyError::InvalidCidr(_)));
1585 }
1586
1587 #[test]
1588 fn validator_rejects_duplicate_sid() {
1589 let bad = r#"{
1590 "id":"p","version":1,"statements":[
1591 {"sid":"x","effect":"allow","actions":["select"],"resources":["table:public.x"]},
1592 {"sid":"x","effect":"deny","actions":["delete"],"resources":["table:public.y"]}
1593 ]}"#;
1594 let err = Policy::from_json_str(bad).unwrap_err();
1595 assert!(matches!(err, PolicyError::DuplicateSid(_)));
1596 }
1597
1598 #[test]
1599 fn validator_rejects_empty_statements() {
1600 let bad = r#"{"id":"p","version":1,"statements":[]}"#;
1601 let err = Policy::from_json_str(bad).unwrap_err();
1602 assert!(matches!(err, PolicyError::EmptyStatements));
1603 }
1604
1605 #[test]
1606 fn validator_rejects_empty_actions() {
1607 let bad = r#"{
1608 "id":"p","version":1,"statements":[
1609 {"effect":"allow","actions":[],"resources":["table:public.x"]}
1610 ]}"#;
1611 let err = Policy::from_json_str(bad).unwrap_err();
1612 assert!(matches!(err, PolicyError::EmptyActions));
1613 }
1614
1615 #[test]
1616 fn validator_rejects_empty_resources() {
1617 let bad = r#"{
1618 "id":"p","version":1,"statements":[
1619 {"effect":"allow","actions":["select"],"resources":[]}
1620 ]}"#;
1621 let err = Policy::from_json_str(bad).unwrap_err();
1622 assert!(matches!(err, PolicyError::EmptyResources));
1623 }
1624
1625 #[test]
1626 fn validator_rejects_too_many_statements() {
1627 let mut p = Policy::from_json_str(minimal_policy_json()).unwrap();
1628 let st = p.statements[0].clone();
1629 for _ in 0..MAX_STATEMENTS {
1630 p.statements.push(st.clone());
1631 }
1632 let err = p.validate().unwrap_err();
1633 assert!(matches!(err, PolicyError::TooManyStatements(_)));
1634 }
1635
1636 #[test]
1637 fn validator_rejects_too_many_actions() {
1638 let mut p = Policy::from_json_str(minimal_policy_json()).unwrap();
1639 for _ in 0..MAX_ACTIONS {
1640 p.statements[0].actions.push(ActionPattern::Wildcard);
1641 }
1642 let err = p.validate().unwrap_err();
1643 assert!(matches!(err, PolicyError::TooManyActions(_)));
1644 }
1645
1646 #[test]
1647 fn validator_rejects_too_many_resources() {
1648 let mut p = Policy::from_json_str(minimal_policy_json()).unwrap();
1649 for _ in 0..MAX_RESOURCES {
1650 p.statements[0].resources.push(ResourcePattern::Wildcard);
1651 }
1652 let err = p.validate().unwrap_err();
1653 assert!(matches!(err, PolicyError::TooManyResources(_)));
1654 }
1655
1656 #[test]
1657 fn validator_rejects_oversize_json() {
1658 let big = "x".repeat(MAX_POLICY_BYTES + 1);
1659 let err = Policy::from_json_str(&big).unwrap_err();
1660 assert!(matches!(err, PolicyError::PolicyTooLarge(_)));
1661 }
1662
1663 #[test]
1668 fn glob_matches_table_public_star() {
1669 let pat = compile_glob("table:public.*");
1670 assert!(glob_matches(&pat, "table:public.orders"));
1671 assert!(glob_matches(&pat, "table:public."));
1672 assert!(!glob_matches(&pat, "table:other.x"));
1673 }
1674
1675 #[test]
1676 fn glob_matches_tenant_star() {
1677 let pat = compile_glob("tenant:acme/*");
1678 assert!(glob_matches(&pat, "tenant:acme/whatever"));
1679 assert!(glob_matches(&pat, "tenant:acme/a/b/c"));
1680 assert!(!glob_matches(&pat, "tenant:other/whatever"));
1681 }
1682
1683 #[test]
1684 fn action_match_exact() {
1685 assert!(action_matches(&compile_action("select"), "select"));
1686 assert!(!action_matches(&compile_action("select"), "selectall"));
1687 assert!(!action_matches(&compile_action("select"), "insert"));
1688 }
1689
1690 #[test]
1691 fn action_match_prefix() {
1692 let p = compile_action("admin:*");
1693 assert!(action_matches(&p, "admin:bootstrap"));
1694 assert!(action_matches(&p, "admin:reload"));
1695 assert!(!action_matches(&p, "admin"));
1696 assert!(!action_matches(&p, "select"));
1697 }
1698
1699 #[test]
1700 fn action_match_wildcard() {
1701 let p = compile_action("*");
1702 assert!(action_matches(&p, "select"));
1703 assert!(action_matches(&p, "admin:bootstrap"));
1704 assert!(action_matches(&p, "policy:put"));
1705 }
1706
1707 #[test]
1712 fn condition_expires_at() {
1713 let c = Condition {
1714 expires_at: Some(2_000),
1715 valid_from: None,
1716 tenant_match: None,
1717 source_ip: None,
1718 mfa: None,
1719 time_window: None,
1720 system_owned: None,
1721 platform_scoped: None,
1722 };
1723 let r = ResourceRef::new("table", "x");
1724 assert!(condition_holds(Some(&c), &r, &ctx_now(1_000)));
1725 assert!(!condition_holds(Some(&c), &r, &ctx_now(2_000)));
1726 assert!(!condition_holds(Some(&c), &r, &ctx_now(2_500)));
1727 }
1728
1729 #[test]
1730 fn condition_valid_from() {
1731 let c = Condition {
1732 expires_at: None,
1733 valid_from: Some(2_000),
1734 tenant_match: None,
1735 source_ip: None,
1736 mfa: None,
1737 time_window: None,
1738 system_owned: None,
1739 platform_scoped: None,
1740 };
1741 let r = ResourceRef::new("table", "x");
1742 assert!(!condition_holds(Some(&c), &r, &ctx_now(1_999)));
1743 assert!(condition_holds(Some(&c), &r, &ctx_now(2_000)));
1744 assert!(condition_holds(Some(&c), &r, &ctx_now(3_000)));
1745 }
1746
1747 #[test]
1748 fn condition_source_ip_v4() {
1749 let c = Condition {
1750 expires_at: None,
1751 valid_from: None,
1752 tenant_match: None,
1753 source_ip: Some(vec![parse_cidr("10.0.0.0/8").unwrap()]),
1754 mfa: None,
1755 time_window: None,
1756 system_owned: None,
1757 platform_scoped: None,
1758 };
1759 let r = ResourceRef::new("table", "x");
1760 let mut ctx = ctx_now(1);
1761 ctx.peer_ip = Some(IpAddr::from_str("10.0.0.1").unwrap());
1762 assert!(condition_holds(Some(&c), &r, &ctx));
1763 ctx.peer_ip = Some(IpAddr::from_str("11.0.0.1").unwrap());
1764 assert!(!condition_holds(Some(&c), &r, &ctx));
1765 ctx.peer_ip = None;
1766 assert!(!condition_holds(Some(&c), &r, &ctx));
1767 }
1768
1769 #[test]
1770 fn condition_source_ip_accepts_single_ip() {
1771 let cidr = parse_cidr("192.168.1.5").unwrap();
1772 assert_eq!(cidr.prefix_len, 32);
1773
1774 let c = Condition {
1775 expires_at: None,
1776 valid_from: None,
1777 tenant_match: None,
1778 source_ip: Some(vec![cidr]),
1779 mfa: None,
1780 time_window: None,
1781 system_owned: None,
1782 platform_scoped: None,
1783 };
1784 let r = ResourceRef::new("table", "public.x");
1785 let mut ctx = ctx_now(1);
1786 ctx.peer_ip = Some(IpAddr::from_str("192.168.1.5").unwrap());
1787 assert!(condition_holds(Some(&c), &r, &ctx));
1788 ctx.peer_ip = Some(IpAddr::from_str("192.168.1.6").unwrap());
1789 assert!(!condition_holds(Some(&c), &r, &ctx));
1790 }
1791
1792 #[test]
1793 fn condition_tenant_match() {
1794 let c = Condition {
1795 expires_at: None,
1796 valid_from: None,
1797 tenant_match: Some(true),
1798 source_ip: None,
1799 mfa: None,
1800 time_window: None,
1801 system_owned: None,
1802 platform_scoped: None,
1803 };
1804 let r = ResourceRef::new("table", "x").with_tenant("acme");
1805 let mut ctx = ctx_now(1);
1806 ctx.current_tenant = Some("acme".into());
1807 assert!(condition_holds(Some(&c), &r, &ctx));
1808 ctx.current_tenant = Some("globex".into());
1809 assert!(!condition_holds(Some(&c), &r, &ctx));
1810 }
1811
1812 #[test]
1813 fn condition_mfa() {
1814 let c = Condition {
1815 expires_at: None,
1816 valid_from: None,
1817 tenant_match: None,
1818 source_ip: None,
1819 mfa: Some(true),
1820 time_window: None,
1821 system_owned: None,
1822 platform_scoped: None,
1823 };
1824 let r = ResourceRef::new("table", "x");
1825 let mut ctx = ctx_now(1);
1826 ctx.mfa_present = true;
1827 assert!(condition_holds(Some(&c), &r, &ctx));
1828 ctx.mfa_present = false;
1829 assert!(!condition_holds(Some(&c), &r, &ctx));
1830 }
1831
1832 #[test]
1833 fn condition_time_window_normal() {
1834 let tw = TimeWindow {
1836 from_minute: 9 * 60,
1837 to_minute: 17 * 60,
1838 tz_offset_secs: 0,
1839 };
1840 assert!(time_window_contains(&tw, 12 * 3_600_000));
1841 assert!(time_window_contains(&tw, 9 * 3_600_000));
1842 assert!(time_window_contains(&tw, 17 * 3_600_000));
1843 assert!(!time_window_contains(&tw, 18 * 3_600_000));
1845 assert!(!time_window_contains(&tw, 6 * 3_600_000));
1847 }
1848
1849 #[test]
1850 fn condition_time_window_wraparound() {
1851 let tw = TimeWindow {
1853 from_minute: 22 * 60,
1854 to_minute: 6 * 60,
1855 tz_offset_secs: 0,
1856 };
1857 assert!(time_window_contains(&tw, 23 * 3_600_000));
1858 assert!(time_window_contains(&tw, 1 * 3_600_000));
1859 assert!(time_window_contains(&tw, 6 * 3_600_000));
1860 assert!(!time_window_contains(&tw, 12 * 3_600_000));
1861 assert!(!time_window_contains(&tw, 21 * 3_600_000));
1862 }
1863
1864 fn analyst_policy() -> Policy {
1869 Policy::from_json_str(
1870 r#"{
1871 "id":"analyst","version":1,"statements":[
1872 {"sid":"reads","effect":"allow",
1873 "actions":["select"],"resources":["table:public.orders"]}
1874 ]}"#,
1875 )
1876 .unwrap()
1877 }
1878
1879 fn no_deletes_policy() -> Policy {
1880 Policy::from_json_str(
1881 r#"{
1882 "id":"no-deletes","version":1,"statements":[
1883 {"sid":"hard-stop","effect":"deny",
1884 "actions":["delete"],"resources":["*"]}
1885 ]}"#,
1886 )
1887 .unwrap()
1888 }
1889
1890 #[test]
1891 fn evaluator_pure_allow() {
1892 let p = analyst_policy();
1893 let r = ResourceRef::new("table", "public.orders");
1894 let d = evaluate(&[&p], "select", &r, &EvalContext::default());
1895 match d {
1896 Decision::Allow {
1897 matched_policy_id,
1898 matched_sid,
1899 } => {
1900 assert_eq!(matched_policy_id, "analyst");
1901 assert_eq!(matched_sid.as_deref(), Some("reads"));
1902 }
1903 other => panic!("expected Allow, got {other:?}"),
1904 }
1905 }
1906
1907 #[test]
1908 fn evaluator_deny_overrides_allow() {
1909 let allow = analyst_policy();
1910 let deny = no_deletes_policy();
1911 let r = ResourceRef::new("table", "public.orders");
1912 let d = evaluate(&[&allow, &deny], "delete", &r, &EvalContext::default());
1914 match d {
1915 Decision::Deny {
1916 matched_policy_id, ..
1917 } => {
1918 assert_eq!(matched_policy_id, "no-deletes");
1919 }
1920 other => panic!("expected Deny, got {other:?}"),
1921 }
1922 }
1923
1924 #[test]
1925 fn evaluator_default_deny() {
1926 let p = analyst_policy();
1927 let r = ResourceRef::new("table", "public.invoices");
1928 let d = evaluate(&[&p], "select", &r, &EvalContext::default());
1929 assert_eq!(d, Decision::DefaultDeny);
1930 }
1931
1932 #[test]
1933 fn evaluator_admin_allow_when_no_deny() {
1934 let p = analyst_policy();
1937 let r = ResourceRef::new("table", "anything");
1938 let mut ctx = EvalContext::default();
1939 ctx.principal_is_admin_role = true;
1940 let d = evaluate(&[&p], "delete", &r, &ctx);
1941 assert_eq!(d, Decision::AdminBypass);
1942 }
1943
1944 #[test]
1945 fn evaluator_admin_does_not_bypass_explicit_deny() {
1946 let allow = analyst_policy();
1949 let deny = no_deletes_policy();
1950 let r = ResourceRef::new("table", "public.orders");
1951 let mut ctx = EvalContext::default();
1952 ctx.principal_is_admin_role = true;
1953 let d = evaluate(&[&allow, &deny], "delete", &r, &ctx);
1954 match d {
1955 Decision::Deny {
1956 matched_policy_id, ..
1957 } => assert_eq!(matched_policy_id, "no-deletes"),
1958 other => panic!("expected Deny for admin, got {other:?}"),
1959 }
1960 }
1961
1962 #[test]
1963 fn evaluator_implicit_tenant_scoping() {
1964 let p = Policy::from_json_str(
1969 r#"{
1970 "id":"impl","version":1,"statements":[
1971 {"sid":"s","effect":"allow",
1972 "actions":["select"],"resources":["table:public.x"]}
1973 ]}"#,
1974 )
1975 .unwrap();
1976 let r_acme = ResourceRef::new("table", "public.x").with_tenant("acme");
1977 let r_globex = ResourceRef::new("table", "public.x").with_tenant("globex");
1978 let mut ctx = EvalContext::default();
1979 ctx.current_tenant = Some("acme".into());
1980 assert!(matches!(
1981 evaluate(&[&p], "select", &r_acme, &ctx),
1982 Decision::Allow { .. }
1983 ));
1984 assert_eq!(
1985 evaluate(&[&p], "select", &r_globex, &ctx),
1986 Decision::DefaultDeny
1987 );
1988 }
1989
1990 #[test]
1995 fn simulator_produces_trail() {
1996 let allow = analyst_policy();
1997 let deny = no_deletes_policy();
1998 let r = ResourceRef::new("table", "public.orders");
1999 let out = simulate(&[&allow, &deny], "delete", &r, &EvalContext::default());
2000 assert!(out.trail.len() >= 2);
2003 assert!(matches!(out.decision, Decision::Deny { .. }));
2004 assert!(out.reason.contains("deny"));
2005 }
2006
2007 #[test]
2012 fn rfc3339_parses_to_ms() {
2013 let ms = parse_rfc3339_ms("1970-01-01T00:00:00Z").unwrap();
2014 assert_eq!(ms, 0);
2015 let ms = parse_rfc3339_ms("1970-01-01T00:00:01.500Z").unwrap();
2016 assert_eq!(ms, 1_500);
2017 let ms = parse_rfc3339_ms("2024-01-01T00:00:00+00:00").unwrap();
2018 assert_eq!(ms, 19_723u128 * 86_400_000);
2020 }
2021
2022 #[test]
2023 fn rfc3339_handles_negative_offset() {
2024 let a = parse_rfc3339_ms("2024-01-01T01:00:00+01:00").unwrap();
2026 let b = parse_rfc3339_ms("2024-01-01T00:00:00Z").unwrap();
2027 assert_eq!(a, b);
2028 }
2029
2030 #[test]
2031 fn cidr_v6_basic() {
2032 let c = parse_cidr("::1/128").unwrap();
2033 assert_eq!(c.prefix_len, 128);
2034 assert!(cidr_contains(&c, IpAddr::from_str("::1").unwrap()));
2035 assert!(!cidr_contains(&c, IpAddr::from_str("::2").unwrap()));
2036 }
2037}