cellos-core 0.7.3

CellOS domain types and ports — typed authority, formation DAG, CloudEvent envelopes, RBAC primitives. No I/O.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
//! Typed result variants for `AuthorityDerivationToken` verification (T2-B21).
//!
//! Today, [`crate::verify_authority_derivation`] collapses every failure class
//! (structural-superset violation, missing role key, malformed base64, bad
//! point on curve, invalid signature, scope-policy rejection …) into a single
//! [`crate::error::CellosError::InvalidSpec(String)`] variant. That is fine for
//! end-user error reporting, but admission-side callers (supervisor, taudit
//! emitters) want to *pattern-match* on the failure class so they can:
//!
//! 1. Emit class-specific CloudEvents (`signature_invalid` vs.
//!    `authority_overclaim`) without parsing a free-form message.
//! 2. Distinguish *expired* tokens (operationally remediable — re-issue) from
//!    *forged* tokens (security incident — alert).
//! 3. Surface concrete numeric overclaim deltas (`declared=12, allowed=8`)
//!    rather than a sentence.
//!
//! This module is the **typed alternative return type** for the existing
//! `verify_authority_derivation` flow. It does NOT replace
//! [`crate::error::CellosError`] — both live side by side under the typed-
//! validator scaffold (PR #40 / T2.A). The legacy `Result<(), CellosError>`
//! function stays as the stable public surface; the new
//! [`validate_authority_derivation`] returns [`AuthorityValidationResult`] for
//! callers that need to match on the variant.
//!
//! # Doctrine
//!
//! * **D11 (no I/O in `cellos-core`):** every variant is a plain enum carrying
//!   already-formatted strings + numbers. No tokio, no syscalls.
//! * **Authority Model §9 (non-inflation):** the
//!   [`AuthorityValidationResult::ExceedsGrantorEgress`] and
//!   [`AuthorityValidationResult::ExceedsGrantorSecrets`] variants carry the
//!   *declared* and *allowed* counts so consumers can emit a precise overclaim
//!   delta without recomputing.

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

/// Outcome of validating an [`AuthorityDerivationToken`] against the spec it
/// accompanies and the operator's role-keys map.
///
/// Returned by [`validate_authority_derivation`]. See module docs for why this
/// exists alongside [`crate::verify_authority_derivation`].
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum AuthorityValidationResult {
    /// Token's leaf capability is a subset of the spec's declared authority,
    /// the role key resolved, the signature verified, and the scope policy
    /// (universal-vs-scoped) passed.
    Valid,

    /// Cryptographic signature failed verification, OR the role key /
    /// signature bytes were malformed (bad base64, wrong length, bad curve
    /// point). The `reason` is a human-readable diagnostic — callers MUST NOT
    /// parse it as a security boundary.
    InvalidSignature {
        /// Free-form diagnostic (e.g. "verifying key not valid base64",
        /// "signature must be 64 bytes (got 63)").
        reason: String,
    },

    /// Token's `leafCapability.egressRules` claims a rule the spec does NOT
    /// declare — i.e., the grantor-signed narrowed leaf exceeds what the
    /// spec declares (Authority Model §9 non-inflation: child ⊆ parent).
    ///
    /// Carries both counts so emitters can quote the overclaim delta directly
    /// (`leaf claims N rules; spec declares M`).
    ExceedsGrantorEgress {
        /// Number of distinct egress rules on `spec.authority.egressRules` —
        /// the authoritative declared surface.
        declared: usize,
        /// Number of distinct egress rules on
        /// `token.leafCapability.egressRules` — the grantor-signed leaf.
        /// `claimed > declared` (with at least one claimed rule not present
        /// in the declared set) is the non-inflation violation.
        claimed: usize,
    },

    /// Token's `leafCapability.secretRefs` claims a secret ref the spec does
    /// NOT declare. Mirror of [`Self::ExceedsGrantorEgress`].
    ExceedsGrantorSecrets {
        /// Number of distinct secret refs on `spec.authority.secretRefs`.
        declared: usize,
        /// Number of distinct secret refs on
        /// `token.leafCapability.secretRefs`.
        claimed: usize,
    },

    /// The spec's `authority.authorityDerivation` field was `None`. This is
    /// distinct from "token present but invalid" — emitters may treat it as
    /// "fail-open in MVP" or "fail-closed in 1.0" depending on the operator
    /// posture flag.
    MissingDerivationToken,

    /// The token's `notBefore` / `notAfter` window did not contain the
    /// current time at validation. RFC3339 strings preserved verbatim so
    /// emitters can quote the exact window in the CloudEvent.
    ///
    /// **Note:** the existing [`crate::verify_authority_derivation`] flow does
    /// not check temporal validity — [`AuthorityDerivationToken`] has no
    /// `not_before`/`not_after` fields today. This variant is reserved for
    /// the post-1.0 scope-policy expansion (Authority Model §14). It exists
    /// in the enum now so consumers can write exhaustive matches and the
    /// future field addition becomes a deny-by-default compile failure
    /// rather than a silent fall-through.
    ExpiredToken {
        /// RFC3339 timestamp — start of validity window.
        not_before: String,
        /// RFC3339 timestamp — end of validity window.
        not_after: String,
    },
}

