1use super::*;
9use crate::adapters::external_runtime::ExternalRuntimeGraph;
10use crate::adapters::secret_supervisor::{SecretRuntimeError, SecretSession};
11use crate::contract::SecretBinding;
12use crate::plan::PlanAction;
13use serde_json::{Value, json};
14use std::collections::HashMap;
15use std::time::Duration;
16use zeroize::Zeroizing;
17
18const STARTUP_TIMEOUT: Duration = Duration::from_secs(10);
19const REQUEST_TIMEOUT: Duration = Duration::from_secs(30);
20
21pub const SECRET_SINK_ROUTE: &str = "secret-sink";
22#[derive(Clone, Debug, PartialEq, Eq)]
23pub struct ConfidentialOutputDecl {
24 pub name: String,
25 pub binding_id: String,
26 pub sink_binding: Option<String>,
27 pub allow_overwrite: bool,
28 pub max_bytes: usize,
29 pub classification: String,
30 pub value_type: String,
31}
32
33#[derive(Default)]
35pub struct ConfidentialRunScope {
36 values: HashMap<String, Zeroizing<Vec<u8>>>,
37}
38
39impl ConfidentialRunScope {
40 pub fn insert(&mut self, key: impl Into<String>, value: Zeroizing<Vec<u8>>) {
41 self.values.insert(key.into(), value);
42 }
43
44 pub fn take(&mut self, key: &str) -> Option<Zeroizing<Vec<u8>>> {
45 self.values.remove(key)
46 }
47
48 pub fn get(&self, key: &str) -> Option<&[u8]> {
49 self.values.get(key).map(|value| value.as_slice())
50 }
51}
52
53impl Drop for ConfidentialRunScope {
54 fn drop(&mut self) {
55 for value in self.values.values_mut() {
56 value.fill(0);
57 }
58 self.values.clear();
59 }
60}
61
62pub fn is_secret_sink_action(action: &PlanAction) -> bool {
63 action.details.get("route").and_then(Value::as_str) == Some(SECRET_SINK_ROUTE)
64}
65
66pub fn parse_confidential_declarations(
67 action: &PlanAction,
68) -> Result<Vec<ConfidentialOutputDecl>, NxdError> {
69 let Some(outputs) = action.details.pointer("/contract/outputs") else {
70 return Ok(Vec::new());
71 };
72 let array = outputs.as_array().ok_or_else(|| {
73 NxdError::InvalidConfig(format!("action {} contract outputs must be an array", action.id))
74 })?;
75 let mut declarations = Vec::new();
76 for entry in array {
77 let name = entry
78 .get("outputName")
79 .and_then(Value::as_str)
80 .ok_or_else(|| NxdError::InvalidConfig("confidential output missing name".into()))?
81 .to_string();
82 let binding_id = entry
83 .get("bindingId")
84 .and_then(Value::as_str)
85 .ok_or_else(|| NxdError::InvalidConfig("confidential output missing bindingId".into()))?
86 .to_string();
87 let sink_binding = entry
88 .get("sinkBinding")
89 .and_then(Value::as_str)
90 .filter(|value| !value.is_empty())
91 .map(str::to_string);
92 let allow_overwrite = entry.get("allowOverwrite").and_then(Value::as_bool).unwrap_or(false);
93 let max_bytes = entry.get("maxBytes").and_then(Value::as_u64).unwrap_or(0) as usize;
94 let classification =
95 entry.get("classification").and_then(Value::as_str).unwrap_or("").to_string();
96 let value_type = entry.get("valueType").and_then(Value::as_str).unwrap_or("").to_string();
97 declarations.push(ConfidentialOutputDecl {
98 name,
99 binding_id,
100 sink_binding,
101 allow_overwrite,
102 max_bytes,
103 classification,
104 value_type,
105 });
106 }
107 Ok(declarations)
108}
109
110pub fn resolve_action_secrets(
112 graph: &ExternalRuntimeGraph,
113 config: &CanonicalConfig,
114 action: &PlanAction,
115) -> Result<HashMap<String, Vec<u8>>, NxdError> {
116 resolve_action_secrets_with_values(graph, config, action, HashMap::new())
117}
118
119pub fn resolve_action_secrets_with_values(
120 graph: &ExternalRuntimeGraph,
121 config: &CanonicalConfig,
122 action: &PlanAction,
123 mut resolved: HashMap<String, Vec<u8>>,
124) -> Result<HashMap<String, Vec<u8>>, NxdError> {
125 if action.secret_references.is_empty() {
126 return Ok(resolved);
127 }
128 for reference in &action.secret_references {
129 if resolved.contains_key(reference) {
130 continue;
131 }
132 let binding = binding_for_reference(action, config, reference)?;
133 let value = resolve_binding(graph, &binding, &action.id)?;
134 resolved.insert(binding.id.clone(), value.to_vec());
135 }
136 Ok(resolved)
137}
138
139fn binding_for_reference(
140 action: &PlanAction,
141 config: &CanonicalConfig,
142 reference: &str,
143) -> Result<SecretBinding, NxdError> {
144 if let Some(array) = action.details.get("secretBindings").and_then(Value::as_array) {
145 for entry in array {
146 if entry.get("id").and_then(Value::as_str) == Some(reference) {
147 return Ok(SecretBinding {
148 id: reference.to_string(),
149 resolver: entry
150 .get("resolver")
151 .and_then(Value::as_str)
152 .ok_or_else(|| NxdError::InvalidConfig("secret binding resolver missing".into()))?
153 .to_string(),
154 reference: entry
155 .get("reference")
156 .and_then(Value::as_str)
157 .ok_or_else(|| NxdError::InvalidConfig("secret binding reference missing".into()))?
158 .to_string(),
159 });
160 }
161 }
162 }
163 config.spec.secret_bindings.iter().find(|binding| binding.id == reference).cloned().ok_or_else(
164 || {
165 NxdError::InvalidConfig(format!(
166 "action {} references unknown secret binding {reference}",
167 action.id
168 ))
169 },
170 )
171}
172
173pub fn resolve_binding(
174 graph: &ExternalRuntimeGraph,
175 binding: &SecretBinding,
176 action_id: &str,
177) -> Result<Zeroizing<Vec<u8>>, NxdError> {
178 let executable = graph
179 .require_secret_resolver(&binding.resolver)
180 .map_err(|error| NxdError::InvalidConfig(error.to_string()))?
181 .clone();
182 let binding = binding.clone();
183 let action_id = action_id.to_string();
184 PveBackupJobRuntime::run_async(async move {
185 let mut session = SecretSession::start(&executable, STARTUP_TIMEOUT, REQUEST_TIMEOUT)
186 .await
187 .map_err(map_error)?;
188 session.resolve(&binding, &action_id).await.map_err(map_error)
189 })
190}
191
192pub fn resolve_binding_optional(
198 graph: &ExternalRuntimeGraph,
199 binding: &SecretBinding,
200 action_id: &str,
201) -> Result<Option<Zeroizing<Vec<u8>>>, NxdError> {
202 let executable = graph
203 .require_secret_resolver(&binding.resolver)
204 .map_err(|error| NxdError::InvalidConfig(error.to_string()))?
205 .clone();
206 let binding = binding.clone();
207 let action_id = action_id.to_string();
208 PveBackupJobRuntime::run_async(async move {
209 let mut session = SecretSession::start(&executable, STARTUP_TIMEOUT, REQUEST_TIMEOUT)
210 .await
211 .map_err(map_error)?;
212 match session.resolve(&binding, &action_id).await {
213 Ok(value) => Ok(Some(value)),
214 Err(SecretRuntimeError::NotFound) => Ok(None),
215 Err(error) => Err(map_error(error)),
216 }
217 })
218}
219
220pub fn inspect_binding(
224 graph: &ExternalRuntimeGraph,
225 binding: &SecretBinding,
226 action_id: &str,
227) -> Result<nxd_secret_protocol::v1::InspectBindingResponse, NxdError> {
228 let executable = graph
229 .require_secret_resolver(&binding.resolver)
230 .map_err(|error| NxdError::InvalidConfig(error.to_string()))?
231 .clone();
232 let binding = binding.clone();
233 let action_id = action_id.to_string();
234 PveBackupJobRuntime::run_async(async move {
235 let mut session = SecretSession::start(&executable, STARTUP_TIMEOUT, REQUEST_TIMEOUT)
236 .await
237 .map_err(map_error)?;
238 session.inspect_binding(&binding, &action_id).await.map_err(map_error)
239 })
240}
241
242pub fn resolve_public_artifact_optional(
243 graph: &ExternalRuntimeGraph,
244 binding: &SecretBinding,
245 action_id: &str,
246) -> Result<Option<nxd_secret_protocol::v1::ResolvePublicArtifactResponse>, NxdError> {
247 let executable = graph
248 .require_secret_resolver(&binding.resolver)
249 .map_err(|error| NxdError::InvalidConfig(error.to_string()))?
250 .clone();
251 let binding = binding.clone();
252 let action_id = action_id.to_string();
253 PveBackupJobRuntime::run_async(async move {
254 let mut session = SecretSession::start(&executable, STARTUP_TIMEOUT, REQUEST_TIMEOUT)
255 .await
256 .map_err(map_error)?;
257 match session.resolve_public_artifact(&binding, &action_id).await {
258 Ok(value) => Ok(Some(value)),
259 Err(SecretRuntimeError::NotFound) => Ok(None),
260 Err(error) => Err(map_error(error)),
261 }
262 })
263}
264
265pub fn inspect_recipient_policy(
266 graph: &ExternalRuntimeGraph,
267 resolver: &str,
268 action_id: &str,
269 identity_id: &str,
270 recipient_alias: &str,
271 creation_rule_path_regex: &str,
272 document_references: Vec<String>,
273) -> Result<nxd_secret_protocol::v1::InspectRecipientPolicyResponse, NxdError> {
274 let executable = graph
275 .require_secret_resolver(resolver)
276 .map_err(|error| NxdError::InvalidConfig(error.to_string()))?
277 .clone();
278 let action_id = action_id.to_string();
279 let identity_id = identity_id.to_string();
280 let recipient_alias = recipient_alias.to_string();
281 let creation_rule_path_regex = creation_rule_path_regex.to_string();
282 PveBackupJobRuntime::run_async(async move {
283 let mut session = SecretSession::start(&executable, STARTUP_TIMEOUT, REQUEST_TIMEOUT)
284 .await
285 .map_err(map_error)?;
286 session
287 .inspect_recipient_policy(
288 &action_id,
289 &identity_id,
290 "",
291 &recipient_alias,
292 &creation_rule_path_regex,
293 document_references,
294 )
295 .await
296 .map_err(map_error)
297 })
298}
299
300pub fn apply_secret_sink_action(
301 graph: &ExternalRuntimeGraph,
302 config: &CanonicalConfig,
303 action: &PlanAction,
304 run_scope: &mut ConfidentialRunScope,
305) -> Result<(), NxdError> {
306 let operation = action.details.get("operation").and_then(Value::as_str).ok_or_else(|| {
307 NxdError::InvalidConfig(format!("secret-sink action {} missing operation", action.id))
308 })?;
309 let binding = binding_from_action_details(action, config)?;
310 let executable = graph
311 .require_secret_resolver(&binding.resolver)
312 .map_err(|error| NxdError::InvalidConfig(error.to_string()))?
313 .clone();
314 match operation {
315 "public-artifact-store" => {
316 let source = required_detail(action, "valueSource")?;
317 let value = run_scope
318 .get(source)
319 .ok_or_else(|| {
320 NxdError::InvalidConfig(format!(
321 "public artifact action {} is missing {source}",
322 action.id
323 ))
324 })?
325 .to_vec();
326 let expected = action
327 .details
328 .get("expectedPreviousSha256")
329 .and_then(Value::as_str)
330 .unwrap_or("")
331 .to_string();
332 let action_id = action.id.clone();
333 PveBackupJobRuntime::run_async(async move {
334 let mut session = SecretSession::start(&executable, STARTUP_TIMEOUT, REQUEST_TIMEOUT)
335 .await
336 .map_err(map_error)?;
337 session
338 .store_public_artifact(&binding, &action_id, &value, &expected)
339 .await
340 .map_err(map_error)
341 })?;
342 Ok(())
343 }
344 "store" => {
345 let allow_overwrite =
346 action.details.get("allowOverwrite").and_then(Value::as_bool).unwrap_or(false);
347 let allow_create =
348 action.details.get("allowCreate").and_then(Value::as_bool).unwrap_or(false);
349 let expected_previous_document_sha256 = if let Some(source) =
350 action.details.get("expectedPreviousDocumentSha256Source").and_then(Value::as_str)
351 {
352 std::str::from_utf8(run_scope.get(source).ok_or_else(|| {
353 NxdError::InvalidConfig(format!(
354 "secret-sink store action {} is missing policy document digest {source}",
355 action.id
356 ))
357 })?)
358 .map_err(|_| NxdError::InvalidConfig("policy document digest is not UTF-8".into()))?
359 .to_string()
360 } else {
361 action
362 .details
363 .get("expectedPreviousDocumentSha256")
364 .and_then(Value::as_str)
365 .unwrap_or("")
366 .to_string()
367 };
368 let value = if let Some(bytes) = run_scope.get(&binding.id) {
369 Zeroizing::new(bytes.to_vec())
370 } else if let Some(inline) = action.details.get("valueSource").and_then(Value::as_str) {
371 run_scope.get(inline).map(|bytes| Zeroizing::new(bytes.to_vec())).ok_or_else(|| {
372 NxdError::InvalidConfig(format!(
373 "secret-sink store action {} missing confidential value for {inline}",
374 action.id
375 ))
376 })?
377 } else {
378 return Err(NxdError::InvalidConfig(format!(
379 "secret-sink store action {} has no confidential value in the run scope",
380 action.id
381 )));
382 };
383 let action_id = action.id.clone();
384 let receipt = PveBackupJobRuntime::run_async(async move {
385 let mut session = SecretSession::start(&executable, STARTUP_TIMEOUT, REQUEST_TIMEOUT)
386 .await
387 .map_err(map_error)?;
388 session
389 .store(
390 &binding,
391 &action_id,
392 value.as_slice(),
393 allow_overwrite,
394 allow_create,
395 &expected_previous_document_sha256,
396 )
397 .await
398 .map_err(map_error)
399 })?;
400 let _ = receipt;
401 Ok(())
402 }
403 "delete" => {
404 let action_id = action.id.clone();
405 let receipt = PveBackupJobRuntime::run_async(async move {
406 let mut session = SecretSession::start(&executable, STARTUP_TIMEOUT, REQUEST_TIMEOUT)
407 .await
408 .map_err(map_error)?;
409 session.delete(&binding, &action_id).await.map_err(map_error)
410 })?;
411 let _ = receipt;
412 Ok(())
413 }
414 "recipient-policy" => {
415 let identity_id = required_detail(action, "identityId")?.to_string();
416 let recipient_alias = required_detail(action, "recipientAlias")?.to_string();
417 let creation_rule = required_detail(action, "creationRulePathRegex")?.to_string();
418 let expected_policy = required_detail(action, "expectedPolicySha256")?.to_string();
419 let source = required_detail(action, "ageRecipientSource")?;
420 let age = run_scope.get(source).ok_or_else(|| {
421 NxdError::InvalidConfig(format!(
422 "secret-sink recipient-policy action {} is missing {source}",
423 action.id
424 ))
425 })?;
426 let age = std::str::from_utf8(age)
427 .map_err(|_| NxdError::InvalidConfig("age recipient output is not UTF-8".into()))?
428 .to_string();
429 let previous = action
430 .details
431 .get("expectedPreviousAgeRecipient")
432 .and_then(Value::as_str)
433 .filter(|value| !value.is_empty())
434 .map(str::to_string);
435 let documents = action
436 .details
437 .get("documents")
438 .and_then(Value::as_array)
439 .ok_or_else(|| NxdError::InvalidConfig("recipient-policy documents missing".into()))?
440 .iter()
441 .map(|document| {
442 Ok(nxd_secret_protocol::v1::RecipientPolicyDocument {
443 reference: document
444 .get("reference")
445 .and_then(Value::as_str)
446 .ok_or_else(|| NxdError::InvalidConfig("policy document reference missing".into()))?
447 .to_string(),
448 sha256: document
449 .get("sha256")
450 .and_then(Value::as_str)
451 .ok_or_else(|| NxdError::InvalidConfig("policy document digest missing".into()))?
452 .to_string(),
453 })
454 })
455 .collect::<Result<Vec<_>, NxdError>>()?;
456 let action_id = action.id.clone();
457 let response = PveBackupJobRuntime::run_async(async move {
458 let mut session = SecretSession::start(&executable, STARTUP_TIMEOUT, REQUEST_TIMEOUT)
459 .await
460 .map_err(map_error)?;
461 session
462 .apply_recipient_policy(
463 &action_id,
464 &identity_id,
465 &age,
466 &recipient_alias,
467 &creation_rule,
468 &expected_policy,
469 documents,
470 previous.as_deref(),
471 )
472 .await
473 .map_err(map_error)
474 })?;
475 for document in response.documents {
476 run_scope.insert(
477 recipient_policy_document_digest_binding(&action.id, &document.reference),
478 Zeroizing::new(document.sha256.into_bytes()),
479 );
480 }
481 Ok(())
482 }
483 other => Err(NxdError::InvalidConfig(format!(
484 "unsupported secret-sink operation {other} on action {}",
485 action.id
486 ))),
487 }
488}
489
490fn required_detail<'a>(action: &'a PlanAction, field: &str) -> Result<&'a str, NxdError> {
491 action.details.get(field).and_then(Value::as_str).ok_or_else(|| {
492 NxdError::InvalidConfig(format!("secret-sink action {} missing {field}", action.id))
493 })
494}
495
496pub fn route_confidential_outputs(
498 action: &PlanAction,
499 produced: HashMap<String, Zeroizing<Vec<u8>>>,
500 run_scope: &mut ConfidentialRunScope,
501) -> Result<(), NxdError> {
502 route_action_outputs(action, produced, HashMap::new(), run_scope)
503}
504
505pub fn route_action_outputs(
509 action: &PlanAction,
510 produced_confidential: HashMap<String, Zeroizing<Vec<u8>>>,
511 produced_public: HashMap<String, Vec<u8>>,
512 run_scope: &mut ConfidentialRunScope,
513) -> Result<(), NxdError> {
514 let declarations = parse_confidential_declarations(action)?;
515 if declarations.is_empty() {
516 if !produced_confidential.is_empty() || !produced_public.is_empty() {
517 return Err(NxdError::PolicyRejected(format!(
518 "action {} produced values without plan declarations",
519 action.id
520 )));
521 }
522 return Ok(());
523 }
524 let declared_names =
525 declarations.iter().map(|value| value.name.as_str()).collect::<std::collections::BTreeSet<_>>();
526 if produced_confidential
527 .keys()
528 .chain(produced_public.keys())
529 .any(|name| !declared_names.contains(name.as_str()))
530 {
531 return Err(NxdError::PolicyRejected(format!(
532 "action {} produced an undeclared output",
533 action.id
534 )));
535 }
536 for declaration in declarations {
537 let public = declaration.classification == "OUTPUT_CLASSIFICATION_PUBLIC";
538 let value = if public {
539 produced_public.get(&declaration.name).map(Vec::as_slice)
540 } else {
541 produced_confidential.get(&declaration.name).map(|value| value.as_slice())
542 }
543 .ok_or_else(|| {
544 NxdError::Io(format!(
545 "action {} did not produce declared output {} in its reviewed classification",
546 action.id, declaration.name
547 ))
548 })?;
549 if value.len() > declaration.max_bytes {
550 return Err(NxdError::PolicyRejected(format!(
551 "action {} output {} exceeds its declared maximum",
552 action.id, declaration.name
553 )));
554 }
555 if declaration.value_type == "BINDING_VALUE_TYPE_ENDPOINT_ATTESTATION" {
556 validate_endpoint_attestation(action, value)?;
557 }
558 validate_public_identity_output(action, &declaration, value)?;
559 run_scope.insert(declaration.binding_id, Zeroizing::new(value.to_vec()));
560 }
561 Ok(())
562}
563
564fn validate_public_identity_output(
565 action: &PlanAction,
566 declaration: &ConfidentialOutputDecl,
567 value: &[u8],
568) -> Result<(), NxdError> {
569 if declaration.classification != "OUTPUT_CLASSIFICATION_PUBLIC" {
570 return Ok(());
571 }
572 let text = std::str::from_utf8(value).map_err(|_| {
573 NxdError::PolicyRejected(format!("action {} produced non-UTF-8 public identity", action.id))
574 })?;
575 let valid = match declaration.value_type.as_str() {
576 "BINDING_VALUE_TYPE_SSH_ED25519_PUBLIC_KEY" => {
577 let mut fields = text.split_whitespace();
578 fields.next() == Some("ssh-ed25519") && fields.next().is_some() && fields.next().is_none()
579 }
580 "BINDING_VALUE_TYPE_SSH_SHA256_FINGERPRINT" => {
581 text.starts_with("SHA256:") && !text.chars().any(char::is_whitespace)
582 }
583 "BINDING_VALUE_TYPE_AGE_RECIPIENT" => {
584 text.starts_with("age1") && !text.chars().any(char::is_whitespace)
585 }
586 "BINDING_VALUE_TYPE_SHA256_DIGEST" => {
587 text.len() == 71
588 && text.starts_with("sha256:")
589 && text[7..].bytes().all(|byte| byte.is_ascii_hexdigit() && !byte.is_ascii_uppercase())
590 }
591 "BINDING_VALUE_TYPE_PUBLIC_IDENTITY_DOCUMENT" => {
592 serde_json::from_str::<serde_json::Value>(text).ok().is_some_and(|document| {
593 let Some(object) = document.as_object() else { return false };
594 object.len() == 4
595 && object
596 .get("publicKey")
597 .and_then(Value::as_str)
598 .is_some_and(|value| value.starts_with("ssh-ed25519 "))
599 && object
600 .get("sshFingerprint")
601 .and_then(Value::as_str)
602 .is_some_and(|value| value.starts_with("SHA256:"))
603 && object
604 .get("ageRecipient")
605 .and_then(Value::as_str)
606 .is_some_and(|value| value.starts_with("age1"))
607 && object
608 .get("identityDigest")
609 .and_then(Value::as_str)
610 .is_some_and(|value| value.starts_with("sha256:"))
611 })
612 }
613 "BINDING_VALUE_TYPE_OPAQUE_BYTES" => !value.is_empty(),
614 _ => false,
615 };
616 if !valid {
617 return Err(NxdError::PolicyRejected(format!(
618 "action {} produced invalid public identity output {}",
619 action.id, declaration.name
620 )));
621 }
622 Ok(())
623}
624
625fn validate_endpoint_attestation(action: &PlanAction, value: &[u8]) -> Result<(), NxdError> {
626 let document: Value = serde_json::from_slice(value).map_err(|_| {
627 NxdError::PolicyRejected(format!(
628 "action {} produced a malformed endpoint attestation",
629 action.id
630 ))
631 })?;
632 let target = document.get("deploymentTarget").and_then(Value::as_str).unwrap_or("");
633 let expected =
634 action.details.pointer("/contract/deploymentTarget").and_then(Value::as_str).unwrap_or("");
635 if target != expected {
636 return Err(NxdError::PolicyRejected(format!(
637 "action {} endpoint attestation target does not match its contract",
638 action.id
639 )));
640 }
641 let endpoint = document.get("endpoint").and_then(Value::as_str).ok_or_else(|| {
642 NxdError::PolicyRejected(format!("action {} endpoint attestation has no endpoint", action.id))
643 })?;
644 let address = endpoint.parse::<std::net::IpAddr>().map_err(|_| {
645 NxdError::PolicyRejected(format!(
646 "action {} endpoint attestation is not an IP literal",
647 action.id
648 ))
649 })?;
650 if address.is_unspecified() || address.is_multicast() || address.is_loopback() {
651 return Err(NxdError::PolicyRejected(format!(
652 "action {} endpoint attestation is not usable",
653 action.id
654 )));
655 }
656 Ok(())
657}
658
659pub fn secret_sink_store_action(
660 id: impl Into<String>,
661 binding: &SecretBinding,
662 depends_on: Vec<String>,
663 allow_overwrite: bool,
664 value_source: String,
665) -> PlanAction {
666 PlanAction {
667 id: id.into(),
668 provider_instance: format!("secret/{}", binding.resolver),
669 resource: binding.id.clone(),
670 operation: crate::plan::PlanActionOperation::Update,
671 risk: crate::plan::PlanActionRisk::IdentityCritical,
672 depends_on,
673 lock_keys: vec![binding.id.clone()],
674 timeout_seconds: 120,
675 secret_references: vec![binding.id.clone()],
676 details: json!({
677 "route": SECRET_SINK_ROUTE,
678 "operation": "store",
679 "allowOverwrite": allow_overwrite,
680 "allowCreate": false,
681 "expectedPreviousDocumentSha256": "",
682 "valueSource": value_source,
683 "binding": {
684 "id": binding.id,
685 "resolver": binding.resolver,
686 "reference": binding.reference,
687 }
688 }),
689 }
690}
691
692pub fn public_artifact_store_action(
693 id: impl Into<String>,
694 binding: &SecretBinding,
695 depends_on: Vec<String>,
696 value_source: String,
697 expected_previous_sha256: String,
698) -> PlanAction {
699 PlanAction {
700 id: id.into(),
701 provider_instance: format!("secret/{}", binding.resolver),
702 resource: binding.id.clone(),
703 operation: crate::plan::PlanActionOperation::Update,
704 risk: crate::plan::PlanActionRisk::Reversible,
705 depends_on,
706 lock_keys: vec![binding.id.clone()],
707 timeout_seconds: 120,
708 secret_references: Vec::new(),
709 details: json!({
710 "route": SECRET_SINK_ROUTE,
711 "operation": "public-artifact-store",
712 "valueSource": value_source,
713 "expectedPreviousSha256": expected_previous_sha256,
714 "binding": {
715 "id": binding.id,
716 "resolver": binding.resolver,
717 "reference": binding.reference,
718 }
719 }),
720 }
721}
722
723#[allow(clippy::too_many_arguments)]
724pub fn secret_sink_recipient_policy_action(
725 id: impl Into<String>,
726 binding: &SecretBinding,
727 depends_on: Vec<String>,
728 identity_id: String,
729 recipient_alias: &str,
730 creation_rule_path_regex: &str,
731 age_recipient_source: String,
732 inspection: nxd_secret_protocol::v1::InspectRecipientPolicyResponse,
733 expected_previous_age_recipient: Option<&str>,
734) -> PlanAction {
735 PlanAction {
736 id: id.into(),
737 provider_instance: format!("secret/{}", binding.resolver),
738 resource: identity_id.clone(),
739 operation: crate::plan::PlanActionOperation::Update,
740 risk: crate::plan::PlanActionRisk::IdentityCritical,
741 depends_on,
742 lock_keys: vec![format!("recipient-policy/{}", binding.resolver)],
743 timeout_seconds: 300,
744 secret_references: Vec::new(),
745 details: json!({
746 "route": SECRET_SINK_ROUTE,
747 "operation": "recipient-policy",
748 "identityId": identity_id,
749 "recipientAlias": recipient_alias,
750 "creationRulePathRegex": creation_rule_path_regex,
751 "ageRecipientSource": age_recipient_source,
752 "expectedPolicySha256": inspection.policy_sha256,
753 "expectedPreviousAgeRecipient": expected_previous_age_recipient,
754 "documents": inspection.documents.iter().map(|document| json!({
755 "reference": document.reference,
756 "sha256": document.sha256,
757 })).collect::<Vec<_>>(),
758 "binding": {
759 "id": binding.id,
760 "resolver": binding.resolver,
761 "reference": binding.reference,
762 }
763 }),
764 }
765}
766
767pub(crate) fn recipient_policy_document_digest_binding(action_id: &str, reference: &str) -> String {
768 format!("run/{action_id}/documentSha256/{reference}")
769}
770
771fn binding_from_action_details(
772 action: &PlanAction,
773 config: &CanonicalConfig,
774) -> Result<SecretBinding, NxdError> {
775 if let Some(object) = action.details.get("binding") {
776 let id = object
777 .get("id")
778 .and_then(Value::as_str)
779 .ok_or_else(|| NxdError::InvalidConfig("secret binding id missing".into()))?
780 .to_string();
781 let resolver = object
782 .get("resolver")
783 .and_then(Value::as_str)
784 .ok_or_else(|| NxdError::InvalidConfig("secret binding resolver missing".into()))?
785 .to_string();
786 let reference = object
787 .get("reference")
788 .and_then(Value::as_str)
789 .ok_or_else(|| NxdError::InvalidConfig("secret binding reference missing".into()))?
790 .to_string();
791 return Ok(SecretBinding { id, resolver, reference });
792 }
793 let id = action.secret_references.first().ok_or_else(|| {
794 NxdError::InvalidConfig(format!("secret-sink action {} missing binding", action.id))
795 })?;
796 config
797 .spec
798 .secret_bindings
799 .iter()
800 .find(|binding| binding.id == *id)
801 .cloned()
802 .ok_or_else(|| NxdError::InvalidConfig(format!("unknown secret binding {id}")))
803}
804
805fn map_error(error: SecretRuntimeError) -> NxdError {
806 match error {
807 SecretRuntimeError::InvalidCommand
808 | SecretRuntimeError::Incompatible
809 | SecretRuntimeError::Protocol
810 | SecretRuntimeError::CapabilityUnavailable(_)
811 | SecretRuntimeError::Unsupported => NxdError::InvalidConfig(error.to_string()),
812 SecretRuntimeError::PermissionDenied => NxdError::PolicyRejected(error.to_string()),
815 SecretRuntimeError::NotFound => NxdError::PolicyRejected(format!(
816 "secret binding not found (use ensure path for optional absence): {error}"
817 )),
818 SecretRuntimeError::AlreadyExists => NxdError::PolicyRejected(error.to_string()),
819 other => NxdError::Io(other.to_string()),
820 }
821}
822
823#[cfg(test)]
824#[path = "secret_mediation_tests.rs"]
825mod tests;