Skip to main content

greentic_deploy_spec/engine/
trust_root.rs

1//! Trust-root verb group — wire shapes only (PR-4.2f).
2//!
3//! Unlike the other verb groups, the trust-root TRANSFORMS do not live in
4//! this module: key-id canonicalization and Ed25519 SPKI validation need
5//! crypto (`greentic-distributor-client::signing`), and this crate is
6//! deliberately crypto-free. The shared pure semantics live in
7//! `greentic-operator-trust::trust_root` (`validate_trusted_key` /
8//! `apply_add` / `apply_remove`), which both `LocalFsStore` and the
9//! operator-store-server drive. What IS shared here is the A8 wire
10//! vocabulary: the request payload and the three outcome shapes the
11//! `HttpEnvironmentStore` client decodes and the server serializes.
12
13use serde::{Deserialize, Serialize};
14
15/// Request body for `POST /environments/{env_id}/trust-root/keys`
16/// (`EnvironmentMutations::add_trusted_key`). `key_id` must match the
17/// canonical derivation from `public_key_pem` — the backend validates via
18/// `greentic-operator-trust`'s `validate_trusted_key`.
19#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
20pub struct AddTrustedKeyPayload {
21    pub key_id: String,
22    pub public_key_pem: String,
23}
24
25/// Outcome of seeding the bootstrap trust root for an env (the operator
26/// signing key for revenue policies and other env-scoped DSSE artifacts).
27///
28/// `trusted_key_count` is the post-add total — the CLI surfaces it on the
29/// wire so operators can see at a glance whether they added a duplicate.
30#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
31pub struct TrustRootSeed {
32    pub key_id: String,
33    pub public_key_pem: String,
34    pub trusted_key_count: usize,
35}
36
37/// Outcome of `EnvironmentMutations::add_trusted_key`. The store returns
38/// typed data so every backend stays uniform; the CLI shapes the wire JSON
39/// (adding `environment_id` from the caller's request context).
40#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
41pub struct TrustRootAddOutcome {
42    pub added_key_id: String,
43    pub trusted_key_count: usize,
44}
45
46/// Outcome of `EnvironmentMutations::remove_trusted_key`.
47/// `removed_public_key_pem` is `None` when the key was already absent
48/// (silent no-op); the HTTP backend MUST cache the original `Some(pem)`
49/// against the idempotency key so a retry returns the original PEM rather
50/// than `None`.
51#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
52pub struct TrustRootRemoveOutcome {
53    pub removed_key_id: String,
54    /// Stays a bare `Option` (no `skip_serializing_if`): the local CLI wire
55    /// shape emits an explicit `null` for the no-op case, so the remote
56    /// encoding pins the same.
57    pub removed_public_key_pem: Option<String>,
58    pub trusted_key_count: usize,
59}
60
61#[cfg(test)]
62mod tests {
63    use super::*;
64    use serde_json::json;
65
66    // Wire-format pins: these encodings were established by the PR-3b
67    // `HttpEnvironmentStore` client DTOs (deleted in PR-4.2f in favour of
68    // these shared structs). Changing them breaks deployed client/server
69    // pairs.
70
71    #[test]
72    fn add_payload_encoding_is_pinned() {
73        let payload = AddTrustedKeyPayload {
74            key_id: "abc123".to_string(),
75            public_key_pem: "-----BEGIN PUBLIC KEY-----".to_string(),
76        };
77        assert_eq!(
78            serde_json::to_value(&payload).unwrap(),
79            json!({"key_id": "abc123", "public_key_pem": "-----BEGIN PUBLIC KEY-----"})
80        );
81    }
82
83    #[test]
84    fn seed_encoding_is_pinned() {
85        let seed = TrustRootSeed {
86            key_id: "abc123".to_string(),
87            public_key_pem: "pem".to_string(),
88            trusted_key_count: 1,
89        };
90        assert_eq!(
91            serde_json::to_value(&seed).unwrap(),
92            json!({"key_id": "abc123", "public_key_pem": "pem", "trusted_key_count": 1})
93        );
94        // The seed-if-absent no-op travels as a JSON `null` result.
95        assert_eq!(
96            serde_json::to_value(Option::<TrustRootSeed>::None).unwrap(),
97            json!(null)
98        );
99    }
100
101    #[test]
102    fn add_outcome_encoding_is_pinned() {
103        let outcome = TrustRootAddOutcome {
104            added_key_id: "abc123".to_string(),
105            trusted_key_count: 2,
106        };
107        assert_eq!(
108            serde_json::to_value(&outcome).unwrap(),
109            json!({"added_key_id": "abc123", "trusted_key_count": 2})
110        );
111    }
112
113    #[test]
114    fn remove_outcome_encodes_noop_pem_as_explicit_null() {
115        let outcome = TrustRootRemoveOutcome {
116            removed_key_id: "abc123".to_string(),
117            removed_public_key_pem: None,
118            trusted_key_count: 0,
119        };
120        assert_eq!(
121            serde_json::to_value(&outcome).unwrap(),
122            json!({
123                "removed_key_id": "abc123",
124                "removed_public_key_pem": null,
125                "trusted_key_count": 0
126            })
127        );
128    }
129
130    #[test]
131    fn outcomes_round_trip() {
132        let removed = TrustRootRemoveOutcome {
133            removed_key_id: "abc123".to_string(),
134            removed_public_key_pem: Some("pem".to_string()),
135            trusted_key_count: 1,
136        };
137        let parsed: TrustRootRemoveOutcome =
138            serde_json::from_str(&serde_json::to_string(&removed).unwrap()).unwrap();
139        assert_eq!(parsed, removed);
140    }
141}