Skip to main content

nxd_core/application/
secret_mediation.rs

1//! Action-scoped secret mediation and confidential output routing.
2//!
3//! Secrets are resolved only for the currently approved action identity through
4//! the composed external secret executable. Store/delete are planned sink
5//! actions with the same binding contract. Confidential provider outputs are
6//! held only in the run scope and routed to a declared sink or dependent action.
7
8use 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/// Run-scoped confidential values. Dropped when the apply run completes.
34#[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
110/// Resolve secrets declared on an approved action for immediate provider apply.
111pub 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
192/// Resolve a binding for ensure/create planning.
193///
194/// Returns `Ok(None)` **only** for explicit NotFound (binding absence).
195/// Permission, decryption, transport, timeout, cancellation, and protocol
196/// failures return `Err` and must not authorize mint/overwrite (prompt 03.1 / D.2).
197pub 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
220/// Inspect whether a logical binding exists without resolving or returning its
221/// confidential value. Identity planning uses this metadata to distinguish a
222/// first create from steady-state publication.
223pub 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
496/// Route declared confidential outputs after a provider action succeeds.
497pub 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
505/// Route the exact declared confidential and public outputs into the bounded
506/// action run scope. Public identity artifacts are kept separate on the wire
507/// so a provider cannot disguise private bytes as public output.
508pub 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		// NotFound is not a soft-success for hard resolves; optional paths must use
813		// resolve_binding_optional. Permission never looks like absence.
814		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;