1use std::env;
18use std::fmt;
19use std::fs::File;
20use std::net::SocketAddr;
21use std::path::Path;
22use std::path::PathBuf;
23use std::str::FromStr;
24use std::time::SystemTime;
25
26use crate::DeploymentProfile;
27use crate::SecurityContractViolation;
28use crate::SecurityOperation;
29use crate::SecurityProviderError;
30use crate::SecurityProviderFailure;
31
32pub const SECURITY_PROFILE_ENV: &str = "ROCKETMQ_SECURITY_PROFILE";
34pub const SECURITY_TRUST_ANCHOR_ENV: &str = "ROCKETMQ_SECURITY_TRUST_ANCHOR";
36pub const SECURITY_TLS_CERT_ENV: &str = "ROCKETMQ_SECURITY_TLS_CERT";
38pub const SECURITY_TLS_KEY_ENV: &str = "ROCKETMQ_SECURITY_TLS_KEY";
40pub const SECURITY_SECRET_PROVIDER_ENV: &str = "ROCKETMQ_SECURITY_SECRET_PROVIDER";
42pub const SECURITY_ADMIN_IDENTITY_ENV: &str = "ROCKETMQ_SECURITY_ADMIN_IDENTITY";
44pub const SECURITY_REQUEST_POLICY_ENV: &str = "ROCKETMQ_SECURITY_REQUEST_POLICY";
46pub const MOUNTED_FILES_SECRET_PROVIDER: &str = "mounted-files";
48
49#[derive(Debug, Clone, Copy, PartialEq, Eq)]
51pub enum SecurityBootstrapProfile {
52 DevelopmentInsecureLoopback,
54 SecureEnforced,
56}
57
58impl SecurityBootstrapProfile {
59 pub const fn as_str(self) -> &'static str {
61 match self {
62 Self::DevelopmentInsecureLoopback => "development-insecure-loopback",
63 Self::SecureEnforced => "secure-enforced",
64 }
65 }
66}
67
68impl FromStr for SecurityBootstrapProfile {
69 type Err = SecurityContractViolation;
70
71 fn from_str(value: &str) -> Result<Self, Self::Err> {
72 match value.trim().to_ascii_lowercase().as_str() {
73 "development-insecure-loopback" => Ok(Self::DevelopmentInsecureLoopback),
74 "secure-enforced" => Ok(Self::SecureEnforced),
75 _ => Err(SecurityContractViolation::UnknownSecurityProfile),
76 }
77 }
78}
79
80#[derive(Clone)]
82pub struct SecurityBootstrapConfig {
83 profile: SecurityBootstrapProfile,
84 trust_anchor: Option<PathBuf>,
85 tls_certificate: Option<PathBuf>,
86 tls_private_key: Option<PathBuf>,
87 secret_provider: Option<String>,
88 admin_identity: Option<PathBuf>,
89 request_policy: Option<PathBuf>,
90}
91
92#[derive(Debug, Clone)]
94pub enum SecurityBootstrap {
95 Disabled,
97 Enabled(SecurityBootstrapConfig),
99}
100
101impl SecurityBootstrap {
102 pub const fn is_enabled(&self) -> bool {
104 matches!(self, Self::Enabled(_))
105 }
106
107 pub const fn requires_authentication(&self) -> bool {
109 matches!(self, Self::Enabled(config) if matches!(config.profile, SecurityBootstrapProfile::SecureEnforced))
110 }
111
112 pub fn validate(
118 &self,
119 listener_addresses: &[SocketAddr],
120 ) -> Result<SecurityBootstrapOutcome, SecurityProviderError> {
121 match self {
122 Self::Disabled => Ok(SecurityBootstrapOutcome::Disabled),
123 Self::Enabled(config) => config
124 .validate(listener_addresses)
125 .map(SecurityBootstrapOutcome::Validated),
126 }
127 }
128}
129
130#[derive(Default)]
131struct SecurityBootstrapEnvironment {
132 profile: Option<String>,
133 trust_anchor: Option<PathBuf>,
134 tls_certificate: Option<PathBuf>,
135 tls_private_key: Option<PathBuf>,
136 secret_provider: Option<String>,
137 admin_identity: Option<PathBuf>,
138 request_policy: Option<PathBuf>,
139}
140
141impl SecurityBootstrapEnvironment {
142 fn from_env() -> Result<Self, SecurityContractViolation> {
143 Ok(Self {
144 profile: optional_env(SECURITY_PROFILE_ENV)?,
145 trust_anchor: optional_path_env(SECURITY_TRUST_ANCHOR_ENV)?,
146 tls_certificate: optional_path_env(SECURITY_TLS_CERT_ENV)?,
147 tls_private_key: optional_path_env(SECURITY_TLS_KEY_ENV)?,
148 secret_provider: optional_env(SECURITY_SECRET_PROVIDER_ENV)?,
149 admin_identity: optional_path_env(SECURITY_ADMIN_IDENTITY_ENV)?,
150 request_policy: optional_path_env(SECURITY_REQUEST_POLICY_ENV)?,
151 })
152 }
153
154 fn resolve(self) -> Result<SecurityBootstrap, SecurityContractViolation> {
155 let Self {
156 profile,
157 trust_anchor,
158 tls_certificate,
159 tls_private_key,
160 secret_provider,
161 admin_identity,
162 request_policy,
163 } = self;
164 let Some(profile) = profile else {
165 if trust_anchor.is_some()
166 || tls_certificate.is_some()
167 || tls_private_key.is_some()
168 || secret_provider.is_some()
169 || admin_identity.is_some()
170 || request_policy.is_some()
171 {
172 return Err(SecurityContractViolation::BootstrapProfileRequired);
173 }
174 return Ok(SecurityBootstrap::Disabled);
175 };
176 let profile = profile.parse::<SecurityBootstrapProfile>()?;
177 Ok(SecurityBootstrap::Enabled(SecurityBootstrapConfig {
178 profile,
179 trust_anchor,
180 tls_certificate,
181 tls_private_key,
182 secret_provider,
183 admin_identity,
184 request_policy,
185 }))
186 }
187}
188
189impl fmt::Debug for SecurityBootstrapConfig {
190 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
191 f.debug_struct("SecurityBootstrapConfig")
192 .field("profile", &self.profile)
193 .field("trust_anchor_configured", &self.trust_anchor.is_some())
194 .field("tls_certificate_configured", &self.tls_certificate.is_some())
195 .field("tls_private_key_configured", &self.tls_private_key.is_some())
196 .field("secret_provider_configured", &self.secret_provider.is_some())
197 .field("admin_identity_configured", &self.admin_identity.is_some())
198 .field("request_policy_configured", &self.request_policy.is_some())
199 .finish()
200 }
201}
202
203impl SecurityBootstrapConfig {
204 pub const fn new(profile: SecurityBootstrapProfile) -> Self {
206 Self {
207 profile,
208 trust_anchor: None,
209 tls_certificate: None,
210 tls_private_key: None,
211 secret_provider: None,
212 admin_identity: None,
213 request_policy: None,
214 }
215 }
216
217 pub fn from_env() -> Result<SecurityBootstrap, SecurityContractViolation> {
227 SecurityBootstrapEnvironment::from_env()?.resolve()
228 }
229
230 pub fn with_trust_anchor(mut self, path: impl Into<PathBuf>) -> Self {
232 self.trust_anchor = Some(path.into());
233 self
234 }
235
236 pub fn with_tls_identity(mut self, certificate: impl Into<PathBuf>, private_key: impl Into<PathBuf>) -> Self {
238 self.tls_certificate = Some(certificate.into());
239 self.tls_private_key = Some(private_key.into());
240 self
241 }
242
243 pub fn with_secret_provider(mut self, provider: impl Into<String>) -> Self {
245 self.secret_provider = Some(provider.into());
246 self
247 }
248
249 pub fn with_admin_identity(mut self, path: impl Into<PathBuf>) -> Self {
251 self.admin_identity = Some(path.into());
252 self
253 }
254
255 pub fn with_request_policy(mut self, path: impl Into<PathBuf>) -> Self {
257 self.request_policy = Some(path.into());
258 self
259 }
260
261 pub fn validate(
268 &self,
269 listener_addresses: &[SocketAddr],
270 ) -> Result<ValidatedSecurityBootstrap, SecurityProviderError> {
271 match self.profile {
272 SecurityBootstrapProfile::DevelopmentInsecureLoopback => {
273 if listener_addresses.iter().any(|address| !address.ip().is_loopback()) {
274 return Err(SecurityProviderError::contract(
275 SecurityOperation::Validate,
276 SecurityContractViolation::DevelopmentListenerNotLoopback,
277 ));
278 }
279 }
280 SecurityBootstrapProfile::SecureEnforced => {
281 inspect_bootstrap_file(self.trust_anchor.as_deref(), SecurityBootstrapMaterial::TrustAnchor)?;
282 inspect_bootstrap_file(
283 self.tls_certificate.as_deref(),
284 SecurityBootstrapMaterial::TlsCertificate,
285 )?;
286 inspect_bootstrap_file(
287 self.tls_private_key.as_deref(),
288 SecurityBootstrapMaterial::TlsPrivateKey,
289 )?;
290 let provider = self
291 .secret_provider
292 .as_deref()
293 .map(str::trim)
294 .filter(|provider| !provider.is_empty())
295 .ok_or(SecurityContractViolation::SecretProviderRequired)
296 .map_err(|violation| SecurityProviderError::contract(SecurityOperation::Validate, violation))?;
297 if provider != MOUNTED_FILES_SECRET_PROVIDER {
298 return Err(SecurityProviderError::contract(
299 SecurityOperation::Validate,
300 SecurityContractViolation::SecretProviderUnsupported,
301 ));
302 }
303 inspect_bootstrap_file(self.admin_identity.as_deref(), SecurityBootstrapMaterial::AdminIdentity)?;
304 inspect_bootstrap_file(self.request_policy.as_deref(), SecurityBootstrapMaterial::RequestPolicy)?;
305 }
306 }
307
308 Ok(ValidatedSecurityBootstrap {
309 profile: self.profile,
310 listener_count: listener_addresses.len(),
311 })
312 }
313}
314
315pub fn validate_security_bootstrap_from_env(
321 listener_addresses: &[SocketAddr],
322) -> Result<SecurityBootstrapOutcome, SecurityProviderError> {
323 SecurityBootstrapConfig::from_env()
324 .map_err(SecurityProviderError::from)?
325 .validate(listener_addresses)
326}
327
328#[derive(Debug, Clone, Copy, PartialEq, Eq)]
330pub enum SecurityBootstrapOutcome {
331 Disabled,
333 Validated(ValidatedSecurityBootstrap),
335}
336
337#[derive(Debug, Clone, Copy, PartialEq, Eq)]
339pub struct ValidatedSecurityBootstrap {
340 profile: SecurityBootstrapProfile,
341 listener_count: usize,
342}
343
344impl ValidatedSecurityBootstrap {
345 pub const fn profile(self) -> SecurityBootstrapProfile {
347 self.profile
348 }
349
350 pub const fn listener_count(self) -> usize {
352 self.listener_count
353 }
354}
355
356#[derive(Debug, Clone, Copy, PartialEq, Eq)]
357pub enum SecurityBootstrapMaterial {
359 TrustAnchor,
361 TlsCertificate,
363 TlsPrivateKey,
365 AdminIdentity,
367 RequestPolicy,
369}
370
371impl fmt::Display for SecurityBootstrapMaterial {
372 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
373 f.write_str(match self {
374 Self::TrustAnchor => "trust anchor",
375 Self::TlsCertificate => "TLS certificate",
376 Self::TlsPrivateKey => "TLS private key",
377 Self::AdminIdentity => "administrator identity",
378 Self::RequestPolicy => "request policy",
379 })
380 }
381}
382
383fn optional_path_env(name: &'static str) -> Result<Option<PathBuf>, SecurityContractViolation> {
384 optional_env(name).map(|value| value.map(PathBuf::from))
385}
386
387fn optional_env(name: &'static str) -> Result<Option<String>, SecurityContractViolation> {
388 let Some(value) = env::var_os(name) else {
389 return Ok(None);
390 };
391 let value = value
392 .into_string()
393 .map_err(|_| SecurityContractViolation::BootstrapEnvironmentEncoding)?;
394 let value = value.trim();
395 Ok((!value.is_empty()).then(|| value.to_string()))
396}
397
398fn inspect_bootstrap_file(
399 path: Option<&Path>,
400 material: SecurityBootstrapMaterial,
401) -> Result<(), SecurityProviderError> {
402 let path = path
403 .ok_or(SecurityContractViolation::BootstrapMaterialRequired { material })
404 .map_err(|violation| SecurityProviderError::contract(SecurityOperation::Validate, violation))?;
405 let metadata = path.metadata().map_err(|source| {
406 SecurityProviderError::caused_by(
407 SecurityProviderFailure::Unavailable,
408 SecurityOperation::InspectBootstrapMaterial,
409 source,
410 )
411 })?;
412 if !metadata.is_file() {
413 return Err(SecurityProviderError::contract(
414 SecurityOperation::InspectBootstrapMaterial,
415 SecurityContractViolation::BootstrapMaterialNotRegularFile { material },
416 ));
417 }
418 let file = File::open(path).map_err(|source| {
419 SecurityProviderError::caused_by(
420 SecurityProviderFailure::Unavailable,
421 SecurityOperation::InspectBootstrapMaterial,
422 source,
423 )
424 })?;
425 let metadata = file.metadata().map_err(|source| {
426 SecurityProviderError::caused_by(
427 SecurityProviderFailure::OperationFailed,
428 SecurityOperation::InspectBootstrapMaterial,
429 source,
430 )
431 })?;
432 if !metadata.is_file() {
433 return Err(SecurityProviderError::contract(
434 SecurityOperation::InspectBootstrapMaterial,
435 SecurityContractViolation::BootstrapMaterialNotRegularFile { material },
436 ));
437 }
438 if metadata.len() == 0 {
439 return Err(SecurityProviderError::contract(
440 SecurityOperation::InspectBootstrapMaterial,
441 SecurityContractViolation::BootstrapMaterialEmpty { material },
442 ));
443 }
444 Ok(())
445}
446
447#[derive(Debug, Clone, Copy, PartialEq, Eq)]
449pub enum DeploymentOrigin {
450 New,
452 Existing,
454}
455
456#[derive(Debug, Clone, Copy)]
458pub struct SecurityProfileSelection<'a> {
459 origin: DeploymentOrigin,
460 configured_profile: Option<&'a str>,
461}
462
463impl<'a> SecurityProfileSelection<'a> {
464 pub const fn new(origin: DeploymentOrigin) -> Self {
466 Self {
467 origin,
468 configured_profile: None,
469 }
470 }
471
472 pub const fn with_configured_profile(mut self, profile: &'a str) -> Self {
474 self.configured_profile = Some(profile);
475 self
476 }
477}
478
479#[derive(Debug, Clone, Copy, PartialEq, Eq)]
481pub enum SecurityMigrationStatus {
482 NotRequired,
484 CompatibilityProfileMustBePersisted,
486 MigrationToSecurePending,
488}
489
490#[derive(Debug, Clone, Copy, PartialEq, Eq)]
492pub struct SecurityProfileResolution {
493 profile: DeploymentProfile,
494 migration_status: SecurityMigrationStatus,
495}
496
497impl SecurityProfileResolution {
498 pub const fn profile(self) -> DeploymentProfile {
500 self.profile
501 }
502
503 pub const fn migration_status(self) -> SecurityMigrationStatus {
505 self.migration_status
506 }
507}
508
509pub fn resolve_security_profile(
515 selection: SecurityProfileSelection<'_>,
516) -> Result<SecurityProfileResolution, SecurityContractViolation> {
517 let configured = selection
518 .configured_profile
519 .map(str::trim)
520 .filter(|profile| !profile.is_empty());
521 let profile = match configured {
522 Some(profile) => DeploymentProfile::from_str(profile)?,
523 None if selection.origin == DeploymentOrigin::New => DeploymentProfile::Secure,
524 None => DeploymentProfile::Compatibility,
525 };
526 let migration_status = match (selection.origin, configured, profile) {
527 (DeploymentOrigin::Existing, None, DeploymentProfile::Compatibility) => {
528 SecurityMigrationStatus::CompatibilityProfileMustBePersisted
529 }
530 (_, _, DeploymentProfile::Compatibility | DeploymentProfile::Development) => {
531 SecurityMigrationStatus::MigrationToSecurePending
532 }
533 _ => SecurityMigrationStatus::NotRequired,
534 };
535 Ok(SecurityProfileResolution {
536 profile,
537 migration_status,
538 })
539}
540
541#[derive(Debug, Clone, Copy)]
543pub struct BootstrapReadinessView {
544 expires_at: SystemTime,
545 material_available: bool,
546 verified_tls_listener: bool,
547}
548
549impl BootstrapReadinessView {
550 pub const fn new(expires_at: SystemTime) -> Self {
552 Self {
553 expires_at,
554 material_available: false,
555 verified_tls_listener: false,
556 }
557 }
558
559 pub const fn with_available_material(mut self) -> Self {
561 self.material_available = true;
562 self
563 }
564
565 pub const fn with_verified_tls_listener(mut self) -> Self {
567 self.verified_tls_listener = true;
568 self
569 }
570}
571
572#[derive(Debug, Clone, Copy)]
574pub struct DeploymentSecurityConfigView<'a> {
575 profile: SecurityProfileSelection<'a>,
576 trust_anchor: Option<&'a Path>,
577 secret_provider_registered: bool,
578 provisioned_admin_identity: bool,
579 one_time_bootstrap: Option<BootstrapReadinessView>,
580 insecure_downgrade: bool,
581}
582
583impl<'a> DeploymentSecurityConfigView<'a> {
584 pub const fn new(profile: SecurityProfileSelection<'a>) -> Self {
586 Self {
587 profile,
588 trust_anchor: None,
589 secret_provider_registered: false,
590 provisioned_admin_identity: false,
591 one_time_bootstrap: None,
592 insecure_downgrade: false,
593 }
594 }
595
596 pub const fn with_trust_anchor(mut self, path: &'a Path) -> Self {
598 self.trust_anchor = Some(path);
599 self
600 }
601
602 pub const fn with_registered_secret_provider(mut self) -> Self {
604 self.secret_provider_registered = true;
605 self
606 }
607
608 pub const fn with_provisioned_admin_identity(mut self) -> Self {
610 self.provisioned_admin_identity = true;
611 self
612 }
613
614 pub const fn with_one_time_bootstrap(mut self, bootstrap: BootstrapReadinessView) -> Self {
616 self.one_time_bootstrap = Some(bootstrap);
617 self
618 }
619
620 pub const fn with_insecure_downgrade(mut self) -> Self {
622 self.insecure_downgrade = true;
623 self
624 }
625}
626
627#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
629pub enum DeploymentSecurityFailure {
630 MissingTrustAnchor,
632 TrustAnchorUnavailable,
634 TrustAnchorNotRegularFile,
636 MissingSecretProvider,
638 MissingIdentityBootstrap,
640 MultipleIdentityBootstrapSources,
642 MissingBootstrapMaterial,
644 ExpiredBootstrap,
646 BootstrapListenerNotTls,
648 InsecureDowngrade,
650}
651
652#[derive(Debug, Clone, PartialEq, Eq)]
654pub struct DeploymentSecurityReport {
655 resolution: SecurityProfileResolution,
656 failures: Vec<DeploymentSecurityFailure>,
657}
658
659impl DeploymentSecurityReport {
660 pub fn is_ready(&self) -> bool {
662 self.failures.is_empty()
663 }
664
665 pub const fn resolution(&self) -> SecurityProfileResolution {
667 self.resolution
668 }
669
670 pub fn failures(&self) -> &[DeploymentSecurityFailure] {
672 &self.failures
673 }
674}
675
676pub fn validate_deployment_security(
682 view: DeploymentSecurityConfigView<'_>,
683 now: SystemTime,
684) -> Result<DeploymentSecurityReport, SecurityContractViolation> {
685 let resolution = resolve_security_profile(view.profile)?;
686 let mut failures = Vec::new();
687 if resolution.profile == DeploymentProfile::Secure {
688 inspect_trust_anchor(view.trust_anchor, &mut failures);
689 if !view.secret_provider_registered {
690 failures.push(DeploymentSecurityFailure::MissingSecretProvider);
691 }
692 match (view.provisioned_admin_identity, view.one_time_bootstrap) {
693 (false, None) => failures.push(DeploymentSecurityFailure::MissingIdentityBootstrap),
694 (true, Some(_)) => failures.push(DeploymentSecurityFailure::MultipleIdentityBootstrapSources),
695 _ => {}
696 }
697 if let Some(bootstrap) = view.one_time_bootstrap {
698 if !bootstrap.material_available {
699 failures.push(DeploymentSecurityFailure::MissingBootstrapMaterial);
700 }
701 if bootstrap.expires_at <= now {
702 failures.push(DeploymentSecurityFailure::ExpiredBootstrap);
703 }
704 if !bootstrap.verified_tls_listener {
705 failures.push(DeploymentSecurityFailure::BootstrapListenerNotTls);
706 }
707 }
708 if view.insecure_downgrade {
709 failures.push(DeploymentSecurityFailure::InsecureDowngrade);
710 }
711 }
712 Ok(DeploymentSecurityReport { resolution, failures })
713}
714
715fn inspect_trust_anchor(path: Option<&Path>, failures: &mut Vec<DeploymentSecurityFailure>) {
716 let Some(path) = path else {
717 failures.push(DeploymentSecurityFailure::MissingTrustAnchor);
718 return;
719 };
720 let Ok(path_metadata) = path.metadata() else {
721 failures.push(DeploymentSecurityFailure::TrustAnchorUnavailable);
722 return;
723 };
724 if !path_metadata.is_file() {
725 failures.push(DeploymentSecurityFailure::TrustAnchorNotRegularFile);
726 return;
727 }
728 let Ok(file) = File::open(path) else {
729 failures.push(DeploymentSecurityFailure::TrustAnchorUnavailable);
730 return;
731 };
732 match file.metadata() {
733 Ok(metadata) if metadata.is_file() => {}
734 Ok(_) => failures.push(DeploymentSecurityFailure::TrustAnchorNotRegularFile),
735 Err(_) => failures.push(DeploymentSecurityFailure::TrustAnchorUnavailable),
736 }
737}
738
739#[cfg(test)]
740mod tests {
741 use std::time::Duration;
742
743 use tempfile::NamedTempFile;
744
745 use super::*;
746
747 fn contract_source(error: &SecurityProviderError) -> SecurityContractViolation {
748 *error
749 .source()
750 .and_then(|source| source.downcast_ref::<SecurityContractViolation>())
751 .expect("provider error should retain its contract source")
752 }
753
754 #[test]
755 fn security_bootstrap_environment_without_profile_or_fields_is_disabled() {
756 let security_bootstrap = SecurityBootstrapEnvironment::default()
757 .resolve()
758 .expect("empty security bootstrap environment should resolve");
759
760 assert!(matches!(security_bootstrap, SecurityBootstrap::Disabled));
761 }
762
763 #[test]
764 fn security_bootstrap_environment_rejects_every_field_without_profile() {
765 let configured_environments = [
766 SecurityBootstrapEnvironment {
767 trust_anchor: Some(PathBuf::from("ca.crt")),
768 ..SecurityBootstrapEnvironment::default()
769 },
770 SecurityBootstrapEnvironment {
771 tls_certificate: Some(PathBuf::from("tls.crt")),
772 ..SecurityBootstrapEnvironment::default()
773 },
774 SecurityBootstrapEnvironment {
775 tls_private_key: Some(PathBuf::from("tls.key")),
776 ..SecurityBootstrapEnvironment::default()
777 },
778 SecurityBootstrapEnvironment {
779 secret_provider: Some(MOUNTED_FILES_SECRET_PROVIDER.to_string()),
780 ..SecurityBootstrapEnvironment::default()
781 },
782 SecurityBootstrapEnvironment {
783 admin_identity: Some(PathBuf::from("admin.identity")),
784 ..SecurityBootstrapEnvironment::default()
785 },
786 SecurityBootstrapEnvironment {
787 request_policy: Some(PathBuf::from("request-policy.json")),
788 ..SecurityBootstrapEnvironment::default()
789 },
790 ];
791
792 for environment in configured_environments {
793 assert_eq!(
794 environment
795 .resolve()
796 .expect_err("security fields without a profile must fail"),
797 SecurityContractViolation::BootstrapProfileRequired
798 );
799 }
800 }
801
802 #[test]
803 fn security_bootstrap_environment_enables_known_profiles_and_rejects_unknown_profile() {
804 let development = SecurityBootstrapEnvironment {
805 profile: Some("development-insecure-loopback".to_string()),
806 ..SecurityBootstrapEnvironment::default()
807 }
808 .resolve()
809 .expect("development profile should resolve");
810 let SecurityBootstrap::Enabled(development) = development else {
811 panic!("development profile must enable security bootstrap");
812 };
813 assert_eq!(
814 development
815 .validate(&[])
816 .expect("development profile without listeners should validate")
817 .profile(),
818 SecurityBootstrapProfile::DevelopmentInsecureLoopback
819 );
820
821 let secure = SecurityBootstrapEnvironment {
822 profile: Some("secure-enforced".to_string()),
823 ..SecurityBootstrapEnvironment::default()
824 }
825 .resolve()
826 .expect("secure profile should resolve");
827 let SecurityBootstrap::Enabled(secure) = secure else {
828 panic!("secure profile must enable security bootstrap");
829 };
830 let error = secure
831 .validate(&[])
832 .expect_err("secure profile without material must fail closed");
833 assert_eq!(
834 contract_source(&error),
835 SecurityContractViolation::BootstrapMaterialRequired {
836 material: SecurityBootstrapMaterial::TrustAnchor,
837 }
838 );
839
840 assert_eq!(
841 SecurityBootstrapEnvironment {
842 profile: Some("unknown".to_string()),
843 ..SecurityBootstrapEnvironment::default()
844 }
845 .resolve()
846 .expect_err("unknown profile must fail closed"),
847 SecurityContractViolation::UnknownSecurityProfile
848 );
849 }
850
851 #[test]
852 fn new_defaults_secure_while_existing_defaults_compatibility_with_report() {
853 let new = resolve_security_profile(SecurityProfileSelection::new(DeploymentOrigin::New)).unwrap();
854 assert_eq!(new.profile(), DeploymentProfile::Secure);
855 assert_eq!(new.migration_status(), SecurityMigrationStatus::NotRequired);
856
857 let existing = resolve_security_profile(SecurityProfileSelection::new(DeploymentOrigin::Existing)).unwrap();
858 assert_eq!(existing.profile(), DeploymentProfile::Compatibility);
859 assert_eq!(
860 existing.migration_status(),
861 SecurityMigrationStatus::CompatibilityProfileMustBePersisted
862 );
863 }
864
865 #[test]
866 fn unknown_profile_never_downgrades() {
867 let selection = SecurityProfileSelection::new(DeploymentOrigin::Existing).with_configured_profile("unknown");
868 assert_eq!(
869 resolve_security_profile(selection).unwrap_err(),
870 SecurityContractViolation::UnknownSecurityProfile
871 );
872 }
873
874 #[test]
875 fn secure_provisioned_identity_requires_trust_and_provider() {
876 let now = SystemTime::now();
877 let missing = validate_deployment_security(
878 DeploymentSecurityConfigView::new(SecurityProfileSelection::new(DeploymentOrigin::New))
879 .with_provisioned_admin_identity(),
880 now,
881 )
882 .unwrap();
883 assert_eq!(
884 missing.failures(),
885 &[
886 DeploymentSecurityFailure::MissingTrustAnchor,
887 DeploymentSecurityFailure::MissingSecretProvider,
888 ]
889 );
890
891 let trust_anchor = NamedTempFile::new().unwrap();
892 let ready = validate_deployment_security(
893 DeploymentSecurityConfigView::new(SecurityProfileSelection::new(DeploymentOrigin::New))
894 .with_trust_anchor(trust_anchor.path())
895 .with_registered_secret_provider()
896 .with_provisioned_admin_identity(),
897 now,
898 )
899 .unwrap();
900 assert!(ready.is_ready());
901 }
902
903 #[test]
904 fn token_bootstrap_requires_one_source_material_expiry_and_tls() {
905 let now = SystemTime::now();
906 let trust_anchor = NamedTempFile::new().unwrap();
907 let invalid = validate_deployment_security(
908 DeploymentSecurityConfigView::new(SecurityProfileSelection::new(DeploymentOrigin::New))
909 .with_trust_anchor(trust_anchor.path())
910 .with_registered_secret_provider()
911 .with_provisioned_admin_identity()
912 .with_one_time_bootstrap(BootstrapReadinessView::new(now - Duration::from_secs(1))),
913 now,
914 )
915 .unwrap();
916 assert_eq!(
917 invalid.failures(),
918 &[
919 DeploymentSecurityFailure::MultipleIdentityBootstrapSources,
920 DeploymentSecurityFailure::MissingBootstrapMaterial,
921 DeploymentSecurityFailure::ExpiredBootstrap,
922 DeploymentSecurityFailure::BootstrapListenerNotTls,
923 ]
924 );
925
926 let ready = validate_deployment_security(
927 DeploymentSecurityConfigView::new(SecurityProfileSelection::new(DeploymentOrigin::New))
928 .with_trust_anchor(trust_anchor.path())
929 .with_registered_secret_provider()
930 .with_one_time_bootstrap(
931 BootstrapReadinessView::new(now + Duration::from_secs(60))
932 .with_available_material()
933 .with_verified_tls_listener(),
934 ),
935 now,
936 )
937 .unwrap();
938 assert!(ready.is_ready());
939 }
940
941 #[test]
942 fn compatibility_is_ready_without_secure_material_but_reports_migration() {
943 let report = validate_deployment_security(
944 DeploymentSecurityConfigView::new(SecurityProfileSelection::new(DeploymentOrigin::Existing)),
945 SystemTime::now(),
946 )
947 .unwrap();
948 assert!(report.is_ready());
949 assert_eq!(
950 report.resolution().migration_status(),
951 SecurityMigrationStatus::CompatibilityProfileMustBePersisted
952 );
953 }
954}