Skip to main content

cellos_core/authority/
declared.rs

1//! Guest-declared capability surface (F2 surface form).
2//!
3//! # Why this exists alongside [`crate::authority::DeclaredAuthority`]
4//!
5//! The B2-1 / F2-lattice landing in [`crate::authority::validator`] introduced
6//! [`crate::authority::DeclaredAuthority`] — a fully ADG-validated typed
7//! authority that wraps an [`crate::authority::AuthorityDerivation`] whose
8//! rule-class set is the mono-class `{GuestAgentDeclaration}`. That type is the
9//! *evidence form*: the validated record of how a guest declaration was
10//! derived, attached to a CloudEvent, audited for §9 non-inflation.
11//!
12//! This module ships the *surface form*: the plain capability surface the
13//! in-VM guest agent CLAIMS it will use (egress rules, secret refs, DNS
14//! queries), independent of any ADG bookkeeping. The host receives this
15//! surface over the per-cell vsock channel ahead of (or alongside) telemetry
16//! and validates `declared ⊆ authorized` before accepting any guest-side
17//! evidence. The check is the F2 admission gate for in-VM telemetry per
18//! [ADR-0006](../../../../docs/adr/0006-in-vm-observability-runner-evidence.md):
19//! a workload cannot launder authority by declaring more than the host
20//! authorized.
21//!
22//! Conceptually:
23//!
24//! | Form | Type | Role |
25//! |---|---|---|
26//! | **Evidence** (ADG-bound) | [`crate::authority::DeclaredAuthority`] | One emission's validated derivation; carries `AuthorityDerivation` |
27//! | **Surface** (plain) | [`DeclaredAuthoritySurface`] | The set of capabilities the guest claims it will exercise; subset-checked against [`AuthorityCapability`] |
28//!
29//! The two are not interchangeable and there is no `From`/`Into` between
30//! them. The surface form is naming-disambiguated as
31//! `DeclaredAuthoritySurface` because the unqualified name `DeclaredAuthority`
32//! is already taken by the ADG variant (and that name's public-API contract
33//! is fixed by B2-1).
34//!
35//! # Doctrine
36//!
37//! * **D9 (mechanical separation):** the surface form is a plain struct with
38//!   no derivation, no superset trait link to host-side typed authorities, no
39//!   conversion to/from [`crate::authority::DeclaredAuthority`]. Cross-witness
40//!   composites (host + guest evidence for the same observed action) travel
41//!   via two separately-typed emissions, not via a fused surface.
42//! * **D11 (no I/O in `cellos-core`):** [`validate_declared_authority_surface`]
43//!   is pure — plain values in, `Result` out.
44
45#![deny(missing_docs)]
46
47use serde::{Deserialize, Serialize};
48
49use crate::types::{AuthorityCapability, EgressRule};
50
51/// Capability surface declared by the in-VM guest agent.
52///
53/// Distinct from [`AuthorityCapability`] (what the host has authorized) —
54/// `DeclaredAuthoritySurface` is what the workload CLAIMS it will use. The
55/// host validates `declared ⊆ authorized` via
56/// [`validate_declared_authority_surface`] before accepting any guest-side
57/// telemetry per [ADR-0006 §3](../../../../docs/adr/0006-in-vm-observability-runner-evidence.md).
58///
59/// All three fields are bare-data: no derivation bookkeeping, no signature,
60/// no ADG. The surface is treated as an *input* to host-side validation, not
61/// as evidence in itself. Backing evidence for individual events lives in
62/// [`crate::authority::DeclaredAuthority`].
63///
64/// An empty surface (all three fields empty) is valid — it means the
65/// workload claims the minimal authority surface, which is always a subset of
66/// any authorized capability.
67#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
68#[serde(rename_all = "camelCase")]
69pub struct DeclaredAuthoritySurface {
70    /// Egress rules the workload declares it will exercise. Checked as a
71    /// subset of the authorizing [`AuthorityCapability::egress_rules`] under
72    /// the same host/port/protocol equality the host-side superset check uses
73    /// (host case-insensitive, port and protocol exact).
74    #[serde(default)]
75    pub egress_rules: Vec<EgressRule>,
76
77    /// Secret refs the workload declares it will request from the broker.
78    /// Checked as a subset of [`AuthorityCapability::secret_refs`].
79    #[serde(default)]
80    pub secret_refs: Vec<String>,
81
82    /// Hostname patterns the workload declares it will resolve. Kept as a
83    /// distinct list from `egress_rules` because DNS resolution can target
84    /// hostnames the cell does not subsequently dial (e.g. health-check
85    /// lookups, telemetry of `getaddrinfo` calls). The host does not
86    /// subset-check these against `egress_rules` — DNS authority lives on its
87    /// own dimension (see [`crate::types::DnsAuthority`]). The list is
88    /// retained here so the host can later cross-reference against
89    /// `dnsAuthority.hostnameAllowlist` if the surface is wired into the
90    /// SEC-21 / SEC-22 dataplane validators; F2 itself only audits presence.
91    #[serde(default)]
92    pub dns_queries: Vec<String>,
93}
94
95impl DeclaredAuthoritySurface {
96    /// Construct an empty surface — the workload claims the minimal authority
97    /// surface (no egress, no secrets, no DNS).
98    #[must_use]
99    pub fn empty() -> Self {
100        Self::default()
101    }
102
103    /// `true` iff every field is empty. An empty surface is always a valid
104    /// subset of any authorized [`AuthorityCapability`].
105    #[must_use]
106    pub fn is_empty(&self) -> bool {
107        self.egress_rules.is_empty() && self.secret_refs.is_empty() && self.dns_queries.is_empty()
108    }
109}
110
111/// Validate that a guest-declared capability surface is a subset of the
112/// host-authorized [`AuthorityCapability`].
113///
114/// Two dimensions are checked:
115///
116/// 1. **Egress rules.** Every entry in `declared.egress_rules` must appear in
117///    `authorized.egress_rules` under the host-case-insensitive, port+protocol-
118///    exact equality used by [`AuthorityCapability::is_superset_of`].
119/// 2. **Secret refs.** Every entry in `declared.secret_refs` must appear in
120///    `authorized.secret_refs` byte-for-byte.
121///
122/// `declared.dns_queries` is **not** subset-checked against the authorizing
123/// capability: DNS authority lives on a separate dimension (the optional
124/// [`crate::types::DnsAuthority`] block on [`crate::types::AuthorityBundle`]),
125/// and validating DNS authority belongs in the SEC-21 / SEC-22 dataplane
126/// path, not the F2 telemetry-admission gate. The field is preserved on the
127/// surface so the same struct can feed both gates without re-shaping.
128///
129/// # Errors
130///
131/// Returns `Err(String)` with a human-readable diagnostic on the first
132/// violation. The diagnostic names the failing dimension and the offending
133/// entry; callers (supervisor admission, taudit emitters) MUST treat this as
134/// a free-form message and not parse it as a security boundary.
135///
136/// An empty `declared` always validates Ok.
137///
138/// # Example
139///
140/// ```
141/// use cellos_core::authority::{
142///     declared::{validate_declared_authority_surface, DeclaredAuthoritySurface},
143/// };
144/// use cellos_core::types::{AuthorityCapability, EgressRule};
145///
146/// let authorized = AuthorityCapability {
147///     egress_rules: vec![EgressRule {
148///         host: "api.example.com".into(),
149///         port: 443,
150///         protocol: Some("tcp".into()),
151///         dns_egress_justification: None,
152///     }],
153///     secret_refs: vec!["api-key".into()],
154/// };
155///
156/// let declared = DeclaredAuthoritySurface {
157///     egress_rules: vec![EgressRule {
158///         host: "api.example.com".into(),
159///         port: 443,
160///         protocol: Some("tcp".into()),
161///         dns_egress_justification: None,
162///     }],
163///     secret_refs: vec!["api-key".into()],
164///     dns_queries: vec!["api.example.com".into()],
165/// };
166///
167/// validate_declared_authority_surface(&declared, &authorized).expect("subset");
168/// ```
169pub fn validate_declared_authority_surface(
170    declared: &DeclaredAuthoritySurface,
171    authorized: &AuthorityCapability,
172) -> Result<(), String> {
173    for dr in &declared.egress_rules {
174        let matched = authorized.egress_rules.iter().any(|ar| {
175            ar.host.eq_ignore_ascii_case(&dr.host)
176                && ar.port == dr.port
177                && ar.protocol == dr.protocol
178        });
179        if !matched {
180            return Err(format!(
181                "declared egress rule {host}:{port} (protocol={proto:?}) is not authorized",
182                host = dr.host,
183                port = dr.port,
184                proto = dr.protocol,
185            ));
186        }
187    }
188
189    for ds in &declared.secret_refs {
190        if !authorized.secret_refs.iter().any(|asr| asr == ds) {
191            return Err(format!("declared secret ref {ds:?} is not authorized",));
192        }
193    }
194
195    Ok(())
196}
197
198#[cfg(test)]
199mod tests {
200    use super::*;
201
202    fn egress(host: &str, port: u16, proto: Option<&str>) -> EgressRule {
203        EgressRule {
204            host: host.into(),
205            port,
206            protocol: proto.map(str::to_string),
207            dns_egress_justification: None,
208        }
209    }
210
211    fn authorized_with(
212        egress_rules: Vec<EgressRule>,
213        secret_refs: Vec<String>,
214    ) -> AuthorityCapability {
215        AuthorityCapability {
216            egress_rules,
217            secret_refs,
218        }
219    }
220
221    #[test]
222    fn declared_within_authorized_returns_ok() {
223        let authorized = authorized_with(
224            vec![
225                egress("api.example.com", 443, Some("tcp")),
226                egress("db.example.com", 5432, Some("tcp")),
227            ],
228            vec!["api-key".into(), "db-pass".into()],
229        );
230        let declared = DeclaredAuthoritySurface {
231            egress_rules: vec![egress("api.example.com", 443, Some("tcp"))],
232            secret_refs: vec!["api-key".into()],
233            dns_queries: vec!["api.example.com".into()],
234        };
235        validate_declared_authority_surface(&declared, &authorized)
236            .expect("declared subset must validate");
237    }
238
239    #[test]
240    fn declared_exceeds_egress_returns_err() {
241        let authorized = authorized_with(
242            vec![egress("api.example.com", 443, Some("tcp"))],
243            vec!["api-key".into()],
244        );
245        // Workload claims a host the operator never authorized.
246        let declared = DeclaredAuthoritySurface {
247            egress_rules: vec![
248                egress("api.example.com", 443, Some("tcp")),
249                egress("evil.example.com", 443, Some("tcp")),
250            ],
251            secret_refs: vec!["api-key".into()],
252            dns_queries: vec![],
253        };
254        let err = validate_declared_authority_surface(&declared, &authorized)
255            .expect_err("declared egress overreach must fail");
256        assert!(
257            err.contains("evil.example.com"),
258            "diagnostic should name offending host; got {err}"
259        );
260        assert!(
261            err.contains("not authorized"),
262            "diagnostic should explain failure; got {err}"
263        );
264    }
265
266    #[test]
267    fn declared_exceeds_secrets_returns_err() {
268        let authorized = authorized_with(vec![], vec!["api-key".into()]);
269        let declared = DeclaredAuthoritySurface {
270            egress_rules: vec![],
271            secret_refs: vec!["api-key".into(), "root-token".into()],
272            dns_queries: vec![],
273        };
274        let err = validate_declared_authority_surface(&declared, &authorized)
275            .expect_err("declared secret overreach must fail");
276        assert!(
277            err.contains("root-token"),
278            "diagnostic should name offending secret ref; got {err}"
279        );
280    }
281
282    #[test]
283    fn empty_declared_is_ok() {
284        // Workload claims minimal surface — always a valid subset of any
285        // authorized capability, including the empty authorized capability.
286        let authorized = AuthorityCapability::default();
287        let declared = DeclaredAuthoritySurface::empty();
288        assert!(declared.is_empty());
289        validate_declared_authority_surface(&declared, &authorized)
290            .expect("empty declared surface always validates");
291
292        // Non-empty authorized, empty declared — still OK.
293        let authorized = authorized_with(
294            vec![egress("api.example.com", 443, Some("tcp"))],
295            vec!["api-key".into()],
296        );
297        validate_declared_authority_surface(&DeclaredAuthoritySurface::empty(), &authorized)
298            .expect("empty declared surface validates against any authorized");
299    }
300
301    #[test]
302    fn egress_match_is_host_case_insensitive() {
303        let authorized = authorized_with(vec![egress("API.Example.com", 443, Some("tcp"))], vec![]);
304        let declared = DeclaredAuthoritySurface {
305            egress_rules: vec![egress("api.example.com", 443, Some("tcp"))],
306            secret_refs: vec![],
307            dns_queries: vec![],
308        };
309        validate_declared_authority_surface(&declared, &authorized)
310            .expect("host comparison must be case-insensitive");
311    }
312
313    #[test]
314    fn egress_protocol_mismatch_fails() {
315        let authorized = authorized_with(vec![egress("api.example.com", 443, Some("tcp"))], vec![]);
316        // Same host/port, different protocol — not authorized.
317        let declared = DeclaredAuthoritySurface {
318            egress_rules: vec![egress("api.example.com", 443, Some("udp"))],
319            secret_refs: vec![],
320            dns_queries: vec![],
321        };
322        let err = validate_declared_authority_surface(&declared, &authorized)
323            .expect_err("protocol mismatch must fail");
324        assert!(err.contains("api.example.com"));
325    }
326
327    #[test]
328    fn dns_queries_are_not_subset_checked_against_egress() {
329        // Declared DNS queries that have no corresponding egress rule still
330        // validate — the F2 admission gate does not enforce DNS authority on
331        // this dimension. (DNS authority lives on its own bundle field.)
332        let authorized = authorized_with(vec![], vec![]);
333        let declared = DeclaredAuthoritySurface {
334            egress_rules: vec![],
335            secret_refs: vec![],
336            dns_queries: vec!["api.example.com".into(), "telemetry.example.com".into()],
337        };
338        validate_declared_authority_surface(&declared, &authorized)
339            .expect("dns_queries are not subset-checked in F2 surface");
340    }
341}