cellos-core 0.7.3

CellOS domain types and ports — typed authority, formation DAG, CloudEvent envelopes, RBAC primitives. No I/O.
Documentation
//! Guest-declared capability surface (F2 surface form).
//!
//! # Why this exists alongside [`crate::authority::DeclaredAuthority`]
//!
//! The B2-1 / F2-lattice landing in [`crate::authority::validator`] introduced
//! [`crate::authority::DeclaredAuthority`] — a fully ADG-validated typed
//! authority that wraps an [`crate::authority::AuthorityDerivation`] whose
//! rule-class set is the mono-class `{GuestAgentDeclaration}`. That type is the
//! *evidence form*: the validated record of how a guest declaration was
//! derived, attached to a CloudEvent, audited for §9 non-inflation.
//!
//! This module ships the *surface form*: the plain capability surface the
//! in-VM guest agent CLAIMS it will use (egress rules, secret refs, DNS
//! queries), independent of any ADG bookkeeping. The host receives this
//! surface over the per-cell vsock channel ahead of (or alongside) telemetry
//! and validates `declared ⊆ authorized` before accepting any guest-side
//! evidence. The check is the F2 admission gate for in-VM telemetry per
//! [ADR-0006](../../../../docs/adr/0006-in-vm-observability-runner-evidence.md):
//! a workload cannot launder authority by declaring more than the host
//! authorized.
//!
//! Conceptually:
//!
//! | Form | Type | Role |
//! |---|---|---|
//! | **Evidence** (ADG-bound) | [`crate::authority::DeclaredAuthority`] | One emission's validated derivation; carries `AuthorityDerivation` |
//! | **Surface** (plain) | [`DeclaredAuthoritySurface`] | The set of capabilities the guest claims it will exercise; subset-checked against [`AuthorityCapability`] |
//!
//! The two are not interchangeable and there is no `From`/`Into` between
//! them. The surface form is naming-disambiguated as
//! `DeclaredAuthoritySurface` because the unqualified name `DeclaredAuthority`
//! is already taken by the ADG variant (and that name's public-API contract
//! is fixed by B2-1).
//!
//! # Doctrine
//!
//! * **D9 (mechanical separation):** the surface form is a plain struct with
//!   no derivation, no superset trait link to host-side typed authorities, no
//!   conversion to/from [`crate::authority::DeclaredAuthority`]. Cross-witness
//!   composites (host + guest evidence for the same observed action) travel
//!   via two separately-typed emissions, not via a fused surface.
//! * **D11 (no I/O in `cellos-core`):** [`validate_declared_authority_surface`]
//!   is pure — plain values in, `Result` out.

#![deny(missing_docs)]

use serde::{Deserialize, Serialize};

use crate::types::{AuthorityCapability, EgressRule};

/// Capability surface declared by the in-VM guest agent.
///
/// Distinct from [`AuthorityCapability`] (what the host has authorized) —
/// `DeclaredAuthoritySurface` is what the workload CLAIMS it will use. The
/// host validates `declared ⊆ authorized` via
/// [`validate_declared_authority_surface`] before accepting any guest-side
/// telemetry per [ADR-0006 §3](../../../../docs/adr/0006-in-vm-observability-runner-evidence.md).
///
/// All three fields are bare-data: no derivation bookkeeping, no signature,
/// no ADG. The surface is treated as an *input* to host-side validation, not
/// as evidence in itself. Backing evidence for individual events lives in
/// [`crate::authority::DeclaredAuthority`].
///
/// An empty surface (all three fields empty) is valid — it means the
/// workload claims the minimal authority surface, which is always a subset of
/// any authorized capability.
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct DeclaredAuthoritySurface {
    /// Egress rules the workload declares it will exercise. Checked as a
    /// subset of the authorizing [`AuthorityCapability::egress_rules`] under
    /// the same host/port/protocol equality the host-side superset check uses
    /// (host case-insensitive, port and protocol exact).
    #[serde(default)]
    pub egress_rules: Vec<EgressRule>,

    /// Secret refs the workload declares it will request from the broker.
    /// Checked as a subset of [`AuthorityCapability::secret_refs`].
    #[serde(default)]
    pub secret_refs: Vec<String>,

    /// Hostname patterns the workload declares it will resolve. Kept as a
    /// distinct list from `egress_rules` because DNS resolution can target
    /// hostnames the cell does not subsequently dial (e.g. health-check
    /// lookups, telemetry of `getaddrinfo` calls). The host does not
    /// subset-check these against `egress_rules` — DNS authority lives on its
    /// own dimension (see [`crate::types::DnsAuthority`]). The list is
    /// retained here so the host can later cross-reference against
    /// `dnsAuthority.hostnameAllowlist` if the surface is wired into the
    /// SEC-21 / SEC-22 dataplane validators; F2 itself only audits presence.
    #[serde(default)]
    pub dns_queries: Vec<String>,
}

