1use serde::{Deserialize, Serialize};
28
29use crate::schema_versions::{SchemaComponent, current_version};
30
31pub const FLEET_DEPLOY_AUDIT_SCHEMA_VERSION: &str =
33 current_version(SchemaComponent::FleetDeployAudit);
34
35pub mod reason_code {
40 pub const WRONG_TARGET_TRIPLE: &str = "provenance_wrong_target_triple";
42 pub const CHECKSUM_MISMATCH: &str = "provenance_checksum_mismatch";
44 pub const CHECKSUM_MISSING: &str = "provenance_checksum_missing";
46 pub const SIGNATURE_INVALID: &str = "provenance_signature_invalid";
48 pub const SIGNATURE_MISSING: &str = "provenance_signature_missing";
50 pub const UNVERIFIABLE: &str = "provenance_unverifiable";
52 pub const DEV_ARTIFACT_ALLOWED: &str = "provenance_dev_artifact_allowed";
54}
55
56#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
59pub struct SignatureMaterial {
60 pub bundle_ref: String,
62 pub identity_pattern: String,
65}
66
67#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
71pub struct ArtifactProvenance {
72 pub artifact_id: String,
74 #[serde(default, skip_serializing_if = "Option::is_none")]
77 pub release_id: Option<String>,
78 pub target_triple: String,
80 #[serde(default, skip_serializing_if = "Option::is_none")]
83 pub expected_sha256: Option<String>,
84 #[serde(default, skip_serializing_if = "Option::is_none")]
86 pub signature: Option<SignatureMaterial>,
87 #[serde(default, skip_serializing_if = "Option::is_none")]
89 pub builder_identity: Option<String>,
90 #[serde(default, skip_serializing_if = "Option::is_none")]
93 pub expected_protocol_version: Option<u32>,
94}
95
96impl ArtifactProvenance {
97 #[must_use]
100 pub fn dev_artifact(artifact_id: impl Into<String>, triple: impl Into<String>) -> Self {
101 Self {
102 artifact_id: artifact_id.into(),
103 release_id: None,
104 target_triple: triple.into(),
105 expected_sha256: None,
106 signature: None,
107 builder_identity: None,
108 expected_protocol_version: None,
109 }
110 }
111}
112
113#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
116#[serde(rename_all = "snake_case")]
117pub enum SignatureCheck {
118 Absent,
120 Valid,
122 Invalid,
124}
125
126#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
128pub struct ProvenancePolicy {
129 pub require_signature: bool,
131 pub require_checksum: bool,
134 pub allow_dev_artifacts: bool,
138}
139
140impl ProvenancePolicy {
141 pub const STRICT: Self = Self {
143 require_signature: true,
144 require_checksum: true,
145 allow_dev_artifacts: false,
146 };
147
148 #[must_use]
152 pub const fn dev_friendly() -> Self {
153 Self {
154 require_signature: false,
155 require_checksum: false,
156 allow_dev_artifacts: true,
157 }
158 }
159
160 #[must_use]
167 pub fn from_env_value(value: Option<&str>) -> Self {
168 match value.map(|v| v.trim().to_ascii_lowercase()).as_deref() {
169 Some("strict") => Self::STRICT,
170 _ => Self::dev_friendly(),
171 }
172 }
173}
174
175impl Default for ProvenancePolicy {
176 fn default() -> Self {
177 Self::dev_friendly()
178 }
179}
180
181#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
183#[serde(tag = "status", rename_all = "snake_case")]
184pub enum ProvenanceVerdict {
185 Verified,
189 DevArtifactAllowed { reason: String },
192 Rejected { reason_code: String, detail: String },
195}
196
197impl ProvenanceVerdict {
198 #[must_use]
200 pub fn may_transfer(&self) -> bool {
201 !matches!(self, Self::Rejected { .. })
202 }
203
204 #[must_use]
206 pub fn verification_status(&self) -> &'static str {
207 match self {
208 Self::Verified => "verified",
209 Self::DevArtifactAllowed { .. } => "dev_allowed",
210 Self::Rejected { .. } => "rejected",
211 }
212 }
213
214 #[must_use]
216 pub fn reason_code(&self) -> Option<&str> {
217 match self {
218 Self::Verified => None,
219 Self::DevArtifactAllowed { .. } => Some(reason_code::DEV_ARTIFACT_ALLOWED),
220 Self::Rejected { reason_code, .. } => Some(reason_code.as_str()),
221 }
222 }
223}
224
225#[must_use]
243pub fn verify_artifact_provenance(
244 provenance: &ArtifactProvenance,
245 worker_triple: &str,
246 actual_sha256: Option<&str>,
247 signature_check: SignatureCheck,
248 policy: &ProvenancePolicy,
249) -> ProvenanceVerdict {
250 if provenance.target_triple != worker_triple {
253 return ProvenanceVerdict::Rejected {
254 reason_code: reason_code::WRONG_TARGET_TRIPLE.to_string(),
255 detail: format!(
256 "artifact {} targets {} but worker platform is {}",
257 provenance.artifact_id, provenance.target_triple, worker_triple
258 ),
259 };
260 }
261
262 let checksum_checked = match (provenance.expected_sha256.as_deref(), actual_sha256) {
264 (Some(expected), Some(actual)) => {
265 if !expected.eq_ignore_ascii_case(actual) {
266 return ProvenanceVerdict::Rejected {
267 reason_code: reason_code::CHECKSUM_MISMATCH.to_string(),
268 detail: format!(
269 "artifact {} checksum mismatch (expected {expected}, got {actual})",
270 provenance.artifact_id
271 ),
272 };
273 }
274 true
275 }
276 (Some(_), None) => {
277 if policy.require_checksum {
278 return ProvenanceVerdict::Rejected {
279 reason_code: reason_code::CHECKSUM_MISSING.to_string(),
280 detail: format!(
281 "artifact {} has an expected checksum but the actual checksum could not be computed",
282 provenance.artifact_id
283 ),
284 };
285 }
286 false
287 }
288 (None, _) => {
289 if policy.require_checksum {
290 return ProvenanceVerdict::Rejected {
291 reason_code: reason_code::CHECKSUM_MISSING.to_string(),
292 detail: format!(
293 "no expected checksum is available for artifact {}",
294 provenance.artifact_id
295 ),
296 };
297 }
298 false
299 }
300 };
301
302 let signature_valid = match signature_check {
304 SignatureCheck::Valid => true,
305 SignatureCheck::Invalid => {
306 return ProvenanceVerdict::Rejected {
307 reason_code: reason_code::SIGNATURE_INVALID.to_string(),
308 detail: format!(
309 "artifact {} signature failed verification",
310 provenance.artifact_id
311 ),
312 };
313 }
314 SignatureCheck::Absent => {
315 if policy.require_signature {
316 return ProvenanceVerdict::Rejected {
317 reason_code: reason_code::SIGNATURE_MISSING.to_string(),
318 detail: format!(
319 "artifact {} has no signature material and strict policy requires a valid signature",
320 provenance.artifact_id
321 ),
322 };
323 }
324 false
325 }
326 };
327
328 if checksum_checked || signature_valid {
330 ProvenanceVerdict::Verified
331 } else if policy.allow_dev_artifacts {
332 ProvenanceVerdict::DevArtifactAllowed {
333 reason: format!(
334 "no signature/checksum material for artifact {} (release_id={}); permitted by dev-artifact policy",
335 provenance.artifact_id,
336 provenance.release_id.as_deref().unwrap_or("none")
337 ),
338 }
339 } else {
340 ProvenanceVerdict::Rejected {
341 reason_code: reason_code::UNVERIFIABLE.to_string(),
342 detail: format!(
343 "artifact {} has no verifiable provenance material and policy forbids dev artifacts",
344 provenance.artifact_id
345 ),
346 }
347 }
348}
349
350#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
357pub struct FleetDeployAuditRecord {
358 pub schema_version: String,
360 pub run_id: String,
362 pub bead_id: String,
364 pub worker_id: String,
366 pub remote_user: String,
368 pub remote_path: String,
370 pub artifact_id: String,
372 #[serde(default, skip_serializing_if = "Option::is_none")]
374 pub release_id: Option<String>,
375 pub target_triple: String,
377 #[serde(default, skip_serializing_if = "Option::is_none")]
379 pub previous_artifact_id: Option<String>,
380 #[serde(default, skip_serializing_if = "Option::is_none")]
382 pub builder_identity: Option<String>,
383 #[serde(default, skip_serializing_if = "Option::is_none")]
385 pub expected_protocol_version: Option<u32>,
386 pub deployed_at_unix_ms: u64,
388 #[serde(default)]
392 pub duration_ms: u64,
393 pub verification_status: String,
395 pub rollback_status: String,
397 #[serde(default, skip_serializing_if = "Option::is_none")]
399 pub reason_code: Option<String>,
400 pub trigger: String,
403 pub detail: String,
405}
406
407pub mod rollback_status {
409 pub const NONE: &str = "none";
411 pub const ROLLED_BACK: &str = "rolled_back";
413 pub const ROLLBACK_FAILED: &str = "rollback_failed";
415}
416
417impl FleetDeployAuditRecord {
418 #[allow(clippy::too_many_arguments)]
422 #[must_use]
423 pub fn from_verdict(
424 run_id: impl Into<String>,
425 bead_id: impl Into<String>,
426 worker_id: impl Into<String>,
427 remote_user: impl Into<String>,
428 remote_path: impl Into<String>,
429 provenance: &ArtifactProvenance,
430 previous_artifact_id: Option<String>,
431 deployed_at_unix_ms: u64,
432 verdict: &ProvenanceVerdict,
433 trigger: impl Into<String>,
434 detail: impl Into<String>,
435 ) -> Self {
436 Self {
437 schema_version: FLEET_DEPLOY_AUDIT_SCHEMA_VERSION.to_string(),
438 run_id: run_id.into(),
439 bead_id: bead_id.into(),
440 worker_id: worker_id.into(),
441 remote_user: remote_user.into(),
442 remote_path: remote_path.into(),
443 artifact_id: provenance.artifact_id.clone(),
444 release_id: provenance.release_id.clone(),
445 target_triple: provenance.target_triple.clone(),
446 previous_artifact_id,
447 builder_identity: provenance.builder_identity.clone(),
448 expected_protocol_version: provenance.expected_protocol_version,
449 deployed_at_unix_ms,
450 duration_ms: 0,
451 verification_status: verdict.verification_status().to_string(),
452 rollback_status: rollback_status::NONE.to_string(),
453 reason_code: verdict.reason_code().map(ToString::to_string),
454 trigger: trigger.into(),
455 detail: detail.into(),
456 }
457 }
458
459 pub fn mark_rolled_back(&mut self) {
465 self.rollback_status = rollback_status::ROLLED_BACK.to_string();
466 }
467
468 pub fn mark_rollback_failed(&mut self) {
473 self.rollback_status = rollback_status::ROLLBACK_FAILED.to_string();
474 }
475
476 pub fn set_duration_ms(&mut self, duration_ms: u64) {
479 self.duration_ms = duration_ms;
480 }
481}
482
483#[cfg(test)]
484mod tests {
485 use super::*;
486
487 fn signed_release(triple: &str, sha: &str) -> ArtifactProvenance {
488 ArtifactProvenance {
489 artifact_id: format!("rch-wkr-v1.0.42-{triple}"),
490 release_id: Some("v1.0.42".to_string()),
491 target_triple: triple.to_string(),
492 expected_sha256: Some(sha.to_string()),
493 signature: Some(SignatureMaterial {
494 bundle_ref: "rch-wkr.sigstore.json".to_string(),
495 identity_pattern: "^https://github.com/.*/release.yml@refs/.*$".to_string(),
496 }),
497 builder_identity: Some("github-actions".to_string()),
498 expected_protocol_version: Some(1),
499 }
500 }
501
502 const SHA: &str = "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad";
503 const LINUX: &str = "x86_64-unknown-linux-musl";
504
505 #[test]
507 fn good_signature_and_checksum_is_verified() {
508 let p = signed_release(LINUX, SHA);
509 let v = verify_artifact_provenance(
510 &p,
511 LINUX,
512 Some(SHA),
513 SignatureCheck::Valid,
514 &ProvenancePolicy::STRICT,
515 );
516 assert_eq!(v, ProvenanceVerdict::Verified);
517 assert!(v.may_transfer());
518 assert_eq!(v.verification_status(), "verified");
519 assert_eq!(v.reason_code(), None);
520 }
521
522 #[test]
524 fn missing_signature_under_strict_policy_is_rejected() {
525 let mut p = signed_release(LINUX, SHA);
526 p.signature = None;
527 let v = verify_artifact_provenance(
528 &p,
529 LINUX,
530 Some(SHA),
531 SignatureCheck::Absent,
532 &ProvenancePolicy::STRICT,
533 );
534 assert!(!v.may_transfer());
535 assert_eq!(v.reason_code(), Some(reason_code::SIGNATURE_MISSING));
536 assert_eq!(v.verification_status(), "rejected");
537 }
538
539 #[test]
541 fn invalid_signature_is_rejected_under_any_policy() {
542 let p = signed_release(LINUX, SHA);
543 let v = verify_artifact_provenance(
544 &p,
545 LINUX,
546 Some(SHA),
547 SignatureCheck::Invalid,
548 &ProvenancePolicy::dev_friendly(),
549 );
550 assert_eq!(v.reason_code(), Some(reason_code::SIGNATURE_INVALID));
551 assert!(!v.may_transfer());
552 }
553
554 #[test]
556 fn checksum_mismatch_is_rejected() {
557 let p = signed_release(LINUX, SHA);
558 let wrong = "0000000000000000000000000000000000000000000000000000000000000000";
559 let v = verify_artifact_provenance(
560 &p,
561 LINUX,
562 Some(wrong),
563 SignatureCheck::Valid,
564 &ProvenancePolicy::STRICT,
565 );
566 assert_eq!(v.reason_code(), Some(reason_code::CHECKSUM_MISMATCH));
567 assert!(!v.may_transfer());
568 }
569
570 #[test]
572 fn wrong_target_triple_is_rejected_even_with_valid_signature() {
573 let p = signed_release(LINUX, SHA);
574 let v = verify_artifact_provenance(
575 &p,
576 "aarch64-apple-darwin",
577 Some(SHA),
578 SignatureCheck::Valid,
579 &ProvenancePolicy::STRICT,
580 );
581 assert_eq!(v.reason_code(), Some(reason_code::WRONG_TARGET_TRIPLE));
582 assert!(!v.may_transfer());
583 }
584
585 #[test]
588 fn dev_artifact_is_allowed_with_explicit_reason() {
589 let p = ArtifactProvenance::dev_artifact("rch-wkr-dev", LINUX);
590 let v = verify_artifact_provenance(
591 &p,
592 LINUX,
593 None,
594 SignatureCheck::Absent,
595 &ProvenancePolicy::dev_friendly(),
596 );
597 match &v {
598 ProvenanceVerdict::DevArtifactAllowed { reason } => {
599 assert!(reason.contains("dev-artifact policy"));
600 }
601 other => panic!("expected DevArtifactAllowed, got {other:?}"),
602 }
603 assert!(v.may_transfer());
604 assert_eq!(v.verification_status(), "dev_allowed");
605 assert_eq!(v.reason_code(), Some(reason_code::DEV_ARTIFACT_ALLOWED));
606 }
607
608 #[test]
611 fn dev_artifact_without_allowance_is_unverifiable() {
612 let p = ArtifactProvenance::dev_artifact("rch-wkr-dev", LINUX);
613 let policy = ProvenancePolicy {
614 require_signature: false,
615 require_checksum: false,
616 allow_dev_artifacts: false,
617 };
618 let v = verify_artifact_provenance(&p, LINUX, None, SignatureCheck::Absent, &policy);
619 assert_eq!(v.reason_code(), Some(reason_code::UNVERIFIABLE));
620 assert!(!v.may_transfer());
621 }
622
623 #[test]
626 fn checksum_only_match_is_verified() {
627 let mut p = signed_release(LINUX, SHA);
628 p.signature = None;
629 let policy = ProvenancePolicy {
630 require_signature: false,
631 require_checksum: true,
632 allow_dev_artifacts: false,
633 };
634 let v = verify_artifact_provenance(&p, LINUX, Some(SHA), SignatureCheck::Absent, &policy);
635 assert_eq!(v, ProvenanceVerdict::Verified);
636 }
637
638 #[test]
640 fn require_checksum_without_value_is_rejected() {
641 let p = ArtifactProvenance::dev_artifact("rch-wkr-dev", LINUX);
642 let policy = ProvenancePolicy {
643 require_signature: false,
644 require_checksum: true,
645 allow_dev_artifacts: true,
646 };
647 let v = verify_artifact_provenance(&p, LINUX, None, SignatureCheck::Absent, &policy);
648 assert_eq!(v.reason_code(), Some(reason_code::CHECKSUM_MISSING));
649 }
650
651 #[test]
652 fn checksum_comparison_is_case_insensitive() {
653 let p = signed_release(LINUX, SHA);
654 let upper = SHA.to_ascii_uppercase();
655 let v = verify_artifact_provenance(
656 &p,
657 LINUX,
658 Some(&upper),
659 SignatureCheck::Valid,
660 &ProvenancePolicy::STRICT,
661 );
662 assert_eq!(v, ProvenanceVerdict::Verified);
663 }
664
665 #[test]
666 fn default_policy_is_dev_friendly() {
667 assert_eq!(
668 ProvenancePolicy::default(),
669 ProvenancePolicy::dev_friendly()
670 );
671 }
672
673 #[test]
674 fn audit_record_from_verdict_carries_status_and_reason() {
675 let p = signed_release(LINUX, SHA);
676 let verdict = verify_artifact_provenance(
677 &p,
678 LINUX,
679 Some(SHA),
680 SignatureCheck::Valid,
681 &ProvenancePolicy::STRICT,
682 );
683 let rec = FleetDeployAuditRecord::from_verdict(
684 "run-1",
685 "bd-session-history-remediation-ocv9i.7.4",
686 "css",
687 "ubuntu",
688 "/home/ubuntu/.local/bin/rch-wkr",
689 &p,
690 Some("rch-wkr-v1.0.41-x86_64-unknown-linux-musl".to_string()),
691 1_700_000_000_000,
692 &verdict,
693 "operator",
694 "deploy verified",
695 );
696 assert_eq!(rec.schema_version, FLEET_DEPLOY_AUDIT_SCHEMA_VERSION);
697 assert_eq!(rec.verification_status, "verified");
698 assert_eq!(rec.rollback_status, rollback_status::NONE);
699 assert_eq!(rec.reason_code, None);
700 assert_eq!(rec.target_triple, LINUX);
701 assert_eq!(rec.duration_ms, 0);
703 assert_eq!(rec.deployed_at_unix_ms, 1_700_000_000_000);
704 assert_eq!(
705 rec.previous_artifact_id.as_deref(),
706 Some("rch-wkr-v1.0.41-x86_64-unknown-linux-musl")
707 );
708 }
709
710 #[test]
711 fn audit_record_serde_roundtrip_is_stable() {
712 let p = ArtifactProvenance::dev_artifact("rch-wkr-dev", LINUX);
713 let verdict = ProvenanceVerdict::DevArtifactAllowed {
714 reason: "dev".to_string(),
715 };
716 let rec = FleetDeployAuditRecord::from_verdict(
717 "run-2",
718 "bd-x",
719 "hz1",
720 "ubuntu",
721 "/home/ubuntu/.local/bin/rch-wkr",
722 &p,
723 None,
724 42,
725 &verdict,
726 "remediation",
727 "dev deploy",
728 );
729 let json = serde_json::to_string(&rec).unwrap();
730 let back: FleetDeployAuditRecord = serde_json::from_str(&json).unwrap();
731 assert_eq!(rec, back);
732 let vjson = serde_json::to_string(&verdict).unwrap();
734 assert!(vjson.contains("\"status\":\"dev_artifact_allowed\""));
735 }
736
737 #[test]
738 fn verdict_is_tagged_with_snake_case_status() {
739 let rejected = ProvenanceVerdict::Rejected {
740 reason_code: reason_code::CHECKSUM_MISMATCH.to_string(),
741 detail: "x".to_string(),
742 };
743 let json = serde_json::to_string(&rejected).unwrap();
744 assert!(json.contains("\"status\":\"rejected\""));
745 assert!(json.contains("provenance_checksum_mismatch"));
746
747 let verified = ProvenanceVerdict::Verified;
750 let vjson = serde_json::to_string(&verified).unwrap();
751 assert_eq!(vjson, "{\"status\":\"verified\"}");
752 let back: ProvenanceVerdict = serde_json::from_str(&vjson).unwrap();
753 assert_eq!(back, ProvenanceVerdict::Verified);
754 }
755
756 #[test]
757 fn schema_version_is_pinned() {
758 assert_eq!(FLEET_DEPLOY_AUDIT_SCHEMA_VERSION, "1.0.0");
759 }
760
761 #[test]
762 fn policy_from_env_value_selects_strict_only_for_strict() {
763 assert_eq!(
764 ProvenancePolicy::from_env_value(Some("strict")),
765 ProvenancePolicy::STRICT
766 );
767 assert_eq!(
769 ProvenancePolicy::from_env_value(Some(" STRICT \n")),
770 ProvenancePolicy::STRICT
771 );
772 for v in [
774 None,
775 Some(""),
776 Some("dev"),
777 Some("1"),
778 Some("relaxed"),
779 Some("strictish"),
780 ] {
781 assert_eq!(
782 ProvenancePolicy::from_env_value(v),
783 ProvenancePolicy::dev_friendly(),
784 "value {v:?} should resolve to dev_friendly"
785 );
786 }
787 }
788
789 #[test]
792 fn rollback_after_canary_marks_record_rolled_back() {
793 let p = ArtifactProvenance::dev_artifact("rch-wkr", LINUX);
794 let verdict = ProvenanceVerdict::DevArtifactAllowed {
795 reason: "dev".to_string(),
796 };
797 let mut rec = FleetDeployAuditRecord::from_verdict(
798 "run-canary",
799 "bd-x",
800 "css",
801 "ubuntu",
802 "/home/ubuntu/.local/bin/rch-wkr",
803 &p,
804 Some("rch-wkr-prev".to_string()),
805 1,
806 &verdict,
807 "canary_failure",
808 "canary failed, reverting",
809 );
810 assert_eq!(rec.rollback_status, rollback_status::NONE);
811 rec.set_duration_ms(4_200);
812 rec.mark_rolled_back();
813 assert_eq!(rec.rollback_status, rollback_status::ROLLED_BACK);
814 assert_eq!(rec.duration_ms, 4_200);
815 let json = serde_json::to_string(&rec).unwrap();
817 let back: FleetDeployAuditRecord = serde_json::from_str(&json).unwrap();
818 assert_eq!(back, rec);
819 }
820
821 #[test]
824 fn interrupted_deploy_with_failed_rollback_is_flagged() {
825 let p = ArtifactProvenance::dev_artifact("rch-wkr", LINUX);
826 let verdict = ProvenanceVerdict::Verified;
827 let mut rec = FleetDeployAuditRecord::from_verdict(
828 "run-interrupt",
829 "bd-x",
830 "hz1",
831 "ubuntu",
832 "/home/ubuntu/.local/bin/rch-wkr",
833 &p,
834 None,
835 1,
836 &verdict,
837 "operator",
838 "transfer interrupted mid-deploy",
839 );
840 rec.mark_rollback_failed();
841 assert_eq!(rec.rollback_status, rollback_status::ROLLBACK_FAILED);
842 }
843}