1use std::collections::HashMap;
21
22use serde::{Deserialize, Serialize};
23
24use crate::{CellosError, ExecutionCellSpec, PlacementSpec, SecretDeliveryMode};
25
26#[derive(Debug, Clone, Serialize, Deserialize)]
30#[serde(rename_all = "camelCase")]
31pub struct PolicyPackDocument {
32 pub api_version: String,
33 pub kind: String,
34 pub spec: PolicyPackSpec,
35}
36
37#[derive(Debug, Clone, Serialize, Deserialize)]
38#[serde(rename_all = "camelCase")]
39pub struct PolicyPackSpec {
40 pub id: String,
42 #[serde(default)]
43 pub description: Option<String>,
44 #[serde(default)]
54 pub version: Option<String>,
55 #[serde(default, skip_serializing_if = "Option::is_none")]
64 pub placement: Option<PlacementSpec>,
65 pub rules: PolicyRules,
66}
67
68pub const MIN_SUPPORTED_POLICY_PACK_VERSION: &str = "1.0.0";
72
73pub const POLICY_ALLOW_DOWNGRADE_ENV: &str = "CELLOS_POLICY_ALLOW_DOWNGRADE";
77
78#[derive(Debug, Clone, Default, Serialize, Deserialize)]
84#[serde(rename_all = "camelCase")]
85pub struct PolicyRules {
86 #[serde(default)]
88 pub max_lifetime_ttl_seconds: Option<u64>,
89
90 #[serde(default)]
92 pub max_memory_max_bytes: Option<u64>,
93
94 #[serde(default)]
96 pub max_run_timeout_ms: Option<u64>,
97
98 #[serde(default)]
103 pub require_egress_declared: bool,
104
105 #[serde(default)]
110 pub forbid_outbound_egress_rules: bool,
111
112 #[serde(default)]
118 pub allowed_egress_hosts: Vec<String>,
119
120 #[serde(default)]
125 pub require_runtime_secret_delivery: bool,
126
127 #[serde(default)]
131 pub require_resource_limits: bool,
132
133 #[serde(default)]
149 pub flag_dns_egress_without_acknowledgment: Option<bool>,
150
151 #[serde(default, rename = "requireDnsEgressJustification")]
162 pub require_dns_egress_justification: Option<bool>,
163
164 #[serde(default, rename = "secretRefAllowlist")]
192 pub secret_ref_allowlist: Option<HashMap<String, Vec<String>>>,
193}
194
195#[derive(Debug, Clone, PartialEq, Eq)]
199pub struct PolicyViolation {
200 pub rule: String,
202 pub message: String,
204}
205
206impl std::fmt::Display for PolicyViolation {
207 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
208 write!(f, "[{}] {}", self.rule, self.message)
209 }
210}
211
212fn parse_semver_triple(value: &str) -> Result<(u64, u64, u64), CellosError> {
224 let core = match value.split_once('-') {
225 Some((core, pre)) => {
226 if pre.is_empty()
227 || !pre
228 .chars()
229 .all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '-'))
230 {
231 return Err(CellosError::InvalidSpec(format!(
232 "policy pack spec.version {value:?} has malformed pre-release suffix"
233 )));
234 }
235 core
236 }
237 None => value,
238 };
239 let parts: Vec<&str> = core.split('.').collect();
240 if parts.len() != 3 {
241 return Err(CellosError::InvalidSpec(format!(
242 "policy pack spec.version {value:?} must be a MAJOR.MINOR.PATCH semver string"
243 )));
244 }
245 let mut triple = [0u64; 3];
246 for (i, p) in parts.iter().enumerate() {
247 if p.is_empty() || !p.chars().all(|c| c.is_ascii_digit()) {
248 return Err(CellosError::InvalidSpec(format!(
249 "policy pack spec.version {value:?} component {p:?} is not a non-negative integer"
250 )));
251 }
252 if p.len() > 1 && p.starts_with('0') {
253 return Err(CellosError::InvalidSpec(format!(
254 "policy pack spec.version {value:?} component {p:?} has a leading zero"
255 )));
256 }
257 triple[i] = p.parse::<u64>().map_err(|_| {
258 CellosError::InvalidSpec(format!(
259 "policy pack spec.version {value:?} component {p:?} overflows u64"
260 ))
261 })?;
262 }
263 Ok((triple[0], triple[1], triple[2]))
264}
265
266pub fn check_policy_pack_version_compatibility(
284 declared: Option<&str>,
285 allow_downgrade: bool,
286) -> Result<(), CellosError> {
287 let declared_triple = match declared {
288 Some(v) => parse_semver_triple(v)?,
289 None => return Ok(()),
290 };
291 let floor_triple = parse_semver_triple(MIN_SUPPORTED_POLICY_PACK_VERSION)
292 .expect("MIN_SUPPORTED_POLICY_PACK_VERSION must parse");
293
294 if declared_triple < floor_triple {
295 if allow_downgrade {
296 return Ok(());
297 }
298 return Err(CellosError::InvalidSpec(format!(
299 "policy pack spec.version {} is older than runtime-supported floor {} \
300 (set {}=1 to override)",
301 declared.unwrap_or(""),
302 MIN_SUPPORTED_POLICY_PACK_VERSION,
303 POLICY_ALLOW_DOWNGRADE_ENV
304 )));
305 }
306 Ok(())
307}
308
309pub fn validate_policy_pack_document(doc: &PolicyPackDocument) -> Result<(), CellosError> {
320 if doc.api_version != "cellos.io/v1" {
321 return Err(CellosError::InvalidSpec(format!(
322 "policy pack apiVersion must be \"cellos.io/v1\", got {:?}",
323 doc.api_version
324 )));
325 }
326 if doc.kind != "PolicyPack" {
327 return Err(CellosError::InvalidSpec(format!(
328 "policy pack kind must be \"PolicyPack\", got {:?}",
329 doc.kind
330 )));
331 }
332
333 if !crate::spec_validation::is_portable_identifier(&doc.spec.id) {
334 return Err(CellosError::InvalidSpec(format!(
335 "policy pack spec.id {:?} is not a valid portable identifier",
336 doc.spec.id
337 )));
338 }
339
340 check_policy_pack_version_compatibility(doc.spec.version.as_deref(), false)?;
347
348 let rules = &doc.spec.rules;
349
350 if let Some(v) = rules.max_lifetime_ttl_seconds {
351 if v == 0 {
352 return Err(CellosError::InvalidSpec(
353 "policy pack rules.maxLifetimeTtlSeconds must be > 0".into(),
354 ));
355 }
356 }
357 if let Some(v) = rules.max_memory_max_bytes {
358 if v == 0 {
359 return Err(CellosError::InvalidSpec(
360 "policy pack rules.maxMemoryMaxBytes must be > 0".into(),
361 ));
362 }
363 }
364 if let Some(v) = rules.max_run_timeout_ms {
365 if v == 0 {
366 return Err(CellosError::InvalidSpec(
367 "policy pack rules.maxRunTimeoutMs must be > 0".into(),
368 ));
369 }
370 }
371 if rules.require_egress_declared && rules.forbid_outbound_egress_rules {
372 return Err(CellosError::InvalidSpec(
373 "policy pack rules.requireEgressDeclared and rules.forbidOutboundEgressRules \
374 are mutually exclusive"
375 .into(),
376 ));
377 }
378 for pattern in &rules.allowed_egress_hosts {
379 if pattern.is_empty() {
380 return Err(CellosError::InvalidSpec(
381 "policy pack rules.allowedEgressHosts contains an empty pattern".into(),
382 ));
383 }
384 }
385
386 Ok(())
387}
388
389pub fn spec_matches_placement_scope(
402 spec_placement: Option<&PlacementSpec>,
403 scope: &PlacementSpec,
404) -> bool {
405 let scope_has_any = scope.pool_id.is_some()
406 || scope.kubernetes_namespace.is_some()
407 || scope.queue_name.is_some();
408 if !scope_has_any {
409 return true;
410 }
411 let Some(spec_placement) = spec_placement else {
412 return false;
413 };
414 if let Some(pool) = scope.pool_id.as_deref() {
415 if spec_placement.pool_id.as_deref() != Some(pool) {
416 return false;
417 }
418 }
419 if let Some(ns) = scope.kubernetes_namespace.as_deref() {
420 if spec_placement.kubernetes_namespace.as_deref() != Some(ns) {
421 return false;
422 }
423 }
424 if let Some(queue) = scope.queue_name.as_deref() {
425 if spec_placement.queue_name.as_deref() != Some(queue) {
426 return false;
427 }
428 }
429 true
430}
431
432pub fn validate_spec_against_policy(
438 spec: &ExecutionCellSpec,
439 pack: &PolicyPackSpec,
440) -> Vec<PolicyViolation> {
441 let mut violations = Vec::new();
442
443 if let Some(scope) = &pack.placement {
451 if !spec_matches_placement_scope(spec.placement.as_ref(), scope) {
452 return violations;
453 }
454 }
455
456 let rules = &pack.rules;
457
458 if let Some(max) = rules.max_lifetime_ttl_seconds {
460 if spec.lifetime.ttl_seconds > max {
461 violations.push(PolicyViolation {
462 rule: "maxLifetimeTtlSeconds".into(),
463 message: format!(
464 "spec.lifetime.ttlSeconds {} exceeds policy maximum {}",
465 spec.lifetime.ttl_seconds, max
466 ),
467 });
468 }
469 }
470
471 if let Some(max) = rules.max_memory_max_bytes {
473 let actual = spec
474 .run
475 .as_ref()
476 .and_then(|r| r.limits.as_ref())
477 .and_then(|l| l.memory_max_bytes);
478 if let Some(actual) = actual {
479 if actual > max {
480 violations.push(PolicyViolation {
481 rule: "maxMemoryMaxBytes".into(),
482 message: format!(
483 "spec.run.limits.memoryMaxBytes {actual} exceeds policy maximum {max}"
484 ),
485 });
486 }
487 }
488 }
489
490 if let Some(max) = rules.max_run_timeout_ms {
492 let actual = spec.run.as_ref().and_then(|r| r.timeout_ms);
493 if let Some(actual) = actual {
494 if actual > max {
495 violations.push(PolicyViolation {
496 rule: "maxRunTimeoutMs".into(),
497 message: format!("spec.run.timeoutMs {actual} exceeds policy maximum {max}"),
498 });
499 }
500 }
501 }
502
503 let egress_rules = spec.authority.egress_rules.as_deref().unwrap_or_default();
505
506 if rules.require_egress_declared && egress_rules.is_empty() {
507 violations.push(PolicyViolation {
508 rule: "requireEgressDeclared".into(),
509 message: "policy requires spec.authority.egressRules to be non-empty".into(),
510 });
511 }
512
513 if rules.forbid_outbound_egress_rules && !egress_rules.is_empty() {
514 violations.push(PolicyViolation {
515 rule: "forbidOutboundEgressRules".into(),
516 message: format!(
517 "policy forbids outbound egress rules but spec declares {} rule(s)",
518 egress_rules.len()
519 ),
520 });
521 }
522
523 if !rules.allowed_egress_hosts.is_empty() {
524 for rule in egress_rules {
525 if !rules
526 .allowed_egress_hosts
527 .iter()
528 .any(|pat| host_matches_pattern(&rule.host, pat))
529 {
530 violations.push(PolicyViolation {
531 rule: "allowedEgressHosts".into(),
532 message: format!(
533 "egress host {:?} does not match any allowed pattern in {:?}",
534 rule.host, rules.allowed_egress_hosts
535 ),
536 });
537 }
538 }
539 }
540
541 if rules.require_runtime_secret_delivery {
543 let delivery = spec
544 .run
545 .as_ref()
546 .map(|r| &r.secret_delivery)
547 .unwrap_or(&SecretDeliveryMode::Env);
548 if *delivery == SecretDeliveryMode::Env {
549 violations.push(PolicyViolation {
550 rule: "requireRuntimeSecretDelivery".into(),
551 message: "policy requires spec.run.secretDelivery to be runtimeBroker or \
552 runtimeLeasedBroker, not env"
553 .into(),
554 });
555 }
556 }
557
558 if rules.require_resource_limits {
560 let has_limits = spec.run.as_ref().and_then(|r| r.limits.as_ref()).is_some();
561 if !has_limits {
562 violations.push(PolicyViolation {
563 rule: "requireResourceLimits".into(),
564 message: "policy requires spec.run.limits to be declared".into(),
565 });
566 }
567 }
568
569 if rules.flag_dns_egress_without_acknowledgment == Some(true) {
577 let mut has_dns_egress = false;
578 let mut all_acknowledged = true;
579 for rule in egress_rules {
580 if rule.port == 53 {
581 has_dns_egress = true;
582 let acknowledged = rule
583 .protocol
584 .as_deref()
585 .is_some_and(|p| p.eq_ignore_ascii_case("dns-acknowledged"));
586 if !acknowledged {
587 all_acknowledged = false;
588 }
589 }
590 }
591 if has_dns_egress && !all_acknowledged {
592 violations.push(PolicyViolation {
593 rule: "flagDnsEgressWithoutAcknowledgment".into(),
594 message: "spec declares port 53 (DNS) egress without acknowledgment — \
595 DNS can be used as a covert exfiltration channel; set \
596 protocol: dns-acknowledged to acknowledge this risk"
597 .into(),
598 });
599 }
600 }
601
602 if rules.require_dns_egress_justification == Some(true) {
612 for rule in egress_rules {
613 if rule.port != 53 {
614 continue;
615 }
616 let acknowledged = rule
617 .protocol
618 .as_deref()
619 .is_some_and(|p| p.eq_ignore_ascii_case("dns-acknowledged"));
620 if !acknowledged {
621 continue;
622 }
623 let justified = rule
624 .dns_egress_justification
625 .as_deref()
626 .is_some_and(|s| !s.trim().is_empty());
627 if !justified {
628 violations.push(PolicyViolation {
629 rule: "requireDnsEgressJustification".into(),
630 message: "port-53 egress rule with protocol dns-acknowledged \
631 requires a non-empty dnsEgressJustification field"
632 .into(),
633 });
634 }
635 }
636 }
637
638 violations
639}
640
641pub fn validate_secret_refs_against_allowlist(
667 spec: &ExecutionCellSpec,
668 rules: &PolicyRules,
669 caller_identity: &str,
670) -> Result<(), CellosError> {
671 let Some(allowlist) = rules.secret_ref_allowlist.as_ref() else {
672 return Ok(());
674 };
675
676 let Some(allowed) = allowlist.get(caller_identity) else {
677 return Err(CellosError::InvalidSpec(format!(
678 "caller_unmapped: caller identity {caller_identity:?} is not present in \
679 policy pack rules.secretRefAllowlist; admission rejected per ADR-0007"
680 )));
681 };
682
683 let Some(requested) = spec.authority.secret_refs.as_ref() else {
684 return Ok(());
686 };
687
688 for ref_name in requested {
689 if !allowed.iter().any(|granted| granted == ref_name) {
690 return Err(CellosError::InvalidSpec(format!(
691 "secret_ref_denied: caller {caller_identity:?} is not granted secretRef \
692 {ref_name:?} by policy pack rules.secretRefAllowlist; admission rejected \
693 per ADR-0007"
694 )));
695 }
696 }
697
698 Ok(())
699}
700
701fn host_matches_pattern(host: &str, pattern: &str) -> bool {
708 if pattern == "*" {
709 return true;
710 }
711 if let Some(suffix) = pattern.strip_prefix("*.") {
712 host.ends_with(&format!(".{suffix}"))
714 } else {
715 host == pattern
716 }
717}
718
719#[derive(Debug, Clone, Serialize, Deserialize)]
738#[serde(rename_all = "camelCase")]
739pub struct AuthorizationPolicyDocument {
740 pub api_version: String,
741 pub kind: String,
742 pub spec: AuthorizationPolicy,
743}
744
745#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
756#[serde(rename_all = "camelCase")]
757pub struct AuthorizationPolicy {
758 pub subjects: Vec<String>,
762
763 #[serde(default)]
766 pub allowed_pools: Vec<String>,
767
768 #[serde(default)]
771 pub allowed_policy_packs: Vec<String>,
772
773 #[serde(default, skip_serializing_if = "Option::is_none")]
777 pub max_cells_per_hour: Option<u32>,
778}
779
780pub fn validate_authorization_policy(doc: &AuthorizationPolicyDocument) -> Result<(), CellosError> {
790 if doc.api_version != "cellos.io/v1" {
791 return Err(CellosError::InvalidSpec(format!(
792 "authorization policy apiVersion must be \"cellos.io/v1\", got {:?}",
793 doc.api_version
794 )));
795 }
796 if doc.kind != "AuthorizationPolicy" {
797 return Err(CellosError::InvalidSpec(format!(
798 "authorization policy kind must be \"AuthorizationPolicy\", got {:?}",
799 doc.kind
800 )));
801 }
802 let policy = &doc.spec;
803 if policy.subjects.is_empty() {
804 return Err(CellosError::InvalidSpec(
805 "authorization policy spec.subjects must be non-empty — \
806 an empty subjects list would reject every spec; \
807 remove CELLOS_AUTHZ_POLICY_PATH to disable the gate instead"
808 .into(),
809 ));
810 }
811 for s in &policy.subjects {
812 if s.trim().is_empty() {
813 return Err(CellosError::InvalidSpec(
814 "authorization policy spec.subjects contains an empty / whitespace-only entry"
815 .into(),
816 ));
817 }
818 }
819 for p in &policy.allowed_pools {
820 if p.trim().is_empty() {
821 return Err(CellosError::InvalidSpec(
822 "authorization policy spec.allowedPools contains an empty entry".into(),
823 ));
824 }
825 }
826 for p in &policy.allowed_policy_packs {
827 if p.trim().is_empty() {
828 return Err(CellosError::InvalidSpec(
829 "authorization policy spec.allowedPolicyPacks contains an empty entry".into(),
830 ));
831 }
832 }
833 if let Some(0) = policy.max_cells_per_hour {
834 return Err(CellosError::InvalidSpec(
835 "authorization policy spec.maxCellsPerHour must be > 0 when set".into(),
836 ));
837 }
838 Ok(())
839}
840
841#[cfg(test)]
844mod tests {
845 use super::*;
846 use crate::types::{AuthorityBundle, EgressRule, Lifetime, RunLimits, RunSpec};
847
848 fn minimal_spec() -> ExecutionCellSpec {
849 ExecutionCellSpec {
850 id: "test-cell".into(),
851 correlation: None,
852 ingress: None,
853 environment: None,
854 placement: None,
855 policy: None,
856 identity: None,
857 run: Some(RunSpec {
858 argv: vec!["/usr/bin/true".into()],
859 working_directory: None,
860 timeout_ms: None,
861 limits: None,
862 secret_delivery: SecretDeliveryMode::Env,
863 }),
864 authority: AuthorityBundle {
865 filesystem: None,
866 network: None,
867 egress_rules: None,
868 secret_refs: None,
869 authority_derivation: None,
870 dns_authority: None,
871 cdn_authority: None,
872 },
873 lifetime: Lifetime { ttl_seconds: 300 },
874 export: None,
875 telemetry: None,
876 }
877 }
878
879 fn minimal_pack(rules: PolicyRules) -> PolicyPackSpec {
880 PolicyPackSpec {
881 id: "test-policy".into(),
882 description: None,
883 version: None,
884 placement: None,
885 rules,
886 }
887 }
888
889 fn minimal_doc(rules: PolicyRules) -> PolicyPackDocument {
890 PolicyPackDocument {
891 api_version: "cellos.io/v1".into(),
892 kind: "PolicyPack".into(),
893 spec: minimal_pack(rules),
894 }
895 }
896
897 #[test]
900 fn valid_doc_passes_structural_check() {
901 let doc = minimal_doc(PolicyRules::default());
902 assert!(validate_policy_pack_document(&doc).is_ok());
903 }
904
905 #[test]
906 fn wrong_api_version_is_rejected() {
907 let mut doc = minimal_doc(PolicyRules::default());
908 doc.api_version = "v1".into();
909 assert!(validate_policy_pack_document(&doc).is_err());
910 }
911
912 #[test]
913 fn wrong_kind_is_rejected() {
914 let mut doc = minimal_doc(PolicyRules::default());
915 doc.kind = "ExecutionCell".into();
916 assert!(validate_policy_pack_document(&doc).is_err());
917 }
918
919 #[test]
920 fn invalid_spec_id_is_rejected() {
921 let mut doc = minimal_doc(PolicyRules::default());
922 doc.spec.id = "-bad".into();
923 assert!(validate_policy_pack_document(&doc).is_err());
924 }
925
926 #[test]
927 fn zero_max_ttl_is_rejected() {
928 let doc = minimal_doc(PolicyRules {
929 max_lifetime_ttl_seconds: Some(0),
930 ..Default::default()
931 });
932 assert!(validate_policy_pack_document(&doc).is_err());
933 }
934
935 #[test]
936 fn require_and_forbid_egress_together_is_rejected() {
937 let doc = minimal_doc(PolicyRules {
938 require_egress_declared: true,
939 forbid_outbound_egress_rules: true,
940 ..Default::default()
941 });
942 assert!(validate_policy_pack_document(&doc).is_err());
943 }
944
945 #[test]
946 fn empty_egress_host_pattern_is_rejected() {
947 let doc = minimal_doc(PolicyRules {
948 allowed_egress_hosts: vec!["".into()],
949 ..Default::default()
950 });
951 assert!(validate_policy_pack_document(&doc).is_err());
952 }
953
954 #[test]
957 fn spec_passes_empty_policy() {
958 let spec = minimal_spec();
959 let pack = minimal_pack(PolicyRules::default());
960 assert!(validate_spec_against_policy(&spec, &pack).is_empty());
961 }
962
963 #[test]
964 fn ttl_exceeds_max_is_violation() {
965 let spec = minimal_spec(); let pack = minimal_pack(PolicyRules {
967 max_lifetime_ttl_seconds: Some(60),
968 ..Default::default()
969 });
970 let violations = validate_spec_against_policy(&spec, &pack);
971 assert_eq!(violations.len(), 1);
972 assert_eq!(violations[0].rule, "maxLifetimeTtlSeconds");
973 }
974
975 #[test]
976 fn ttl_at_exact_max_passes() {
977 let spec = minimal_spec(); let pack = minimal_pack(PolicyRules {
979 max_lifetime_ttl_seconds: Some(300),
980 ..Default::default()
981 });
982 assert!(validate_spec_against_policy(&spec, &pack).is_empty());
983 }
984
985 #[test]
986 fn memory_exceeds_max_is_violation() {
987 let mut spec = minimal_spec();
988 spec.run = Some(RunSpec {
989 argv: vec!["/usr/bin/true".into()],
990 working_directory: None,
991 timeout_ms: None,
992 limits: Some(RunLimits {
993 memory_max_bytes: Some(8 * 1024 * 1024 * 1024), cpu_max: None,
995 graceful_shutdown_seconds: None,
996 }),
997 secret_delivery: SecretDeliveryMode::Env,
998 });
999 let pack = minimal_pack(PolicyRules {
1000 max_memory_max_bytes: Some(4 * 1024 * 1024 * 1024), ..Default::default()
1002 });
1003 let violations = validate_spec_against_policy(&spec, &pack);
1004 assert_eq!(violations.len(), 1);
1005 assert_eq!(violations[0].rule, "maxMemoryMaxBytes");
1006 }
1007
1008 #[test]
1009 fn run_timeout_exceeds_max_is_violation() {
1010 let mut spec = minimal_spec();
1011 spec.run = Some(RunSpec {
1012 argv: vec!["/usr/bin/true".into()],
1013 working_directory: None,
1014 timeout_ms: Some(7_200_000), limits: None,
1016 secret_delivery: SecretDeliveryMode::Env,
1017 });
1018 let pack = minimal_pack(PolicyRules {
1019 max_run_timeout_ms: Some(3_600_000), ..Default::default()
1021 });
1022 let violations = validate_spec_against_policy(&spec, &pack);
1023 assert_eq!(violations.len(), 1);
1024 assert_eq!(violations[0].rule, "maxRunTimeoutMs");
1025 }
1026
1027 #[test]
1028 fn require_egress_declared_fails_when_no_egress_rules() {
1029 let spec = minimal_spec(); let pack = minimal_pack(PolicyRules {
1031 require_egress_declared: true,
1032 ..Default::default()
1033 });
1034 let violations = validate_spec_against_policy(&spec, &pack);
1035 assert_eq!(violations.len(), 1);
1036 assert_eq!(violations[0].rule, "requireEgressDeclared");
1037 }
1038
1039 #[test]
1040 fn require_egress_declared_passes_when_egress_present() {
1041 let mut spec = minimal_spec();
1042 spec.authority.egress_rules = Some(vec![EgressRule {
1043 host: "api.github.com".into(),
1044 port: 443,
1045 protocol: None,
1046 dns_egress_justification: None,
1047 }]);
1048 let pack = minimal_pack(PolicyRules {
1049 require_egress_declared: true,
1050 ..Default::default()
1051 });
1052 assert!(validate_spec_against_policy(&spec, &pack).is_empty());
1053 }
1054
1055 #[test]
1056 fn forbid_outbound_egress_fails_when_rules_declared() {
1057 let mut spec = minimal_spec();
1058 spec.authority.egress_rules = Some(vec![EgressRule {
1059 host: "external.example.com".into(),
1060 port: 443,
1061 protocol: None,
1062 dns_egress_justification: None,
1063 }]);
1064 let pack = minimal_pack(PolicyRules {
1065 forbid_outbound_egress_rules: true,
1066 ..Default::default()
1067 });
1068 let violations = validate_spec_against_policy(&spec, &pack);
1069 assert_eq!(violations.len(), 1);
1070 assert_eq!(violations[0].rule, "forbidOutboundEgressRules");
1071 }
1072
1073 #[test]
1074 fn forbid_outbound_egress_passes_when_no_rules() {
1075 let spec = minimal_spec(); let pack = minimal_pack(PolicyRules {
1077 forbid_outbound_egress_rules: true,
1078 ..Default::default()
1079 });
1080 assert!(validate_spec_against_policy(&spec, &pack).is_empty());
1081 }
1082
1083 #[test]
1084 fn allowed_egress_hosts_rejects_unlisted_host() {
1085 let mut spec = minimal_spec();
1086 spec.authority.egress_rules = Some(vec![EgressRule {
1087 host: "evil.example.com".into(),
1088 port: 443,
1089 protocol: None,
1090 dns_egress_justification: None,
1091 }]);
1092 let pack = minimal_pack(PolicyRules {
1093 allowed_egress_hosts: vec!["*.internal".into(), "api.github.com".into()],
1094 ..Default::default()
1095 });
1096 let violations = validate_spec_against_policy(&spec, &pack);
1097 assert_eq!(violations.len(), 1);
1098 assert_eq!(violations[0].rule, "allowedEgressHosts");
1099 }
1100
1101 #[test]
1102 fn allowed_egress_hosts_accepts_wildcard_subdomain() {
1103 let mut spec = minimal_spec();
1104 spec.authority.egress_rules = Some(vec![EgressRule {
1105 host: "cache.internal".into(),
1106 port: 443,
1107 protocol: None,
1108 dns_egress_justification: None,
1109 }]);
1110 let pack = minimal_pack(PolicyRules {
1111 allowed_egress_hosts: vec!["*.internal".into()],
1112 ..Default::default()
1113 });
1114 assert!(validate_spec_against_policy(&spec, &pack).is_empty());
1115 }
1116
1117 #[test]
1118 fn wildcard_subdomain_does_not_match_bare_domain() {
1119 assert!(!host_matches_pattern("internal", "*.internal"));
1121 assert!(host_matches_pattern("foo.internal", "*.internal"));
1122 }
1123
1124 #[test]
1125 fn require_runtime_secret_delivery_rejects_env_mode() {
1126 let spec = minimal_spec(); let pack = minimal_pack(PolicyRules {
1128 require_runtime_secret_delivery: true,
1129 ..Default::default()
1130 });
1131 let violations = validate_spec_against_policy(&spec, &pack);
1132 assert_eq!(violations.len(), 1);
1133 assert_eq!(violations[0].rule, "requireRuntimeSecretDelivery");
1134 }
1135
1136 #[test]
1137 fn require_runtime_secret_delivery_accepts_broker_mode() {
1138 let mut spec = minimal_spec();
1139 spec.run = Some(RunSpec {
1140 argv: vec!["/usr/bin/true".into()],
1141 working_directory: None,
1142 timeout_ms: None,
1143 limits: None,
1144 secret_delivery: SecretDeliveryMode::RuntimeBroker,
1145 });
1146 let pack = minimal_pack(PolicyRules {
1147 require_runtime_secret_delivery: true,
1148 ..Default::default()
1149 });
1150 assert!(validate_spec_against_policy(&spec, &pack).is_empty());
1151 }
1152
1153 #[test]
1154 fn require_resource_limits_rejects_spec_without_limits() {
1155 let spec = minimal_spec(); let pack = minimal_pack(PolicyRules {
1157 require_resource_limits: true,
1158 ..Default::default()
1159 });
1160 let violations = validate_spec_against_policy(&spec, &pack);
1161 assert_eq!(violations.len(), 1);
1162 assert_eq!(violations[0].rule, "requireResourceLimits");
1163 }
1164
1165 #[test]
1166 fn require_resource_limits_passes_with_limits_set() {
1167 let mut spec = minimal_spec();
1168 spec.run = Some(RunSpec {
1169 argv: vec!["/usr/bin/true".into()],
1170 working_directory: None,
1171 timeout_ms: None,
1172 limits: Some(RunLimits {
1173 memory_max_bytes: Some(512 * 1024 * 1024),
1174 cpu_max: None,
1175 graceful_shutdown_seconds: None,
1176 }),
1177 secret_delivery: SecretDeliveryMode::Env,
1178 });
1179 let pack = minimal_pack(PolicyRules {
1180 require_resource_limits: true,
1181 ..Default::default()
1182 });
1183 assert!(validate_spec_against_policy(&spec, &pack).is_empty());
1184 }
1185
1186 #[test]
1187 fn multiple_violations_are_all_reported() {
1188 let spec = minimal_spec(); let pack = minimal_pack(PolicyRules {
1191 max_lifetime_ttl_seconds: Some(60),
1192 require_runtime_secret_delivery: true,
1193 ..Default::default()
1194 });
1195 let violations = validate_spec_against_policy(&spec, &pack);
1196 assert_eq!(violations.len(), 2);
1197 let rules: Vec<&str> = violations.iter().map(|v| v.rule.as_str()).collect();
1198 assert!(rules.contains(&"maxLifetimeTtlSeconds"));
1199 assert!(rules.contains(&"requireRuntimeSecretDelivery"));
1200 }
1201
1202 #[test]
1205 fn dns_egress_flagged_when_rule_enabled() {
1206 let mut spec = minimal_spec();
1207 spec.authority.egress_rules = Some(vec![EgressRule {
1208 host: "ns.example.com".into(),
1209 port: 53,
1210 protocol: None,
1211 dns_egress_justification: None,
1212 }]);
1213 let pack = minimal_pack(PolicyRules {
1214 flag_dns_egress_without_acknowledgment: Some(true),
1215 ..Default::default()
1216 });
1217 let violations = validate_spec_against_policy(&spec, &pack);
1218 assert_eq!(violations.len(), 1);
1219 assert_eq!(violations[0].rule, "flagDnsEgressWithoutAcknowledgment");
1220 assert!(violations[0].message.contains("dns-acknowledged"));
1221 }
1222
1223 #[test]
1224 fn dns_egress_not_flagged_when_protocol_acknowledged() {
1225 let mut spec = minimal_spec();
1226 spec.authority.egress_rules = Some(vec![EgressRule {
1227 host: "ns.example.com".into(),
1228 port: 53,
1229 protocol: Some("dns-acknowledged".into()),
1230 dns_egress_justification: None,
1231 }]);
1232 let pack = minimal_pack(PolicyRules {
1233 flag_dns_egress_without_acknowledgment: Some(true),
1234 ..Default::default()
1235 });
1236 assert!(validate_spec_against_policy(&spec, &pack).is_empty());
1237 }
1238
1239 #[test]
1240 fn dns_egress_acknowledgment_is_case_insensitive() {
1241 let mut spec = minimal_spec();
1242 spec.authority.egress_rules = Some(vec![EgressRule {
1243 host: "ns.example.com".into(),
1244 port: 53,
1245 protocol: Some("DNS-Acknowledged".into()),
1246 dns_egress_justification: None,
1247 }]);
1248 let pack = minimal_pack(PolicyRules {
1249 flag_dns_egress_without_acknowledgment: Some(true),
1250 ..Default::default()
1251 });
1252 assert!(validate_spec_against_policy(&spec, &pack).is_empty());
1253 }
1254
1255 #[test]
1256 fn dns_egress_not_checked_when_rule_disabled() {
1257 let mut spec = minimal_spec();
1258 spec.authority.egress_rules = Some(vec![EgressRule {
1259 host: "ns.example.com".into(),
1260 port: 53,
1261 protocol: None,
1262 dns_egress_justification: None,
1263 }]);
1264 let pack = minimal_pack(PolicyRules::default());
1266 assert!(validate_spec_against_policy(&spec, &pack).is_empty());
1267 }
1268
1269 #[test]
1270 fn dns_egress_not_checked_when_rule_explicitly_false() {
1271 let mut spec = minimal_spec();
1272 spec.authority.egress_rules = Some(vec![EgressRule {
1273 host: "ns.example.com".into(),
1274 port: 53,
1275 protocol: None,
1276 dns_egress_justification: None,
1277 }]);
1278 let pack = minimal_pack(PolicyRules {
1279 flag_dns_egress_without_acknowledgment: Some(false),
1280 ..Default::default()
1281 });
1282 assert!(validate_spec_against_policy(&spec, &pack).is_empty());
1283 }
1284
1285 #[test]
1286 fn dns_egress_rule_does_not_affect_non_dns_ports() {
1287 let mut spec = minimal_spec();
1288 spec.authority.egress_rules = Some(vec![EgressRule {
1289 host: "api.github.com".into(),
1290 port: 443,
1291 protocol: None,
1292 dns_egress_justification: None,
1293 }]);
1294 let pack = minimal_pack(PolicyRules {
1295 flag_dns_egress_without_acknowledgment: Some(true),
1296 ..Default::default()
1297 });
1298 assert!(validate_spec_against_policy(&spec, &pack).is_empty());
1299 }
1300
1301 #[test]
1302 fn dns_egress_flagged_when_some_rules_acknowledged_but_not_all() {
1303 let mut spec = minimal_spec();
1304 spec.authority.egress_rules = Some(vec![
1305 EgressRule {
1306 host: "ns1.example.com".into(),
1307 port: 53,
1308 protocol: Some("dns-acknowledged".into()),
1309 dns_egress_justification: None,
1310 },
1311 EgressRule {
1312 host: "ns2.example.com".into(),
1313 port: 53,
1314 protocol: None,
1315 dns_egress_justification: None,
1316 },
1317 ]);
1318 let pack = minimal_pack(PolicyRules {
1319 flag_dns_egress_without_acknowledgment: Some(true),
1320 ..Default::default()
1321 });
1322 let violations = validate_spec_against_policy(&spec, &pack);
1323 assert_eq!(violations.len(), 1);
1324 assert_eq!(violations[0].rule, "flagDnsEgressWithoutAcknowledgment");
1325 }
1326
1327 #[test]
1345 fn dns_egress_ack_gate_covers_tcp_protocol() {
1346 let mut spec = minimal_spec();
1349 spec.authority.egress_rules = Some(vec![EgressRule {
1350 host: "1.1.1.1".into(),
1351 port: 53,
1352 protocol: Some("tcp".into()),
1353 dns_egress_justification: None,
1354 }]);
1355 let pack = minimal_pack(PolicyRules {
1356 flag_dns_egress_without_acknowledgment: Some(true),
1357 ..Default::default()
1358 });
1359 let violations = validate_spec_against_policy(&spec, &pack);
1360 assert_eq!(
1361 violations.len(),
1362 1,
1363 "TCP/53 without dns-acknowledged must violate the SEC-15 gate; \
1364 got: {violations:?}"
1365 );
1366 assert_eq!(violations[0].rule, "flagDnsEgressWithoutAcknowledgment");
1367 }
1368
1369 #[test]
1370 fn dns_egress_ack_gate_admits_acknowledged_tcp_53() {
1371 let mut spec = minimal_spec();
1383 spec.authority.egress_rules = Some(vec![EgressRule {
1384 host: "1.1.1.1".into(),
1385 port: 53,
1386 protocol: Some("dns-acknowledged".into()),
1387 dns_egress_justification: None,
1388 }]);
1389 let pack = minimal_pack(PolicyRules {
1390 flag_dns_egress_without_acknowledgment: Some(true),
1391 ..Default::default()
1392 });
1393 assert!(
1394 validate_spec_against_policy(&spec, &pack).is_empty(),
1395 "acknowledged port-53 rule must pass the SEC-15 gate"
1396 );
1397 }
1398
1399 #[test]
1400 fn dns_egress_ack_gate_rejects_mixed_acknowledged_and_tcp_53() {
1401 let mut spec = minimal_spec();
1407 spec.authority.egress_rules = Some(vec![
1408 EgressRule {
1409 host: "1.1.1.1".into(),
1410 port: 53,
1411 protocol: Some("dns-acknowledged".into()),
1412 dns_egress_justification: None,
1413 },
1414 EgressRule {
1415 host: "8.8.8.8".into(),
1416 port: 53,
1417 protocol: Some("tcp".into()),
1418 dns_egress_justification: None,
1419 },
1420 ]);
1421 let pack = minimal_pack(PolicyRules {
1422 flag_dns_egress_without_acknowledgment: Some(true),
1423 ..Default::default()
1424 });
1425 let violations = validate_spec_against_policy(&spec, &pack);
1426 assert_eq!(
1427 violations.len(),
1428 1,
1429 "mixed ack+TCP/53 must violate the SEC-15 gate; got: {violations:?}"
1430 );
1431 assert_eq!(violations[0].rule, "flagDnsEgressWithoutAcknowledgment");
1432 }
1433
1434 #[test]
1435 fn policy_violation_display_includes_rule_and_message() {
1436 let v = PolicyViolation {
1437 rule: "maxLifetimeTtlSeconds".into(),
1438 message: "300 exceeds 60".into(),
1439 };
1440 let s = v.to_string();
1441 assert!(s.contains("maxLifetimeTtlSeconds"));
1442 assert!(s.contains("300 exceeds 60"));
1443 }
1444
1445 #[test]
1448 fn dns_justification_required_when_rule_enabled_and_acknowledged() {
1449 let mut spec = minimal_spec();
1450 spec.authority.egress_rules = Some(vec![EgressRule {
1451 host: "ns.example.com".into(),
1452 port: 53,
1453 protocol: Some("dns-acknowledged".into()),
1454 dns_egress_justification: None,
1455 }]);
1456 let pack = minimal_pack(PolicyRules {
1457 require_dns_egress_justification: Some(true),
1458 ..Default::default()
1459 });
1460 let violations = validate_spec_against_policy(&spec, &pack);
1461 assert_eq!(violations.len(), 1);
1462 assert_eq!(violations[0].rule, "requireDnsEgressJustification");
1463 assert!(violations[0].message.contains("dnsEgressJustification"));
1464 }
1465
1466 #[test]
1467 fn dns_justification_satisfied_with_nonempty_string() {
1468 let mut spec = minimal_spec();
1469 spec.authority.egress_rules = Some(vec![EgressRule {
1470 host: "ns.example.com".into(),
1471 port: 53,
1472 protocol: Some("dns-acknowledged".into()),
1473 dns_egress_justification: Some("internal resolver at 10.0.0.1".into()),
1474 }]);
1475 let pack = minimal_pack(PolicyRules {
1476 require_dns_egress_justification: Some(true),
1477 ..Default::default()
1478 });
1479 assert!(validate_spec_against_policy(&spec, &pack).is_empty());
1480 }
1481
1482 #[test]
1483 fn dns_justification_empty_string_rejected() {
1484 let mut spec = minimal_spec();
1485 spec.authority.egress_rules = Some(vec![EgressRule {
1486 host: "ns.example.com".into(),
1487 port: 53,
1488 protocol: Some("dns-acknowledged".into()),
1489 dns_egress_justification: Some(" ".into()),
1490 }]);
1491 let pack = minimal_pack(PolicyRules {
1492 require_dns_egress_justification: Some(true),
1493 ..Default::default()
1494 });
1495 let violations = validate_spec_against_policy(&spec, &pack);
1496 assert_eq!(violations.len(), 1);
1497 assert_eq!(violations[0].rule, "requireDnsEgressJustification");
1498 }
1499
1500 #[test]
1501 fn dns_justification_not_required_when_rule_disabled() {
1502 let mut spec = minimal_spec();
1503 spec.authority.egress_rules = Some(vec![EgressRule {
1504 host: "ns.example.com".into(),
1505 port: 53,
1506 protocol: Some("dns-acknowledged".into()),
1507 dns_egress_justification: None,
1508 }]);
1509 let pack = minimal_pack(PolicyRules::default());
1511 let violations = validate_spec_against_policy(&spec, &pack);
1512 assert!(
1514 !violations
1515 .iter()
1516 .any(|v| v.rule == "requireDnsEgressJustification"),
1517 "unexpected requireDnsEgressJustification violation: {violations:?}"
1518 );
1519 }
1520
1521 #[test]
1531 fn version_absent_is_accepted() {
1532 assert!(check_policy_pack_version_compatibility(None, false).is_ok());
1533 }
1534
1535 #[test]
1536 fn version_at_floor_is_accepted() {
1537 assert!(check_policy_pack_version_compatibility(
1538 Some(MIN_SUPPORTED_POLICY_PACK_VERSION),
1539 false
1540 )
1541 .is_ok());
1542 }
1543
1544 #[test]
1545 fn version_above_floor_is_accepted() {
1546 assert!(check_policy_pack_version_compatibility(Some("1.4.2"), false).is_ok());
1547 assert!(check_policy_pack_version_compatibility(Some("2.0.0"), false).is_ok());
1548 }
1549
1550 #[test]
1551 fn version_with_prerelease_is_accepted() {
1552 assert!(check_policy_pack_version_compatibility(Some("1.0.0-rc.1"), false).is_ok());
1554 }
1555
1556 #[test]
1557 fn malformed_version_is_rejected() {
1558 assert!(check_policy_pack_version_compatibility(Some("v1.0"), false).is_err());
1559 assert!(check_policy_pack_version_compatibility(Some("1.0"), false).is_err());
1560 assert!(check_policy_pack_version_compatibility(Some("01.00.00"), false).is_err());
1561 assert!(check_policy_pack_version_compatibility(Some(""), false).is_err());
1562 }
1563
1564 #[test]
1565 fn document_validates_with_explicit_floor_version() {
1566 let mut doc = minimal_doc(PolicyRules::default());
1567 doc.spec.version = Some(MIN_SUPPORTED_POLICY_PACK_VERSION.into());
1568 assert!(validate_policy_pack_document(&doc).is_ok());
1569 }
1570
1571 #[test]
1572 fn document_rejects_malformed_version() {
1573 let mut doc = minimal_doc(PolicyRules::default());
1574 doc.spec.version = Some("not-a-semver".into());
1575 assert!(validate_policy_pack_document(&doc).is_err());
1576 }
1577
1578 fn pack_with_placement(rules: PolicyRules, placement: PlacementSpec) -> PolicyPackSpec {
1585 PolicyPackSpec {
1586 id: "scoped-policy".into(),
1587 description: None,
1588 version: None,
1589 placement: Some(placement),
1590 rules,
1591 }
1592 }
1593
1594 fn spec_with_ttl_and_placement(
1595 ttl_seconds: u64,
1596 placement: Option<PlacementSpec>,
1597 ) -> ExecutionCellSpec {
1598 let mut s = minimal_spec();
1599 s.lifetime.ttl_seconds = ttl_seconds;
1600 s.placement = placement;
1601 s
1602 }
1603
1604 #[test]
1605 fn placement_scoped_pack_applies_when_pool_matches() {
1606 let pack = pack_with_placement(
1608 PolicyRules {
1609 max_lifetime_ttl_seconds: Some(60),
1610 ..Default::default()
1611 },
1612 PlacementSpec {
1613 pool_id: Some("runner-pool-amd64".into()),
1614 kubernetes_namespace: None,
1615 queue_name: None,
1616 },
1617 );
1618 let spec = spec_with_ttl_and_placement(
1620 300,
1621 Some(PlacementSpec {
1622 pool_id: Some("runner-pool-amd64".into()),
1623 kubernetes_namespace: None,
1624 queue_name: None,
1625 }),
1626 );
1627 let violations = validate_spec_against_policy(&spec, &pack);
1628 assert_eq!(violations.len(), 1, "scoped pack should apply on match");
1629 assert_eq!(violations[0].rule, "maxLifetimeTtlSeconds");
1630 }
1631
1632 #[test]
1633 fn placement_scoped_pack_is_skipped_when_pool_differs() {
1634 let pack = pack_with_placement(
1635 PolicyRules {
1636 max_lifetime_ttl_seconds: Some(60),
1637 ..Default::default()
1638 },
1639 PlacementSpec {
1640 pool_id: Some("runner-pool-amd64".into()),
1641 kubernetes_namespace: None,
1642 queue_name: None,
1643 },
1644 );
1645 let spec = spec_with_ttl_and_placement(
1647 300,
1648 Some(PlacementSpec {
1649 pool_id: Some("runner-pool-arm64".into()),
1650 kubernetes_namespace: None,
1651 queue_name: None,
1652 }),
1653 );
1654 let violations = validate_spec_against_policy(&spec, &pack);
1655 assert!(
1656 violations.is_empty(),
1657 "scoped pack must not apply to mismatched placement, got {violations:?}"
1658 );
1659 }
1660
1661 #[test]
1662 fn unscoped_pack_applies_everywhere() {
1663 let pack = minimal_pack(PolicyRules {
1665 max_lifetime_ttl_seconds: Some(60),
1666 ..Default::default()
1667 });
1668 let spec_no_placement = spec_with_ttl_and_placement(300, None);
1669 let spec_with_pool = spec_with_ttl_and_placement(
1670 300,
1671 Some(PlacementSpec {
1672 pool_id: Some("runner-pool-amd64".into()),
1673 kubernetes_namespace: None,
1674 queue_name: None,
1675 }),
1676 );
1677 assert_eq!(
1678 validate_spec_against_policy(&spec_no_placement, &pack).len(),
1679 1,
1680 "unscoped pack must apply to specs without placement"
1681 );
1682 assert_eq!(
1683 validate_spec_against_policy(&spec_with_pool, &pack).len(),
1684 1,
1685 "unscoped pack must apply to specs with any placement"
1686 );
1687 }
1688
1689 #[test]
1690 fn placement_scope_with_no_populated_fields_is_universal() {
1691 let pack = pack_with_placement(
1694 PolicyRules {
1695 max_lifetime_ttl_seconds: Some(60),
1696 ..Default::default()
1697 },
1698 PlacementSpec::default(),
1699 );
1700 let spec = spec_with_ttl_and_placement(300, None);
1701 let violations = validate_spec_against_policy(&spec, &pack);
1702 assert_eq!(violations.len(), 1, "empty scope must behave as universal");
1703 }
1704
1705 #[test]
1706 fn scope_with_multiple_fields_requires_all_to_match() {
1707 let pack = pack_with_placement(
1709 PolicyRules {
1710 max_lifetime_ttl_seconds: Some(60),
1711 ..Default::default()
1712 },
1713 PlacementSpec {
1714 pool_id: Some("runner-pool-amd64".into()),
1715 kubernetes_namespace: Some("cellos-prod".into()),
1716 queue_name: None,
1717 },
1718 );
1719 let half_match = spec_with_ttl_and_placement(
1721 300,
1722 Some(PlacementSpec {
1723 pool_id: Some("runner-pool-amd64".into()),
1724 kubernetes_namespace: Some("cellos-staging".into()),
1725 queue_name: None,
1726 }),
1727 );
1728 assert!(validate_spec_against_policy(&half_match, &pack).is_empty());
1729
1730 let full_match = spec_with_ttl_and_placement(
1732 300,
1733 Some(PlacementSpec {
1734 pool_id: Some("runner-pool-amd64".into()),
1735 kubernetes_namespace: Some("cellos-prod".into()),
1736 queue_name: None,
1737 }),
1738 );
1739 assert_eq!(validate_spec_against_policy(&full_match, &pack).len(), 1);
1740 }
1741
1742 fn minimal_authz_doc(policy: AuthorizationPolicy) -> AuthorizationPolicyDocument {
1745 AuthorizationPolicyDocument {
1746 api_version: "cellos.io/v1".into(),
1747 kind: "AuthorizationPolicy".into(),
1748 spec: policy,
1749 }
1750 }
1751
1752 #[test]
1753 fn authz_policy_valid_doc_passes() {
1754 let doc = minimal_authz_doc(AuthorizationPolicy {
1755 subjects: vec!["tenant:acme".into(), "oidc:github:foo/bar".into()],
1756 allowed_pools: vec!["pool-a".into()],
1757 allowed_policy_packs: vec!["strict-1".into()],
1758 max_cells_per_hour: Some(100),
1759 });
1760 assert!(validate_authorization_policy(&doc).is_ok());
1761 }
1762
1763 #[test]
1764 fn authz_policy_empty_subjects_rejected() {
1765 let doc = minimal_authz_doc(AuthorizationPolicy {
1766 subjects: vec![],
1767 ..AuthorizationPolicy::default()
1768 });
1769 let err = validate_authorization_policy(&doc).expect_err("empty subjects must reject");
1770 assert!(
1771 err.to_string().contains("subjects must be non-empty"),
1772 "got: {err}"
1773 );
1774 }
1775
1776 #[test]
1777 fn authz_policy_wrong_kind_rejected() {
1778 let mut doc = minimal_authz_doc(AuthorizationPolicy {
1779 subjects: vec!["tenant:acme".into()],
1780 ..AuthorizationPolicy::default()
1781 });
1782 doc.kind = "PolicyPack".into();
1783 assert!(validate_authorization_policy(&doc).is_err());
1784 }
1785
1786 #[test]
1787 fn authz_policy_zero_rate_limit_rejected() {
1788 let doc = minimal_authz_doc(AuthorizationPolicy {
1789 subjects: vec!["tenant:acme".into()],
1790 max_cells_per_hour: Some(0),
1791 ..AuthorizationPolicy::default()
1792 });
1793 let err = validate_authorization_policy(&doc).expect_err("zero rate limit must reject");
1794 assert!(err.to_string().contains("maxCellsPerHour"), "got: {err}");
1795 }
1796
1797 #[test]
1798 fn authz_policy_empty_pool_entry_rejected() {
1799 let doc = minimal_authz_doc(AuthorizationPolicy {
1800 subjects: vec!["tenant:acme".into()],
1801 allowed_pools: vec!["valid".into(), " ".into()],
1802 ..AuthorizationPolicy::default()
1803 });
1804 assert!(validate_authorization_policy(&doc).is_err());
1805 }
1806}