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::Integer(n) if *n >= 0 => Ok(*n as u128),
772 Value::Number(n) if *n >= 0.0 => Ok(*n as u128),
773 Value::String(s) => parse_rfc3339_ms(s),
774 _ => Err(PolicyError::InvalidCondition(format!(
775 "timestamp expected (rfc3339 or ms epoch), got {v:?}"
776 ))),
777 }
778}
779
780fn parse_rfc3339_ms(s: &str) -> Result<u128, PolicyError> {
784 let bad = || PolicyError::InvalidCondition(format!("not rfc3339: {s}"));
785 if s.len() < 20 {
786 return Err(bad());
787 }
788 let bytes = s.as_bytes();
789 if bytes[4] != b'-' || bytes[7] != b'-' || bytes[10] != b'T' {
790 return Err(bad());
791 }
792 let year: i64 = s[0..4].parse().map_err(|_| bad())?;
793 let month: u32 = s[5..7].parse().map_err(|_| bad())?;
794 let day: u32 = s[8..10].parse().map_err(|_| bad())?;
795 if bytes[13] != b':' || bytes[16] != b':' {
796 return Err(bad());
797 }
798 let hour: u64 = s[11..13].parse().map_err(|_| bad())?;
799 let minute: u64 = s[14..16].parse().map_err(|_| bad())?;
800 let second: u64 = s[17..19].parse().map_err(|_| bad())?;
801
802 let mut idx = 19;
804 let mut millis: u64 = 0;
805 if idx < bytes.len() && bytes[idx] == b'.' {
806 idx += 1;
807 let start = idx;
808 while idx < bytes.len() && bytes[idx].is_ascii_digit() {
809 idx += 1;
810 }
811 let frac = &s[start..idx];
812 if !frac.is_empty() {
813 let take = frac.len().min(3);
815 let pad = "0".repeat(3 - take);
816 let combined = format!("{}{}", &frac[..take], pad);
817 millis = combined.parse().map_err(|_| bad())?;
818 }
819 }
820
821 let mut offset_secs: i64 = 0;
823 if idx < bytes.len() {
824 match bytes[idx] {
825 b'Z' | b'z' => {
826 idx += 1;
827 }
828 b'+' | b'-' => {
829 if bytes.len() < idx + 6 || bytes[idx + 3] != b':' {
830 return Err(bad());
831 }
832 let sign: i64 = if bytes[idx] == b'+' { 1 } else { -1 };
833 let oh: i64 = s[idx + 1..idx + 3].parse().map_err(|_| bad())?;
834 let om: i64 = s[idx + 4..idx + 6].parse().map_err(|_| bad())?;
835 offset_secs = sign * (oh * 3600 + om * 60);
836 idx += 6;
837 }
838 _ => return Err(bad()),
839 }
840 }
841 if idx != bytes.len() {
842 return Err(bad());
843 }
844
845 let days = days_from_civil(year, month as i64, day as i64);
846 let total_secs =
847 days * 86_400 + (hour as i64) * 3600 + (minute as i64) * 60 + second as i64 - offset_secs;
848 if total_secs < 0 {
849 return Err(bad());
850 }
851 Ok((total_secs as u128) * 1000 + millis as u128)
852}
853
854fn days_from_civil(y: i64, m: i64, d: i64) -> i64 {
857 let y = if m <= 2 { y - 1 } else { y };
858 let era = if y >= 0 { y } else { y - 399 } / 400;
859 let yoe = y - era * 400; 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
863}
864
865fn parse_hhmm(s: &str) -> Result<u16, PolicyError> {
866 let bad = || PolicyError::InvalidCondition(format!("HH:MM expected, got {s}"));
867 if s.len() != 5 || s.as_bytes()[2] != b':' {
868 return Err(bad());
869 }
870 let h: u16 = s[0..2].parse().map_err(|_| bad())?;
871 let m: u16 = s[3..5].parse().map_err(|_| bad())?;
872 if h >= 24 || m >= 60 {
873 return Err(bad());
874 }
875 Ok(h * 60 + m)
876}
877
878fn format_hhmm(min: u16) -> String {
879 format!("{:02}:{:02}", min / 60, min % 60)
880}
881
882fn parse_tz_offset(s: &str) -> Result<i32, PolicyError> {
883 if s == "UTC" || s == "Z" {
884 return Ok(0);
885 }
886 let bytes = s.as_bytes();
887 if bytes.len() == 6 && (bytes[0] == b'+' || bytes[0] == b'-') && bytes[3] == b':' {
888 let sign: i32 = if bytes[0] == b'+' { 1 } else { -1 };
889 let h: i32 = s[1..3]
890 .parse()
891 .map_err(|_| PolicyError::InvalidCondition(format!("bad tz: {s}")))?;
892 let m: i32 = s[4..6]
893 .parse()
894 .map_err(|_| PolicyError::InvalidCondition(format!("bad tz: {s}")))?;
895 return Ok(sign * (h * 3600 + m * 60));
896 }
897 Err(PolicyError::InvalidCondition(format!(
898 "tz must be UTC or +HH:MM/-HH:MM (got {s})"
899 )))
900}
901
902fn format_tz(secs: i32) -> String {
903 if secs == 0 {
904 return "UTC".into();
905 }
906 let sign = if secs >= 0 { '+' } else { '-' };
907 let abs = secs.abs();
908 format!("{}{:02}:{:02}", sign, abs / 3600, (abs % 3600) / 60)
909}
910
911fn parse_cidr(s: &str) -> Result<IpCidr, PolicyError> {
916 let (addr_s, prefix_s) = match s.split_once('/') {
917 Some(parts) => parts,
918 None => {
919 let addr =
920 IpAddr::from_str(s).map_err(|e| PolicyError::InvalidCidr(format!("{s}: {e}")))?;
921 let prefix_len = match addr {
922 IpAddr::V4(_) => 32,
923 IpAddr::V6(_) => 128,
924 };
925 return Ok(IpCidr { addr, prefix_len });
926 }
927 };
928 let addr =
929 IpAddr::from_str(addr_s).map_err(|e| PolicyError::InvalidCidr(format!("{s}: {e}")))?;
930 let prefix_len: u8 = prefix_s
931 .parse()
932 .map_err(|_| PolicyError::InvalidCidr(format!("bad prefix in {s}")))?;
933 let max = match addr {
934 IpAddr::V4(_) => 32,
935 IpAddr::V6(_) => 128,
936 };
937 if prefix_len > max {
938 return Err(PolicyError::InvalidCidr(format!("prefix > {max} in {s}")));
939 }
940 Ok(IpCidr { addr, prefix_len })
941}
942
943fn cidr_contains(cidr: &IpCidr, ip: IpAddr) -> bool {
944 match (cidr.addr, ip) {
945 (IpAddr::V4(net), IpAddr::V4(ip)) => {
946 let n = u32::from_be_bytes(net.octets());
947 let i = u32::from_be_bytes(ip.octets());
948 let mask = if cidr.prefix_len == 0 {
949 0u32
950 } else {
951 u32::MAX << (32 - cidr.prefix_len)
952 };
953 (n & mask) == (i & mask)
954 }
955 (IpAddr::V6(net), IpAddr::V6(ip)) => {
956 let n = u128::from_be_bytes(net.octets());
957 let i = u128::from_be_bytes(ip.octets());
958 let mask = if cidr.prefix_len == 0 {
959 0u128
960 } else {
961 u128::MAX << (128 - cidr.prefix_len)
962 };
963 (n & mask) == (i & mask)
964 }
965 _ => false, }
967}
968
969fn action_matches(pat: &ActionPattern, action: &str) -> bool {
974 match pat {
975 ActionPattern::Wildcard => true,
976 ActionPattern::Exact(s) => s == action,
977 ActionPattern::Prefix(p) => {
978 action.len() > p.len() + 1
980 && action.starts_with(p.as_str())
981 && action.as_bytes()[p.len()] == b':'
982 }
983 }
984}
985
986fn resource_matches(pat: &ResourcePattern, resource: &ResourceRef, ctx: &EvalContext) -> bool {
991 let target = qualified_name(&resource.kind, &resource.name, resource.tenant.as_deref());
992 match pat {
993 ResourcePattern::Wildcard => true,
994 ResourcePattern::Exact { kind, name } => {
995 if kind != &resource.kind {
996 return false;
997 }
998 let qualified = if name.starts_with("tenant/") {
999 format!("{kind}:{name}")
1000 } else {
1001 qualified_name(kind, name, ctx.current_tenant.as_deref())
1002 };
1003 qualified == target
1004 }
1005 ResourcePattern::Glob(raw) => {
1006 let (pkind, pname) = match raw.split_once(':') {
1007 Some(parts) => parts,
1008 None => return false,
1009 };
1010 if !pkind.is_empty() && pkind != "*" && pkind != resource.kind {
1011 return false;
1012 }
1013 let qualified_pat = if pname.starts_with("tenant/") || pname == "*" {
1014 format!("{pkind}:{pname}")
1015 } else {
1016 let scoped = match ctx.current_tenant.as_deref() {
1017 Some(t) => format!("tenant/{t}/{pname}"),
1018 None => pname.to_string(),
1019 };
1020 format!("{pkind}:{scoped}")
1021 };
1022 let compiled = compile_glob(&qualified_pat);
1023 glob_matches(&compiled, &target)
1024 }
1025 }
1026}
1027
1028fn qualified_name(kind: &str, name: &str, tenant: Option<&str>) -> String {
1031 if name.starts_with("tenant/") {
1032 return format!("{kind}:{name}");
1033 }
1034 match tenant {
1035 Some(t) => format!("{kind}:tenant/{t}/{name}"),
1036 None => format!("{kind}:{name}"),
1037 }
1038}
1039
1040fn condition_holds(cond: Option<&Condition>, resource: &ResourceRef, ctx: &EvalContext) -> bool {
1045 let Some(c) = cond else { return true };
1046 if let Some(exp) = c.expires_at {
1047 if ctx.now_ms >= exp {
1048 return false;
1049 }
1050 }
1051 if let Some(vf) = c.valid_from {
1052 if ctx.now_ms < vf {
1053 return false;
1054 }
1055 }
1056 if let Some(true) = c.tenant_match {
1057 if resource.tenant.as_deref() != ctx.current_tenant.as_deref() {
1058 return false;
1059 }
1060 }
1061 if let Some(true) = c.mfa {
1062 if !ctx.mfa_present {
1063 return false;
1064 }
1065 }
1066 if let Some(want) = c.platform_scoped {
1067 if ctx.principal_is_platform_scoped != want {
1068 return false;
1069 }
1070 }
1071 if let Some(cidrs) = &c.source_ip {
1072 let Some(ip) = ctx.peer_ip else {
1073 return false;
1074 };
1075 if !cidrs.iter().any(|c| cidr_contains(c, ip)) {
1076 return false;
1077 }
1078 }
1079 if let Some(tw) = &c.time_window {
1080 if !time_window_contains(tw, ctx.now_ms) {
1081 return false;
1082 }
1083 }
1084 true
1085}
1086
1087fn time_window_contains(tw: &TimeWindow, now_ms: u128) -> bool {
1088 let now_secs = (now_ms / 1000) as i128 + tw.tz_offset_secs as i128;
1090 let day_secs = now_secs.rem_euclid(86_400);
1091 let minute = (day_secs / 60) as u16;
1092 if tw.from_minute <= tw.to_minute {
1093 minute >= tw.from_minute && minute <= tw.to_minute
1094 } else {
1095 minute >= tw.from_minute || minute <= tw.to_minute
1097 }
1098}
1099
1100pub fn evaluate(
1107 policies: &[&Policy],
1108 action: &str,
1109 resource: &ResourceRef,
1110 ctx: &EvalContext,
1111) -> Decision {
1112 let mut allow_hit: Option<(String, Option<String>)> = None;
1113
1114 for p in policies {
1115 for st in &p.statements {
1116 if !condition_holds(st.condition.as_ref(), resource, ctx) {
1117 continue;
1118 }
1119 if !st.actions.iter().any(|a| action_matches(a, action)) {
1120 continue;
1121 }
1122 if !st
1123 .resources
1124 .iter()
1125 .any(|r| resource_matches(r, resource, ctx))
1126 {
1127 continue;
1128 }
1129 match st.effect {
1130 Effect::Deny => {
1133 return Decision::Deny {
1134 matched_policy_id: p.id.clone(),
1135 matched_sid: st.sid.clone(),
1136 };
1137 }
1138 Effect::Allow => {
1139 if allow_hit.is_none() {
1140 allow_hit = Some((p.id.clone(), st.sid.clone()));
1141 }
1142 }
1143 }
1144 }
1145 }
1146
1147 let _ = ctx.principal_is_admin_role;
1152
1153 match allow_hit {
1154 Some((pid, sid)) => Decision::Allow {
1155 matched_policy_id: pid,
1156 matched_sid: sid,
1157 },
1158 None => Decision::DefaultDeny,
1159 }
1160}
1161
1162#[derive(Debug, Clone, PartialEq)]
1164pub struct TrailEntry {
1165 pub policy_id: String,
1166 pub sid: Option<String>,
1167 pub matched: bool,
1168 pub effect: Effect,
1169 pub why_skipped: Option<&'static str>,
1170}
1171
1172#[derive(Debug, Clone, PartialEq)]
1174pub struct SimulationOutcome {
1175 pub decision: Decision,
1176 pub reason: String,
1177 pub trail: Vec<TrailEntry>,
1178}
1179
1180pub fn simulate(
1184 policies: &[&Policy],
1185 action: &str,
1186 resource: &ResourceRef,
1187 ctx: &EvalContext,
1188) -> SimulationOutcome {
1189 let mut trail = Vec::new();
1190 let mut allow_hit: Option<(String, Option<String>, usize)> = None;
1191 let mut deny_hit: Option<(String, Option<String>, usize)> = None;
1192
1193 'outer: for p in policies {
1194 for (idx, st) in p.statements.iter().enumerate() {
1195 let mut why: Option<&'static str> = None;
1196 let mut matched = false;
1197
1198 if !condition_holds(st.condition.as_ref(), resource, ctx) {
1199 why = Some("condition not met");
1200 } else if !st.actions.iter().any(|a| action_matches(a, action)) {
1201 why = Some("no action match");
1202 } else if !st
1203 .resources
1204 .iter()
1205 .any(|r| resource_matches(r, resource, ctx))
1206 {
1207 why = Some("no resource match");
1208 } else {
1209 matched = true;
1210 }
1211
1212 trail.push(TrailEntry {
1213 policy_id: p.id.clone(),
1214 sid: st.sid.clone(),
1215 matched,
1216 effect: st.effect,
1217 why_skipped: why,
1218 });
1219
1220 if matched {
1221 match st.effect {
1222 Effect::Deny => {
1223 deny_hit = Some((p.id.clone(), st.sid.clone(), idx));
1224 break 'outer;
1225 }
1226 Effect::Allow => {
1227 if allow_hit.is_none() {
1228 allow_hit = Some((p.id.clone(), st.sid.clone(), idx));
1229 }
1230 }
1231 }
1232 }
1233 }
1234 }
1235
1236 if let Some((pid, sid, idx)) = deny_hit {
1237 let reason = format!(
1238 "deny at {}.statement[{}]{}",
1239 pid,
1240 idx,
1241 sid.as_ref()
1242 .map(|s| format!(" (sid={s})"))
1243 .unwrap_or_default()
1244 );
1245 return SimulationOutcome {
1246 decision: Decision::Deny {
1247 matched_policy_id: pid,
1248 matched_sid: sid,
1249 },
1250 reason,
1251 trail,
1252 };
1253 }
1254 let _ = ctx.principal_is_admin_role;
1258 if let Some((pid, sid, idx)) = allow_hit {
1259 let reason = format!(
1260 "allow at {}.statement[{}]{}",
1261 pid,
1262 idx,
1263 sid.as_ref()
1264 .map(|s| format!(" (sid={s})"))
1265 .unwrap_or_default()
1266 );
1267 return SimulationOutcome {
1268 decision: Decision::Allow {
1269 matched_policy_id: pid,
1270 matched_sid: sid,
1271 },
1272 reason,
1273 trail,
1274 };
1275 }
1276 SimulationOutcome {
1277 decision: Decision::DefaultDeny,
1278 reason: "no statement matched (default deny)".into(),
1279 trail,
1280 }
1281}
1282
1283fn string_field(obj: &Map<String, Value>, key: &str) -> Result<String, PolicyError> {
1288 obj.get(key)
1289 .and_then(|v| v.as_str())
1290 .map(|s| s.to_string())
1291 .ok_or_else(|| PolicyError::InvalidJson(format!("policy.{key} required string")))
1292}
1293
1294#[cfg(test)]
1299mod tests {
1300 use super::*;
1301
1302 fn minimal_policy_json() -> &'static str {
1303 r#"{
1304 "id": "p-min",
1305 "version": 1,
1306 "statements": [
1307 { "effect": "allow", "actions": ["select"], "resources": ["table:public.x"] }
1308 ]
1309 }"#
1310 }
1311
1312 fn full_policy_json() -> &'static str {
1313 r#"{
1314 "id": "p-full",
1315 "version": 1,
1316 "tenant": "acme",
1317 "created_at": 1700000000000,
1318 "updated_at": 1700000001000,
1319 "statements": [
1320 {
1321 "sid": "s1",
1322 "effect": "allow",
1323 "actions": ["select", "insert"],
1324 "resources": ["table:public.orders", "table:public.*"]
1325 },
1326 {
1327 "sid": "s2",
1328 "effect": "deny",
1329 "actions": ["delete"],
1330 "resources": ["*"]
1331 }
1332 ]
1333 }"#
1334 }
1335
1336 fn cond_policy_json() -> &'static str {
1337 r#"{
1338 "id": "p-cond",
1339 "version": 1,
1340 "statements": [
1341 {
1342 "sid": "biz-hours",
1343 "effect": "allow",
1344 "actions": ["select"],
1345 "resources": ["table:public.orders"],
1346 "condition": {
1347 "expires_at": "2099-12-31T23:59:59Z",
1348 "valid_from": 1700000000000,
1349 "tenant_match": true,
1350 "source_ip": ["10.0.0.0/8"],
1351 "mfa": true,
1352 "time_window": { "from": "09:00", "to": "17:00", "tz": "UTC" }
1353 }
1354 }
1355 ]
1356 }"#
1357 }
1358
1359 fn ctx_now(now_ms: u128) -> EvalContext {
1360 EvalContext {
1361 now_ms,
1362 ..Default::default()
1363 }
1364 }
1365
1366 #[test]
1371 fn roundtrip_minimal() {
1372 let p = Policy::from_json_str(minimal_policy_json()).unwrap();
1373 let s = p.to_json_string();
1374 let p2 = Policy::from_json_str(&s).unwrap();
1375 assert_eq!(p, p2);
1376 assert_eq!(p.id, "p-min");
1377 assert_eq!(p.statements.len(), 1);
1378 }
1379
1380 #[test]
1381 fn roundtrip_full() {
1382 let p = Policy::from_json_str(full_policy_json()).unwrap();
1383 let s = p.to_json_string();
1384 let p2 = Policy::from_json_str(&s).unwrap();
1385 assert_eq!(p, p2);
1386 assert_eq!(p.tenant.as_deref(), Some("acme"));
1387 assert_eq!(p.statements.len(), 2);
1388 }
1389
1390 #[test]
1391 fn roundtrip_platform_scoped_condition() {
1392 let p = Policy::from_json_str(
1393 r#"{
1394 "id": "p-attrs",
1395 "version": 1,
1396 "statements": [{
1397 "effect": "allow",
1398 "actions": ["admin:reload"],
1399 "resources": ["*"],
1400 "condition": { "platform_scoped": false }
1401 }]
1402 }"#,
1403 )
1404 .unwrap();
1405 let c = p.statements[0].condition.as_ref().unwrap();
1406 assert_eq!(c.platform_scoped, Some(false));
1407 let p2 = Policy::from_json_str(&p.to_json_string()).unwrap();
1408 assert_eq!(p, p2);
1409 }
1410
1411 #[test]
1412 fn system_owned_condition_is_rejected() {
1413 let err = Policy::from_json_str(
1414 r#"{
1415 "id": "p-sys",
1416 "version": 1,
1417 "statements": [{
1418 "effect": "allow",
1419 "actions": ["admin:reload"],
1420 "resources": ["*"],
1421 "condition": { "system_owned": true }
1422 }]
1423 }"#,
1424 )
1425 .unwrap_err();
1426 assert!(matches!(err, PolicyError::InvalidCondition(_)));
1427 }
1428
1429 #[test]
1430 fn roundtrip_with_conditions() {
1431 let p = Policy::from_json_str(cond_policy_json()).unwrap();
1432 let s = p.to_json_string();
1433 let p2 = Policy::from_json_str(&s).unwrap();
1434 assert_eq!(p, p2);
1435 let c = p.statements[0].condition.as_ref().unwrap();
1436 assert!(c.expires_at.is_some());
1437 assert!(c.valid_from.is_some());
1438 assert_eq!(c.tenant_match, Some(true));
1439 assert_eq!(c.mfa, Some(true));
1440 let cidrs = c.source_ip.as_ref().unwrap();
1441 assert_eq!(cidrs.len(), 1);
1442 assert_eq!(cidrs[0].prefix_len, 8);
1443 }
1444
1445 #[test]
1450 fn validator_rejects_invalid_json() {
1451 let err = Policy::from_json_str("{ not json").unwrap_err();
1452 matches!(err, PolicyError::InvalidJson(_));
1453 }
1454
1455 #[test]
1456 fn validator_rejects_invalid_action() {
1457 let bad = r#"{
1458 "id":"p","version":1,"statements":[
1459 {"effect":"allow","actions":["bogus"],"resources":["table:public.x"]}
1460 ]}"#;
1461 let err = Policy::from_json_str(bad).unwrap_err();
1462 assert!(matches!(err, PolicyError::InvalidAction(_)));
1463 }
1464
1465 #[test]
1466 fn validator_rejects_per_verb_kv_actions_except_invalidate() {
1467 for action in [
1468 "kv:get",
1469 "kv:put",
1470 "kv:delete",
1471 "kv:incr",
1472 "kv:cas",
1473 "kv:watch",
1474 ] {
1475 let bad = format!(
1476 r#"{{
1477 "id":"p","version":1,"statements":[
1478 {{"effect":"allow","actions":["{action}"],"resources":["kv:sessions"]}}
1479 ]}}"#
1480 );
1481 let err = Policy::from_json_str(&bad).unwrap_err();
1482 assert!(
1483 matches!(err, PolicyError::InvalidAction(ref invalid) if invalid == action),
1484 "expected {action} to be rejected, got {err:?}"
1485 );
1486 }
1487
1488 let allowed = r#"{
1489 "id":"p","version":1,"statements":[
1490 {"effect":"allow","actions":["kv:invalidate"],"resources":["kv:sessions"]}
1491 ]}"#;
1492 Policy::from_json_str(allowed).expect("kv:invalidate is the only per-KV verb action");
1493 }
1494
1495 #[test]
1496 fn validator_rejects_invalid_resource() {
1497 let bad = r#"{
1498 "id":"p","version":1,"statements":[
1499 {"effect":"allow","actions":["select"],"resources":["nokind"]}
1500 ]}"#;
1501 let err = Policy::from_json_str(bad).unwrap_err();
1502 assert!(matches!(err, PolicyError::InvalidResource(_)));
1503 }
1504
1505 #[test]
1506 fn validator_rejects_invalid_condition() {
1507 let bad = r#"{
1508 "id":"p","version":1,"statements":[
1509 {"effect":"allow","actions":["select"],"resources":["table:public.x"],
1510 "condition":{"expires_at":{}}}
1511 ]}"#;
1512 let err = Policy::from_json_str(bad).unwrap_err();
1513 assert!(matches!(err, PolicyError::InvalidCondition(_)));
1514 }
1515
1516 #[test]
1517 fn validator_rejects_invalid_cidr() {
1518 let bad = r#"{
1519 "id":"p","version":1,"statements":[
1520 {"effect":"allow","actions":["select"],"resources":["table:public.x"],
1521 "condition":{"source_ip":["10.0.0.0/99"]}}
1522 ]}"#;
1523 let err = Policy::from_json_str(bad).unwrap_err();
1524 assert!(matches!(err, PolicyError::InvalidCidr(_)));
1525 }
1526
1527 #[test]
1528 fn validator_rejects_duplicate_sid() {
1529 let bad = r#"{
1530 "id":"p","version":1,"statements":[
1531 {"sid":"x","effect":"allow","actions":["select"],"resources":["table:public.x"]},
1532 {"sid":"x","effect":"deny","actions":["delete"],"resources":["table:public.y"]}
1533 ]}"#;
1534 let err = Policy::from_json_str(bad).unwrap_err();
1535 assert!(matches!(err, PolicyError::DuplicateSid(_)));
1536 }
1537
1538 #[test]
1539 fn validator_rejects_empty_statements() {
1540 let bad = r#"{"id":"p","version":1,"statements":[]}"#;
1541 let err = Policy::from_json_str(bad).unwrap_err();
1542 assert!(matches!(err, PolicyError::EmptyStatements));
1543 }
1544
1545 #[test]
1546 fn validator_rejects_empty_actions() {
1547 let bad = r#"{
1548 "id":"p","version":1,"statements":[
1549 {"effect":"allow","actions":[],"resources":["table:public.x"]}
1550 ]}"#;
1551 let err = Policy::from_json_str(bad).unwrap_err();
1552 assert!(matches!(err, PolicyError::EmptyActions));
1553 }
1554
1555 #[test]
1556 fn validator_rejects_empty_resources() {
1557 let bad = r#"{
1558 "id":"p","version":1,"statements":[
1559 {"effect":"allow","actions":["select"],"resources":[]}
1560 ]}"#;
1561 let err = Policy::from_json_str(bad).unwrap_err();
1562 assert!(matches!(err, PolicyError::EmptyResources));
1563 }
1564
1565 #[test]
1566 fn validator_rejects_too_many_statements() {
1567 let mut p = Policy::from_json_str(minimal_policy_json()).unwrap();
1568 let st = p.statements[0].clone();
1569 for _ in 0..MAX_STATEMENTS {
1570 p.statements.push(st.clone());
1571 }
1572 let err = p.validate().unwrap_err();
1573 assert!(matches!(err, PolicyError::TooManyStatements(_)));
1574 }
1575
1576 #[test]
1577 fn validator_rejects_too_many_actions() {
1578 let mut p = Policy::from_json_str(minimal_policy_json()).unwrap();
1579 for _ in 0..MAX_ACTIONS {
1580 p.statements[0].actions.push(ActionPattern::Wildcard);
1581 }
1582 let err = p.validate().unwrap_err();
1583 assert!(matches!(err, PolicyError::TooManyActions(_)));
1584 }
1585
1586 #[test]
1587 fn validator_rejects_too_many_resources() {
1588 let mut p = Policy::from_json_str(minimal_policy_json()).unwrap();
1589 for _ in 0..MAX_RESOURCES {
1590 p.statements[0].resources.push(ResourcePattern::Wildcard);
1591 }
1592 let err = p.validate().unwrap_err();
1593 assert!(matches!(err, PolicyError::TooManyResources(_)));
1594 }
1595
1596 #[test]
1597 fn validator_rejects_oversize_json() {
1598 let big = "x".repeat(MAX_POLICY_BYTES + 1);
1599 let err = Policy::from_json_str(&big).unwrap_err();
1600 assert!(matches!(err, PolicyError::PolicyTooLarge(_)));
1601 }
1602
1603 #[test]
1608 fn glob_matches_table_public_star() {
1609 let pat = compile_glob("table:public.*");
1610 assert!(glob_matches(&pat, "table:public.orders"));
1611 assert!(glob_matches(&pat, "table:public."));
1612 assert!(!glob_matches(&pat, "table:other.x"));
1613 }
1614
1615 #[test]
1616 fn glob_matches_tenant_star() {
1617 let pat = compile_glob("tenant:acme/*");
1618 assert!(glob_matches(&pat, "tenant:acme/whatever"));
1619 assert!(glob_matches(&pat, "tenant:acme/a/b/c"));
1620 assert!(!glob_matches(&pat, "tenant:other/whatever"));
1621 }
1622
1623 #[test]
1624 fn glob_ai_credential_alias_paths_are_separable_per_alias() {
1625 let prod = compile_glob("vault:red.vault/red.secret.ai.providers.openai.tokens.prod*");
1631 assert!(glob_matches(
1632 &prod,
1633 "vault:red.vault/red.secret.ai.providers.openai.tokens.prod"
1634 ));
1635 assert!(!glob_matches(
1638 &prod,
1639 "vault:red.vault/red.secret.ai.providers.openai.tokens.default"
1640 ));
1641 }
1642
1643 #[test]
1644 fn action_match_exact() {
1645 assert!(action_matches(&compile_action("select"), "select"));
1646 assert!(!action_matches(&compile_action("select"), "selectall"));
1647 assert!(!action_matches(&compile_action("select"), "insert"));
1648 }
1649
1650 #[test]
1651 fn action_match_prefix() {
1652 let p = compile_action("admin:*");
1653 assert!(action_matches(&p, "admin:bootstrap"));
1654 assert!(action_matches(&p, "admin:reload"));
1655 assert!(!action_matches(&p, "admin"));
1656 assert!(!action_matches(&p, "select"));
1657 }
1658
1659 #[test]
1660 fn action_match_wildcard() {
1661 let p = compile_action("*");
1662 assert!(action_matches(&p, "select"));
1663 assert!(action_matches(&p, "admin:bootstrap"));
1664 assert!(action_matches(&p, "policy:put"));
1665 }
1666
1667 #[test]
1672 fn condition_expires_at() {
1673 let c = Condition {
1674 expires_at: Some(2_000),
1675 valid_from: None,
1676 tenant_match: None,
1677 source_ip: None,
1678 mfa: None,
1679 time_window: None,
1680 platform_scoped: None,
1681 };
1682 let r = ResourceRef::new("table", "x");
1683 assert!(condition_holds(Some(&c), &r, &ctx_now(1_000)));
1684 assert!(!condition_holds(Some(&c), &r, &ctx_now(2_000)));
1685 assert!(!condition_holds(Some(&c), &r, &ctx_now(2_500)));
1686 }
1687
1688 #[test]
1689 fn condition_valid_from() {
1690 let c = Condition {
1691 expires_at: None,
1692 valid_from: Some(2_000),
1693 tenant_match: None,
1694 source_ip: None,
1695 mfa: None,
1696 time_window: None,
1697 platform_scoped: None,
1698 };
1699 let r = ResourceRef::new("table", "x");
1700 assert!(!condition_holds(Some(&c), &r, &ctx_now(1_999)));
1701 assert!(condition_holds(Some(&c), &r, &ctx_now(2_000)));
1702 assert!(condition_holds(Some(&c), &r, &ctx_now(3_000)));
1703 }
1704
1705 #[test]
1706 fn condition_source_ip_v4() {
1707 let c = Condition {
1708 expires_at: None,
1709 valid_from: None,
1710 tenant_match: None,
1711 source_ip: Some(vec![parse_cidr("10.0.0.0/8").unwrap()]),
1712 mfa: None,
1713 time_window: None,
1714 platform_scoped: None,
1715 };
1716 let r = ResourceRef::new("table", "x");
1717 let mut ctx = ctx_now(1);
1718 ctx.peer_ip = Some(IpAddr::from_str("10.0.0.1").unwrap());
1719 assert!(condition_holds(Some(&c), &r, &ctx));
1720 ctx.peer_ip = Some(IpAddr::from_str("11.0.0.1").unwrap());
1721 assert!(!condition_holds(Some(&c), &r, &ctx));
1722 ctx.peer_ip = None;
1723 assert!(!condition_holds(Some(&c), &r, &ctx));
1724 }
1725
1726 #[test]
1727 fn condition_source_ip_accepts_single_ip() {
1728 let cidr = parse_cidr("192.168.1.5").unwrap();
1729 assert_eq!(cidr.prefix_len, 32);
1730
1731 let c = Condition {
1732 expires_at: None,
1733 valid_from: None,
1734 tenant_match: None,
1735 source_ip: Some(vec![cidr]),
1736 mfa: None,
1737 time_window: None,
1738 platform_scoped: None,
1739 };
1740 let r = ResourceRef::new("table", "public.x");
1741 let mut ctx = ctx_now(1);
1742 ctx.peer_ip = Some(IpAddr::from_str("192.168.1.5").unwrap());
1743 assert!(condition_holds(Some(&c), &r, &ctx));
1744 ctx.peer_ip = Some(IpAddr::from_str("192.168.1.6").unwrap());
1745 assert!(!condition_holds(Some(&c), &r, &ctx));
1746 }
1747
1748 #[test]
1749 fn condition_tenant_match() {
1750 let c = Condition {
1751 expires_at: None,
1752 valid_from: None,
1753 tenant_match: Some(true),
1754 source_ip: None,
1755 mfa: None,
1756 time_window: None,
1757 platform_scoped: None,
1758 };
1759 let r = ResourceRef::new("table", "x").with_tenant("acme");
1760 let mut ctx = ctx_now(1);
1761 ctx.current_tenant = Some("acme".into());
1762 assert!(condition_holds(Some(&c), &r, &ctx));
1763 ctx.current_tenant = Some("globex".into());
1764 assert!(!condition_holds(Some(&c), &r, &ctx));
1765 }
1766
1767 #[test]
1768 fn condition_mfa() {
1769 let c = Condition {
1770 expires_at: None,
1771 valid_from: None,
1772 tenant_match: None,
1773 source_ip: None,
1774 mfa: Some(true),
1775 time_window: None,
1776 platform_scoped: None,
1777 };
1778 let r = ResourceRef::new("table", "x");
1779 let mut ctx = ctx_now(1);
1780 ctx.mfa_present = true;
1781 assert!(condition_holds(Some(&c), &r, &ctx));
1782 ctx.mfa_present = false;
1783 assert!(!condition_holds(Some(&c), &r, &ctx));
1784 }
1785
1786 #[test]
1787 fn condition_time_window_normal() {
1788 let tw = TimeWindow {
1790 from_minute: 9 * 60,
1791 to_minute: 17 * 60,
1792 tz_offset_secs: 0,
1793 };
1794 assert!(time_window_contains(&tw, 12 * 3_600_000));
1795 assert!(time_window_contains(&tw, 9 * 3_600_000));
1796 assert!(time_window_contains(&tw, 17 * 3_600_000));
1797 assert!(!time_window_contains(&tw, 18 * 3_600_000));
1799 assert!(!time_window_contains(&tw, 6 * 3_600_000));
1801 }
1802
1803 #[test]
1804 fn condition_time_window_wraparound() {
1805 let tw = TimeWindow {
1807 from_minute: 22 * 60,
1808 to_minute: 6 * 60,
1809 tz_offset_secs: 0,
1810 };
1811 assert!(time_window_contains(&tw, 23 * 3_600_000));
1812 assert!(time_window_contains(&tw, 1 * 3_600_000));
1813 assert!(time_window_contains(&tw, 6 * 3_600_000));
1814 assert!(!time_window_contains(&tw, 12 * 3_600_000));
1815 assert!(!time_window_contains(&tw, 21 * 3_600_000));
1816 }
1817
1818 fn analyst_policy() -> Policy {
1823 Policy::from_json_str(
1824 r#"{
1825 "id":"analyst","version":1,"statements":[
1826 {"sid":"reads","effect":"allow",
1827 "actions":["select"],"resources":["table:public.orders"]}
1828 ]}"#,
1829 )
1830 .unwrap()
1831 }
1832
1833 fn no_deletes_policy() -> Policy {
1834 Policy::from_json_str(
1835 r#"{
1836 "id":"no-deletes","version":1,"statements":[
1837 {"sid":"hard-stop","effect":"deny",
1838 "actions":["delete"],"resources":["*"]}
1839 ]}"#,
1840 )
1841 .unwrap()
1842 }
1843
1844 #[test]
1845 fn evaluator_pure_allow() {
1846 let p = analyst_policy();
1847 let r = ResourceRef::new("table", "public.orders");
1848 let d = evaluate(&[&p], "select", &r, &EvalContext::default());
1849 match d {
1850 Decision::Allow {
1851 matched_policy_id,
1852 matched_sid,
1853 } => {
1854 assert_eq!(matched_policy_id, "analyst");
1855 assert_eq!(matched_sid.as_deref(), Some("reads"));
1856 }
1857 other => panic!("expected Allow, got {other:?}"),
1858 }
1859 }
1860
1861 #[test]
1862 fn evaluator_deny_overrides_allow() {
1863 let allow = analyst_policy();
1864 let deny = no_deletes_policy();
1865 let r = ResourceRef::new("table", "public.orders");
1866 let d = evaluate(&[&allow, &deny], "delete", &r, &EvalContext::default());
1868 match d {
1869 Decision::Deny {
1870 matched_policy_id, ..
1871 } => {
1872 assert_eq!(matched_policy_id, "no-deletes");
1873 }
1874 other => panic!("expected Deny, got {other:?}"),
1875 }
1876 }
1877
1878 #[test]
1879 fn evaluator_default_deny() {
1880 let p = analyst_policy();
1881 let r = ResourceRef::new("table", "public.invoices");
1882 let d = evaluate(&[&p], "select", &r, &EvalContext::default());
1883 assert_eq!(d, Decision::DefaultDeny);
1884 }
1885
1886 #[test]
1887 fn evaluator_admin_gets_default_deny_without_allow_policy() {
1888 let p = analyst_policy();
1893 let r = ResourceRef::new("table", "anything");
1894 let mut ctx = EvalContext::default();
1895 ctx.principal_is_admin_role = true;
1896 let d = evaluate(&[&p], "delete", &r, &ctx);
1897 assert_eq!(d, Decision::DefaultDeny);
1898 }
1899
1900 #[test]
1901 fn evaluator_admin_does_not_bypass_explicit_deny() {
1902 let allow = analyst_policy();
1905 let deny = no_deletes_policy();
1906 let r = ResourceRef::new("table", "public.orders");
1907 let mut ctx = EvalContext::default();
1908 ctx.principal_is_admin_role = true;
1909 let d = evaluate(&[&allow, &deny], "delete", &r, &ctx);
1910 match d {
1911 Decision::Deny {
1912 matched_policy_id, ..
1913 } => assert_eq!(matched_policy_id, "no-deletes"),
1914 other => panic!("expected Deny for admin, got {other:?}"),
1915 }
1916 }
1917
1918 #[test]
1919 fn evaluator_implicit_tenant_scoping() {
1920 let p = Policy::from_json_str(
1925 r#"{
1926 "id":"impl","version":1,"statements":[
1927 {"sid":"s","effect":"allow",
1928 "actions":["select"],"resources":["table:public.x"]}
1929 ]}"#,
1930 )
1931 .unwrap();
1932 let r_acme = ResourceRef::new("table", "public.x").with_tenant("acme");
1933 let r_globex = ResourceRef::new("table", "public.x").with_tenant("globex");
1934 let mut ctx = EvalContext::default();
1935 ctx.current_tenant = Some("acme".into());
1936 assert!(matches!(
1937 evaluate(&[&p], "select", &r_acme, &ctx),
1938 Decision::Allow { .. }
1939 ));
1940 assert_eq!(
1941 evaluate(&[&p], "select", &r_globex, &ctx),
1942 Decision::DefaultDeny
1943 );
1944 }
1945
1946 #[test]
1951 fn simulator_produces_trail() {
1952 let allow = analyst_policy();
1953 let deny = no_deletes_policy();
1954 let r = ResourceRef::new("table", "public.orders");
1955 let out = simulate(&[&allow, &deny], "delete", &r, &EvalContext::default());
1956 assert!(out.trail.len() >= 2);
1959 assert!(matches!(out.decision, Decision::Deny { .. }));
1960 assert!(out.reason.contains("deny"));
1961 }
1962
1963 #[test]
1968 fn rfc3339_parses_to_ms() {
1969 let ms = parse_rfc3339_ms("1970-01-01T00:00:00Z").unwrap();
1970 assert_eq!(ms, 0);
1971 let ms = parse_rfc3339_ms("1970-01-01T00:00:01.500Z").unwrap();
1972 assert_eq!(ms, 1_500);
1973 let ms = parse_rfc3339_ms("2024-01-01T00:00:00+00:00").unwrap();
1974 assert_eq!(ms, 19_723u128 * 86_400_000);
1976 }
1977
1978 #[test]
1979 fn rfc3339_handles_negative_offset() {
1980 let a = parse_rfc3339_ms("2024-01-01T01:00:00+01:00").unwrap();
1982 let b = parse_rfc3339_ms("2024-01-01T00:00:00Z").unwrap();
1983 assert_eq!(a, b);
1984 }
1985
1986 #[test]
1987 fn cidr_v6_basic() {
1988 let c = parse_cidr("::1/128").unwrap();
1989 assert_eq!(c.prefix_len, 128);
1990 assert!(cidr_contains(&c, IpAddr::from_str("::1").unwrap()));
1991 assert!(!cidr_contains(&c, IpAddr::from_str("::2").unwrap()));
1992 }
1993}