Skip to main content

greentic_deployer/cli/
trust_root.rs

1//! `gtc op env trust-root {list,add,remove}` (C2 of `plans/next-gen-deployment.md`).
2//!
3//! Wraps the typed trust-root verbs on [`LocalFsStore`] (Phase D PR-3a.2)
4//! in the operator-CLI shape so external callers can manage the per-env
5//! trust root without writing JSON by hand.
6
7use std::path::PathBuf;
8
9use greentic_deploy_spec::EnvId;
10use greentic_distributor_client::signing::TrustedKey;
11use serde::{Deserialize, Serialize};
12use serde_json::{Value, json};
13
14use crate::environment::{
15    LocalFsStore, TrustRootAddOutcome, TrustRootRemoveOutcome, TrustRootSeed,
16    trust_root as store_trust_root,
17};
18
19use super::{
20    AuditCtx, OpError, OpFlags, OpOutcome, audit_and_record, map_store_err_preserving_noun,
21    mint_idempotency_key,
22};
23
24const NOUN: &str = "trust-root";
25
26/// Payload for `op env trust-root add`. Either inline `public_key_pem` OR a
27/// `public_key_file` path; one is required.
28#[derive(Debug, Clone, Serialize, Deserialize)]
29pub struct TrustRootAddPayload {
30    pub environment_id: String,
31    pub key_id: String,
32    #[serde(default, skip_serializing_if = "Option::is_none")]
33    pub public_key_pem: Option<String>,
34    #[serde(default, skip_serializing_if = "Option::is_none")]
35    pub public_key_file: Option<PathBuf>,
36}
37
38/// Payload for `op env trust-root remove`.
39#[derive(Debug, Clone, Serialize, Deserialize)]
40pub struct TrustRootRemovePayload {
41    pub environment_id: String,
42    pub key_id: String,
43}
44
45/// Payload for `op env trust-root bootstrap`.
46#[derive(Debug, Clone, Serialize, Deserialize)]
47pub struct TrustRootBootstrapPayload {
48    pub environment_id: String,
49}
50
51/// `op env trust-root bootstrap` — load (or generate) the operator key and
52/// add its `(key_id, public_pem)` to the env trust root. This is the
53/// **only** seeded code path: the revenue-policy writer NEVER mutates the
54/// trust root, so revocation via [`remove`] is a durable boundary. Run
55/// once per env; subsequent runs with the same operator key are a no-op.
56pub fn bootstrap(
57    store: &LocalFsStore,
58    flags: &OpFlags,
59    payload: Option<TrustRootBootstrapPayload>,
60) -> Result<OpOutcome, OpError> {
61    if flags.schema_only {
62        return Ok(OpOutcome::new(NOUN, "bootstrap", bootstrap_schema()));
63    }
64    let payload = resolve_payload::<TrustRootBootstrapPayload>(flags, payload)?;
65    let env_id = parse_env_id(&payload.environment_id)?;
66    // Build ctx with a placeholder target; we only know which key we
67    // bootstrapped AFTER `audit_and_record` runs the local-only authz gate.
68    // If the gate denies, we never auto-generate `~/.greentic/operator/key.pem`
69    // as a side effect of a verb that wasn't authorized to touch the env.
70    let ctx = AuditCtx {
71        env_id: env_id.clone(),
72        noun: NOUN,
73        verb: "bootstrap",
74        target: json!({"environment_id": env_id.as_str()}),
75        idempotency_key: None,
76    };
77    audit_and_record(store, ctx, |_committed| {
78        // Authz passed: now safe to load-or-generate the operator key.
79        let seed = store
80            .bootstrap_trust_root(&env_id)
81            .map_err(map_store_err_preserving_noun)?;
82        Ok((
83            OpOutcome::new(NOUN, "bootstrap", trust_root_seed_to_wire(&env_id, &seed)),
84            super::AuditGens::NONE,
85        ))
86    })
87}
88
89/// Wire shape the CLI emits for a [`TrustRootSeed`] — preserves the
90/// pre-PR-3a.2 envelope (`environment_id`/`operator_key_id`/`operator_public_key_pem`/`trusted_key_count`).
91/// Used by `bootstrap` here and `op env init` (via [`trust_root_seed_to_wire_opt`]).
92pub(crate) fn trust_root_seed_to_wire(env_id: &EnvId, seed: &TrustRootSeed) -> Value {
93    json!({
94        "environment_id": env_id.as_str(),
95        "operator_key_id": seed.key_id,
96        "operator_public_key_pem": seed.public_key_pem,
97        "trusted_key_count": seed.trusted_key_count,
98    })
99}
100
101/// `Option` variant: returns JSON `null` when the trust root was already
102/// present (op no-op), preserving the pre-PR-3a.2 `Option<TrustRootSeedResult>`
103/// serialization the `env init` payload depended on.
104pub(super) fn trust_root_seed_to_wire_opt(env_id: &EnvId, seed: Option<&TrustRootSeed>) -> Value {
105    match seed {
106        Some(s) => trust_root_seed_to_wire(env_id, s),
107        None => Value::Null,
108    }
109}
110
111pub(crate) fn trust_root_add_outcome_to_wire(env_id: &EnvId, out: &TrustRootAddOutcome) -> Value {
112    json!({
113        "environment_id": env_id.as_str(),
114        "added_key_id": out.added_key_id,
115        "trusted_key_count": out.trusted_key_count,
116    })
117}
118
119pub(crate) fn trust_root_remove_outcome_to_wire(
120    env_id: &EnvId,
121    out: &TrustRootRemoveOutcome,
122) -> Value {
123    json!({
124        "environment_id": env_id.as_str(),
125        "removed_key_id": out.removed_key_id,
126        "removed_public_key_pem": out.removed_public_key_pem,
127        "trusted_key_count": out.trusted_key_count,
128    })
129}
130
131/// `op env trust-root list <env_id>` — return all trusted keys for the env.
132pub fn list(store: &LocalFsStore, flags: &OpFlags, env_id: &str) -> Result<OpOutcome, OpError> {
133    if flags.schema_only {
134        return Ok(OpOutcome::new(NOUN, "list", list_schema()));
135    }
136    let env_id = parse_env_id(env_id)?;
137    let env_dir = store.env_dir(&env_id)?;
138    let trust = store_trust_root::load(&env_dir)?;
139    Ok(list_outcome(&env_id, &trust.keys))
140}
141
142/// Render the `trust-root list` outcome from a key set. Shared by the local
143/// FS path above and the remote `--store-url` dispatch, which fetches the
144/// keys from `GET /environments/{env_id}/trust-root` instead of disk.
145pub(crate) fn list_outcome(env_id: &EnvId, keys: &[TrustedKey]) -> OpOutcome {
146    OpOutcome::new(
147        NOUN,
148        "list",
149        json!({
150            "environment_id": env_id.as_str(),
151            "keys": keys
152                .iter()
153                .map(|k| json!({"key_id": k.key_id, "public_key_pem": k.public_key_pem}))
154                .collect::<Vec<_>>(),
155        }),
156    )
157}
158
159/// `op env trust-root add` — validate the (key_id, public_pem) pair and
160/// persist it (idempotent on case-insensitive key_id).
161pub fn add(
162    store: &LocalFsStore,
163    flags: &OpFlags,
164    payload: Option<TrustRootAddPayload>,
165) -> Result<OpOutcome, OpError> {
166    if flags.schema_only {
167        return Ok(OpOutcome::new(NOUN, "add", add_schema()));
168    }
169    let payload = resolve_payload::<TrustRootAddPayload>(flags, payload)?;
170    let env_id = parse_env_id(&payload.environment_id)?;
171    let public_key_pem = resolve_pem(&payload)?;
172    // Codex #3: audit `target` carries the full PEM, so a removed key can be
173    // reconstructed from the audit log alone if the on-disk backup is also
174    // lost. `key_id` alone is not sufficient for recovery.
175    // PR-3a.2 follow-up (Codex review of PR #260): every trust-root mutation
176    // gets an idempotency key so the HTTP backend can replay the original
177    // response (Phase D §A8 #2). The CLI auto-generates a ULID per call
178    // today; future direct-args support can plumb a caller-supplied value
179    // through the payload (matches the `cli/traffic.rs` precedent).
180    let idem = mint_idempotency_key();
181    let ctx = AuditCtx {
182        env_id: env_id.clone(),
183        noun: NOUN,
184        verb: "add",
185        target: json!({
186            "key_id": payload.key_id,
187            "public_key_pem": public_key_pem,
188        }),
189        idempotency_key: Some(idem.as_str().to_string()),
190    };
191    audit_and_record(store, ctx, |_committed| {
192        let outcome = store
193            .add_trusted_key(&env_id, payload.key_id, public_key_pem, idem)
194            .map_err(map_store_err_preserving_noun)?;
195        Ok((
196            OpOutcome::new(
197                NOUN,
198                "add",
199                trust_root_add_outcome_to_wire(&env_id, &outcome),
200            ),
201            super::AuditGens::NONE,
202        ))
203    })
204}
205
206/// `op env trust-root remove` — silently no-ops if the key isn't present.
207pub fn remove(
208    store: &LocalFsStore,
209    flags: &OpFlags,
210    payload: Option<TrustRootRemovePayload>,
211) -> Result<OpOutcome, OpError> {
212    if flags.schema_only {
213        return Ok(OpOutcome::new(NOUN, "remove", remove_schema()));
214    }
215    let payload = resolve_payload::<TrustRootRemovePayload>(flags, payload)?;
216    let env_id = parse_env_id(&payload.environment_id)?;
217    let idem = mint_idempotency_key();
218    let ctx = AuditCtx {
219        env_id: env_id.clone(),
220        noun: NOUN,
221        verb: "remove",
222        // Audit ctx target carries only the key_id. The full removed PEM
223        // would be ideal for recovery, but capturing it BEFORE the env
224        // flock races with a concurrent `add` — the audit would log a stale
225        // PEM. The trust-root backup file (Codex #3) is the authoritative
226        // recovery artifact; the outcome value below carries the PEM that
227        // was actually removed under the flock.
228        target: json!({"key_id": payload.key_id}),
229        idempotency_key: Some(idem.as_str().to_string()),
230    };
231    audit_and_record(store, ctx, |_committed| {
232        let outcome = store
233            .remove_trusted_key(&env_id, payload.key_id, idem)
234            .map_err(map_store_err_preserving_noun)?;
235        Ok((
236            OpOutcome::new(
237                NOUN,
238                "remove",
239                trust_root_remove_outcome_to_wire(&env_id, &outcome),
240            ),
241            super::AuditGens::NONE,
242        ))
243    })
244}
245
246pub(crate) fn resolve_pem(payload: &TrustRootAddPayload) -> Result<String, OpError> {
247    match (&payload.public_key_pem, &payload.public_key_file) {
248        (Some(pem), None) => Ok(pem.clone()),
249        (None, Some(path)) => std::fs::read_to_string(path).map_err(|source| OpError::Io {
250            path: path.clone(),
251            source,
252        }),
253        (Some(_), Some(_)) => Err(OpError::InvalidArgument(
254            "trust-root add: pass exactly one of `public_key_pem` or `public_key_file`".to_string(),
255        )),
256        (None, None) => Err(OpError::InvalidArgument(
257            "trust-root add: one of `public_key_pem` or `public_key_file` is required".to_string(),
258        )),
259    }
260}
261
262fn parse_env_id(raw: &str) -> Result<EnvId, OpError> {
263    EnvId::try_from(raw).map_err(|e| OpError::InvalidArgument(format!("environment_id: {e}")))
264}
265
266fn resolve_payload<T: serde::de::DeserializeOwned>(
267    flags: &OpFlags,
268    payload: Option<T>,
269) -> Result<T, OpError> {
270    if let Some(p) = payload {
271        return Ok(p);
272    }
273    if let Some(path) = &flags.answers {
274        return super::load_answers::<T>(path);
275    }
276    Err(OpError::InvalidArgument(
277        "no payload provided: pass --answers <path> or supply the payload directly".to_string(),
278    ))
279}
280
281fn bootstrap_schema() -> Value {
282    json!({
283        "$schema": "https://json-schema.org/draft/2020-12/schema",
284        "title": "TrustRootBootstrapPayload",
285        "type": "object",
286        "required": ["environment_id"],
287        "additionalProperties": false,
288        "properties": {
289            "environment_id": {"type": "string"}
290        }
291    })
292}
293
294fn list_schema() -> Value {
295    json!({
296        "$schema": "https://json-schema.org/draft/2020-12/schema",
297        "title": "TrustRootList",
298        "type": "object",
299        "additionalProperties": false,
300        "properties": {
301            "environment_id": {"type": "string"}
302        }
303    })
304}
305
306fn add_schema() -> Value {
307    json!({
308        "$schema": "https://json-schema.org/draft/2020-12/schema",
309        "title": "TrustRootAddPayload",
310        "type": "object",
311        "required": ["environment_id", "key_id"],
312        "additionalProperties": false,
313        "properties": {
314            "environment_id": {"type": "string"},
315            "key_id": {"type": "string", "description": "Canonical key id (hex SHA-256[..16] of the raw public key)."},
316            "public_key_pem": {"type": ["string", "null"], "description": "Inline SPKI PEM."},
317            "public_key_file": {"type": ["string", "null"], "description": "Path to a SPKI PEM file."}
318        }
319    })
320}
321
322fn remove_schema() -> Value {
323    json!({
324        "$schema": "https://json-schema.org/draft/2020-12/schema",
325        "title": "TrustRootRemovePayload",
326        "type": "object",
327        "required": ["environment_id", "key_id"],
328        "additionalProperties": false,
329        "properties": {
330            "environment_id": {"type": "string"},
331            "key_id": {"type": "string"}
332        }
333    })
334}
335
336#[cfg(test)]
337mod tests {
338    use super::*;
339    use crate::cli::tests_common::make_env;
340    use crate::environment::EnvironmentStore;
341    use greentic_operator_trust::test_support::keypair;
342    use tempfile::tempdir;
343
344    #[test]
345    fn list_on_fresh_env_returns_empty_keys() {
346        let dir = tempdir().unwrap();
347        let store = LocalFsStore::new(dir.path());
348        store.save(&make_env("local")).unwrap();
349        let out = list(&store, &OpFlags::default(), "local").unwrap();
350        assert_eq!(out.result["keys"].as_array().unwrap().len(), 0);
351    }
352
353    #[test]
354    fn add_then_list_includes_the_key() {
355        let dir = tempdir().unwrap();
356        let store = LocalFsStore::new(dir.path());
357        store.save(&make_env("local")).unwrap();
358        let (pem, id) = keypair(31);
359        add(
360            &store,
361            &OpFlags::default(),
362            Some(TrustRootAddPayload {
363                environment_id: "local".into(),
364                key_id: id.clone(),
365                public_key_pem: Some(pem.clone()),
366                public_key_file: None,
367            }),
368        )
369        .unwrap();
370        let listed = list(&store, &OpFlags::default(), "local").unwrap();
371        let keys = listed.result["keys"].as_array().unwrap();
372        assert_eq!(keys.len(), 1);
373        assert_eq!(keys[0]["key_id"].as_str().unwrap(), id);
374    }
375
376    #[test]
377    fn add_loads_pem_from_file_when_inline_omitted() {
378        let dir = tempdir().unwrap();
379        let store = LocalFsStore::new(dir.path());
380        store.save(&make_env("local")).unwrap();
381        let (pem, id) = keypair(32);
382        let pem_path = dir.path().join("key.pub");
383        std::fs::write(&pem_path, &pem).unwrap();
384        add(
385            &store,
386            &OpFlags::default(),
387            Some(TrustRootAddPayload {
388                environment_id: "local".into(),
389                key_id: id.clone(),
390                public_key_pem: None,
391                public_key_file: Some(pem_path),
392            }),
393        )
394        .unwrap();
395        let listed = list(&store, &OpFlags::default(), "local").unwrap();
396        assert_eq!(listed.result["keys"][0]["key_id"].as_str().unwrap(), id);
397    }
398
399    #[test]
400    fn add_rejects_both_pem_sources_set() {
401        let dir = tempdir().unwrap();
402        let store = LocalFsStore::new(dir.path());
403        store.save(&make_env("local")).unwrap();
404        let (pem, id) = keypair(33);
405        let err = add(
406            &store,
407            &OpFlags::default(),
408            Some(TrustRootAddPayload {
409                environment_id: "local".into(),
410                key_id: id,
411                public_key_pem: Some(pem),
412                public_key_file: Some(PathBuf::from("/dev/null")),
413            }),
414        )
415        .unwrap_err();
416        assert!(matches!(err, OpError::InvalidArgument(_)));
417    }
418
419    #[test]
420    fn add_rejects_neither_pem_source_set() {
421        let dir = tempdir().unwrap();
422        let store = LocalFsStore::new(dir.path());
423        store.save(&make_env("local")).unwrap();
424        let (_pem, id) = keypair(34);
425        let err = add(
426            &store,
427            &OpFlags::default(),
428            Some(TrustRootAddPayload {
429                environment_id: "local".into(),
430                key_id: id,
431                public_key_pem: None,
432                public_key_file: None,
433            }),
434        )
435        .unwrap_err();
436        assert!(matches!(err, OpError::InvalidArgument(_)));
437    }
438
439    #[test]
440    fn add_rejects_mismatched_key_id() {
441        let dir = tempdir().unwrap();
442        let store = LocalFsStore::new(dir.path());
443        store.save(&make_env("local")).unwrap();
444        let (pem, _id) = keypair(35);
445        let (_pem_b, id_b) = keypair(36);
446        let err = add(
447            &store,
448            &OpFlags::default(),
449            Some(TrustRootAddPayload {
450                environment_id: "local".into(),
451                key_id: id_b,
452                public_key_pem: Some(pem),
453                public_key_file: None,
454            }),
455        )
456        .unwrap_err();
457        assert!(matches!(err, OpError::TrustRoot(_)));
458    }
459
460    #[test]
461    fn remove_drops_only_matching_key() {
462        let dir = tempdir().unwrap();
463        let store = LocalFsStore::new(dir.path());
464        store.save(&make_env("local")).unwrap();
465        let (pem_a, id_a) = keypair(37);
466        let (pem_b, id_b) = keypair(38);
467        for (pem, id) in [(pem_a, id_a.clone()), (pem_b, id_b.clone())] {
468            add(
469                &store,
470                &OpFlags::default(),
471                Some(TrustRootAddPayload {
472                    environment_id: "local".into(),
473                    key_id: id,
474                    public_key_pem: Some(pem),
475                    public_key_file: None,
476                }),
477            )
478            .unwrap();
479        }
480        remove(
481            &store,
482            &OpFlags::default(),
483            Some(TrustRootRemovePayload {
484                environment_id: "local".into(),
485                key_id: id_a,
486            }),
487        )
488        .unwrap();
489        let listed = list(&store, &OpFlags::default(), "local").unwrap();
490        let keys = listed.result["keys"].as_array().unwrap();
491        assert_eq!(keys.len(), 1);
492        assert_eq!(keys[0]["key_id"].as_str().unwrap(), id_b);
493    }
494
495    #[test]
496    fn bootstrap_seeds_operator_key_into_env_trust_root() {
497        // Codex #1: explicit bootstrap is the only authorized path to seed
498        // the operator key into the env trust root.
499        let dir = tempdir().unwrap();
500        let store = LocalFsStore::new(dir.path());
501        store.save(&make_env("local")).unwrap();
502        let outcome = bootstrap(
503            &store,
504            &OpFlags::default(),
505            Some(TrustRootBootstrapPayload {
506                environment_id: "local".into(),
507            }),
508        )
509        .unwrap();
510        assert_eq!(outcome.op, "bootstrap");
511        assert!(outcome.result["operator_key_id"].is_string());
512        let listed = list(&store, &OpFlags::default(), "local").unwrap();
513        assert_eq!(listed.result["keys"].as_array().unwrap().len(), 1);
514    }
515
516    #[test]
517    fn bootstrap_is_idempotent() {
518        let dir = tempdir().unwrap();
519        let store = LocalFsStore::new(dir.path());
520        store.save(&make_env("local")).unwrap();
521        bootstrap(
522            &store,
523            &OpFlags::default(),
524            Some(TrustRootBootstrapPayload {
525                environment_id: "local".into(),
526            }),
527        )
528        .unwrap();
529        bootstrap(
530            &store,
531            &OpFlags::default(),
532            Some(TrustRootBootstrapPayload {
533                environment_id: "local".into(),
534            }),
535        )
536        .unwrap();
537        let listed = list(&store, &OpFlags::default(), "local").unwrap();
538        assert_eq!(
539            listed.result["keys"].as_array().unwrap().len(),
540            1,
541            "second bootstrap must not duplicate the operator key"
542        );
543    }
544
545    #[test]
546    fn schema_only_returns_payload_schema_without_touching_disk() {
547        let dir = tempdir().unwrap();
548        let store = LocalFsStore::new(dir.path());
549        let out = add(
550            &store,
551            &OpFlags {
552                schema_only: true,
553                ..OpFlags::default()
554            },
555            None,
556        )
557        .unwrap();
558        assert_eq!(out.op, "add");
559        assert_eq!(out.noun, NOUN);
560        assert!(out.result["properties"]["key_id"].is_object());
561    }
562}