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