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}