impl AuthorityValidationResult {
    /// `true` iff the variant is [`AuthorityValidationResult::Valid`].
    pub fn is_valid(&self) -> bool {
        matches!(self, Self::Valid)
    }
}

/// Typed-result counterpart of [`crate::verify_authority_derivation`].
///
/// Performs the same checks in the same order:
///
/// 1. Structural superset: `token.leaf_capability ⊆ spec.authority` — when
///    violated, returns [`AuthorityValidationResult::ExceedsGrantorEgress`] or
///    [`AuthorityValidationResult::ExceedsGrantorSecrets`] with concrete
///    counts.
/// 2. Role-key lookup + signature verify — failures collapse to
///    [`AuthorityValidationResult::InvalidSignature`] with a free-form reason.
///
/// Unlike [`crate::verify_authority_derivation`], structural failures here are
/// distinguished **per dimension** so admission emitters can route egress
/// overclaim to one CloudEvent and secret overclaim to another. The
/// signature-class failures stay collapsed because callers that need to
/// distinguish "bad base64" from "bad point" can already inspect the `reason`
/// field, and splitting those further would expose verifier internals.
///
/// # Parameters
///
/// * `spec` — the cell spec carrying `spec.authority` (the declared claim).
/// * `token` — the [`AuthorityDerivationToken`] from
///   `spec.authority.authorityDerivation`. Note this function takes the token
///   *directly*; callers that want to handle the "no token at all" case
///   should match on
///   `spec.authority.authority_derivation.as_ref().map_or(MissingDerivationToken, …)`
///   themselves — see the example below.
/// * `role_keys` — map of `RoleId → base64-STANDARD-encoded ED25519 verifying
///   key`. Same shape as the legacy function.
///
/// # Example
///
/// ```no_run
/// use cellos_core::authority::{
///     validate_authority_derivation, AuthorityValidationResult,
/// };
/// use cellos_core::ExecutionCellSpec;
/// use std::collections::HashMap;
/// fn run(spec: &ExecutionCellSpec, role_keys: &HashMap<String, String>) {
///     let result = match spec.authority.authority_derivation.as_ref() {
///         Some(token) => validate_authority_derivation(spec, token, role_keys),
///         None => AuthorityValidationResult::MissingDerivationToken,
///     };
///     match result {
///         AuthorityValidationResult::Valid => { /* admit */ }
///         AuthorityValidationResult::ExceedsGrantorEgress { declared, claimed } => {
///             tracing::warn!(declared, claimed, "egress overclaim");
///         }
///         _ => { /* other failure handling */ }
///     }
/// }
/// ```
pub fn validate_authority_derivation(
    spec: &ExecutionCellSpec,
    token: &AuthorityDerivationToken,
    role_keys: &std::collections::HashMap<String, String>,
) -> AuthorityValidationResult {
    // Step 1: structural subset, but split into typed per-dimension variants
    // (the legacy function collapses both into one InvalidSpec(String)).
    let spec_capability = AuthorityCapability {
        egress_rules: spec.authority.egress_rules.clone().unwrap_or_default(),
        secret_refs: spec.authority.secret_refs.clone().unwrap_or_default(),
    };

    if !egress_subset(
        &token.leaf_capability.egress_rules,
        &spec_capability.egress_rules,
    ) {
        return AuthorityValidationResult::ExceedsGrantorEgress {
            declared: spec_capability.egress_rules.len(),
            claimed: token.leaf_capability.egress_rules.len(),
        };
    }

    if !secret_subset(
        &token.leaf_capability.secret_refs,
        &spec_capability.secret_refs,
    ) {
        return AuthorityValidationResult::ExceedsGrantorSecrets {
            declared: spec_capability.secret_refs.len(),
            claimed: token.leaf_capability.secret_refs.len(),
        };
    }

    // Step 2: defer to the legacy signature path; collapse any error into
    // InvalidSignature. We re-call the public function so we don't duplicate
    // the ED25519 wire-format code.
    match crate::verify_authority_derivation(spec, token, role_keys) {
        Ok(()) => AuthorityValidationResult::Valid,
        Err(e) => AuthorityValidationResult::InvalidSignature {
            reason: e.to_string(),
        },
    }
}