impl DeclaredAuthoritySurface {
    /// Construct an empty surface — the workload claims the minimal authority
    /// surface (no egress, no secrets, no DNS).
    #[must_use]
    pub fn empty() -> Self {
        Self::default()
    }

    /// `true` iff every field is empty. An empty surface is always a valid
    /// subset of any authorized [`AuthorityCapability`].
    #[must_use]
    pub fn is_empty(&self) -> bool {
        self.egress_rules.is_empty() && self.secret_refs.is_empty() && self.dns_queries.is_empty()
    }
}

/// Validate that a guest-declared capability surface is a subset of the
/// host-authorized [`AuthorityCapability`].
///
/// Two dimensions are checked:
///
/// 1. **Egress rules.** Every entry in `declared.egress_rules` must appear in
///    `authorized.egress_rules` under the host-case-insensitive, port+protocol-
///    exact equality used by [`AuthorityCapability::is_superset_of`].
/// 2. **Secret refs.** Every entry in `declared.secret_refs` must appear in
///    `authorized.secret_refs` byte-for-byte.
///
/// `declared.dns_queries` is **not** subset-checked against the authorizing
/// capability: DNS authority lives on a separate dimension (the optional
/// [`crate::types::DnsAuthority`] block on [`crate::types::AuthorityBundle`]),
/// and validating DNS authority belongs in the SEC-21 / SEC-22 dataplane
/// path, not the F2 telemetry-admission gate. The field is preserved on the
/// surface so the same struct can feed both gates without re-shaping.
///
/// # Errors
///
/// Returns `Err(String)` with a human-readable diagnostic on the first
/// violation. The diagnostic names the failing dimension and the offending
/// entry; callers (supervisor admission, taudit emitters) MUST treat this as
/// a free-form message and not parse it as a security boundary.
///
/// An empty `declared` always validates Ok.
///
/// # Example
///
/// ```
/// use cellos_core::authority::{
///     declared::{validate_declared_authority_surface, DeclaredAuthoritySurface},
/// };
/// use cellos_core::types::{AuthorityCapability, EgressRule};
///
/// let authorized = AuthorityCapability {
///     egress_rules: vec![EgressRule {
///         host: "api.example.com".into(),
///         port: 443,
///         protocol: Some("tcp".into()),
///         dns_egress_justification: None,
///     }],
///     secret_refs: vec!["api-key".into()],
/// };
///
/// let declared = DeclaredAuthoritySurface {
///     egress_rules: vec![EgressRule {
///         host: "api.example.com".into(),
///         port: 443,
///         protocol: Some("tcp".into()),
///         dns_egress_justification: None,
///     }],
///     secret_refs: vec!["api-key".into()],
///     dns_queries: vec!["api.example.com".into()],
/// };
///
/// validate_declared_authority_surface(&declared, &authorized).expect("subset");
/// ```
pub fn validate_declared_authority_surface(
    declared: &DeclaredAuthoritySurface,
    authorized: &AuthorityCapability,
) -> Result<(), String> {
    for dr in &declared.egress_rules {
        let matched = authorized.egress_rules.iter().any(|ar| {
            ar.host.eq_ignore_ascii_case(&dr.host)
                && ar.port == dr.port
                && ar.protocol == dr.protocol
        });
        if !matched {
            return Err(format!(
                "declared egress rule {host}:{port} (protocol={proto:?}) is not authorized",
                host = dr.host,
                port = dr.port,
                proto = dr.protocol,
            ));
        }
    }

    for ds in &declared.secret_refs {
        if !authorized.secret_refs.iter().any(|asr| asr == ds) {
            return Err(format!("declared secret ref {ds:?} is not authorized",));
        }
    }

    Ok(())
}

#[cfg(test)]
mod tests {
    use super::*;

    fn egress(host: &str, port: u16, proto: Option<&str>) -> EgressRule {
        EgressRule {
            host: host.into(),
            port,
            protocol: proto.map(str::to_string),
            dns_egress_justification: None,
        }
    }

    fn authorized_with(
        egress_rules: Vec<EgressRule>,
        secret_refs: Vec<String>,
    ) -> AuthorityCapability {
        AuthorityCapability {
            egress_rules,
            secret_refs,
        }
    }

