1use 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#[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#[derive(Debug, Clone, Serialize, Deserialize)]
40pub struct TrustRootRemovePayload {
41 pub environment_id: String,
42 pub key_id: String,
43}
44
45#[derive(Debug, Clone, Serialize, Deserialize)]
47pub struct TrustRootBootstrapPayload {
48 pub environment_id: String,
49}
50
51pub 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 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 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
89pub(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
101pub(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
131pub 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
142pub(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
159pub 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 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
206pub 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 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 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}