/// Returns `true` when every rule in `child` matches some rule in `parent`
/// (host case-insensitive, port and protocol exact). Mirrors
/// [`AuthorityCapability::is_superset_of`]'s egress half — we re-implement it
/// here only because we need to call it on the *opposite* direction (token
/// leaf ⊆ spec authority) without taking a full `AuthorityCapability` clone.
fn egress_subset(child: &[EgressRule], parent: &[EgressRule]) -> bool {
    child.iter().all(|cr| {
        parent.iter().any(|pr| {
            pr.host.eq_ignore_ascii_case(&cr.host)
                && pr.port == cr.port
                && pr.protocol == cr.protocol
        })
    })
}

fn secret_subset(child: &[String], parent: &[String]) -> bool {
    child.iter().all(|cs| parent.iter().any(|ps| ps == cs))
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::types::{AuthorityBundle, AuthoritySignature, EgressRule, Lifetime, RoleId};

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

    fn spec_with(egress_rules: Vec<EgressRule>, secret_refs: Vec<String>) -> ExecutionCellSpec {
        // Mirror of `derivation_spec_with_authority` in spec_validation tests —
        // direct struct construction avoids the JSON-fixture pitfall where
        // `id` (and other required fields) need to be present for deserialize.
        ExecutionCellSpec {
            id: "deriv-test".into(),
            correlation: None,
            ingress: None,
            environment: None,
            placement: None,
            policy: None,
            identity: None,
            run: None,
            authority: AuthorityBundle {
                filesystem: None,
                network: None,
                egress_rules: Some(egress_rules),
                secret_refs: Some(secret_refs),
                authority_derivation: None,
                dns_authority: None,
                cdn_authority: None,
            },
            lifetime: Lifetime { ttl_seconds: 60 },
            export: None,
            telemetry: None,
        }
    }

    fn token_with(leaf: AuthorityCapability) -> AuthorityDerivationToken {
        AuthorityDerivationToken {
            role_root: RoleId("test-role".into()),
            parent_run_id: Some("run-0".into()),
            derivation_steps: vec![],
            leaf_capability: leaf,
            grantor_signature: AuthoritySignature {
                algorithm: "ed25519".into(),
                // 64 zero bytes base64 — deliberately invalid; signature step
                // is tested separately. These unit tests focus on structural
                // pre-signature variants.
                bytes: "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA".into(),
            },
        }
    }

    #[test]
    fn is_valid_helper_matches_variant() {
        assert!(AuthorityValidationResult::Valid.is_valid());
        assert!(!AuthorityValidationResult::MissingDerivationToken.is_valid());
        assert!(!AuthorityValidationResult::ExceedsGrantorEgress {
            declared: 2,
            claimed: 3,
        }
        .is_valid());
    }

    #[test]
    fn exceeds_egress_when_leaf_claims_rule_spec_does_not_declare() {
        // Spec declares one host; leaf claims a DIFFERENT host. Leaf is not a
        // subset of spec — non-inflation violation.
        let spec = spec_with(vec![egress("api.example.com", 443, Some("tcp"))], vec![]);
        let token = token_with(AuthorityCapability {
            egress_rules: vec![egress("evil.example.com", 443, Some("tcp"))],
            secret_refs: vec![],
        });
        let role_keys = std::collections::HashMap::new();
        let result = validate_authority_derivation(&spec, &token, &role_keys);
        assert_eq!(
            result,
            AuthorityValidationResult::ExceedsGrantorEgress {
                declared: 1,
                claimed: 1,
            }
        );
    }

    #[test]
    fn exceeds_secrets_when_leaf_claims_ref_spec_does_not_declare() {
        let spec = spec_with(vec![], vec!["api-key".into()]);
        let token = token_with(AuthorityCapability {
            egress_rules: vec![],
            secret_refs: vec!["root-token".into()],
        });
        let role_keys = std::collections::HashMap::new();
        let result = validate_authority_derivation(&spec, &token, &role_keys);
        assert_eq!(
            result,
            AuthorityValidationResult::ExceedsGrantorSecrets {
                declared: 1,
                claimed: 1,
            }
        );
    }

    #[test]
    fn leaf_narrower_than_spec_is_structurally_valid() {
        // Spec declares 2 rules; leaf claims a subset of 1. This is the
        // canonical "narrowing" — must pass the structural check (and then
        // hit InvalidSignature because the fixture signature is junk).
        let spec = spec_with(
            vec![
                egress("api.example.com", 443, Some("tcp")),
                egress("db.example.com", 5432, Some("tcp")),
            ],
            vec!["k1".into(), "k2".into()],
        );
        let token = token_with(AuthorityCapability {
            egress_rules: vec![egress("api.example.com", 443, Some("tcp"))],
            secret_refs: vec!["k1".into()],
        });
        let role_keys = std::collections::HashMap::new();
        let result = validate_authority_derivation(&spec, &token, &role_keys);
        assert!(
            matches!(result, AuthorityValidationResult::InvalidSignature { .. }),
            "expected InvalidSignature (structural narrowing OK), got {result:?}"
        );
    }

    #[test]
    fn structural_subset_then_signature_failure_collapses_to_invalid_signature() {
        // Token leaf == spec — structural pass; signature is junk so we land
        // in InvalidSignature.
        let rule = egress("api.example.com", 443, Some("tcp"));
        let spec = spec_with(vec![rule.clone()], vec!["k".into()]);
        let token = token_with(AuthorityCapability {
            egress_rules: vec![rule],
            secret_refs: vec!["k".into()],
        });
        let role_keys = std::collections::HashMap::new();
        let result = validate_authority_derivation(&spec, &token, &role_keys);
        match result {
            AuthorityValidationResult::InvalidSignature { reason } => {
                assert!(!reason.is_empty(), "reason should carry diagnostic");
            }
            other => panic!("expected InvalidSignature, got {other:?}"),
        }
    }

    #[test]
    fn missing_derivation_token_variant_is_inert() {
        // Just construct it — the variant exists for callers who detect the
        // None case before calling validate_authority_derivation.
        let v = AuthorityValidationResult::MissingDerivationToken;
        assert!(!v.is_valid());
    }

    #[test]
    fn expired_token_variant_preserves_window() {
        let v = AuthorityValidationResult::ExpiredToken {
            not_before: "2026-01-01T00:00:00Z".into(),
            not_after: "2026-02-01T00:00:00Z".into(),
        };
        match v {
            AuthorityValidationResult::ExpiredToken {
                not_before,
                not_after,
            } => {
                assert!(not_before.contains("2026-01-01"));
                assert!(not_after.contains("2026-02-01"));
            }
            _ => panic!("variant mismatch"),
        }
    }

    #[test]
    fn host_match_is_case_insensitive_for_egress_subset() {
        let spec = spec_with(vec![egress("API.example.com", 443, Some("tcp"))], vec![]);
        let token = token_with(AuthorityCapability {
            egress_rules: vec![egress("api.example.com", 443, Some("tcp"))],
            secret_refs: vec![],
        });
        let role_keys = std::collections::HashMap::new();
        // Structural step passes; we land in InvalidSignature (junk sig).
        let result = validate_authority_derivation(&spec, &token, &role_keys);
        assert!(matches!(
            result,
            AuthorityValidationResult::InvalidSignature { .. }
        ));
    }
}