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