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
1100fn statement_matches(
1108 statement: &Statement,
1109 action: &str,
1110 resource: &ResourceRef,
1111 ctx: &EvalContext,
1112) -> bool {
1113 let matches = |action: &str, resource: &ResourceRef| {
1114 statement
1115 .actions
1116 .iter()
1117 .any(|pattern| action_matches(pattern, action))
1118 && statement
1119 .resources
1120 .iter()
1121 .any(|pattern| resource_matches(pattern, resource, ctx))
1122 };
1123 if matches(action, resource) {
1124 return true;
1125 }
1126 if resource.kind != "vault" {
1127 return false;
1128 }
1129 match action {
1130 "vault:reveal" => matches("vault:read", resource),
1131 "vault:reveal_history" => matches("vault:unseal_history", resource),
1132 "vault:use" | "vault:write" => {
1133 let Some(key) = resource.name.strip_prefix("red.vault/") else {
1134 return false;
1135 };
1136 let mut legacy = ResourceRef::new("secret", key);
1137 legacy.tenant = resource.tenant.clone();
1138 matches(
1139 if action == "vault:use" {
1140 "secret:read"
1141 } else {
1142 "secret:write"
1143 },
1144 &legacy,
1145 )
1146 }
1147 _ => false,
1148 }
1149}
1150
1151pub fn evaluate(
1154 policies: &[&Policy],
1155 action: &str,
1156 resource: &ResourceRef,
1157 ctx: &EvalContext,
1158) -> Decision {
1159 let mut allow_hit: Option<(String, Option<String>)> = None;
1160
1161 for p in policies {
1162 if p.tenant
1165 .as_ref()
1166 .is_some_and(|tenant| resource.tenant.as_ref() != Some(tenant))
1167 {
1168 continue;
1169 }
1170 for st in &p.statements {
1171 if !condition_holds(st.condition.as_ref(), resource, ctx) {
1172 continue;
1173 }
1174 if !statement_matches(st, action, resource, ctx) {
1175 continue;
1176 }
1177 match st.effect {
1178 Effect::Deny => {
1181 return Decision::Deny {
1182 matched_policy_id: p.id.clone(),
1183 matched_sid: st.sid.clone(),
1184 };
1185 }
1186 Effect::Allow => {
1187 if allow_hit.is_none() {
1188 allow_hit = Some((p.id.clone(), st.sid.clone()));
1189 }
1190 }
1191 }
1192 }
1193 }
1194
1195 let _ = ctx.principal_is_admin_role;
1200
1201 match allow_hit {
1202 Some((pid, sid)) => Decision::Allow {
1203 matched_policy_id: pid,
1204 matched_sid: sid,
1205 },
1206 None => Decision::DefaultDeny,
1207 }
1208}
1209
1210#[derive(Debug, Clone, PartialEq)]
1212pub struct TrailEntry {
1213 pub policy_id: String,
1214 pub sid: Option<String>,
1215 pub matched: bool,
1216 pub effect: Effect,
1217 pub why_skipped: Option<&'static str>,
1218}
1219
1220#[derive(Debug, Clone, PartialEq)]
1222pub struct SimulationOutcome {
1223 pub decision: Decision,
1224 pub reason: String,
1225 pub trail: Vec<TrailEntry>,
1226}
1227
1228pub fn simulate(
1232 policies: &[&Policy],
1233 action: &str,
1234 resource: &ResourceRef,
1235 ctx: &EvalContext,
1236) -> SimulationOutcome {
1237 let mut trail = Vec::new();
1238 let mut allow_hit: Option<(String, Option<String>, usize)> = None;
1239 let mut deny_hit: Option<(String, Option<String>, usize)> = None;
1240
1241 'outer: for p in policies {
1242 for (idx, st) in p.statements.iter().enumerate() {
1243 let mut why: Option<&'static str> = None;
1244 let mut matched = false;
1245
1246 if p.tenant
1247 .as_ref()
1248 .is_some_and(|tenant| resource.tenant.as_ref() != Some(tenant))
1249 {
1250 why = Some("outside policy tenant");
1251 } else if !condition_holds(st.condition.as_ref(), resource, ctx) {
1252 why = Some("condition not met");
1253 } else if statement_matches(st, action, resource, ctx) {
1254 matched = true;
1255 } else if !st.actions.iter().any(|a| action_matches(a, action)) {
1256 why = Some("no action match");
1257 } else if !st
1258 .resources
1259 .iter()
1260 .any(|r| resource_matches(r, resource, ctx))
1261 {
1262 why = Some("no resource match");
1263 } else {
1264 matched = true;
1265 }
1266
1267 trail.push(TrailEntry {
1268 policy_id: p.id.clone(),
1269 sid: st.sid.clone(),
1270 matched,
1271 effect: st.effect,
1272 why_skipped: why,
1273 });
1274
1275 if matched {
1276 match st.effect {
1277 Effect::Deny => {
1278 deny_hit = Some((p.id.clone(), st.sid.clone(), idx));
1279 break 'outer;
1280 }
1281 Effect::Allow => {
1282 if allow_hit.is_none() {
1283 allow_hit = Some((p.id.clone(), st.sid.clone(), idx));
1284 }
1285 }
1286 }
1287 }
1288 }
1289 }
1290
1291 if let Some((pid, sid, idx)) = deny_hit {
1292 let reason = format!(
1293 "deny at {}.statement[{}]{}",
1294 pid,
1295 idx,
1296 sid.as_ref()
1297 .map(|s| format!(" (sid={s})"))
1298 .unwrap_or_default()
1299 );
1300 return SimulationOutcome {
1301 decision: Decision::Deny {
1302 matched_policy_id: pid,
1303 matched_sid: sid,
1304 },
1305 reason,
1306 trail,
1307 };
1308 }
1309 let _ = ctx.principal_is_admin_role;
1313 if let Some((pid, sid, idx)) = allow_hit {
1314 let reason = format!(
1315 "allow at {}.statement[{}]{}",
1316 pid,
1317 idx,
1318 sid.as_ref()
1319 .map(|s| format!(" (sid={s})"))
1320 .unwrap_or_default()
1321 );
1322 return SimulationOutcome {
1323 decision: Decision::Allow {
1324 matched_policy_id: pid,
1325 matched_sid: sid,
1326 },
1327 reason,
1328 trail,
1329 };
1330 }
1331 SimulationOutcome {
1332 decision: Decision::DefaultDeny,
1333 reason: "no statement matched (default deny)".into(),
1334 trail,
1335 }
1336}
1337
1338fn string_field(obj: &Map<String, Value>, key: &str) -> Result<String, PolicyError> {
1343 obj.get(key)
1344 .and_then(|v| v.as_str())
1345 .map(|s| s.to_string())
1346 .ok_or_else(|| PolicyError::InvalidJson(format!("policy.{key} required string")))
1347}
1348
1349#[cfg(test)]
1354mod tests {
1355 use super::*;
1356
1357 #[test]
1358 fn vault_aliases_preserve_allow_deny_and_simulator_decisions() {
1359 let context = EvalContext::default();
1360 for (old_action, old_resource, new_action, new_resource) in [
1361 ("vault:read", "vault:app.token", "vault:reveal", "app.token"),
1362 (
1363 "vault:unseal_history",
1364 "vault:app.token",
1365 "vault:reveal_history",
1366 "app.token",
1367 ),
1368 (
1369 "secret:read",
1370 "secret:acme.*",
1371 "vault:use",
1372 "red.vault/acme.token",
1373 ),
1374 (
1375 "secret:write",
1376 "secret:acme.*",
1377 "vault:write",
1378 "red.vault/acme.token",
1379 ),
1380 ] {
1381 for effect in ["allow", "deny"] {
1382 let policy = Policy::from_json_str(&format!(r#"{{"id":"legacy","version":1,"statements":[{{"effect":"{effect}","actions":["{old_action}"],"resources":["{old_resource}"]}}]}}"#)).expect("policy");
1383 let resource = ResourceRef::new("vault", new_resource);
1384 let decision = evaluate(&[&policy], new_action, &resource, &context);
1385 assert_eq!(
1386 matches!(decision, Decision::Allow { .. }),
1387 effect == "allow"
1388 );
1389 assert!(matches!(
1390 decision,
1391 Decision::Allow { .. } | Decision::Deny { .. }
1392 ));
1393 assert_eq!(
1394 simulate(&[&policy], new_action, &resource, &context).decision,
1395 decision
1396 );
1397 }
1398 }
1399 }
1400
1401 #[test]
1402 fn tenant_owned_wildcards_are_scoped_in_evaluation_and_simulation() {
1403 let policy = Policy::from_json_str(r#"{"id":"local","tenant":"acme","version":1,"statements":[{"effect":"allow","actions":["*"],"resources":["*"]}]}"#).expect("policy");
1404 let context = EvalContext {
1405 principal_tenant: Some("acme".into()),
1406 current_tenant: Some("acme".into()),
1407 ..EvalContext::default()
1408 };
1409 for tenant in [None, Some("acme"), Some("globex")] {
1410 let mut resource = ResourceRef::new("vault", "app.token");
1411 resource.tenant = tenant.map(str::to_owned);
1412 let decision = evaluate(&[&policy], "vault:use", &resource, &context);
1413 assert_eq!(
1414 matches!(decision, Decision::Allow { .. }),
1415 tenant == Some("acme")
1416 );
1417 assert_eq!(
1418 simulate(&[&policy], "vault:use", &resource, &context).decision,
1419 decision
1420 );
1421 }
1422 }
1423
1424 fn minimal_policy_json() -> &'static str {
1425 r#"{
1426 "id": "p-min",
1427 "version": 1,
1428 "statements": [
1429 { "effect": "allow", "actions": ["select"], "resources": ["table:public.x"] }
1430 ]
1431 }"#
1432 }
1433
1434 fn full_policy_json() -> &'static str {
1435 r#"{
1436 "id": "p-full",
1437 "version": 1,
1438 "tenant": "acme",
1439 "created_at": 1700000000000,
1440 "updated_at": 1700000001000,
1441 "statements": [
1442 {
1443 "sid": "s1",
1444 "effect": "allow",
1445 "actions": ["select", "insert"],
1446 "resources": ["table:public.orders", "table:public.*"]
1447 },
1448 {
1449 "sid": "s2",
1450 "effect": "deny",
1451 "actions": ["delete"],
1452 "resources": ["*"]
1453 }
1454 ]
1455 }"#
1456 }
1457
1458 fn cond_policy_json() -> &'static str {
1459 r#"{
1460 "id": "p-cond",
1461 "version": 1,
1462 "statements": [
1463 {
1464 "sid": "biz-hours",
1465 "effect": "allow",
1466 "actions": ["select"],
1467 "resources": ["table:public.orders"],
1468 "condition": {
1469 "expires_at": "2099-12-31T23:59:59Z",
1470 "valid_from": 1700000000000,
1471 "tenant_match": true,
1472 "source_ip": ["10.0.0.0/8"],
1473 "mfa": true,
1474 "time_window": { "from": "09:00", "to": "17:00", "tz": "UTC" }
1475 }
1476 }
1477 ]
1478 }"#
1479 }
1480
1481 fn ctx_now(now_ms: u128) -> EvalContext {
1482 EvalContext {
1483 now_ms,
1484 ..Default::default()
1485 }
1486 }
1487
1488 #[test]
1493 fn roundtrip_minimal() {
1494 let p = Policy::from_json_str(minimal_policy_json()).unwrap();
1495 let s = p.to_json_string();
1496 let p2 = Policy::from_json_str(&s).unwrap();
1497 assert_eq!(p, p2);
1498 assert_eq!(p.id, "p-min");
1499 assert_eq!(p.statements.len(), 1);
1500 }
1501
1502 #[test]
1503 fn roundtrip_full() {
1504 let p = Policy::from_json_str(full_policy_json()).unwrap();
1505 let s = p.to_json_string();
1506 let p2 = Policy::from_json_str(&s).unwrap();
1507 assert_eq!(p, p2);
1508 assert_eq!(p.tenant.as_deref(), Some("acme"));
1509 assert_eq!(p.statements.len(), 2);
1510 }
1511
1512 #[test]
1513 fn roundtrip_platform_scoped_condition() {
1514 let p = Policy::from_json_str(
1515 r#"{
1516 "id": "p-attrs",
1517 "version": 1,
1518 "statements": [{
1519 "effect": "allow",
1520 "actions": ["admin:reload"],
1521 "resources": ["*"],
1522 "condition": { "platform_scoped": false }
1523 }]
1524 }"#,
1525 )
1526 .unwrap();
1527 let c = p.statements[0].condition.as_ref().unwrap();
1528 assert_eq!(c.platform_scoped, Some(false));
1529 let p2 = Policy::from_json_str(&p.to_json_string()).unwrap();
1530 assert_eq!(p, p2);
1531 }
1532
1533 #[test]
1534 fn system_owned_condition_is_rejected() {
1535 let err = Policy::from_json_str(
1536 r#"{
1537 "id": "p-sys",
1538 "version": 1,
1539 "statements": [{
1540 "effect": "allow",
1541 "actions": ["admin:reload"],
1542 "resources": ["*"],
1543 "condition": { "system_owned": true }
1544 }]
1545 }"#,
1546 )
1547 .unwrap_err();
1548 assert!(matches!(err, PolicyError::InvalidCondition(_)));
1549 }
1550
1551 #[test]
1552 fn roundtrip_with_conditions() {
1553 let p = Policy::from_json_str(cond_policy_json()).unwrap();
1554 let s = p.to_json_string();
1555 let p2 = Policy::from_json_str(&s).unwrap();
1556 assert_eq!(p, p2);
1557 let c = p.statements[0].condition.as_ref().unwrap();
1558 assert!(c.expires_at.is_some());
1559 assert!(c.valid_from.is_some());
1560 assert_eq!(c.tenant_match, Some(true));
1561 assert_eq!(c.mfa, Some(true));
1562 let cidrs = c.source_ip.as_ref().unwrap();
1563 assert_eq!(cidrs.len(), 1);
1564 assert_eq!(cidrs[0].prefix_len, 8);
1565 }
1566
1567 #[test]
1572 fn validator_rejects_invalid_json() {
1573 let err = Policy::from_json_str("{ not json").unwrap_err();
1574 matches!(err, PolicyError::InvalidJson(_));
1575 }
1576
1577 #[test]
1578 fn validator_rejects_invalid_action() {
1579 let bad = r#"{
1580 "id":"p","version":1,"statements":[
1581 {"effect":"allow","actions":["bogus"],"resources":["table:public.x"]}
1582 ]}"#;
1583 let err = Policy::from_json_str(bad).unwrap_err();
1584 assert!(matches!(err, PolicyError::InvalidAction(_)));
1585 }
1586
1587 #[test]
1588 fn validator_rejects_per_verb_kv_actions_except_invalidate() {
1589 for action in [
1590 "kv:get",
1591 "kv:put",
1592 "kv:delete",
1593 "kv:incr",
1594 "kv:cas",
1595 "kv:watch",
1596 ] {
1597 let bad = format!(
1598 r#"{{
1599 "id":"p","version":1,"statements":[
1600 {{"effect":"allow","actions":["{action}"],"resources":["kv:sessions"]}}
1601 ]}}"#
1602 );
1603 let err = Policy::from_json_str(&bad).unwrap_err();
1604 assert!(
1605 matches!(err, PolicyError::InvalidAction(ref invalid) if invalid == action),
1606 "expected {action} to be rejected, got {err:?}"
1607 );
1608 }
1609
1610 let allowed = r#"{
1611 "id":"p","version":1,"statements":[
1612 {"effect":"allow","actions":["kv:invalidate"],"resources":["kv:sessions"]}
1613 ]}"#;
1614 Policy::from_json_str(allowed).expect("kv:invalidate is the only per-KV verb action");
1615 }
1616
1617 #[test]
1618 fn validator_rejects_invalid_resource() {
1619 let bad = r#"{
1620 "id":"p","version":1,"statements":[
1621 {"effect":"allow","actions":["select"],"resources":["nokind"]}
1622 ]}"#;
1623 let err = Policy::from_json_str(bad).unwrap_err();
1624 assert!(matches!(err, PolicyError::InvalidResource(_)));
1625 }
1626
1627 #[test]
1628 fn validator_rejects_invalid_condition() {
1629 let bad = r#"{
1630 "id":"p","version":1,"statements":[
1631 {"effect":"allow","actions":["select"],"resources":["table:public.x"],
1632 "condition":{"expires_at":{}}}
1633 ]}"#;
1634 let err = Policy::from_json_str(bad).unwrap_err();
1635 assert!(matches!(err, PolicyError::InvalidCondition(_)));
1636 }
1637
1638 #[test]
1639 fn validator_rejects_invalid_cidr() {
1640 let bad = r#"{
1641 "id":"p","version":1,"statements":[
1642 {"effect":"allow","actions":["select"],"resources":["table:public.x"],
1643 "condition":{"source_ip":["10.0.0.0/99"]}}
1644 ]}"#;
1645 let err = Policy::from_json_str(bad).unwrap_err();
1646 assert!(matches!(err, PolicyError::InvalidCidr(_)));
1647 }
1648
1649 #[test]
1650 fn validator_rejects_duplicate_sid() {
1651 let bad = r#"{
1652 "id":"p","version":1,"statements":[
1653 {"sid":"x","effect":"allow","actions":["select"],"resources":["table:public.x"]},
1654 {"sid":"x","effect":"deny","actions":["delete"],"resources":["table:public.y"]}
1655 ]}"#;
1656 let err = Policy::from_json_str(bad).unwrap_err();
1657 assert!(matches!(err, PolicyError::DuplicateSid(_)));
1658 }
1659
1660 #[test]
1661 fn validator_rejects_empty_statements() {
1662 let bad = r#"{"id":"p","version":1,"statements":[]}"#;
1663 let err = Policy::from_json_str(bad).unwrap_err();
1664 assert!(matches!(err, PolicyError::EmptyStatements));
1665 }
1666
1667 #[test]
1668 fn validator_rejects_empty_actions() {
1669 let bad = r#"{
1670 "id":"p","version":1,"statements":[
1671 {"effect":"allow","actions":[],"resources":["table:public.x"]}
1672 ]}"#;
1673 let err = Policy::from_json_str(bad).unwrap_err();
1674 assert!(matches!(err, PolicyError::EmptyActions));
1675 }
1676
1677 #[test]
1678 fn validator_rejects_empty_resources() {
1679 let bad = r#"{
1680 "id":"p","version":1,"statements":[
1681 {"effect":"allow","actions":["select"],"resources":[]}
1682 ]}"#;
1683 let err = Policy::from_json_str(bad).unwrap_err();
1684 assert!(matches!(err, PolicyError::EmptyResources));
1685 }
1686
1687 #[test]
1688 fn validator_rejects_too_many_statements() {
1689 let mut p = Policy::from_json_str(minimal_policy_json()).unwrap();
1690 let st = p.statements[0].clone();
1691 for _ in 0..MAX_STATEMENTS {
1692 p.statements.push(st.clone());
1693 }
1694 let err = p.validate().unwrap_err();
1695 assert!(matches!(err, PolicyError::TooManyStatements(_)));
1696 }
1697
1698 #[test]
1699 fn validator_rejects_too_many_actions() {
1700 let mut p = Policy::from_json_str(minimal_policy_json()).unwrap();
1701 for _ in 0..MAX_ACTIONS {
1702 p.statements[0].actions.push(ActionPattern::Wildcard);
1703 }
1704 let err = p.validate().unwrap_err();
1705 assert!(matches!(err, PolicyError::TooManyActions(_)));
1706 }
1707
1708 #[test]
1709 fn validator_rejects_too_many_resources() {
1710 let mut p = Policy::from_json_str(minimal_policy_json()).unwrap();
1711 for _ in 0..MAX_RESOURCES {
1712 p.statements[0].resources.push(ResourcePattern::Wildcard);
1713 }
1714 let err = p.validate().unwrap_err();
1715 assert!(matches!(err, PolicyError::TooManyResources(_)));
1716 }
1717
1718 #[test]
1719 fn validator_rejects_oversize_json() {
1720 let big = "x".repeat(MAX_POLICY_BYTES + 1);
1721 let err = Policy::from_json_str(&big).unwrap_err();
1722 assert!(matches!(err, PolicyError::PolicyTooLarge(_)));
1723 }
1724
1725 #[test]
1730 fn glob_matches_table_public_star() {
1731 let pat = compile_glob("table:public.*");
1732 assert!(glob_matches(&pat, "table:public.orders"));
1733 assert!(glob_matches(&pat, "table:public."));
1734 assert!(!glob_matches(&pat, "table:other.x"));
1735 }
1736
1737 #[test]
1738 fn glob_matches_tenant_star() {
1739 let pat = compile_glob("tenant:acme/*");
1740 assert!(glob_matches(&pat, "tenant:acme/whatever"));
1741 assert!(glob_matches(&pat, "tenant:acme/a/b/c"));
1742 assert!(!glob_matches(&pat, "tenant:other/whatever"));
1743 }
1744
1745 #[test]
1746 fn glob_ai_credential_alias_paths_are_separable_per_alias() {
1747 let prod = compile_glob("vault:red.vault/red.secret.ai.providers.openai.tokens.prod*");
1753 assert!(glob_matches(
1754 &prod,
1755 "vault:red.vault/red.secret.ai.providers.openai.tokens.prod"
1756 ));
1757 assert!(!glob_matches(
1760 &prod,
1761 "vault:red.vault/red.secret.ai.providers.openai.tokens.default"
1762 ));
1763 }
1764
1765 #[test]
1766 fn action_match_exact() {
1767 assert!(action_matches(&compile_action("select"), "select"));
1768 assert!(!action_matches(&compile_action("select"), "selectall"));
1769 assert!(!action_matches(&compile_action("select"), "insert"));
1770 }
1771
1772 #[test]
1773 fn action_match_prefix() {
1774 let p = compile_action("admin:*");
1775 assert!(action_matches(&p, "admin:bootstrap"));
1776 assert!(action_matches(&p, "admin:reload"));
1777 assert!(!action_matches(&p, "admin"));
1778 assert!(!action_matches(&p, "select"));
1779 }
1780
1781 #[test]
1782 fn action_match_wildcard() {
1783 let p = compile_action("*");
1784 assert!(action_matches(&p, "select"));
1785 assert!(action_matches(&p, "admin:bootstrap"));
1786 assert!(action_matches(&p, "policy:put"));
1787 }
1788
1789 #[test]
1794 fn condition_expires_at() {
1795 let c = Condition {
1796 expires_at: Some(2_000),
1797 valid_from: None,
1798 tenant_match: None,
1799 source_ip: None,
1800 mfa: None,
1801 time_window: None,
1802 platform_scoped: None,
1803 };
1804 let r = ResourceRef::new("table", "x");
1805 assert!(condition_holds(Some(&c), &r, &ctx_now(1_000)));
1806 assert!(!condition_holds(Some(&c), &r, &ctx_now(2_000)));
1807 assert!(!condition_holds(Some(&c), &r, &ctx_now(2_500)));
1808 }
1809
1810 #[test]
1811 fn condition_valid_from() {
1812 let c = Condition {
1813 expires_at: None,
1814 valid_from: Some(2_000),
1815 tenant_match: None,
1816 source_ip: None,
1817 mfa: None,
1818 time_window: None,
1819 platform_scoped: None,
1820 };
1821 let r = ResourceRef::new("table", "x");
1822 assert!(!condition_holds(Some(&c), &r, &ctx_now(1_999)));
1823 assert!(condition_holds(Some(&c), &r, &ctx_now(2_000)));
1824 assert!(condition_holds(Some(&c), &r, &ctx_now(3_000)));
1825 }
1826
1827 #[test]
1828 fn condition_source_ip_v4() {
1829 let c = Condition {
1830 expires_at: None,
1831 valid_from: None,
1832 tenant_match: None,
1833 source_ip: Some(vec![parse_cidr("10.0.0.0/8").unwrap()]),
1834 mfa: None,
1835 time_window: None,
1836 platform_scoped: None,
1837 };
1838 let r = ResourceRef::new("table", "x");
1839 let mut ctx = ctx_now(1);
1840 ctx.peer_ip = Some(IpAddr::from_str("10.0.0.1").unwrap());
1841 assert!(condition_holds(Some(&c), &r, &ctx));
1842 ctx.peer_ip = Some(IpAddr::from_str("11.0.0.1").unwrap());
1843 assert!(!condition_holds(Some(&c), &r, &ctx));
1844 ctx.peer_ip = None;
1845 assert!(!condition_holds(Some(&c), &r, &ctx));
1846 }
1847
1848 #[test]
1849 fn condition_source_ip_accepts_single_ip() {
1850 let cidr = parse_cidr("192.168.1.5").unwrap();
1851 assert_eq!(cidr.prefix_len, 32);
1852
1853 let c = Condition {
1854 expires_at: None,
1855 valid_from: None,
1856 tenant_match: None,
1857 source_ip: Some(vec![cidr]),
1858 mfa: None,
1859 time_window: None,
1860 platform_scoped: None,
1861 };
1862 let r = ResourceRef::new("table", "public.x");
1863 let mut ctx = ctx_now(1);
1864 ctx.peer_ip = Some(IpAddr::from_str("192.168.1.5").unwrap());
1865 assert!(condition_holds(Some(&c), &r, &ctx));
1866 ctx.peer_ip = Some(IpAddr::from_str("192.168.1.6").unwrap());
1867 assert!(!condition_holds(Some(&c), &r, &ctx));
1868 }
1869
1870 #[test]
1871 fn condition_tenant_match() {
1872 let c = Condition {
1873 expires_at: None,
1874 valid_from: None,
1875 tenant_match: Some(true),
1876 source_ip: None,
1877 mfa: None,
1878 time_window: None,
1879 platform_scoped: None,
1880 };
1881 let r = ResourceRef::new("table", "x").with_tenant("acme");
1882 let mut ctx = ctx_now(1);
1883 ctx.current_tenant = Some("acme".into());
1884 assert!(condition_holds(Some(&c), &r, &ctx));
1885 ctx.current_tenant = Some("globex".into());
1886 assert!(!condition_holds(Some(&c), &r, &ctx));
1887 }
1888
1889 #[test]
1890 fn condition_mfa() {
1891 let c = Condition {
1892 expires_at: None,
1893 valid_from: None,
1894 tenant_match: None,
1895 source_ip: None,
1896 mfa: Some(true),
1897 time_window: None,
1898 platform_scoped: None,
1899 };
1900 let r = ResourceRef::new("table", "x");
1901 let mut ctx = ctx_now(1);
1902 ctx.mfa_present = true;
1903 assert!(condition_holds(Some(&c), &r, &ctx));
1904 ctx.mfa_present = false;
1905 assert!(!condition_holds(Some(&c), &r, &ctx));
1906 }
1907
1908 #[test]
1909 fn condition_time_window_normal() {
1910 let tw = TimeWindow {
1912 from_minute: 9 * 60,
1913 to_minute: 17 * 60,
1914 tz_offset_secs: 0,
1915 };
1916 assert!(time_window_contains(&tw, 12 * 3_600_000));
1917 assert!(time_window_contains(&tw, 9 * 3_600_000));
1918 assert!(time_window_contains(&tw, 17 * 3_600_000));
1919 assert!(!time_window_contains(&tw, 18 * 3_600_000));
1921 assert!(!time_window_contains(&tw, 6 * 3_600_000));
1923 }
1924
1925 #[test]
1926 fn condition_time_window_wraparound() {
1927 let tw = TimeWindow {
1929 from_minute: 22 * 60,
1930 to_minute: 6 * 60,
1931 tz_offset_secs: 0,
1932 };
1933 assert!(time_window_contains(&tw, 23 * 3_600_000));
1934 assert!(time_window_contains(&tw, 1 * 3_600_000));
1935 assert!(time_window_contains(&tw, 6 * 3_600_000));
1936 assert!(!time_window_contains(&tw, 12 * 3_600_000));
1937 assert!(!time_window_contains(&tw, 21 * 3_600_000));
1938 }
1939
1940 fn analyst_policy() -> Policy {
1945 Policy::from_json_str(
1946 r#"{
1947 "id":"analyst","version":1,"statements":[
1948 {"sid":"reads","effect":"allow",
1949 "actions":["select"],"resources":["table:public.orders"]}
1950 ]}"#,
1951 )
1952 .unwrap()
1953 }
1954
1955 fn no_deletes_policy() -> Policy {
1956 Policy::from_json_str(
1957 r#"{
1958 "id":"no-deletes","version":1,"statements":[
1959 {"sid":"hard-stop","effect":"deny",
1960 "actions":["delete"],"resources":["*"]}
1961 ]}"#,
1962 )
1963 .unwrap()
1964 }
1965
1966 #[test]
1967 fn evaluator_pure_allow() {
1968 let p = analyst_policy();
1969 let r = ResourceRef::new("table", "public.orders");
1970 let d = evaluate(&[&p], "select", &r, &EvalContext::default());
1971 match d {
1972 Decision::Allow {
1973 matched_policy_id,
1974 matched_sid,
1975 } => {
1976 assert_eq!(matched_policy_id, "analyst");
1977 assert_eq!(matched_sid.as_deref(), Some("reads"));
1978 }
1979 other => panic!("expected Allow, got {other:?}"),
1980 }
1981 }
1982
1983 #[test]
1984 fn evaluator_deny_overrides_allow() {
1985 let allow = analyst_policy();
1986 let deny = no_deletes_policy();
1987 let r = ResourceRef::new("table", "public.orders");
1988 let d = evaluate(&[&allow, &deny], "delete", &r, &EvalContext::default());
1990 match d {
1991 Decision::Deny {
1992 matched_policy_id, ..
1993 } => {
1994 assert_eq!(matched_policy_id, "no-deletes");
1995 }
1996 other => panic!("expected Deny, got {other:?}"),
1997 }
1998 }
1999
2000 #[test]
2001 fn evaluator_default_deny() {
2002 let p = analyst_policy();
2003 let r = ResourceRef::new("table", "public.invoices");
2004 let d = evaluate(&[&p], "select", &r, &EvalContext::default());
2005 assert_eq!(d, Decision::DefaultDeny);
2006 }
2007
2008 #[test]
2009 fn evaluator_admin_gets_default_deny_without_allow_policy() {
2010 let p = analyst_policy();
2015 let r = ResourceRef::new("table", "anything");
2016 let mut ctx = EvalContext::default();
2017 ctx.principal_is_admin_role = true;
2018 let d = evaluate(&[&p], "delete", &r, &ctx);
2019 assert_eq!(d, Decision::DefaultDeny);
2020 }
2021
2022 #[test]
2023 fn evaluator_admin_does_not_bypass_explicit_deny() {
2024 let allow = analyst_policy();
2027 let deny = no_deletes_policy();
2028 let r = ResourceRef::new("table", "public.orders");
2029 let mut ctx = EvalContext::default();
2030 ctx.principal_is_admin_role = true;
2031 let d = evaluate(&[&allow, &deny], "delete", &r, &ctx);
2032 match d {
2033 Decision::Deny {
2034 matched_policy_id, ..
2035 } => assert_eq!(matched_policy_id, "no-deletes"),
2036 other => panic!("expected Deny for admin, got {other:?}"),
2037 }
2038 }
2039
2040 #[test]
2041 fn evaluator_implicit_tenant_scoping() {
2042 let p = Policy::from_json_str(
2047 r#"{
2048 "id":"impl","version":1,"statements":[
2049 {"sid":"s","effect":"allow",
2050 "actions":["select"],"resources":["table:public.x"]}
2051 ]}"#,
2052 )
2053 .unwrap();
2054 let r_acme = ResourceRef::new("table", "public.x").with_tenant("acme");
2055 let r_globex = ResourceRef::new("table", "public.x").with_tenant("globex");
2056 let mut ctx = EvalContext::default();
2057 ctx.current_tenant = Some("acme".into());
2058 assert!(matches!(
2059 evaluate(&[&p], "select", &r_acme, &ctx),
2060 Decision::Allow { .. }
2061 ));
2062 assert_eq!(
2063 evaluate(&[&p], "select", &r_globex, &ctx),
2064 Decision::DefaultDeny
2065 );
2066 }
2067
2068 #[test]
2073 fn simulator_produces_trail() {
2074 let allow = analyst_policy();
2075 let deny = no_deletes_policy();
2076 let r = ResourceRef::new("table", "public.orders");
2077 let out = simulate(&[&allow, &deny], "delete", &r, &EvalContext::default());
2078 assert!(out.trail.len() >= 2);
2081 assert!(matches!(out.decision, Decision::Deny { .. }));
2082 assert!(out.reason.contains("deny"));
2083 }
2084
2085 #[test]
2090 fn rfc3339_parses_to_ms() {
2091 let ms = parse_rfc3339_ms("1970-01-01T00:00:00Z").unwrap();
2092 assert_eq!(ms, 0);
2093 let ms = parse_rfc3339_ms("1970-01-01T00:00:01.500Z").unwrap();
2094 assert_eq!(ms, 1_500);
2095 let ms = parse_rfc3339_ms("2024-01-01T00:00:00+00:00").unwrap();
2096 assert_eq!(ms, 19_723u128 * 86_400_000);
2098 }
2099
2100 #[test]
2101 fn rfc3339_handles_negative_offset() {
2102 let a = parse_rfc3339_ms("2024-01-01T01:00:00+01:00").unwrap();
2104 let b = parse_rfc3339_ms("2024-01-01T00:00:00Z").unwrap();
2105 assert_eq!(a, b);
2106 }
2107
2108 #[test]
2109 fn cidr_v6_basic() {
2110 let c = parse_cidr("::1/128").unwrap();
2111 assert_eq!(c.prefix_len, 128);
2112 assert!(cidr_contains(&c, IpAddr::from_str("::1").unwrap()));
2113 assert!(!cidr_contains(&c, IpAddr::from_str("::2").unwrap()));
2114 }
2115}