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