1use std::collections::{BTreeMap, BTreeSet};
2
3use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64};
4use ed25519_dalek::{Signature, Signer as _, SigningKey, Verifier as _, VerifyingKey};
5use schemars::JsonSchema;
6use serde::{Deserialize, Serialize};
7use utoipa::ToSchema;
8
9use crate::extraction_input_digest;
10
11use super::{
12 ConfigRevision, DeliveryDecision, DeliveryEffects, DeliveryIssue, DeliveryIssueCode,
13 DeliveryPolicyInputs, DeploymentApplyRejection, DeploymentObservation, DeploymentPlan,
14 DeploymentReceipt, DeploymentState, GatewayConfigurationPlan, GatewayObservation,
15 GatewayObservationProvider, PolicyEvidence, ReleaseTrustEvidence, ReleaseTrustProvider,
16 SecretProvider, ServiceRelease, apply_deployment, config_revision_integrity_is_valid,
17 deployment_observation_integrity_is_valid, deployment_plan_integrity_is_valid,
18 deployment_receipt_integrity_is_valid, gateway_observation_integrity_is_valid,
19 gateway_plan_authority_is_valid, gateway_plan_integrity_is_valid, issue,
20 production_policy_evidence_is_valid, release_trust_evidence_integrity_is_valid,
21 service_release_integrity_is_valid,
22};
23
24pub const ENVIRONMENT_VERIFICATION_PROTOCOL: &str = "lenso.environment-verification.v1";
25pub const PROMOTION_PLAN_PROTOCOL: &str = "lenso.promotion-plan.v1";
26pub const PROMOTION_APPROVAL_PROTOCOL: &str = "lenso.promotion-approval.v1";
27pub const PROMOTION_RECEIPT_PROTOCOL: &str = "lenso.promotion-receipt.v1";
28pub const OPERATOR_OBSERVATION_CLAIMS_PROTOCOL: &str = "lenso.operator-observation-claims.v1";
29
30pub trait OperatorObservationAuthorityProvider: std::fmt::Debug + Send + Sync {
31 fn sign(&self, authority_id: &str, observation_digest: &str) -> Option<String>;
32
33 fn verify(&self, authority_id: &str, observation_digest: &str, proof: &str) -> bool;
34}
35
36#[derive(Debug, Clone, Default)]
37pub struct DeterministicOperatorObservationAuthorityProvider {
38 authority_keys: BTreeMap<String, String>,
39}
40
41impl DeterministicOperatorObservationAuthorityProvider {
42 #[must_use]
43 pub fn new<I, K, V>(authority_keys: I) -> Self
44 where
45 I: IntoIterator<Item = (K, V)>,
46 K: Into<String>,
47 V: Into<String>,
48 {
49 Self {
50 authority_keys: authority_keys
51 .into_iter()
52 .map(|(authority, key)| (authority.into(), key.into()))
53 .collect(),
54 }
55 }
56
57 fn expected_proof(&self, authority_id: &str, observation_digest: &str) -> Option<String> {
58 let key = self.authority_keys.get(authority_id)?;
59 Some(digest_json(&(
60 "lenso.operator-observation-authority-proof.v1",
61 authority_id,
62 observation_digest,
63 key.as_str(),
64 )))
65 }
66}
67
68impl OperatorObservationAuthorityProvider for DeterministicOperatorObservationAuthorityProvider {
69 fn sign(&self, authority_id: &str, observation_digest: &str) -> Option<String> {
70 self.expected_proof(authority_id, observation_digest)
71 }
72
73 fn verify(&self, authority_id: &str, observation_digest: &str, proof: &str) -> bool {
74 self.expected_proof(authority_id, observation_digest)
75 .as_deref()
76 == Some(proof)
77 }
78}
79
80#[derive(Debug, Clone, Default)]
83pub struct Ed25519OperatorObservationAuthorityProvider {
84 authority_keys: BTreeMap<String, VerifyingKey>,
85 signing_keys: BTreeMap<String, SigningKey>,
86}
87
88impl Ed25519OperatorObservationAuthorityProvider {
89 pub fn from_base64_public_keys<I, K, V>(authority_keys: I) -> Result<Self, String>
90 where
91 I: IntoIterator<Item = (K, V)>,
92 K: Into<String>,
93 V: AsRef<str>,
94 {
95 let mut parsed = BTreeMap::new();
96 for (authority_id, encoded) in authority_keys {
97 let bytes = BASE64
98 .decode(encoded.as_ref())
99 .map_err(|error| format!("invalid Ed25519 public key encoding: {error}"))?;
100 let bytes: [u8; 32] = bytes
101 .try_into()
102 .map_err(|_| "Ed25519 public keys must contain exactly 32 bytes".to_owned())?;
103 let key = VerifyingKey::from_bytes(&bytes)
104 .map_err(|error| format!("invalid Ed25519 public key: {error}"))?;
105 parsed.insert(authority_id.into(), key);
106 }
107 Ok(Self {
108 authority_keys: parsed,
109 signing_keys: BTreeMap::new(),
110 })
111 }
112
113 pub fn from_base64_private_keys<I, K, V>(authority_keys: I) -> Result<Self, String>
114 where
115 I: IntoIterator<Item = (K, V)>,
116 K: Into<String>,
117 V: AsRef<str>,
118 {
119 let mut signing_keys = BTreeMap::new();
120 let mut verifying_keys = BTreeMap::new();
121 for (authority_id, encoded) in authority_keys {
122 let authority_id = authority_id.into();
123 let bytes = BASE64
124 .decode(encoded.as_ref())
125 .map_err(|error| format!("invalid Ed25519 private key encoding: {error}"))?;
126 let bytes: [u8; 32] = bytes
127 .try_into()
128 .map_err(|_| "Ed25519 private keys must contain exactly 32 bytes".to_owned())?;
129 let signing_key = SigningKey::from_bytes(&bytes);
130 verifying_keys.insert(authority_id.clone(), signing_key.verifying_key());
131 signing_keys.insert(authority_id, signing_key);
132 }
133 Ok(Self {
134 authority_keys: verifying_keys,
135 signing_keys,
136 })
137 }
138}
139
140impl OperatorObservationAuthorityProvider for Ed25519OperatorObservationAuthorityProvider {
141 fn sign(&self, authority_id: &str, observation_digest: &str) -> Option<String> {
142 self.signing_keys
143 .get(authority_id)
144 .map(|key| BASE64.encode(key.sign(observation_digest.as_bytes()).to_bytes()))
145 }
146
147 fn verify(&self, authority_id: &str, observation_digest: &str, proof: &str) -> bool {
148 let Some(key) = self.authority_keys.get(authority_id) else {
149 return false;
150 };
151 let Ok(bytes) = BASE64.decode(proof) else {
152 return false;
153 };
154 let Ok(signature) = Signature::from_slice(&bytes) else {
155 return false;
156 };
157 key.verify(observation_digest.as_bytes(), &signature)
158 .is_ok()
159 }
160}
161
162#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema, ToSchema)]
163#[serde(rename_all = "camelCase")]
164pub struct OperatorObservationClaims {
165 pub protocol: String,
166 pub service_id: String,
167 pub environment: String,
168 pub deployment_plan_id: String,
169 pub deployment_plan_digest: String,
170 pub expected_environment_revision: u64,
171 pub environment_revision: u64,
172 pub authority_context: String,
173 pub resource_uid: String,
174 pub resource_version: String,
175 pub desired_release_id: String,
176 pub desired_release_digest: String,
177 pub observed_release_id: String,
178 pub observed_release_digest: String,
179 pub desired_workload_digests: BTreeMap<String, String>,
180 pub observed_workload_digests: BTreeMap<String, String>,
181 pub workload_health: BTreeMap<String, bool>,
182 pub config_revision_id: String,
183 pub state: String,
184 pub rollout_phase: String,
185 pub rollback_state: String,
186 pub drifted: bool,
187 pub fresh: bool,
188 pub decision: DeliveryDecision,
189}
190
191#[must_use]
192pub fn operator_observation_claims_digest(claims: &OperatorObservationClaims) -> String {
193 digest_json(claims)
194}
195
196#[must_use]
197pub fn operator_observation_claims_from_deployment(
198 plan: &DeploymentPlan,
199 observation: &DeploymentObservation,
200 workload_health: BTreeMap<String, bool>,
201) -> OperatorObservationClaims {
202 let passed = observation.fresh
203 && !observation.drifted
204 && workload_health.values().all(|healthy| *healthy);
205 OperatorObservationClaims {
206 protocol: OPERATOR_OBSERVATION_CLAIMS_PROTOCOL.to_owned(),
207 service_id: plan.service_id.clone(),
208 environment: observation.environment.clone(),
209 deployment_plan_id: plan.plan_id.clone(),
210 deployment_plan_digest: plan.plan_digest.clone(),
211 expected_environment_revision: plan.expected_environment_revision,
212 environment_revision: plan.expected_environment_revision.saturating_add(1),
213 authority_context: plan.plan_id.clone(),
214 resource_uid: format!("synthetic:{}", plan.plan_id),
215 resource_version: "1".to_owned(),
216 desired_release_id: observation.desired_release_id.clone(),
217 desired_release_digest: plan.release_digest.clone(),
218 observed_release_id: observation.observed_release_id.clone(),
219 observed_release_digest: observation.observed_release_digest.clone(),
220 desired_workload_digests: observation.desired_workload_digests.clone(),
221 observed_workload_digests: observation.observed_workload_digests.clone(),
222 workload_health,
223 config_revision_id: observation.config_revision_id.clone(),
224 state: if passed { "ready" } else { "progressing" }.to_owned(),
225 rollout_phase: if passed { "ready" } else { "observing" }.to_owned(),
226 rollback_state: "available".to_owned(),
227 drifted: observation.drifted,
228 fresh: observation.fresh,
229 decision: if passed {
230 DeliveryDecision::Passed
231 } else {
232 DeliveryDecision::Blocked
233 },
234 }
235}
236
237#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema, ToSchema)]
238#[serde(rename_all = "camelCase")]
239pub struct OperatorObservationAttestation {
240 pub observation_id: String,
241 pub observation_digest: String,
242 pub authority_id: String,
243 pub authority_proof: String,
244 pub claims: OperatorObservationClaims,
245}
246
247pub fn attest_operator_observation(
248 claims: OperatorObservationClaims,
249 authority_id: impl Into<String>,
250 provider: &dyn OperatorObservationAuthorityProvider,
251) -> Result<OperatorObservationAttestation, DeliveryIssue> {
252 let observation_digest = operator_observation_claims_digest(&claims);
253 let authority_id = authority_id.into();
254 let authority_proof = provider
255 .sign(&authority_id, &observation_digest)
256 .ok_or_else(|| {
257 issue(
258 DeliveryIssueCode::ObservationStale,
259 "The Operator adapter authority refused to attest the observation.",
260 "Use the configured Operator observation authority at the adapter read boundary.",
261 "Collect a new Operator observation before verifying the environment.",
262 )
263 })?;
264 Ok(OperatorObservationAttestation {
265 observation_id: format!("operator-observation:{observation_digest}"),
266 observation_digest,
267 authority_id,
268 authority_proof,
269 claims,
270 })
271}
272
273#[must_use]
274pub fn operator_observation_attestation_is_valid(
275 attestation: &OperatorObservationAttestation,
276 provider: &dyn OperatorObservationAuthorityProvider,
277) -> bool {
278 attestation.claims.protocol == OPERATOR_OBSERVATION_CLAIMS_PROTOCOL
279 && attestation.observation_digest == operator_observation_claims_digest(&attestation.claims)
280 && attestation.observation_id
281 == format!("operator-observation:{}", attestation.observation_digest)
282 && provider.verify(
283 &attestation.authority_id,
284 &attestation.observation_digest,
285 &attestation.authority_proof,
286 )
287}
288
289#[must_use]
290pub fn operator_observation_matches_deployment(
291 attestation: &OperatorObservationAttestation,
292 plan: &DeploymentPlan,
293 receipt: &DeploymentReceipt,
294 observation: &DeploymentObservation,
295 workload_health: &BTreeMap<String, bool>,
296) -> bool {
297 let claims = &attestation.claims;
298 claims.protocol == OPERATOR_OBSERVATION_CLAIMS_PROTOCOL
299 && observation.source_observation_id == attestation.observation_id
300 && claims.service_id == plan.service_id
301 && claims.environment == plan.environment
302 && claims.deployment_plan_id == plan.plan_id
303 && claims.deployment_plan_digest == plan.plan_digest
304 && claims.expected_environment_revision == plan.expected_environment_revision
305 && claims.environment_revision == receipt.environment_revision_after
306 && claims.authority_context == plan.plan_id
307 && claims.desired_release_id == observation.desired_release_id
308 && claims.desired_release_digest == plan.release_digest
309 && claims.observed_release_id == observation.observed_release_id
310 && claims.observed_release_digest == observation.observed_release_digest
311 && claims.desired_workload_digests == observation.desired_workload_digests
312 && claims.observed_workload_digests == observation.observed_workload_digests
313 && claims.workload_health == *workload_health
314 && claims.config_revision_id == observation.config_revision_id
315 && claims.drifted == observation.drifted
316 && claims.fresh == observation.fresh
317 && claims.state == "ready"
318 && claims.rollout_phase == "ready"
319 && claims.decision == DeliveryDecision::Passed
320 && claims.fresh
321 && !claims.drifted
322 && claims.workload_health.values().all(|healthy| *healthy)
323}
324
325#[derive(Debug, Clone)]
326pub struct EnvironmentVerificationInput {
327 pub release: ServiceRelease,
328 pub trust: ReleaseTrustEvidence,
329 pub policy: PolicyEvidence,
330 pub policy_inputs: DeliveryPolicyInputs,
331 pub config: ConfigRevision,
332 pub deployment_plan: DeploymentPlan,
333 pub deployment: DeploymentReceipt,
334 pub deployment_observation: DeploymentObservation,
335 pub operator_observation: OperatorObservationAttestation,
336 pub gateway_plan: GatewayConfigurationPlan,
337 pub gateway_observation: GatewayObservation,
338 pub topology_digest: String,
339 pub workload_health: BTreeMap<String, bool>,
340 pub evidence_references: Vec<String>,
341 pub freshness_horizon_revision: u64,
342}
343
344#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema, ToSchema)]
345#[serde(rename_all = "camelCase")]
346pub struct EnvironmentVerification {
347 pub protocol: String,
348 pub verification_id: String,
349 pub verification_digest: String,
350 pub environment: String,
351 pub environment_revision: u64,
352 pub release_id: String,
353 pub release_digest: String,
354 pub workload_digests: BTreeMap<String, String>,
355 pub workload_health: BTreeMap<String, bool>,
356 pub config_revision_id: String,
357 pub trust_evidence_digest: String,
358 pub policy_evidence_id: String,
359 pub policy_evidence_digest: String,
360 pub deployment_plan_id: String,
361 pub deployment_plan_digest: String,
362 pub deployment_receipt_id: String,
363 pub deployment_observation_id: String,
364 pub operator_observation_id: String,
365 pub operator_observation_digest: String,
366 pub operator_observation_authority_id: String,
367 pub operator_observation_authority_proof: String,
368 pub operator_observation_claims: OperatorObservationClaims,
369 pub gateway_plan_id: String,
370 pub gateway_plan_digest: String,
371 pub gateway_observation_id: String,
372 pub gateway_resource_uid: String,
373 pub gateway_resource_version: String,
374 pub gateway_authority_context: String,
375 pub gateway_configuration_identity: String,
376 pub gateway_observation_revision: u64,
377 pub gateway_observation_observed_after: String,
378 pub gateway_observation_fresh: bool,
379 pub gateway_observation_provider_id: String,
380 pub gateway_observation_provider_proof: String,
381 pub topology_digest: String,
382 pub evidence_references: Vec<String>,
383 pub freshness_horizon_revision: u64,
384 pub decision: DeliveryDecision,
385 pub issues: Vec<DeliveryIssue>,
386 pub effects: DeliveryEffects,
387}
388
389#[derive(Serialize)]
390#[serde(rename_all = "camelCase")]
391struct EnvironmentVerificationDigestInput<'a> {
392 protocol: &'a str,
393 environment: &'a str,
394 environment_revision: u64,
395 release_id: &'a str,
396 release_digest: &'a str,
397 workload_digests: &'a BTreeMap<String, String>,
398 workload_health: &'a BTreeMap<String, bool>,
399 config_revision_id: &'a str,
400 trust_evidence_digest: &'a str,
401 policy_evidence_id: &'a str,
402 policy_evidence_digest: &'a str,
403 deployment_plan_id: &'a str,
404 deployment_plan_digest: &'a str,
405 deployment_receipt_id: &'a str,
406 deployment_observation_id: &'a str,
407 operator_observation_id: &'a str,
408 operator_observation_digest: &'a str,
409 operator_observation_authority_id: &'a str,
410 operator_observation_authority_proof: &'a str,
411 operator_observation_claims: &'a OperatorObservationClaims,
412 gateway_plan_id: &'a str,
413 gateway_plan_digest: &'a str,
414 gateway_observation_id: &'a str,
415 gateway_resource_uid: &'a str,
416 gateway_resource_version: &'a str,
417 gateway_authority_context: &'a str,
418 gateway_configuration_identity: &'a str,
419 gateway_observation_revision: u64,
420 gateway_observation_observed_after: &'a str,
421 gateway_observation_fresh: bool,
422 gateway_observation_provider_id: &'a str,
423 gateway_observation_provider_proof: &'a str,
424 topology_digest: &'a str,
425 evidence_references: &'a [String],
426 freshness_horizon_revision: u64,
427 decision: DeliveryDecision,
428 issues: &'a [DeliveryIssue],
429}
430
431#[must_use]
432pub fn verify_staging_environment(
433 mut input: EnvironmentVerificationInput,
434 trust_provider: &dyn ReleaseTrustProvider,
435 secret_provider: &dyn SecretProvider,
436 operator_observation_provider: &dyn OperatorObservationAuthorityProvider,
437 gateway_observation_provider: &dyn GatewayObservationProvider,
438) -> EnvironmentVerification {
439 input.evidence_references.sort();
440 let mut issues = Vec::new();
441 if !service_release_integrity_is_valid(&input.release)
442 || !release_trust_evidence_integrity_is_valid(&input.trust, &input.release, trust_provider)
443 || input.trust.decision != DeliveryDecision::Passed
444 {
445 issues.push(issue(
446 DeliveryIssueCode::PolicyEvidenceMissing,
447 "Staging verification requires an integrity-valid and trusted Service Release.",
448 "Verify the exact signed release before deployment.",
449 "Refresh release trust evidence and verify staging again.",
450 ));
451 }
452 if input.policy_inputs.release != input.release
453 || input.policy_inputs.trust != input.trust
454 || input.policy_inputs.config != input.config
455 || !production_policy_evidence_is_valid(
456 &input.policy,
457 &input.policy_inputs,
458 trust_provider,
459 secret_provider,
460 )
461 || input.policy.decision != DeliveryDecision::Passed
462 || input.policy.evaluated_subject != input.release.release_id
463 || !deployment_binds_policy_evidence(&input.deployment_plan, &input.policy)
464 {
465 issues.push(issue(
466 DeliveryIssueCode::PolicyRuleBlocked,
467 "Staging verification requires passing Policy Evidence for the exact release.",
468 "Evaluate the selected Policy Pack over current canonical inputs.",
469 "Refresh Policy Evidence and verify staging again.",
470 ));
471 }
472 if !config_revision_integrity_is_valid(&input.config)
473 || input.config.contract_digest != input.release.config_contract.digest
474 || input.config.service_id != input.release.service_id
475 || input.deployment.config_revision_id != input.config.revision_id
476 {
477 issues.push(issue(
478 DeliveryIssueCode::ConfigContractMismatch,
479 "The observed Deployment does not use the validated Config Revision.",
480 "Deploy the exact validated Config Revision and opaque Secret References.",
481 "Correct configuration drift and verify staging again.",
482 ));
483 }
484 if !deployment_plan_integrity_is_valid(&input.deployment_plan)
485 || !deployment_receipt_integrity_is_valid(&input.deployment, &input.deployment_plan)
486 || !deployment_observation_integrity_is_valid(
487 &input.deployment_observation,
488 &input.deployment_plan,
489 &input.deployment,
490 )
491 || input.deployment_plan.release_id != input.release.release_id
492 || input.deployment_plan.release_digest != input.release.release_digest
493 || input.deployment_plan.config_revision_id != input.config.revision_id
494 || input.deployment_plan.gateway_plan_digest != input.gateway_plan.plan_digest
495 || input.deployment_observation.observed_release_digest != input.release.release_digest
496 || input.deployment_observation.drifted
497 || !input.deployment_observation.fresh
498 || !operator_observation_attestation_is_valid(
499 &input.operator_observation,
500 operator_observation_provider,
501 )
502 || !operator_observation_matches_deployment(
503 &input.operator_observation,
504 &input.deployment_plan,
505 &input.deployment,
506 &input.deployment_observation,
507 &input.workload_health,
508 )
509 || !operator_observation_evidence_is_bound(
510 &input.operator_observation,
511 &input.evidence_references,
512 )
513 {
514 issues.push(issue(
515 DeliveryIssueCode::ObservationStale,
516 "Staging Deployment observations are stale, drifted, or identify another release.",
517 "Refresh adapter observations after the exact release converges.",
518 "Reconcile staging and collect fresh Deployment evidence.",
519 ));
520 }
521 if !gateway_plan_authority_is_valid(&input.gateway_plan, trust_provider)
522 || input.gateway_plan.edge_release_id != input.release.release_id
523 || input.gateway_plan.edge_release_digest != input.release.release_digest
524 || !gateway_observation_integrity_is_valid(
525 &input.gateway_observation,
526 gateway_observation_provider,
527 )
528 || input.gateway_observation.plan_id != input.gateway_plan.plan_id
529 || input.gateway_observation.plan_digest != input.gateway_plan.plan_digest
530 || input.gateway_observation.environment != input.deployment.environment
531 || input.gateway_observation.release_id != input.release.release_id
532 || input.gateway_observation.release_digest != input.release.release_digest
533 || input.gateway_observation.resource_uid.trim().is_empty()
534 || input.gateway_observation.resource_version.trim().is_empty()
535 || input.gateway_observation.authority_context != input.gateway_plan.plan_id
536 || input.gateway_observation.configuration_identity
537 != input.gateway_plan.configuration_identity
538 || input.gateway_observation.revision != input.gateway_plan.expected_gateway_revision
539 || input.gateway_observation.observed_after
540 != input.deployment_observation.source_observation_id
541 || !input.gateway_observation.fresh
542 {
543 issues.push(issue(
544 DeliveryIssueCode::EdgeExposureUnsafe,
545 "Gateway observations do not match the planned Edge configuration.",
546 "Reconcile the exact Edge Contract and collect a fresh gateway observation.",
547 "Correct gateway drift and verify staging again.",
548 ));
549 }
550 let release_workloads = input
551 .release
552 .workloads
553 .iter()
554 .map(|workload| workload.workload_id.as_str())
555 .collect::<std::collections::BTreeSet<_>>();
556 if release_workloads.iter().any(|workload_id| {
557 !input
558 .workload_health
559 .get(*workload_id)
560 .copied()
561 .unwrap_or(false)
562 }) {
563 issues.push(issue(
564 DeliveryIssueCode::ObservationStale,
565 "One or more release Workloads lack fresh healthy staging observations.",
566 "Wait for Migration, API, and Worker convergence and collect Service-level health evidence.",
567 "Refresh Workload observations before Promotion.",
568 ));
569 }
570 if input.freshness_horizon_revision <= input.deployment.environment_revision_after {
571 issues.push(issue(
572 DeliveryIssueCode::ObservationStale,
573 "Environment Verification freshness horizon is already exhausted.",
574 "Choose a horizon beyond the observed environment revision.",
575 "Collect fresh staging evidence.",
576 ));
577 }
578 let workload_digests = input.deployment.workload_digests.clone();
579 let decision = if issues.is_empty() {
580 DeliveryDecision::Passed
581 } else {
582 DeliveryDecision::Blocked
583 };
584 let verification_digest = digest_json(&EnvironmentVerificationDigestInput {
585 protocol: ENVIRONMENT_VERIFICATION_PROTOCOL,
586 environment: input.deployment.environment.as_str(),
587 environment_revision: input.deployment.environment_revision_after,
588 release_id: input.release.release_id.as_str(),
589 release_digest: input.release.release_digest.as_str(),
590 workload_digests: &workload_digests,
591 workload_health: &input.workload_health,
592 config_revision_id: input.config.revision_id.as_str(),
593 trust_evidence_digest: input.trust.evidence_digest.as_str(),
594 policy_evidence_id: input.policy.evidence_id.as_str(),
595 policy_evidence_digest: input.policy.evidence_digest.as_str(),
596 deployment_plan_id: input.deployment_plan.plan_id.as_str(),
597 deployment_plan_digest: input.deployment_plan.plan_digest.as_str(),
598 deployment_receipt_id: input.deployment.receipt_id.as_str(),
599 deployment_observation_id: input.deployment_observation.observation_id.as_str(),
600 operator_observation_id: input.operator_observation.observation_id.as_str(),
601 operator_observation_digest: input.operator_observation.observation_digest.as_str(),
602 operator_observation_authority_id: input.operator_observation.authority_id.as_str(),
603 operator_observation_authority_proof: input.operator_observation.authority_proof.as_str(),
604 operator_observation_claims: &input.operator_observation.claims,
605 gateway_plan_id: input.gateway_plan.plan_id.as_str(),
606 gateway_plan_digest: input.gateway_plan.plan_digest.as_str(),
607 gateway_observation_id: input.gateway_observation.observation_id.as_str(),
608 gateway_resource_uid: input.gateway_observation.resource_uid.as_str(),
609 gateway_resource_version: input.gateway_observation.resource_version.as_str(),
610 gateway_authority_context: input.gateway_observation.authority_context.as_str(),
611 gateway_configuration_identity: input.gateway_observation.configuration_identity.as_str(),
612 gateway_observation_revision: input.gateway_observation.revision,
613 gateway_observation_observed_after: input.gateway_observation.observed_after.as_str(),
614 gateway_observation_fresh: input.gateway_observation.fresh,
615 gateway_observation_provider_id: input.gateway_observation.provider_id.as_str(),
616 gateway_observation_provider_proof: input.gateway_observation.provider_proof.as_str(),
617 topology_digest: input.topology_digest.as_str(),
618 evidence_references: input.evidence_references.as_slice(),
619 freshness_horizon_revision: input.freshness_horizon_revision,
620 decision,
621 issues: issues.as_slice(),
622 });
623 EnvironmentVerification {
624 protocol: ENVIRONMENT_VERIFICATION_PROTOCOL.to_owned(),
625 verification_id: format!("environment-verification:{verification_digest}"),
626 verification_digest,
627 environment: input.deployment.environment,
628 environment_revision: input.deployment.environment_revision_after,
629 release_id: input.release.release_id,
630 release_digest: input.release.release_digest,
631 workload_digests,
632 workload_health: input.workload_health,
633 config_revision_id: input.config.revision_id,
634 trust_evidence_digest: input.trust.evidence_digest,
635 policy_evidence_id: input.policy.evidence_id,
636 policy_evidence_digest: input.policy.evidence_digest,
637 deployment_plan_id: input.deployment_plan.plan_id,
638 deployment_plan_digest: input.deployment_plan.plan_digest,
639 deployment_receipt_id: input.deployment.receipt_id,
640 deployment_observation_id: input.deployment_observation.observation_id,
641 operator_observation_id: input.operator_observation.observation_id,
642 operator_observation_digest: input.operator_observation.observation_digest,
643 operator_observation_authority_id: input.operator_observation.authority_id,
644 operator_observation_authority_proof: input.operator_observation.authority_proof,
645 operator_observation_claims: input.operator_observation.claims,
646 gateway_plan_id: input.gateway_plan.plan_id,
647 gateway_plan_digest: input.gateway_plan.plan_digest,
648 gateway_observation_id: input.gateway_observation.observation_id,
649 gateway_resource_uid: input.gateway_observation.resource_uid,
650 gateway_resource_version: input.gateway_observation.resource_version,
651 gateway_authority_context: input.gateway_observation.authority_context,
652 gateway_configuration_identity: input.gateway_observation.configuration_identity,
653 gateway_observation_revision: input.gateway_observation.revision,
654 gateway_observation_observed_after: input.gateway_observation.observed_after,
655 gateway_observation_fresh: input.gateway_observation.fresh,
656 gateway_observation_provider_id: input.gateway_observation.provider_id,
657 gateway_observation_provider_proof: input.gateway_observation.provider_proof,
658 topology_digest: input.topology_digest,
659 evidence_references: input.evidence_references,
660 freshness_horizon_revision: input.freshness_horizon_revision,
661 decision,
662 issues,
663 effects: DeliveryEffects::default(),
664 }
665}
666
667#[must_use]
668pub fn environment_verification_digest(verification: &EnvironmentVerification) -> String {
669 digest_json(&EnvironmentVerificationDigestInput {
670 protocol: verification.protocol.as_str(),
671 environment: verification.environment.as_str(),
672 environment_revision: verification.environment_revision,
673 release_id: verification.release_id.as_str(),
674 release_digest: verification.release_digest.as_str(),
675 workload_digests: &verification.workload_digests,
676 workload_health: &verification.workload_health,
677 config_revision_id: verification.config_revision_id.as_str(),
678 trust_evidence_digest: verification.trust_evidence_digest.as_str(),
679 policy_evidence_id: verification.policy_evidence_id.as_str(),
680 policy_evidence_digest: verification.policy_evidence_digest.as_str(),
681 deployment_plan_id: verification.deployment_plan_id.as_str(),
682 deployment_plan_digest: verification.deployment_plan_digest.as_str(),
683 deployment_receipt_id: verification.deployment_receipt_id.as_str(),
684 deployment_observation_id: verification.deployment_observation_id.as_str(),
685 operator_observation_id: verification.operator_observation_id.as_str(),
686 operator_observation_digest: verification.operator_observation_digest.as_str(),
687 operator_observation_authority_id: verification.operator_observation_authority_id.as_str(),
688 operator_observation_authority_proof: verification
689 .operator_observation_authority_proof
690 .as_str(),
691 operator_observation_claims: &verification.operator_observation_claims,
692 gateway_plan_id: verification.gateway_plan_id.as_str(),
693 gateway_plan_digest: verification.gateway_plan_digest.as_str(),
694 gateway_observation_id: verification.gateway_observation_id.as_str(),
695 gateway_resource_uid: verification.gateway_resource_uid.as_str(),
696 gateway_resource_version: verification.gateway_resource_version.as_str(),
697 gateway_authority_context: verification.gateway_authority_context.as_str(),
698 gateway_configuration_identity: verification.gateway_configuration_identity.as_str(),
699 gateway_observation_revision: verification.gateway_observation_revision,
700 gateway_observation_observed_after: verification
701 .gateway_observation_observed_after
702 .as_str(),
703 gateway_observation_fresh: verification.gateway_observation_fresh,
704 gateway_observation_provider_id: verification.gateway_observation_provider_id.as_str(),
705 gateway_observation_provider_proof: verification
706 .gateway_observation_provider_proof
707 .as_str(),
708 topology_digest: verification.topology_digest.as_str(),
709 evidence_references: verification.evidence_references.as_slice(),
710 freshness_horizon_revision: verification.freshness_horizon_revision,
711 decision: verification.decision,
712 issues: verification.issues.as_slice(),
713 })
714}
715
716#[must_use]
717pub fn environment_verification_integrity_is_valid(verification: &EnvironmentVerification) -> bool {
718 verification.protocol == ENVIRONMENT_VERIFICATION_PROTOCOL
719 && verification.verification_id
720 == format!(
721 "environment-verification:{}",
722 verification.verification_digest
723 )
724 && environment_verification_digest(verification) == verification.verification_digest
725 && verification.decision
726 == if verification.issues.is_empty() {
727 DeliveryDecision::Passed
728 } else {
729 DeliveryDecision::Blocked
730 }
731 && verification.effects == DeliveryEffects::default()
732}
733
734fn operator_observation_evidence_is_bound(
735 observation: &OperatorObservationAttestation,
736 evidence_references: &[String],
737) -> bool {
738 let expected = [
739 observation.observation_id.clone(),
740 observation.observation_digest.clone(),
741 format!(
742 "operator-observation-authority:{}",
743 observation.authority_id
744 ),
745 format!("operator-observation-proof:{}", observation.authority_proof),
746 ];
747 expected
748 .iter()
749 .all(|reference| evidence_references.contains(reference))
750}
751
752#[must_use]
753pub fn environment_verification_authority_is_valid(
754 verification: &EnvironmentVerification,
755 operator_provider: &dyn OperatorObservationAuthorityProvider,
756 gateway_provider: &dyn GatewayObservationProvider,
757) -> bool {
758 let operator_observation = OperatorObservationAttestation {
759 observation_id: verification.operator_observation_id.clone(),
760 observation_digest: verification.operator_observation_digest.clone(),
761 authority_id: verification.operator_observation_authority_id.clone(),
762 authority_proof: verification.operator_observation_authority_proof.clone(),
763 claims: verification.operator_observation_claims.clone(),
764 };
765 let gateway_observation = GatewayObservation {
766 protocol: super::GATEWAY_OBSERVATION_PROTOCOL.to_owned(),
767 observation_id: verification.gateway_observation_id.clone(),
768 plan_id: verification.gateway_plan_id.clone(),
769 plan_digest: verification.gateway_plan_digest.clone(),
770 environment: verification.environment.clone(),
771 release_id: verification.release_id.clone(),
772 release_digest: verification.release_digest.clone(),
773 resource_uid: verification.gateway_resource_uid.clone(),
774 resource_version: verification.gateway_resource_version.clone(),
775 authority_context: verification.gateway_authority_context.clone(),
776 configuration_identity: verification.gateway_configuration_identity.clone(),
777 revision: verification.gateway_observation_revision,
778 observed_after: verification.gateway_observation_observed_after.clone(),
779 fresh: verification.gateway_observation_fresh,
780 provider_id: verification.gateway_observation_provider_id.clone(),
781 provider_proof: verification.gateway_observation_provider_proof.clone(),
782 };
783 environment_verification_integrity_is_valid(verification)
784 && operator_observation_attestation_is_valid(&operator_observation, operator_provider)
785 && verification.operator_observation_claims.environment == verification.environment
786 && verification
787 .operator_observation_claims
788 .environment_revision
789 == verification.environment_revision
790 && verification.operator_observation_claims.authority_context
791 == verification.deployment_plan_id
792 && verification.operator_observation_claims.deployment_plan_id
793 == verification.deployment_plan_id
794 && verification
795 .operator_observation_claims
796 .deployment_plan_digest
797 == verification.deployment_plan_digest
798 && verification.operator_observation_claims.desired_release_id == verification.release_id
799 && verification
800 .operator_observation_claims
801 .desired_release_digest
802 == verification.release_digest
803 && verification.operator_observation_claims.observed_release_id == verification.release_id
804 && verification
805 .operator_observation_claims
806 .observed_release_digest
807 == verification.release_digest
808 && verification
809 .operator_observation_claims
810 .desired_workload_digests
811 == verification.workload_digests
812 && verification
813 .operator_observation_claims
814 .observed_workload_digests
815 == verification.workload_digests
816 && verification.operator_observation_claims.workload_health == verification.workload_health
817 && verification.operator_observation_claims.config_revision_id
818 == verification.config_revision_id
819 && verification.operator_observation_claims.state == "ready"
820 && verification.operator_observation_claims.rollout_phase == "ready"
821 && verification.operator_observation_claims.decision == DeliveryDecision::Passed
822 && verification.operator_observation_claims.fresh
823 && !verification.operator_observation_claims.drifted
824 && operator_observation_evidence_is_bound(
825 &operator_observation,
826 &verification.evidence_references,
827 )
828 && gateway_observation_integrity_is_valid(&gateway_observation, gateway_provider)
829 && gateway_observation.plan_id == verification.gateway_plan_id
830 && gateway_observation.plan_digest == verification.gateway_plan_digest
831 && gateway_observation.environment == verification.environment
832 && gateway_observation.release_id == verification.release_id
833 && gateway_observation.release_digest == verification.release_digest
834 && !gateway_observation.resource_uid.trim().is_empty()
835 && !gateway_observation.resource_version.trim().is_empty()
836 && gateway_observation.authority_context == verification.gateway_plan_id
837 && gateway_observation.observed_after == verification.operator_observation_id
838 && gateway_observation.revision == verification.gateway_observation_revision
839 && gateway_observation.fresh
840}
841
842#[derive(Debug, Clone)]
843pub struct PromotionPlanInput {
844 pub source: EnvironmentVerification,
845 pub target_deployment: DeploymentPlan,
846 pub target_gateway: GatewayConfigurationPlan,
847 pub policy: PolicyEvidence,
848 pub policy_inputs: DeliveryPolicyInputs,
849 pub source_environment_revision: u64,
850 pub target_environment_revision: u64,
851 pub target_topology_digest: String,
852 pub secret_reference_ids: Vec<String>,
853 pub evidence_references: Vec<String>,
854}
855
856#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema, ToSchema)]
857#[serde(rename_all = "camelCase")]
858pub struct PromotionPlan {
859 pub protocol: String,
860 pub plan_id: String,
861 pub plan_digest: String,
862 pub release_id: String,
863 pub release_digest: String,
864 pub workload_digests: BTreeMap<String, String>,
865 pub source_environment: String,
866 pub target_environment: String,
867 pub source_environment_revision: u64,
868 pub target_environment_revision: u64,
869 pub source_verification_id: String,
870 pub source_verification_digest: String,
871 pub policy_evidence_id: String,
872 pub policy_evidence_digest: String,
873 pub config_revision_id: String,
874 pub secret_reference_ids: Vec<String>,
875 pub target_deployment: DeploymentPlan,
876 pub target_gateway: GatewayConfigurationPlan,
877 pub source_topology_digest: String,
878 pub target_topology_digest: String,
879 pub evidence_references: Vec<String>,
880 pub freshness_horizon_revision: u64,
881 pub effects: DeliveryEffects,
882}
883
884#[derive(Serialize)]
885#[serde(rename_all = "camelCase")]
886struct PromotionPlanDigestInput<'a> {
887 protocol: &'a str,
888 release_id: &'a str,
889 release_digest: &'a str,
890 workload_digests: &'a BTreeMap<String, String>,
891 source_environment: &'a str,
892 target_environment: &'a str,
893 source_environment_revision: u64,
894 target_environment_revision: u64,
895 source_verification_id: &'a str,
896 source_verification_digest: &'a str,
897 policy_evidence_id: &'a str,
898 policy_evidence_digest: &'a str,
899 config_revision_id: &'a str,
900 secret_reference_ids: &'a [String],
901 target_deployment_digest: &'a str,
902 target_gateway_digest: &'a str,
903 source_topology_digest: &'a str,
904 target_topology_digest: &'a str,
905 evidence_references: &'a [String],
906 freshness_horizon_revision: u64,
907 effects: &'a DeliveryEffects,
908}
909
910pub fn plan_promotion(
911 mut input: PromotionPlanInput,
912 trust_provider: &dyn ReleaseTrustProvider,
913 secret_provider: &dyn SecretProvider,
914 operator_observation_provider: &dyn OperatorObservationAuthorityProvider,
915 gateway_observation_provider: &dyn GatewayObservationProvider,
916) -> Result<PromotionPlan, Vec<DeliveryIssue>> {
917 input.secret_reference_ids.sort();
918 input.evidence_references.sort();
919 let mut issues = Vec::new();
920 if !environment_verification_authority_is_valid(
921 &input.source,
922 operator_observation_provider,
923 gateway_observation_provider,
924 ) || input.source.decision != DeliveryDecision::Passed
925 || input.source_environment_revision != input.source.environment_revision
926 {
927 issues.push(issue(
928 DeliveryIssueCode::ObservationStale,
929 "Promotion requires passing source Environment Verification.",
930 "Deploy and verify the exact release in the source environment.",
931 "Refresh source verification and plan Promotion again.",
932 ));
933 }
934 if !deployment_plan_integrity_is_valid(&input.target_deployment)
935 || !gateway_plan_authority_is_valid(&input.target_gateway, trust_provider)
936 || input.target_gateway.edge_release_id != input.source.release_id
937 || input.target_gateway.edge_release_digest != input.source.release_digest
938 || input.target_deployment.gateway_plan_digest != input.target_gateway.plan_digest
939 || input.target_deployment.release_id != input.source.release_id
940 || input.target_deployment.release_digest != input.source.release_digest
941 || input.target_deployment.workloads.iter().any(|workload| {
942 input.source.workload_digests.get(&workload.workload_id)
943 != Some(&workload.artifact_digest)
944 })
945 {
946 issues.push(issue(
947 DeliveryIssueCode::ReleaseTampered,
948 "Promotion target attempts to rebuild, substitute, or change release Workload digests.",
949 "Use the exact source-verified Service Release and immutable Workload digests.",
950 "Regenerate the target Deployment plan without rebuilding.",
951 ));
952 }
953 if input.policy_inputs.release.release_id != input.source.release_id
954 || input.policy_inputs.release.release_digest != input.source.release_digest
955 || !production_policy_evidence_is_valid(
956 &input.policy,
957 &input.policy_inputs,
958 trust_provider,
959 secret_provider,
960 )
961 || input.policy.decision != DeliveryDecision::Passed
962 || input.policy.evaluated_subject != input.source.release_id
963 || !deployment_binds_policy_evidence(&input.target_deployment, &input.policy)
964 {
965 issues.push(issue(
966 DeliveryIssueCode::PolicyRuleBlocked,
967 "Promotion Policy Evidence is blocked, stale, or evaluates another release.",
968 "Evaluate the exact release and target evidence through the production Policy Pack.",
969 "Refresh Policy Evidence and plan Promotion again.",
970 ));
971 }
972 let mut target_secret_references = input.target_deployment.secret_reference_ids.clone();
973 target_secret_references.sort();
974 if input.target_deployment.config_revision_id != input.source.config_revision_id
975 || target_secret_references != input.secret_reference_ids
976 {
977 issues.push(issue(
978 DeliveryIssueCode::ConfigContractMismatch,
979 "Promotion configuration or Secret References differ from the reviewed source binding.",
980 "Bind the exact verified Config Revision and opaque Secret Reference identifiers.",
981 "Regenerate the target Deployment and Promotion plan.",
982 ));
983 }
984 if input.source.environment == input.target_deployment.environment
985 || input.target_deployment.environment != input.target_gateway.environment
986 || input.target_deployment.expected_environment_revision
987 != input.target_environment_revision
988 || input.source.freshness_horizon_revision < input.source_environment_revision
989 {
990 issues.push(issue(
991 DeliveryIssueCode::StaleInput,
992 "Promotion environment, topology, or freshness inputs are inconsistent.",
993 "Refresh source and target revisions and bind both adapters to the intended environment.",
994 "Correct stale inputs and plan Promotion again.",
995 ));
996 }
997 if !issues.is_empty() {
998 return Err(issues);
999 }
1000 let effects = DeliveryEffects::default();
1001 let plan_digest = digest_json(&PromotionPlanDigestInput {
1002 protocol: PROMOTION_PLAN_PROTOCOL,
1003 release_id: input.source.release_id.as_str(),
1004 release_digest: input.source.release_digest.as_str(),
1005 workload_digests: &input.source.workload_digests,
1006 source_environment: input.source.environment.as_str(),
1007 target_environment: input.target_deployment.environment.as_str(),
1008 source_environment_revision: input.source_environment_revision,
1009 target_environment_revision: input.target_environment_revision,
1010 source_verification_id: input.source.verification_id.as_str(),
1011 source_verification_digest: input.source.verification_digest.as_str(),
1012 policy_evidence_id: input.policy.evidence_id.as_str(),
1013 policy_evidence_digest: input.policy.evidence_digest.as_str(),
1014 config_revision_id: input.source.config_revision_id.as_str(),
1015 secret_reference_ids: input.secret_reference_ids.as_slice(),
1016 target_deployment_digest: input.target_deployment.plan_digest.as_str(),
1017 target_gateway_digest: input.target_gateway.plan_digest.as_str(),
1018 source_topology_digest: input.source.topology_digest.as_str(),
1019 target_topology_digest: input.target_topology_digest.as_str(),
1020 evidence_references: input.evidence_references.as_slice(),
1021 freshness_horizon_revision: input.source.freshness_horizon_revision,
1022 effects: &effects,
1023 });
1024 Ok(PromotionPlan {
1025 protocol: PROMOTION_PLAN_PROTOCOL.to_owned(),
1026 plan_id: format!("promotion-plan:{plan_digest}"),
1027 plan_digest,
1028 release_id: input.source.release_id,
1029 release_digest: input.source.release_digest,
1030 workload_digests: input.source.workload_digests,
1031 source_environment: input.source.environment,
1032 target_environment: input.target_deployment.environment.clone(),
1033 source_environment_revision: input.source_environment_revision,
1034 target_environment_revision: input.target_environment_revision,
1035 source_verification_id: input.source.verification_id,
1036 source_verification_digest: input.source.verification_digest,
1037 policy_evidence_id: input.policy.evidence_id,
1038 policy_evidence_digest: input.policy.evidence_digest,
1039 config_revision_id: input.source.config_revision_id,
1040 secret_reference_ids: input.secret_reference_ids,
1041 target_deployment: input.target_deployment,
1042 target_gateway: input.target_gateway,
1043 source_topology_digest: input.source.topology_digest,
1044 target_topology_digest: input.target_topology_digest,
1045 evidence_references: input.evidence_references,
1046 freshness_horizon_revision: input.source.freshness_horizon_revision,
1047 effects,
1048 })
1049}
1050
1051fn deployment_binds_policy_evidence(plan: &DeploymentPlan, policy: &PolicyEvidence) -> bool {
1052 let expected = [policy.evidence_id.as_str(), policy.evidence_digest.as_str()]
1053 .into_iter()
1054 .collect::<std::collections::BTreeSet<_>>();
1055 let actual = plan
1056 .policy_evidence_references
1057 .iter()
1058 .map(String::as_str)
1059 .collect::<std::collections::BTreeSet<_>>();
1060 actual == expected && plan.policy_evidence_references.len() == expected.len()
1061}
1062
1063#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema, ToSchema)]
1064#[serde(rename_all = "camelCase")]
1065pub struct PromotionApproval {
1066 pub protocol: String,
1067 pub approval_id: String,
1068 pub plan_digest: String,
1069 pub actor: String,
1070 pub authority: String,
1071 pub authority_proof: String,
1072 pub issued_for_target_revision: u64,
1073 pub approved: bool,
1074}
1075
1076pub trait PromotionApprovalAuthority {
1077 fn authority(&self) -> &str;
1078 fn issue_proof(&self, actor: &str, plan_digest: &str, target_revision: u64) -> Option<String>;
1079 fn verify_proof(
1080 &self,
1081 actor: &str,
1082 plan_digest: &str,
1083 target_revision: u64,
1084 proof: &str,
1085 ) -> bool;
1086}
1087
1088#[derive(Debug, Clone)]
1089pub struct DeterministicPromotionApprovalAuthority {
1090 authority: String,
1091 authorized_actors: BTreeSet<String>,
1092 signing_key: String,
1093}
1094
1095impl DeterministicPromotionApprovalAuthority {
1096 pub fn new(
1097 authority: impl Into<String>,
1098 authorized_actors: impl IntoIterator<Item = impl Into<String>>,
1099 signing_key: impl Into<String>,
1100 ) -> Self {
1101 Self {
1102 authority: authority.into(),
1103 authorized_actors: authorized_actors.into_iter().map(Into::into).collect(),
1104 signing_key: signing_key.into(),
1105 }
1106 }
1107
1108 fn proof(&self, actor: &str, plan_digest: &str, target_revision: u64) -> String {
1109 digest_json(&(
1110 "lenso.promotion-authority-proof.v1",
1111 self.authority.as_str(),
1112 actor,
1113 plan_digest,
1114 target_revision,
1115 self.signing_key.as_str(),
1116 ))
1117 }
1118}
1119
1120impl PromotionApprovalAuthority for DeterministicPromotionApprovalAuthority {
1121 fn authority(&self) -> &str {
1122 &self.authority
1123 }
1124
1125 fn issue_proof(&self, actor: &str, plan_digest: &str, target_revision: u64) -> Option<String> {
1126 self.authorized_actors
1127 .contains(actor)
1128 .then(|| self.proof(actor, plan_digest, target_revision))
1129 }
1130
1131 fn verify_proof(
1132 &self,
1133 actor: &str,
1134 plan_digest: &str,
1135 target_revision: u64,
1136 proof: &str,
1137 ) -> bool {
1138 self.authorized_actors.contains(actor)
1139 && proof == self.proof(actor, plan_digest, target_revision)
1140 }
1141}
1142
1143pub fn approve_promotion(
1144 plan: &PromotionPlan,
1145 actor: impl Into<String>,
1146 authority: &impl PromotionApprovalAuthority,
1147) -> Result<PromotionApproval, DeliveryIssue> {
1148 let actor = actor.into();
1149 let authority_name = authority.authority().to_owned();
1150 let authority_proof = authority.issue_proof(
1151 actor.as_str(),
1152 plan.plan_digest.as_str(),
1153 plan.target_environment_revision,
1154 );
1155 if !promotion_plan_integrity_is_valid(plan)
1156 || !actor.starts_with("user:")
1157 || authority_name.trim().is_empty()
1158 || authority_proof.is_none()
1159 {
1160 return Err(issue(
1161 DeliveryIssueCode::ApprovalInvalid,
1162 "Production Promotion approval requires an integrity-valid plan and explicit human actor authority.",
1163 "Bind a reviewed plan digest to an authorized human operator.",
1164 "Correct the Approval Boundary before apply.",
1165 ));
1166 }
1167 let approval_id = format!(
1168 "promotion-approval:{}",
1169 digest_json(&(
1170 PROMOTION_APPROVAL_PROTOCOL,
1171 plan.plan_digest.as_str(),
1172 actor.as_str(),
1173 authority_name.as_str(),
1174 authority_proof.as_deref(),
1175 plan.target_environment_revision,
1176 true,
1177 ))
1178 );
1179 Ok(PromotionApproval {
1180 protocol: PROMOTION_APPROVAL_PROTOCOL.to_owned(),
1181 approval_id,
1182 plan_digest: plan.plan_digest.clone(),
1183 actor,
1184 authority: authority_name,
1185 authority_proof: authority_proof.expect("authority proof was checked"),
1186 issued_for_target_revision: plan.target_environment_revision,
1187 approved: true,
1188 })
1189}
1190
1191#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema, ToSchema)]
1192#[serde(rename_all = "camelCase")]
1193pub struct PromotionProtectedEvidence {
1194 pub source_verification_id: String,
1195 pub source_verification_digest: String,
1196 pub policy_evidence_id: String,
1197 pub policy_evidence_digest: String,
1198 pub source_environment_revision: u64,
1199 pub source_topology_digest: String,
1200 pub target_topology_digest: String,
1201 pub config_revision_id: String,
1202 pub secret_reference_ids: Vec<String>,
1203 pub evidence_references: Vec<String>,
1204}
1205
1206impl PromotionProtectedEvidence {
1207 #[must_use]
1208 pub fn from_plan(plan: &PromotionPlan) -> Self {
1209 Self {
1210 source_verification_id: plan.source_verification_id.clone(),
1211 source_verification_digest: plan.source_verification_digest.clone(),
1212 policy_evidence_id: plan.policy_evidence_id.clone(),
1213 policy_evidence_digest: plan.policy_evidence_digest.clone(),
1214 source_environment_revision: plan.source_environment_revision,
1215 source_topology_digest: plan.source_topology_digest.clone(),
1216 target_topology_digest: plan.target_topology_digest.clone(),
1217 config_revision_id: plan.config_revision_id.clone(),
1218 secret_reference_ids: plan.secret_reference_ids.clone(),
1219 evidence_references: plan.evidence_references.clone(),
1220 }
1221 }
1222}
1223
1224#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema, ToSchema)]
1225#[serde(rename_all = "camelCase")]
1226pub struct PromotionReceipt {
1227 pub protocol: String,
1228 pub receipt_id: String,
1229 pub plan_id: String,
1230 pub approval_id: String,
1231 pub actor: String,
1232 pub source_environment: String,
1233 pub target_environment: String,
1234 pub release_id: String,
1235 pub release_digest: String,
1236 pub workload_digests: BTreeMap<String, String>,
1237 pub deployment_receipt: DeploymentReceipt,
1238 pub environment_revision_before: u64,
1239 pub environment_revision_after: u64,
1240 pub effects: DeliveryEffects,
1241}
1242
1243#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema, ToSchema)]
1244#[serde(rename_all = "camelCase")]
1245pub struct PromotionState {
1246 pub environment: String,
1247 pub environment_revision: u64,
1248 pub coordination_available: bool,
1249 #[serde(default)]
1250 pub history: Vec<PromotionReceipt>,
1251}
1252
1253impl PromotionState {
1254 #[must_use]
1255 pub fn new(environment: impl Into<String>, environment_revision: u64) -> Self {
1256 Self {
1257 environment: environment.into(),
1258 environment_revision,
1259 coordination_available: true,
1260 history: Vec::new(),
1261 }
1262 }
1263}
1264
1265#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema, ToSchema)]
1266#[serde(rename_all = "camelCase")]
1267pub struct PromotionApplyRejection {
1268 pub issues: Vec<DeliveryIssue>,
1269 pub effects: DeliveryEffects,
1270}
1271
1272pub fn apply_promotion(
1273 state: &mut PromotionState,
1274 deployment_state: &mut DeploymentState,
1275 plan: &PromotionPlan,
1276 approval: &PromotionApproval,
1277 protected_evidence: &PromotionProtectedEvidence,
1278 approval_authority: &impl PromotionApprovalAuthority,
1279) -> Result<PromotionReceipt, PromotionApplyRejection> {
1280 let valid_approval = promotion_approval_integrity_is_valid(approval, approval_authority)
1281 && approval.approved
1282 && approval.plan_digest == plan.plan_digest
1283 && approval.issued_for_target_revision == plan.target_environment_revision;
1284 if !promotion_plan_integrity_is_valid(plan) || !valid_approval {
1285 return Err(rejection(
1286 DeliveryIssueCode::ApprovalInvalid,
1287 "Promotion plan integrity or its exact human Approval Boundary is invalid.",
1288 "Review and approve the current plan digest with explicit production authority.",
1289 "Regenerate or reapprove the Promotion plan.",
1290 ));
1291 }
1292 let mut expected_evidence = PromotionProtectedEvidence::from_plan(plan);
1293 expected_evidence.secret_reference_ids.sort();
1294 expected_evidence.evidence_references.sort();
1295 let mut current_evidence = protected_evidence.clone();
1296 current_evidence.secret_reference_ids.sort();
1297 current_evidence.evidence_references.sort();
1298 if current_evidence != expected_evidence
1299 || current_evidence.source_environment_revision > plan.freshness_horizon_revision
1300 {
1301 return Err(rejection(
1302 DeliveryIssueCode::StaleInput,
1303 "Source verification, policy, configuration, topology, reliability, edge, or evidence changed after Promotion planning.",
1304 "Collect current protected evidence and bind a new content-addressed Promotion plan.",
1305 "Refresh staging and replan Promotion before mutation.",
1306 ));
1307 }
1308 if let Some(existing) = state
1309 .history
1310 .iter()
1311 .find(|item| item.plan_id == plan.plan_id)
1312 {
1313 return promotion_receipt_integrity_is_valid(existing, plan, approval)
1314 .then(|| existing.clone())
1315 .ok_or_else(|| {
1316 rejection(
1317 DeliveryIssueCode::StaleInput,
1318 "The completed Promotion receipt no longer matches the exact plan and Approval Boundary.",
1319 "Preserve the immutable plan, approval, protected evidence, and receipt together.",
1320 "Restore the original receipt or create and approve a new Promotion plan.",
1321 )
1322 });
1323 }
1324 if !state.coordination_available {
1325 return Err(rejection(
1326 DeliveryIssueCode::CoordinationUnavailable,
1327 "New Promotion is paused while coordination evidence is unavailable.",
1328 "Restore the System Plane and refresh protected-action evidence.",
1329 "Retry Promotion after coordination recovers.",
1330 ));
1331 }
1332 if state.environment != plan.target_environment
1333 || state.environment_revision != plan.target_environment_revision
1334 || deployment_state.environment != plan.target_environment
1335 || deployment_state.environment_revision
1336 != plan.target_deployment.expected_environment_revision
1337 {
1338 return Err(rejection(
1339 DeliveryIssueCode::ConcurrentMutation,
1340 "Target environment changed after Promotion planning.",
1341 "Use compare-and-set revisions and replan from current target observations.",
1342 "Refresh target state and plan Promotion again.",
1343 ));
1344 }
1345 let mut next_deployment_state = deployment_state.clone();
1346 let deployment_receipt = apply_deployment(&mut next_deployment_state, &plan.target_deployment)
1347 .map_err(deployment_rejection)?;
1348 let revision_before = state.environment_revision;
1349 state.environment_revision += 1;
1350 let effects = DeliveryEffects {
1351 mutates_environment: true,
1352 mutates_deployment: true,
1353 appends_ledger: true,
1354 ..DeliveryEffects::default()
1355 };
1356 let receipt_id = format!(
1357 "promotion-receipt:{}",
1358 digest_json(&(
1359 plan.plan_id.as_str(),
1360 approval.approval_id.as_str(),
1361 deployment_receipt.receipt_id.as_str(),
1362 revision_before,
1363 state.environment_revision,
1364 ))
1365 );
1366 let receipt = PromotionReceipt {
1367 protocol: PROMOTION_RECEIPT_PROTOCOL.to_owned(),
1368 receipt_id,
1369 plan_id: plan.plan_id.clone(),
1370 approval_id: approval.approval_id.clone(),
1371 actor: approval.actor.clone(),
1372 source_environment: plan.source_environment.clone(),
1373 target_environment: plan.target_environment.clone(),
1374 release_id: plan.release_id.clone(),
1375 release_digest: plan.release_digest.clone(),
1376 workload_digests: plan.workload_digests.clone(),
1377 deployment_receipt,
1378 environment_revision_before: revision_before,
1379 environment_revision_after: state.environment_revision,
1380 effects,
1381 };
1382 *deployment_state = next_deployment_state;
1383 state.history.push(receipt.clone());
1384 Ok(receipt)
1385}
1386
1387#[must_use]
1388pub fn promotion_receipt_integrity_is_valid(
1389 receipt: &PromotionReceipt,
1390 plan: &PromotionPlan,
1391 approval: &PromotionApproval,
1392) -> bool {
1393 let effects = DeliveryEffects {
1394 mutates_environment: true,
1395 mutates_deployment: true,
1396 appends_ledger: true,
1397 ..DeliveryEffects::default()
1398 };
1399 receipt.protocol == PROMOTION_RECEIPT_PROTOCOL
1400 && promotion_plan_integrity_is_valid(plan)
1401 && receipt.plan_id == plan.plan_id
1402 && receipt.approval_id == approval.approval_id
1403 && receipt.actor == approval.actor
1404 && receipt.source_environment == plan.source_environment
1405 && receipt.target_environment == plan.target_environment
1406 && receipt.release_id == plan.release_id
1407 && receipt.release_digest == plan.release_digest
1408 && receipt.workload_digests == plan.workload_digests
1409 && deployment_receipt_integrity_is_valid(
1410 &receipt.deployment_receipt,
1411 &plan.target_deployment,
1412 )
1413 && receipt.environment_revision_before == plan.target_environment_revision
1414 && receipt.environment_revision_after == receipt.environment_revision_before + 1
1415 && receipt.effects == effects
1416 && receipt.receipt_id
1417 == format!(
1418 "promotion-receipt:{}",
1419 digest_json(&(
1420 plan.plan_id.as_str(),
1421 approval.approval_id.as_str(),
1422 receipt.deployment_receipt.receipt_id.as_str(),
1423 receipt.environment_revision_before,
1424 receipt.environment_revision_after,
1425 ))
1426 )
1427}
1428
1429#[must_use]
1430pub fn promotion_approval_integrity_is_valid(
1431 approval: &PromotionApproval,
1432 authority: &impl PromotionApprovalAuthority,
1433) -> bool {
1434 let expected_id = format!(
1435 "promotion-approval:{}",
1436 digest_json(&(
1437 PROMOTION_APPROVAL_PROTOCOL,
1438 approval.plan_digest.as_str(),
1439 approval.actor.as_str(),
1440 approval.authority.as_str(),
1441 Some(approval.authority_proof.as_str()),
1442 approval.issued_for_target_revision,
1443 approval.approved,
1444 ))
1445 );
1446 approval.protocol == PROMOTION_APPROVAL_PROTOCOL
1447 && approval.approval_id == expected_id
1448 && approval.authority == authority.authority()
1449 && approval.actor.starts_with("user:")
1450 && authority.verify_proof(
1451 approval.actor.as_str(),
1452 approval.plan_digest.as_str(),
1453 approval.issued_for_target_revision,
1454 approval.authority_proof.as_str(),
1455 )
1456}
1457
1458#[must_use]
1459pub fn promotion_plan_integrity_is_valid(plan: &PromotionPlan) -> bool {
1460 let target_workload_digests = plan
1461 .target_deployment
1462 .workloads
1463 .iter()
1464 .map(|workload| {
1465 (
1466 workload.workload_id.clone(),
1467 workload.artifact_digest.clone(),
1468 )
1469 })
1470 .collect::<BTreeMap<_, _>>();
1471 plan.protocol == PROMOTION_PLAN_PROTOCOL
1472 && deployment_plan_integrity_is_valid(&plan.target_deployment)
1473 && gateway_plan_integrity_is_valid(&plan.target_gateway)
1474 && plan.release_id == plan.target_deployment.release_id
1475 && plan.release_digest == plan.target_deployment.release_digest
1476 && plan.workload_digests == target_workload_digests
1477 && plan.target_environment == plan.target_deployment.environment
1478 && plan.target_environment_revision == plan.target_deployment.expected_environment_revision
1479 && plan.config_revision_id == plan.target_deployment.config_revision_id
1480 && plan.target_deployment.gateway_plan_digest == plan.target_gateway.plan_digest
1481 && plan.release_id == plan.target_gateway.edge_release_id
1482 && plan.release_digest == plan.target_gateway.edge_release_digest
1483 && plan.target_environment == plan.target_gateway.environment
1484 && plan.plan_id == format!("promotion-plan:{}", plan.plan_digest)
1485 && digest_json(&PromotionPlanDigestInput {
1486 protocol: plan.protocol.as_str(),
1487 release_id: plan.release_id.as_str(),
1488 release_digest: plan.release_digest.as_str(),
1489 workload_digests: &plan.workload_digests,
1490 source_environment: plan.source_environment.as_str(),
1491 target_environment: plan.target_environment.as_str(),
1492 source_environment_revision: plan.source_environment_revision,
1493 target_environment_revision: plan.target_environment_revision,
1494 source_verification_id: plan.source_verification_id.as_str(),
1495 source_verification_digest: plan.source_verification_digest.as_str(),
1496 policy_evidence_id: plan.policy_evidence_id.as_str(),
1497 policy_evidence_digest: plan.policy_evidence_digest.as_str(),
1498 config_revision_id: plan.config_revision_id.as_str(),
1499 secret_reference_ids: plan.secret_reference_ids.as_slice(),
1500 target_deployment_digest: plan.target_deployment.plan_digest.as_str(),
1501 target_gateway_digest: plan.target_gateway.plan_digest.as_str(),
1502 source_topology_digest: plan.source_topology_digest.as_str(),
1503 target_topology_digest: plan.target_topology_digest.as_str(),
1504 evidence_references: plan.evidence_references.as_slice(),
1505 freshness_horizon_revision: plan.freshness_horizon_revision,
1506 effects: &plan.effects,
1507 }) == plan.plan_digest
1508}
1509
1510fn deployment_rejection(error: DeploymentApplyRejection) -> PromotionApplyRejection {
1511 PromotionApplyRejection {
1512 issues: error.issues,
1513 effects: DeliveryEffects::default(),
1514 }
1515}
1516
1517fn rejection(
1518 code: DeliveryIssueCode,
1519 message: &str,
1520 remediation: &str,
1521 next_action: &str,
1522) -> PromotionApplyRejection {
1523 PromotionApplyRejection {
1524 issues: vec![issue(code, message, remediation, next_action)],
1525 effects: DeliveryEffects::default(),
1526 }
1527}
1528
1529fn digest_json(value: &impl Serialize) -> String {
1530 extraction_input_digest(serde_json::to_vec(value).expect("Promotion values must serialize"))
1531}