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`](crate::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 [`spec_validation::check_policy_pack_version`]
279/// or directly here. [`validate_policy_pack_document`] always calls this with
280/// `allow_downgrade=false` so structural validation stays strict; the env
281/// override only kicks in when the supervisor explicitly opts in.
282pub 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
308// ── Document validation ───────────────────────────────────────────────────────
309
310/// Validate the structural integrity of a [`PolicyPackDocument`].
311///
312/// Checks:
313/// - `apiVersion == "cellos.io/v1"`
314/// - `kind == "PolicyPack"`
315/// - `spec.id` is a valid portable identifier
316/// - All numeric bounds are > 0 when set
317/// - All `allowedEgressHosts` patterns are non-empty
318pub 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    // P4-04: reject packs whose declared schema version is older than the
340    // runtime's compiled-in floor. Strict by default — `validate_policy_pack_document`
341    // is the structural-correctness gate and must not depend on process env;
342    // the supervisor reads `CELLOS_POLICY_ALLOW_DOWNGRADE` and calls
343    // `check_policy_pack_version_compatibility(declared, allow_downgrade=true)`
344    // separately when the operator opted in.
345    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
388// ── Spec admission gate ───────────────────────────────────────────────────────
389
390/// T11 — match a spec's placement against a policy pack's placement scope.
391///
392/// Returns `true` when **every populated field of the scope** equals the
393/// matching field in `spec_placement`. Empty fields on the scope are
394/// wildcards. When the scope has any populated field and the spec has no
395/// `placement` block at all, the match fails (the spec is unscoped).
396///
397/// This intentionally requires exact, case-sensitive equality on each
398/// populated axis — placement identifiers are operator-curated portable
399/// strings already validated at admission and don't carry hierarchy.
400pub 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
431/// Check `spec` against all rules in `pack`.
432///
433/// Returns the full list of violations. An empty `Vec` means admission is
434/// granted. Callers MAY treat any violation as a hard reject, or log them for
435/// audit purposes without blocking execution.
436pub fn validate_spec_against_policy(
437    spec: &ExecutionCellSpec,
438    pack: &PolicyPackSpec,
439) -> Vec<PolicyViolation> {
440    let mut violations = Vec::new();
441
442    // T11 — placement-scoped policy packs.
443    //
444    // A pack with `placement` constraints applies only to specs whose
445    // placement matches every populated field. A pack without `placement`
446    // is global. When the pack does NOT apply to this spec, we return an
447    // empty violation list — the spec is out of scope, not "policy clean".
448    // Callers wanting "did any pack apply" should track scope separately.
449    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    // ── Lifetime cap ─────────────────────────────────────────────────────────
458    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    // ── Memory cap ───────────────────────────────────────────────────────────
471    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    // ── Run timeout cap ───────────────────────────────────────────────────────
490    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    // ── Egress rules ─────────────────────────────────────────────────────────
503    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    // ── Secret delivery ───────────────────────────────────────────────────────
541    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    // ── Resource limits required ──────────────────────────────────────────────
558    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    // ── DNS egress acknowledgment (SEC-15) ────────────────────────────────────
569    //
570    // Port-53 egress is a covert exfiltration channel: DNS TXT queries can
571    // carry arbitrary data via labels to an attacker-controlled authoritative
572    // nameserver, outside CellOS visibility. Surface this as a policy
573    // violation unless the operator explicitly acknowledged the risk by
574    // setting `protocol: dns-acknowledged` on every port-53 rule.
575    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    // ── DNS egress justification (SEC-15c / SEAM-2) ──────────────────────────
602    //
603    // The `dns-acknowledged` protocol value alone is too cheap a suppression —
604    // an operator can silence the acknowledgment check by typing four words
605    // with zero risk evaluation. When this rule is active, every port-53
606    // dns-acknowledged rule must also carry a non-empty
607    // `dnsEgressJustification` string. The justification is operator metadata
608    // (free-text), not a capability constraint, so it does not participate in
609    // egress allowlist or capability subset checks.
610    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
640/// A2-02 / ADR-0007: per-caller-identity secretRef allowlist gate.
641///
642/// Returns `Ok(())` when admission may proceed. Returns
643/// [`CellosError::InvalidSpec`] with a structured, operator-readable reason
644/// string when the caller is not mapped, or when the spec requests a secretRef
645/// outside the caller's allowlist.
646///
647/// Behaviour:
648/// - When `rules.secret_ref_allowlist` is `None`, the gate is **skipped** and
649///   `Ok(())` is returned regardless of the spec. This is the opt-in posture
650///   from ADR-0007: an operator who has not authored an allowlist is not
651///   suddenly subject to one. The supervisor side is responsible for emitting
652///   the `cell.admission.v1.caller_identity_check_skipped` info event when it
653///   chooses; this function is silent in that case.
654/// - When `rules.secret_ref_allowlist` is `Some(map)`:
655///   - If `caller_identity` is not a key of `map`, the caller is rejected as
656///     `caller_unmapped`.
657///   - Otherwise, every entry in `spec.authority.secret_refs` (when present)
658///     MUST be present in `map[caller_identity]`. The first denied ref is
659///     named in the error message; the function returns on first violation.
660///
661/// **D11 boundary**: this function does NOT read process env vars. The caller
662/// identity is supplied as a parameter; the supervisor's startup path is
663/// responsible for reading `CELLOS_CALLER_IDENTITY` (defaulting to `"default"`
664/// when unset) and threading it in.
665pub 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        // Audit-only / opt-in: pack lacks the allowlist. Admission proceeds.
672        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        // No secretRefs requested — nothing to check.
684        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
700/// Returns `true` when `host` matches `pattern`.
701///
702/// Patterns:
703/// - `"*.example.com"` — matches any subdomain of `example.com` (at least one label prefix)
704/// - `"api.example.com"` — exact match
705/// - `"*"` — matches anything
706fn host_matches_pattern(host: &str, pattern: &str) -> bool {
707    if pattern == "*" {
708        return true;
709    }
710    if let Some(suffix) = pattern.strip_prefix("*.") {
711        // "*.example.com" must match "foo.example.com" but NOT "example.com" itself.
712        host.ends_with(&format!(".{suffix}"))
713    } else {
714        host == pattern
715    }
716}
717
718// ── T12: AuthorizationPolicy ──────────────────────────────────────────────────
719//
720// T12 RBAC ships an operator-defined authorization policy that gates cell
721// admission on the subject (tenant identity), the target pool, and the
722// referenced policy pack — independent of the existing `PolicyPack` admission
723// gate. The supervisor loads one `AuthorizationPolicyDocument` from
724// `CELLOS_AUTHZ_POLICY_PATH` at startup and evaluates every spec against it
725// before `host.create()`. See `contracts/schemas/authorization-policy-v1.schema.json`.
726//
727// Why separate from `PolicyPack`? PolicyPack constrains *what a spec is
728// allowed to express* (egress hosts, TTL caps, etc). AuthorizationPolicy
729// constrains *who is allowed to submit a spec at all* — a different rule
730// namespace with a different rejection event type. Both gates fire
731// independently.
732
733/// Top-level authorization policy document (`apiVersion: cellos.io/v1`,
734/// `kind: AuthorizationPolicy`). Loaded by the supervisor from
735/// `CELLOS_AUTHZ_POLICY_PATH` at startup.
736#[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/// T12: authorization policy gate.
745///
746/// `subjects` is the allowlist of operator identities (currently a tenant id
747/// in 1.0). `allowed_pools` and `allowed_policy_packs` narrow the surface
748/// further — empty means "no restriction on this axis". `max_cells_per_hour`
749/// is an optional rolling-hour rate cap.
750///
751/// All sets are matched by exact equality; no glob/regex semantics. Identity
752/// strings are opaque tokens — `oidc:github:org/team`,
753/// `k8s:serviceaccount:ns/name`, or `tenant:<id>` are all valid.
754#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
755#[serde(rename_all = "camelCase")]
756pub struct AuthorizationPolicy {
757    /// Operator identities authorized by this policy. The supervisor compares
758    /// `spec.correlation.tenantId` against this list at admission. An empty
759    /// list rejects *every* spec — there is no implicit allow-all.
760    pub subjects: Vec<String>,
761
762    /// Pool IDs the subject may target via `spec.placement.poolId`. Empty
763    /// means no pool restriction (all pools allowed).
764    #[serde(default)]
765    pub allowed_pools: Vec<String>,
766
767    /// Policy pack IDs the subject may reference via `spec.policy.packId`.
768    /// Empty means no pack restriction (all packs allowed).
769    #[serde(default)]
770    pub allowed_policy_packs: Vec<String>,
771
772    /// Optional rolling-hour rate cap. When unset (`None`) there is no rate
773    /// limit; when set (`Some(n)`), at most `n` admitted cells per hour per
774    /// subject. The supervisor maintains the per-subject counter in-memory.
775    #[serde(default, skip_serializing_if = "Option::is_none")]
776    pub max_cells_per_hour: Option<u32>,
777}
778
779/// Validate the structural integrity of an [`AuthorizationPolicyDocument`].
780///
781/// Checks:
782/// - `apiVersion == "cellos.io/v1"`
783/// - `kind == "AuthorizationPolicy"`
784/// - `spec.subjects` is non-empty (an empty subjects list would reject every
785///   spec; the operator almost certainly meant to delete the file instead).
786/// - No subject / pool / pack id is empty or whitespace-only.
787/// - `maxCellsPerHour`, when set, is > 0.
788pub 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// ── Tests ─────────────────────────────────────────────────────────────────────
841
842#[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    // ── validate_policy_pack_document ────────────────────────────────────────
897
898    #[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    // ── validate_spec_against_policy ─────────────────────────────────────────
954
955    #[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(); // ttl_seconds = 300
965        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(); // ttl_seconds = 300
977        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), // 8 GiB
993                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), // 4 GiB cap
1000            ..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), // 2 hours
1014            limits: None,
1015            secret_delivery: SecretDeliveryMode::Env,
1016        });
1017        let pack = minimal_pack(PolicyRules {
1018            max_run_timeout_ms: Some(3_600_000), // 1 hour cap
1019            ..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(); // no egress rules
1029        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(); // no egress rules
1075        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        // "*.internal" should NOT match "internal" itself.
1119        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(); // default delivery = Env
1126        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(); // no limits
1155        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        // Spec violates: TTL too long + runtime secret delivery required.
1188        let spec = minimal_spec(); // ttl=300, delivery=Env
1189        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    // ── flagDnsEgressWithoutAcknowledgment (SEC-15) ──────────────────────────
1202
1203    #[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        // Rule unset (None) — the default — should not flag.
1264        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    // ── FC-34b / FC-34e: SEC-15 ack gate is protocol-agnostic on port 53 ─
1327    //
1328    // The W1 audit (`docs/firecracker-dns-audit.md`) drift item 3 / FC-34e
1329    // posited a possible bypass: a spec with `port: 53, protocol: "tcp"`
1330    // might pass the SEC-15 acknowledgment gate, because the gate's wording
1331    // is DNS-flavoured ("dns-acknowledged") and could be read as UDP-only.
1332    //
1333    // Reading the actual evaluation at `policy.rs:361-371`, the gate
1334    // condition is `if rule.port == 53` — it keys off the port number and
1335    // is **completely indifferent** to the L4 protocol value. So any
1336    // port-53 rule (UDP, TCP, or anything else) is gated identically:
1337    // either every port-53 rule is `dns-acknowledged`, or the spec is
1338    // rejected. FC-34e is **resolved as finding A** (gate is protocol-
1339    // agnostic; no bypass exists). These tests pin that property so a
1340    // future refactor that splits the gate by protocol cannot silently
1341    // re-introduce a TCP/53 hole.
1342
1343    #[test]
1344    fn dns_egress_ack_gate_covers_tcp_protocol() {
1345        // FC-34e finding A: a TCP/53 rule WITHOUT acknowledgment must be
1346        // rejected by the SEC-15 gate, exactly like UDP/53.
1347        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        // FC-34b: an explicit `protocol: "tcp"` rule on port 53 with
1371        // `dns-acknowledged` MUST be admitted — but per the SEC-15 gate's
1372        // current contract, "dns-acknowledged" is the literal protocol
1373        // marker. This test pins the operator path: declare a single rule
1374        // with `protocol: "dns-acknowledged"` and rely on the FC backend's
1375        // `udp dport 53 accept` mapping (the host nft layer treats
1376        // dns-acknowledged as UDP/53 — see
1377        // `cellos-host-firecracker/src/lib.rs:1715-1719`). DNS-over-TCP
1378        // (large responses, AXFR) is intentionally not granted by a single
1379        // port-53 rule; an operator who needs both UDP/53 and TCP/53 must
1380        // declare two separate rules and accept the SEC-15 gate on each.
1381        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        // FC-34e finding A, mixed-rule edge case: a spec that declares
1401        // both an acknowledged UDP/53 rule AND a bare TCP/53 rule must
1402        // still be rejected, because the gate requires every port-53 rule
1403        // to carry `dns-acknowledged`. This pins protocol-agnosticism
1404        // even when the operator partially complies.
1405        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    // ── requireDnsEgressJustification (SEC-15c / SEAM-2) ─────────────────────
1445
1446    #[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        // Rule unset (None) — the default — should not flag.
1509        let pack = minimal_pack(PolicyRules::default());
1510        let violations = validate_spec_against_policy(&spec, &pack);
1511        // No requireDnsEgressJustification violation; flagDnsEgress also unset.
1512        assert!(
1513            !violations
1514                .iter()
1515                .any(|v| v.rule == "requireDnsEgressJustification"),
1516            "unexpected requireDnsEgressJustification violation: {violations:?}"
1517        );
1518    }
1519
1520    // ── P4-04: policy pack version compatibility ─────────────────────────────
1521    //
1522    // These tests exercise `check_policy_pack_version_compatibility` directly.
1523    // The integration end (downgrade rejection at admission via
1524    // `validate_policy_pack_document`) is covered by
1525    // `crates/cellos-core/tests/policy_pack_version_admission.rs`. The env-var
1526    // override path is also tested in the integration suite (Rust unit tests
1527    // share a process and would race on `CELLOS_POLICY_ALLOW_DOWNGRADE`).
1528
1529    #[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        // Pre-release is ignored for ordering; "1.0.0-rc.1" >= floor "1.0.0".
1552        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    // ── T11-3 — placement-scoped policy packs ──────────────────────────────
1578    //
1579    // A pack with `placement` constraints applies only to specs whose
1580    // placement matches every populated field of the scope. A pack without
1581    // `placement` is global and applies everywhere.
1582
1583    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        // Pack scoped to pool "amd64" with a 60s TTL ceiling.
1606        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        // Spec with 300s TTL on matching pool — pack applies, violation expected.
1618        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        // Same offending spec but on a DIFFERENT pool — pack must not apply.
1645        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        // No placement on the pack — global pack — must apply regardless.
1663        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        // An empty `PlacementSpec` (all fields None) on a pack means "apply
1691        // everywhere" — equivalent to `placement: None`.
1692        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        // Scope demands both pool AND namespace — partial matches are misses.
1707        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        // Right pool, WRONG namespace → no application.
1719        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        // Both match → pack applies.
1730        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    // ── T12: AuthorizationPolicy ────────────────────────────────────────────
1742
1743    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}