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