    #[test]
    fn declared_within_authorized_returns_ok() {
        let authorized = authorized_with(
            vec![
                egress("api.example.com", 443, Some("tcp")),
                egress("db.example.com", 5432, Some("tcp")),
            ],
            vec!["api-key".into(), "db-pass".into()],
        );
        let declared = DeclaredAuthoritySurface {
            egress_rules: vec![egress("api.example.com", 443, Some("tcp"))],
            secret_refs: vec!["api-key".into()],
            dns_queries: vec!["api.example.com".into()],
        };
        validate_declared_authority_surface(&declared, &authorized)
            .expect("declared subset must validate");
    }

    #[test]
    fn declared_exceeds_egress_returns_err() {
        let authorized = authorized_with(
            vec![egress("api.example.com", 443, Some("tcp"))],
            vec!["api-key".into()],
        );
        // Workload claims a host the operator never authorized.
        let declared = DeclaredAuthoritySurface {
            egress_rules: vec![
                egress("api.example.com", 443, Some("tcp")),
                egress("evil.example.com", 443, Some("tcp")),
            ],
            secret_refs: vec!["api-key".into()],
            dns_queries: vec![],
        };
        let err = validate_declared_authority_surface(&declared, &authorized)
            .expect_err("declared egress overreach must fail");
        assert!(
            err.contains("evil.example.com"),
            "diagnostic should name offending host; got {err}"
        );
        assert!(
            err.contains("not authorized"),
            "diagnostic should explain failure; got {err}"
        );
    }

    #[test]
    fn declared_exceeds_secrets_returns_err() {
        let authorized = authorized_with(vec![], vec!["api-key".into()]);
        let declared = DeclaredAuthoritySurface {
            egress_rules: vec![],
            secret_refs: vec!["api-key".into(), "root-token".into()],
            dns_queries: vec![],
        };
        let err = validate_declared_authority_surface(&declared, &authorized)
            .expect_err("declared secret overreach must fail");
        assert!(
            err.contains("root-token"),
            "diagnostic should name offending secret ref; got {err}"
        );
    }

    #[test]
    fn empty_declared_is_ok() {
        // Workload claims minimal surface — always a valid subset of any
        // authorized capability, including the empty authorized capability.
        let authorized = AuthorityCapability::default();
        let declared = DeclaredAuthoritySurface::empty();
        assert!(declared.is_empty());
        validate_declared_authority_surface(&declared, &authorized)
            .expect("empty declared surface always validates");

        // Non-empty authorized, empty declared — still OK.
        let authorized = authorized_with(
            vec![egress("api.example.com", 443, Some("tcp"))],
            vec!["api-key".into()],
        );
        validate_declared_authority_surface(&DeclaredAuthoritySurface::empty(), &authorized)
            .expect("empty declared surface validates against any authorized");
    }

    #[test]
    fn egress_match_is_host_case_insensitive() {
        let authorized = authorized_with(vec![egress("API.Example.com", 443, Some("tcp"))], vec![]);
        let declared = DeclaredAuthoritySurface {
            egress_rules: vec![egress("api.example.com", 443, Some("tcp"))],
            secret_refs: vec![],
            dns_queries: vec![],
        };
        validate_declared_authority_surface(&declared, &authorized)
            .expect("host comparison must be case-insensitive");
    }

    #[test]
    fn egress_protocol_mismatch_fails() {
        let authorized = authorized_with(vec![egress("api.example.com", 443, Some("tcp"))], vec![]);
        // Same host/port, different protocol — not authorized.
        let declared = DeclaredAuthoritySurface {
            egress_rules: vec![egress("api.example.com", 443, Some("udp"))],
            secret_refs: vec![],
            dns_queries: vec![],
        };
        let err = validate_declared_authority_surface(&declared, &authorized)
            .expect_err("protocol mismatch must fail");
        assert!(err.contains("api.example.com"));
    }

    #[test]
    fn dns_queries_are_not_subset_checked_against_egress() {
        // Declared DNS queries that have no corresponding egress rule still
        // validate — the F2 admission gate does not enforce DNS authority on
        // this dimension. (DNS authority lives on its own bundle field.)
        let authorized = authorized_with(vec![], vec![]);
        let declared = DeclaredAuthoritySurface {
            egress_rules: vec![],
            secret_refs: vec![],
            dns_queries: vec!["api.example.com".into(), "telemetry.example.com".into()],
        };
        validate_declared_authority_surface(&declared, &authorized)
            .expect("dns_queries are not subset-checked in F2 surface");
    }
}