Skip to main content

cellos_core/
policy.rs

1//! Policy pack — operator-defined execution constraints applied at admission.
2//!
3//! A `PolicyPack` document declares a named set of rules that constrain what
4//! an [`ExecutionCellSpec`] is allowed to express.
5//! The spec carries a [`PolicyRef`](crate::PolicyRef) (`spec.policy.packId` /
6//! `spec.policy.packVersion`) so emitted CloudEvents attribute every run to the
7//! policy pack in effect.
8//!
9//! # Validation surface
10//!
11//! Two entry points:
12//!
13//! - [`validate_policy_pack_document`] — structural validity of the pack itself.
14//! - [`validate_spec_against_policy`] — admission gate: returns all violations
15//!   the given spec has against the pack's rules.
16//!
17//! Both functions return structured errors rather than panicking, so callers can
18//! surface them as operator-readable messages.
19
20use std::collections::HashMap;
21
22use serde::{Deserialize, Serialize};
23
24use crate::{CellosError, ExecutionCellSpec, PlacementSpec, SecretDeliveryMode};
25
26// ── Document types ────────────────────────────────────────────────────────────
27
28/// Top-level policy pack document (`apiVersion: cellos.io/v1`, `kind: PolicyPack`).
29#[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    /// Portable identifier for this pack — used in `spec.policy.packId`.
41    pub id: String,
42    #[serde(default)]
43    pub description: Option<String>,
44    /// Pack schema version — semver `MAJOR.MINOR.PATCH` (optional pre-release).
45    ///
46    /// When present, the runtime compares this against
47    /// [`MIN_SUPPORTED_POLICY_PACK_VERSION`] at admission and rejects packs
48    /// whose version is **lower than** the runtime's compiled-in floor unless
49    /// the operator sets `CELLOS_POLICY_ALLOW_DOWNGRADE=1`. When absent, the
50    /// pack is treated as version [`MIN_SUPPORTED_POLICY_PACK_VERSION`] for
51    /// backwards compatibility with packs authored before the field existed.
52    /// See P4-04.
53    #[serde(default)]
54    pub version: Option<String>,
55    /// T11 — optional placement scope. When set, the pack's rules apply only
56    /// to specs whose `spec.placement` matches every populated field of this
57    /// scope (a `None` field on the policy means "any" for that axis).
58    ///
59    /// A pack without a `placement` scope is global — applied to every spec
60    /// regardless of where it runs. The matching rule lives in
61    /// [`spec_matches_placement_scope`] so the contract is testable
62    /// independently of the rest of admission.
63    #[serde(default, skip_serializing_if = "Option::is_none")]
64    pub placement: Option<PlacementSpec>,
65    pub rules: PolicyRules,
66}
67
68/// Compiled-in floor for the policy pack schema version. Packs declaring a
69/// `spec.version` strictly lower than this triple are rejected at admission
70/// unless `CELLOS_POLICY_ALLOW_DOWNGRADE=1` is set. P4-04.
71pub const MIN_SUPPORTED_POLICY_PACK_VERSION: &str = "1.0.0";
72
73/// Environment variable name that, when truthy
74/// (`1` / `true` / `yes` / `on`, case-insensitive), suppresses the policy pack
75/// downgrade check at admission. P4-04.
76pub const POLICY_ALLOW_DOWNGRADE_ENV: &str = "CELLOS_POLICY_ALLOW_DOWNGRADE";
77
78/// Constraint rules within a policy pack.
79///
80/// All fields are optional — an absent field means "no restriction on this axis."
81/// A `PolicyPack` with an empty `PolicyRules` is valid but does not constrain
82/// any spec (useful as an explicit "audit-only" marker).
83#[derive(Debug, Clone, Default, Serialize, Deserialize)]
84#[serde(rename_all = "camelCase")]
85pub struct PolicyRules {
86    /// `spec.lifetime.ttlSeconds` must not exceed this value.
87    #[serde(default)]
88    pub max_lifetime_ttl_seconds: Option<u64>,
89
90    /// `spec.run.limits.memoryMaxBytes` must not exceed this value.
91    #[serde(default)]
92    pub max_memory_max_bytes: Option<u64>,
93
94    /// `spec.run.timeoutMs` must not exceed this value.
95    #[serde(default)]
96    pub max_run_timeout_ms: Option<u64>,
97
98    /// When `true`, `spec.authority.egressRules` must be non-empty.
99    ///
100    /// Use this to force explicit declaration of all outbound network intent —
101    /// specs that declare no egress rules are rejected.
102    #[serde(default)]
103    pub require_egress_declared: bool,
104
105    /// When `true`, specs must declare zero outbound egress rules.
106    ///
107    /// Use this to enforce fully air-gapped workloads. A spec with any
108    /// `spec.authority.egressRules` is rejected.
109    #[serde(default)]
110    pub forbid_outbound_egress_rules: bool,
111
112    /// When non-empty, every `spec.authority.egressRules[].host` must match
113    /// at least one pattern in this list.
114    ///
115    /// Patterns support a single leading `*.` wildcard (e.g. `*.internal`).
116    /// Exact hostnames are also accepted. An empty list means no restriction.
117    #[serde(default)]
118    pub allowed_egress_hosts: Vec<String>,
119
120    /// When `true`, `spec.run.secretDelivery` must not be `env`.
121    ///
122    /// Use this to prohibit the default env-variable secret delivery mode
123    /// and require `runtimeBroker` or `runtimeLeasedBroker` instead.
124    #[serde(default)]
125    pub require_runtime_secret_delivery: bool,
126
127    /// When `true`, `spec.run.limits` must be present.
128    ///
129    /// Use this to ensure every spec explicitly declares resource bounds.
130    #[serde(default)]
131    pub require_resource_limits: bool,
132
133    /// When `true`, any spec that declares an egress rule on port 53 (DNS) is
134    /// flagged with a [`PolicyViolation`] unless the spec also sets
135    /// `authority.egressRules[].protocol = "dns-acknowledged"` on every port-53
136    /// rule.
137    ///
138    /// CellOS's egress policy operates at L3/L4 (IP:port via nftables) and has
139    /// no visibility into DNS query content. A cell with declared port 53
140    /// egress can therefore exfiltrate arbitrary data via DNS TXT query labels
141    /// to an attacker-controlled authoritative nameserver, completely outside
142    /// CellOS observability. See `docs/sec_roadmap.md` R10 / SEC-15.
143    ///
144    /// The `dns-acknowledged` protocol value is a forcing function, not a
145    /// security control: operators who understand the covert-channel risk can
146    /// set it to suppress the violation. Future work (L2-04 extension) is a
147    /// DNS proxy inside the cell netns with query-level CloudEvents.
148    #[serde(default)]
149    pub flag_dns_egress_without_acknowledgment: Option<bool>,
150
151    /// When `true`, any port-53 egress rule with `protocol = "dns-acknowledged"`
152    /// must also supply a non-empty `dnsEgressJustification` string.
153    ///
154    /// This closes SEAM-2 (illusory consent): without it, an operator can
155    /// suppress [`flag_dns_egress_without_acknowledgment`](Self::flag_dns_egress_without_acknowledgment)
156    /// by typing four words and zero further effort, defeating the forcing
157    /// function. With the rule enabled, the operator must record an auditable
158    /// justification alongside the acknowledgment, raising the cost of
159    /// reflexive suppression and creating evidence of stated reasoning.
160    /// See `docs/sec_roadmap.md` R10 / SEC-15c.
161    #[serde(default, rename = "requireDnsEgressJustification")]
162    pub require_dns_egress_justification: Option<bool>,
163
164    /// A2-02: per-caller-identity allowlist for `spec.authority.secretRefs`.
165    ///
166    /// Map shape: `caller-identity → [allowed secretRef key]`. The caller
167    /// identity is host-stamped via `CELLOS_CALLER_IDENTITY` at the supervisor
168    /// boundary (per ADR-0007 / D11: `cellos-core` itself does NOT read process
169    /// env vars; the resolved identity is threaded through to
170    /// [`validate_secret_refs_against_allowlist`] as a parameter).
171    ///
172    /// When this field is `None`, the allowlist gate is **skipped entirely** —
173    /// admission proceeds against the rest of the rules unchanged. This is the
174    /// "audit-only" / opt-in posture documented in ADR-0007 §"What 1.0 ships".
175    ///
176    /// When this field is `Some(map)`:
177    /// - The caller's identity MUST appear as a key in `map` (otherwise the
178    ///   caller is "unmapped" and rejected with [`CellosError::InvalidSpec`]
179    ///   carrying a `caller_unmapped` reason string).
180    /// - Every entry in `spec.authority.secretRefs` MUST appear in
181    ///   `map[caller_identity]` (otherwise admission is rejected with a
182    ///   `secret_ref_denied` reason naming the offending key).
183    ///
184    /// The keys (caller identities) and values (secretRef names) are operator-
185    /// curated portable strings; this field carries no implicit role hierarchy
186    /// (see ADR-0007 non-goal "Hierarchical roles / role inheritance"). Two
187    /// callers needing the same set of refs are two map entries, not one.
188    ///
189    /// See ADR-0007 (`docs/adr/0007-rbac-secret-ref-admission.md`) for the
190    /// contract; A2-03 (multi-tenant event isolation) is a separate surface.
191    #[serde(default, rename = "secretRefAllowlist")]
192    pub secret_ref_allowlist: Option<HashMap<String, Vec<String>>>,
193}
194
195// ── Violation ─────────────────────────────────────────────────────────────────
196
197/// A single rule violation produced by [`validate_spec_against_policy`].
198#[derive(Debug, Clone, PartialEq, Eq)]
199pub struct PolicyViolation {
200    /// The `camelCase` rule name that was violated (e.g. `"maxLifetimeTtlSeconds"`).
201    pub rule: String,
202    /// Human-readable description of the violation.
203    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
212// ── Version compatibility (P4-04) ─────────────────────────────────────────────
213
214/// Parse a semver `MAJOR.MINOR.PATCH` string into a comparable triple.
215///
216/// Accepts an optional `-pre` suffix per SemVer 2.0 (e.g. `"1.2.3-rc.1"`); the
217/// pre-release portion is ignored for ordering — a downgrade-vs-floor check
218/// only needs the numeric core. Leading zeros on numeric components (e.g.
219/// `"01.02.03"`) are rejected to keep the surface canonical.
220///
221/// Returns `Err(CellosError::InvalidSpec)` with a descriptive message on
222/// malformed input.
223fn 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
266/// Validate a declared policy pack `spec.version` against the runtime's
267/// compiled-in floor [`MIN_SUPPORTED_POLICY_PACK_VERSION`]. P4-04.
268///
269/// - When `declared` is `None`, the pack is treated as the floor version
270///   (backwards compatibility for packs authored before the field existed).
271/// - When `declared` parses to a triple **strictly lower** than the floor,
272///   the pack is rejected unless `allow_downgrade` is `true`.
273/// - When `declared` is malformed, the pack is rejected unconditionally — the
274///   downgrade-allow override does **not** suppress structural errors.
275///
276/// Pure free function — `cellos-core` does not read process env vars (D11).
277/// The supervisor admission path reads `CELLOS_POLICY_ALLOW_DOWNGRADE` and
278/// passes the resolved bool through `cellos_core::spec_validation`'s
279/// version check, or directly here. [`validate_policy_pack_document`] always
280/// calls this with
281/// `allow_downgrade=false` so structural validation stays strict; the env
282/// override only kicks in when the supervisor explicitly opts in.
283pub 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
309// ── Document validation ───────────────────────────────────────────────────────
310
311/// Validate the structural integrity of a [`PolicyPackDocument`].
312///
313/// Checks:
314/// - `apiVersion == "cellos.io/v1"`
315/// - `kind == "PolicyPack"`
316/// - `spec.id` is a valid portable identifier
317/// - All numeric bounds are > 0 when set
318/// - All `allowedEgressHosts` patterns are non-empty
319pub 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    // P4-04: reject packs whose declared schema version is older than the
341    // runtime's compiled-in floor. Strict by default — `validate_policy_pack_document`
342    // is the structural-correctness gate and must not depend on process env;
343    // the supervisor reads `CELLOS_POLICY_ALLOW_DOWNGRADE` and calls
344    // `check_policy_pack_version_compatibility(declared, allow_downgrade=true)`
345    // separately when the operator opted in.
346    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
389// ── Spec admission gate ───────────────────────────────────────────────────────
390
391/// T11 — match a spec's placement against a policy pack's placement scope.
392///
393/// Returns `true` when **every populated field of the scope** equals the
394/// matching field in `spec_placement`. Empty fields on the scope are
395/// wildcards. When the scope has any populated field and the spec has no
396/// `placement` block at all, the match fails (the spec is unscoped).
397///
398/// This intentionally requires exact, case-sensitive equality on each
399/// populated axis — placement identifiers are operator-curated portable
400/// strings already validated at admission and don't carry hierarchy.
401pub 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
432/// Check `spec` against all rules in `pack`.
433///
434/// Returns the full list of violations. An empty `Vec` means admission is
435/// granted. Callers MAY treat any violation as a hard reject, or log them for
436/// audit purposes without blocking execution.
437pub fn validate_spec_against_policy(
438    spec: &ExecutionCellSpec,
439    pack: &PolicyPackSpec,
440) -> Vec<PolicyViolation> {
441    let mut violations = Vec::new();
442
443    // T11 — placement-scoped policy packs.
444    //
445    // A pack with `placement` constraints applies only to specs whose
446    // placement matches every populated field. A pack without `placement`
447    // is global. When the pack does NOT apply to this spec, we return an
448    // empty violation list — the spec is out of scope, not "policy clean".
449    // Callers wanting "did any pack apply" should track scope separately.
450    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    // ── Lifetime cap ─────────────────────────────────────────────────────────
459    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    // ── Memory cap ───────────────────────────────────────────────────────────
472    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    // ── Run timeout cap ───────────────────────────────────────────────────────
491    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    // ── Egress rules ─────────────────────────────────────────────────────────
504    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    // ── Secret delivery ───────────────────────────────────────────────────────
542    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    // ── Resource limits required ──────────────────────────────────────────────
559    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    // ── DNS egress acknowledgment (SEC-15) ────────────────────────────────────
570    //
571    // Port-53 egress is a covert exfiltration channel: DNS TXT queries can
572    // carry arbitrary data via labels to an attacker-controlled authoritative
573    // nameserver, outside CellOS visibility. Surface this as a policy
574    // violation unless the operator explicitly acknowledged the risk by
575    // setting `protocol: dns-acknowledged` on every port-53 rule.
576    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    // ── DNS egress justification (SEC-15c / SEAM-2) ──────────────────────────
603    //
604    // The `dns-acknowledged` protocol value alone is too cheap a suppression —
605    // an operator can silence the acknowledgment check by typing four words
606    // with zero risk evaluation. When this rule is active, every port-53
607    // dns-acknowledged rule must also carry a non-empty
608    // `dnsEgressJustification` string. The justification is operator metadata
609    // (free-text), not a capability constraint, so it does not participate in
610    // egress allowlist or capability subset checks.
611    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
641/// A2-02 / ADR-0007: per-caller-identity secretRef allowlist gate.
642///
643/// Returns `Ok(())` when admission may proceed. Returns
644/// [`CellosError::InvalidSpec`] with a structured, operator-readable reason
645/// string when the caller is not mapped, or when the spec requests a secretRef
646/// outside the caller's allowlist.
647///
648/// Behaviour:
649/// - When `rules.secret_ref_allowlist` is `None`, the gate is **skipped** and
650///   `Ok(())` is returned regardless of the spec. This is the opt-in posture
651///   from ADR-0007: an operator who has not authored an allowlist is not
652///   suddenly subject to one. The supervisor side is responsible for emitting
653///   the `cell.admission.v1.caller_identity_check_skipped` info event when it
654///   chooses; this function is silent in that case.
655/// - When `rules.secret_ref_allowlist` is `Some(map)`:
656///   - If `caller_identity` is not a key of `map`, the caller is rejected as
657///     `caller_unmapped`.
658///   - Otherwise, every entry in `spec.authority.secret_refs` (when present)
659///     MUST be present in `map[caller_identity]`. The first denied ref is
660///     named in the error message; the function returns on first violation.
661///
662/// **D11 boundary**: this function does NOT read process env vars. The caller
663/// identity is supplied as a parameter; the supervisor's startup path is
664/// responsible for reading `CELLOS_CALLER_IDENTITY` (defaulting to `"default"`
665/// when unset) and threading it in.
666pub 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        // Audit-only / opt-in: pack lacks the allowlist. Admission proceeds.
673        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        // No secretRefs requested — nothing to check.
685        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
701/// Returns `true` when `host` matches `pattern`.
702///
703/// Patterns:
704/// - `"*.example.com"` — matches any subdomain of `example.com` (at least one label prefix)
705/// - `"api.example.com"` — exact match
706/// - `"*"` — matches anything
707fn host_matches_pattern(host: &str, pattern: &str) -> bool {
708    if pattern == "*" {
709        return true;
710    }
711    if let Some(suffix) = pattern.strip_prefix("*.") {
712        // "*.example.com" must match "foo.example.com" but NOT "example.com" itself.
713        host.ends_with(&format!(".{suffix}"))
714    } else {
715        host == pattern
716    }
717}
718
719// ── T12: AuthorizationPolicy ──────────────────────────────────────────────────
720//
721// T12 RBAC ships an operator-defined authorization policy that gates cell
722// admission on the subject (tenant identity), the target pool, and the
723// referenced policy pack — independent of the existing `PolicyPack` admission
724// gate. The supervisor loads one `AuthorizationPolicyDocument` from
725// `CELLOS_AUTHZ_POLICY_PATH` at startup and evaluates every spec against it
726// before `host.create()`. See `contracts/schemas/authorization-policy-v1.schema.json`.
727//
728// Why separate from `PolicyPack`? PolicyPack constrains *what a spec is
729// allowed to express* (egress hosts, TTL caps, etc). AuthorizationPolicy
730// constrains *who is allowed to submit a spec at all* — a different rule
731// namespace with a different rejection event type. Both gates fire
732// independently.
733
734/// Top-level authorization policy document (`apiVersion: cellos.io/v1`,
735/// `kind: AuthorizationPolicy`). Loaded by the supervisor from
736/// `CELLOS_AUTHZ_POLICY_PATH` at startup.
737#[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/// T12: authorization policy gate.
746///
747/// `subjects` is the allowlist of operator identities (currently a tenant id
748/// in 1.0). `allowed_pools` and `allowed_policy_packs` narrow the surface
749/// further — empty means "no restriction on this axis". `max_cells_per_hour`
750/// is an optional rolling-hour rate cap.
751///
752/// All sets are matched by exact equality; no glob/regex semantics. Identity
753/// strings are opaque tokens — `oidc:github:org/team`,
754/// `k8s:serviceaccount:ns/name`, or `tenant:<id>` are all valid.
755#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
756#[serde(rename_all = "camelCase")]
757pub struct AuthorizationPolicy {
758    /// Operator identities authorized by this policy. The supervisor compares
759    /// `spec.correlation.tenantId` against this list at admission. An empty
760    /// list rejects *every* spec — there is no implicit allow-all.
761    pub subjects: Vec<String>,
762
763    /// Pool IDs the subject may target via `spec.placement.poolId`. Empty
764    /// means no pool restriction (all pools allowed).
765    #[serde(default)]
766    pub allowed_pools: Vec<String>,
767
768    /// Policy pack IDs the subject may reference via `spec.policy.packId`.
769    /// Empty means no pack restriction (all packs allowed).
770    #[serde(default)]
771    pub allowed_policy_packs: Vec<String>,
772
773    /// Optional rolling-hour rate cap. When unset (`None`) there is no rate
774    /// limit; when set (`Some(n)`), at most `n` admitted cells per hour per
775    /// subject. The supervisor maintains the per-subject counter in-memory.
776    #[serde(default, skip_serializing_if = "Option::is_none")]
777    pub max_cells_per_hour: Option<u32>,
778}
779
780/// Validate the structural integrity of an [`AuthorizationPolicyDocument`].
781///
782/// Checks:
783/// - `apiVersion == "cellos.io/v1"`
784/// - `kind == "AuthorizationPolicy"`
785/// - `spec.subjects` is non-empty (an empty subjects list would reject every
786///   spec; the operator almost certainly meant to delete the file instead).
787/// - No subject / pool / pack id is empty or whitespace-only.
788/// - `maxCellsPerHour`, when set, is > 0.
789pub 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// ── Tests ─────────────────────────────────────────────────────────────────────
842
843#[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    // ── validate_policy_pack_document ────────────────────────────────────────
898
899    #[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    // ── validate_spec_against_policy ─────────────────────────────────────────
955
956    #[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(); // ttl_seconds = 300
966        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(); // ttl_seconds = 300
978        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), // 8 GiB
994                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), // 4 GiB cap
1001            ..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), // 2 hours
1015            limits: None,
1016            secret_delivery: SecretDeliveryMode::Env,
1017        });
1018        let pack = minimal_pack(PolicyRules {
1019            max_run_timeout_ms: Some(3_600_000), // 1 hour cap
1020            ..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(); // no egress rules
1030        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(); // no egress rules
1076        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        // "*.internal" should NOT match "internal" itself.
1120        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(); // default delivery = Env
1127        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(); // no limits
1156        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        // Spec violates: TTL too long + runtime secret delivery required.
1189        let spec = minimal_spec(); // ttl=300, delivery=Env
1190        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    // ── flagDnsEgressWithoutAcknowledgment (SEC-15) ──────────────────────────
1203
1204    #[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        // Rule unset (None) — the default — should not flag.
1265        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    // ── FC-34b / FC-34e: SEC-15 ack gate is protocol-agnostic on port 53 ─
1328    //
1329    // The W1 audit (`docs/firecracker-dns-audit.md`) drift item 3 / FC-34e
1330    // posited a possible bypass: a spec with `port: 53, protocol: "tcp"`
1331    // might pass the SEC-15 acknowledgment gate, because the gate's wording
1332    // is DNS-flavoured ("dns-acknowledged") and could be read as UDP-only.
1333    //
1334    // Reading the actual evaluation at `policy.rs:361-371`, the gate
1335    // condition is `if rule.port == 53` — it keys off the port number and
1336    // is **completely indifferent** to the L4 protocol value. So any
1337    // port-53 rule (UDP, TCP, or anything else) is gated identically:
1338    // either every port-53 rule is `dns-acknowledged`, or the spec is
1339    // rejected. FC-34e is **resolved as finding A** (gate is protocol-
1340    // agnostic; no bypass exists). These tests pin that property so a
1341    // future refactor that splits the gate by protocol cannot silently
1342    // re-introduce a TCP/53 hole.
1343
1344    #[test]
1345    fn dns_egress_ack_gate_covers_tcp_protocol() {
1346        // FC-34e finding A: a TCP/53 rule WITHOUT acknowledgment must be
1347        // rejected by the SEC-15 gate, exactly like UDP/53.
1348        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        // FC-34b: an explicit `protocol: "tcp"` rule on port 53 with
1372        // `dns-acknowledged` MUST be admitted — but per the SEC-15 gate's
1373        // current contract, "dns-acknowledged" is the literal protocol
1374        // marker. This test pins the operator path: declare a single rule
1375        // with `protocol: "dns-acknowledged"` and rely on the FC backend's
1376        // `udp dport 53 accept` mapping (the host nft layer treats
1377        // dns-acknowledged as UDP/53 — see
1378        // `cellos-host-firecracker/src/lib.rs:1715-1719`). DNS-over-TCP
1379        // (large responses, AXFR) is intentionally not granted by a single
1380        // port-53 rule; an operator who needs both UDP/53 and TCP/53 must
1381        // declare two separate rules and accept the SEC-15 gate on each.
1382        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        // FC-34e finding A, mixed-rule edge case: a spec that declares
1402        // both an acknowledged UDP/53 rule AND a bare TCP/53 rule must
1403        // still be rejected, because the gate requires every port-53 rule
1404        // to carry `dns-acknowledged`. This pins protocol-agnosticism
1405        // even when the operator partially complies.
1406        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    // ── requireDnsEgressJustification (SEC-15c / SEAM-2) ─────────────────────
1446
1447    #[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        // Rule unset (None) — the default — should not flag.
1510        let pack = minimal_pack(PolicyRules::default());
1511        let violations = validate_spec_against_policy(&spec, &pack);
1512        // No requireDnsEgressJustification violation; flagDnsEgress also unset.
1513        assert!(
1514            !violations
1515                .iter()
1516                .any(|v| v.rule == "requireDnsEgressJustification"),
1517            "unexpected requireDnsEgressJustification violation: {violations:?}"
1518        );
1519    }
1520
1521    // ── P4-04: policy pack version compatibility ─────────────────────────────
1522    //
1523    // These tests exercise `check_policy_pack_version_compatibility` directly.
1524    // The integration end (downgrade rejection at admission via
1525    // `validate_policy_pack_document`) is covered by
1526    // `crates/cellos-core/tests/policy_pack_version_admission.rs`. The env-var
1527    // override path is also tested in the integration suite (Rust unit tests
1528    // share a process and would race on `CELLOS_POLICY_ALLOW_DOWNGRADE`).
1529
1530    #[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        // Pre-release is ignored for ordering; "1.0.0-rc.1" >= floor "1.0.0".
1553        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    // ── T11-3 — placement-scoped policy packs ──────────────────────────────
1579    //
1580    // A pack with `placement` constraints applies only to specs whose
1581    // placement matches every populated field of the scope. A pack without
1582    // `placement` is global and applies everywhere.
1583
1584    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        // Pack scoped to pool "amd64" with a 60s TTL ceiling.
1607        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        // Spec with 300s TTL on matching pool — pack applies, violation expected.
1619        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        // Same offending spec but on a DIFFERENT pool — pack must not apply.
1646        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        // No placement on the pack — global pack — must apply regardless.
1664        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        // An empty `PlacementSpec` (all fields None) on a pack means "apply
1692        // everywhere" — equivalent to `placement: None`.
1693        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        // Scope demands both pool AND namespace — partial matches are misses.
1708        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        // Right pool, WRONG namespace → no application.
1720        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        // Both match → pack applies.
1731        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    // ── T12: AuthorizationPolicy ────────────────────────────────────────────
1743
1744    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}