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}