1mod model;
18
19pub use model::BreakGlassStatus;
20pub use model::CredentialRotationSnapshot;
21pub use model::CredentialVerification;
22pub use model::CredentialVerificationSource;
23pub use model::RetiringCredentialSnapshot;
24
25use std::collections::BTreeSet;
26use std::fmt;
27use std::sync::Arc;
28use std::sync::Mutex;
29use std::time::Duration;
30use std::time::SystemTime;
31use std::time::UNIX_EPOCH;
32
33use arc_swap::ArcSwap;
34use cheetah_string::CheetahString;
35use rocketmq_security_api::SecretMaterial;
36use rocketmq_security_api::SecretName;
37use rocketmq_security_api::SecretProvider;
38use rocketmq_security_api::SecretVersion;
39use sha2::Digest;
40use sha2::Sha256;
41use subtle::ConstantTimeEq;
42
43use crate::AuthFailureKind;
44use crate::AuthOperation;
45use crate::AuthServiceError;
46use crate::AuthServiceResult;
47
48const MINIMUM_CREDENTIAL_PROOF_BYTES: usize = 32;
49
50#[derive(Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
52pub struct CredentialId(CheetahString);
53
54impl CredentialId {
55 pub fn new(value: impl Into<CheetahString>) -> AuthServiceResult<Self> {
62 let value = value.into();
63 if value.is_empty()
64 || value.len() > 128
65 || value.starts_with('.')
66 || !value
67 .bytes()
68 .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.'))
69 {
70 return Err(rotation_error(AuthFailureKind::InvalidInput));
71 }
72 Ok(Self(value))
73 }
74
75 pub fn as_str(&self) -> &str {
76 &self.0
77 }
78}
79
80impl fmt::Debug for CredentialId {
81 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
82 f.debug_tuple("CredentialId").field(&self.0).finish()
83 }
84}
85
86#[derive(Clone, PartialEq, Eq)]
88pub struct CredentialDescriptor {
89 id: CredentialId,
90 certificate_sha256: [u8; 32],
91 valid_from_unix_seconds: u64,
92 expires_at_unix_seconds: u64,
93 provider_version: Option<SecretVersion>,
94}
95
96impl fmt::Debug for CredentialDescriptor {
97 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
98 f.debug_struct("CredentialDescriptor")
99 .field("id", &self.id)
100 .field("certificate_sha256", &"[REDACTED]")
101 .field("valid_from_unix_seconds", &self.valid_from_unix_seconds)
102 .field("expires_at_unix_seconds", &self.expires_at_unix_seconds)
103 .field("provider_version", &self.provider_version)
104 .finish()
105 }
106}
107
108impl CredentialDescriptor {
109 pub fn id(&self) -> &CredentialId {
110 &self.id
111 }
112
113 pub const fn certificate_sha256(&self) -> &[u8; 32] {
114 &self.certificate_sha256
115 }
116
117 pub const fn valid_from_unix_seconds(&self) -> u64 {
118 self.valid_from_unix_seconds
119 }
120
121 pub const fn expires_at_unix_seconds(&self) -> u64 {
122 self.expires_at_unix_seconds
123 }
124
125 pub const fn provider_version(&self) -> Option<SecretVersion> {
126 self.provider_version
127 }
128}
129
130pub struct ValidatedCredential {
132 descriptor: CredentialDescriptor,
133 proof_digest: [u8; 32],
134}
135
136impl ValidatedCredential {
137 pub fn new(
146 id: CredentialId,
147 certificate_sha256: [u8; 32],
148 valid_from: SystemTime,
149 expires_at: SystemTime,
150 proof: SecretMaterial,
151 provider_version: Option<SecretVersion>,
152 ) -> AuthServiceResult<Self> {
153 let valid_from_unix_seconds = unix_seconds(valid_from)?;
154 let expires_at_unix_seconds = unix_seconds(expires_at)?;
155 if valid_from_unix_seconds >= expires_at_unix_seconds
156 || certificate_sha256.iter().all(|byte| *byte == 0)
157 || proof.len() < MINIMUM_CREDENTIAL_PROOF_BYTES
158 {
159 return Err(rotation_error(AuthFailureKind::InvalidInput));
160 }
161 Ok(Self {
162 descriptor: CredentialDescriptor {
163 id,
164 certificate_sha256,
165 valid_from_unix_seconds,
166 expires_at_unix_seconds,
167 provider_version,
168 },
169 proof_digest: Sha256::digest(proof.expose_secret()).into(),
170 })
171 }
172
173 pub fn descriptor(&self) -> &CredentialDescriptor {
174 &self.descriptor
175 }
176
177 fn is_valid_at(&self, now: u64) -> bool {
178 self.descriptor.valid_from_unix_seconds <= now && now < self.descriptor.expires_at_unix_seconds
179 }
180
181 fn verifies(&self, proof: &SecretMaterial) -> bool {
182 let presented: [u8; 32] = Sha256::digest(proof.expose_secret()).into();
183 self.proof_digest.ct_eq(&presented).unwrap_u8() == 1
184 }
185}
186
187impl fmt::Debug for ValidatedCredential {
188 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
189 f.debug_struct("ValidatedCredential")
190 .field("descriptor", &self.descriptor)
191 .field("proof_digest", &"[REDACTED]")
192 .finish()
193 }
194}
195
196pub trait CredentialBundleParser: Send + Sync {
198 fn parse(
203 &self,
204 material: SecretMaterial,
205 provider_version: Option<SecretVersion>,
206 ) -> AuthServiceResult<ValidatedCredential>;
207}
208
209#[derive(Debug, Clone, Copy, PartialEq, Eq)]
211pub enum BreakGlassReason {
212 IdentityProviderOutage,
213 CertificateRecovery,
214 AdministrativeRecovery,
215}
216
217#[derive(Debug, Clone, Copy, PartialEq, Eq)]
219pub enum CredentialAuditAction {
220 RotationStarted,
221 RetiringCredentialRevoked,
222 RotationRolledBack,
223 ReloadRejected,
224 BreakGlassEnabled,
225 BreakGlassDisabled,
226}
227
228#[derive(Debug, Clone, Copy, PartialEq, Eq)]
229pub enum CredentialAuditOutcome {
230 Authorized,
231 Rejected,
232}
233
234#[derive(Clone, PartialEq, Eq)]
236pub struct CredentialAuditEvent {
237 action: CredentialAuditAction,
238 outcome: CredentialAuditOutcome,
239 generation: u64,
240 credential_id: Option<CredentialId>,
241 related_credential_id: Option<CredentialId>,
242 break_glass_reason: Option<BreakGlassReason>,
243}
244
245impl fmt::Debug for CredentialAuditEvent {
246 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
247 f.debug_struct("CredentialAuditEvent")
248 .field("action", &self.action)
249 .field("outcome", &self.outcome)
250 .field("generation", &self.generation)
251 .field("credential_id", &self.credential_id.as_ref().map(|_| "[REDACTED]"))
252 .field(
253 "related_credential_id",
254 &self.related_credential_id.as_ref().map(|_| "[REDACTED]"),
255 )
256 .field("break_glass_reason", &self.break_glass_reason)
257 .finish()
258 }
259}
260
261impl CredentialAuditEvent {
262 pub const fn action(&self) -> CredentialAuditAction {
263 self.action
264 }
265
266 pub const fn outcome(&self) -> CredentialAuditOutcome {
267 self.outcome
268 }
269
270 pub const fn generation(&self) -> u64 {
271 self.generation
272 }
273
274 pub fn credential_id(&self) -> Option<&CredentialId> {
275 self.credential_id.as_ref()
276 }
277
278 pub fn related_credential_id(&self) -> Option<&CredentialId> {
279 self.related_credential_id.as_ref()
280 }
281
282 pub const fn break_glass_reason(&self) -> Option<BreakGlassReason> {
283 self.break_glass_reason
284 }
285}
286
287pub trait CredentialAuditSink: Send + Sync {
289 fn record(&self, event: &CredentialAuditEvent) -> AuthServiceResult<()>;
290}
291
292struct RetiringCredential {
293 credential: Arc<ValidatedCredential>,
294 accept_until_unix_seconds: u64,
295}
296
297struct BreakGlassActivation {
298 expires_at_unix_seconds: u64,
299 reason: BreakGlassReason,
300}
301
302struct BreakGlassCredential {
303 credential: Arc<ValidatedCredential>,
304 activation: Option<BreakGlassActivation>,
305}
306
307struct RotationState {
308 generation: u64,
309 active: Arc<ValidatedCredential>,
310 retiring: Option<RetiringCredential>,
311 revoked: BTreeSet<CredentialId>,
312 break_glass: Option<BreakGlassCredential>,
313}
314
315pub struct CredentialRotationManager {
318 state: ArcSwap<RotationState>,
319 writer: Mutex<()>,
320 audit: Arc<dyn CredentialAuditSink>,
321 maximum_break_glass_duration: Duration,
322}
323
324impl CredentialRotationManager {
325 pub fn new(
332 active: ValidatedCredential,
333 break_glass: Option<ValidatedCredential>,
334 maximum_break_glass_duration: Duration,
335 now: SystemTime,
336 audit: Arc<dyn CredentialAuditSink>,
337 ) -> AuthServiceResult<Self> {
338 let now = unix_seconds(now)?;
339 if !active.is_valid_at(now) || maximum_break_glass_duration.is_zero() {
340 return Err(rotation_error(AuthFailureKind::InvalidInput));
341 }
342 if break_glass
343 .as_ref()
344 .is_some_and(|credential| credential.descriptor.id == active.descriptor.id || !credential.is_valid_at(now))
345 {
346 return Err(rotation_error(AuthFailureKind::InvalidInput));
347 }
348 Ok(Self {
349 state: ArcSwap::from_pointee(RotationState {
350 generation: 1,
351 active: Arc::new(active),
352 retiring: None,
353 revoked: BTreeSet::new(),
354 break_glass: break_glass.map(|credential| BreakGlassCredential {
355 credential: Arc::new(credential),
356 activation: None,
357 }),
358 }),
359 writer: Mutex::new(()),
360 audit,
361 maximum_break_glass_duration,
362 })
363 }
364
365 pub fn snapshot(&self) -> CredentialRotationSnapshot {
366 let state = self.state.load();
367 CredentialRotationSnapshot {
368 generation: state.generation,
369 active: state.active.descriptor.clone(),
370 retiring: state.retiring.as_ref().map(|retiring| RetiringCredentialSnapshot {
371 credential: retiring.credential.descriptor.clone(),
372 accept_until_unix_seconds: retiring.accept_until_unix_seconds,
373 }),
374 revoked: state.revoked.iter().cloned().collect(),
375 break_glass: match state.break_glass.as_ref() {
376 None => BreakGlassStatus::NotConfigured,
377 Some(break_glass) => match break_glass.activation.as_ref() {
378 None => BreakGlassStatus::Disabled {
379 credential: break_glass.credential.descriptor.clone(),
380 },
381 Some(activation) => BreakGlassStatus::Enabled {
382 credential: break_glass.credential.descriptor.clone(),
383 expires_at_unix_seconds: activation.expires_at_unix_seconds,
384 reason: activation.reason,
385 },
386 },
387 },
388 }
389 }
390
391 pub fn reload_from_provider(
398 &self,
399 provider: &dyn SecretProvider,
400 name: &SecretName,
401 parser: &dyn CredentialBundleParser,
402 overlap_until: SystemTime,
403 now: SystemTime,
404 ) -> AuthServiceResult<u64> {
405 let loaded = match provider.read(name) {
406 Ok(loaded) => loaded,
407 Err(source) => {
408 self.audit_rejected_reload()?;
409 return Err(AuthServiceError::with_source(
410 AuthOperation::RotateCredential,
411 AuthFailureKind::Unavailable,
412 source,
413 ));
414 }
415 };
416 let provider_version = loaded.version();
417 let candidate = match parser.parse(loaded.into_material(), provider_version) {
418 Ok(candidate) if candidate.descriptor.provider_version == provider_version => candidate,
419 Ok(_) => {
420 self.audit_rejected_reload()?;
421 return Err(rotation_error(AuthFailureKind::InvalidData));
422 }
423 Err(source) => {
424 self.audit_rejected_reload()?;
425 return Err(AuthServiceError::with_source(
426 AuthOperation::RotateCredential,
427 AuthFailureKind::InvalidData,
428 source,
429 ));
430 }
431 };
432 self.start_rotation(candidate, overlap_until, now)
433 }
434
435 pub fn start_rotation(
438 &self,
439 candidate: ValidatedCredential,
440 overlap_until: SystemTime,
441 now: SystemTime,
442 ) -> AuthServiceResult<u64> {
443 let _writer = self
444 .writer
445 .lock()
446 .map_err(|_| rotation_error(AuthFailureKind::Internal))?;
447 let state = self.state.load_full();
448 if state.retiring.is_some() {
449 return Err(rotation_error(AuthFailureKind::Conflict));
450 }
451 let now = unix_seconds(now)?;
452 let overlap_until = unix_seconds(overlap_until)?;
453 if !candidate.is_valid_at(now)
454 || overlap_until <= now
455 || overlap_until > candidate.descriptor.expires_at_unix_seconds
456 || overlap_until > state.active.descriptor.expires_at_unix_seconds
457 || candidate.descriptor.id == state.active.descriptor.id
458 || state.revoked.contains(&candidate.descriptor.id)
459 || state
460 .break_glass
461 .as_ref()
462 .is_some_and(|break_glass| break_glass.credential.descriptor.id == candidate.descriptor.id)
463 {
464 return Err(rotation_error(AuthFailureKind::InvalidInput));
465 }
466 let generation = next_generation(state.generation)?;
467 self.record_audit(CredentialAuditEvent {
468 action: CredentialAuditAction::RotationStarted,
469 outcome: CredentialAuditOutcome::Authorized,
470 generation,
471 credential_id: Some(candidate.descriptor.id.clone()),
472 related_credential_id: Some(state.active.descriptor.id.clone()),
473 break_glass_reason: None,
474 })?;
475 self.state.store(Arc::new(RotationState {
476 generation,
477 active: Arc::new(candidate),
478 retiring: Some(RetiringCredential {
479 credential: state.active.clone(),
480 accept_until_unix_seconds: overlap_until,
481 }),
482 revoked: state.revoked.clone(),
483 break_glass: clone_break_glass(&state.break_glass),
484 }));
485 Ok(generation)
486 }
487
488 pub fn finalize_rotation(&self, now: SystemTime) -> AuthServiceResult<u64> {
490 let _writer = self
491 .writer
492 .lock()
493 .map_err(|_| rotation_error(AuthFailureKind::Internal))?;
494 let state = self.state.load_full();
495 let retiring = state
496 .retiring
497 .as_ref()
498 .ok_or_else(|| rotation_error(AuthFailureKind::Conflict))?;
499 if unix_seconds(now)? < retiring.accept_until_unix_seconds {
500 return Err(rotation_error(AuthFailureKind::Conflict));
501 }
502 let generation = next_generation(state.generation)?;
503 self.record_audit(CredentialAuditEvent {
504 action: CredentialAuditAction::RetiringCredentialRevoked,
505 outcome: CredentialAuditOutcome::Authorized,
506 generation,
507 credential_id: Some(retiring.credential.descriptor.id.clone()),
508 related_credential_id: Some(state.active.descriptor.id.clone()),
509 break_glass_reason: None,
510 })?;
511 let mut revoked = state.revoked.clone();
512 revoked.insert(retiring.credential.descriptor.id.clone());
513 self.state.store(Arc::new(RotationState {
514 generation,
515 active: state.active.clone(),
516 retiring: None,
517 revoked,
518 break_glass: clone_break_glass(&state.break_glass),
519 }));
520 Ok(generation)
521 }
522
523 pub fn rollback(&self, now: SystemTime) -> AuthServiceResult<u64> {
525 let _writer = self
526 .writer
527 .lock()
528 .map_err(|_| rotation_error(AuthFailureKind::Internal))?;
529 let state = self.state.load_full();
530 let retiring = state
531 .retiring
532 .as_ref()
533 .ok_or_else(|| rotation_error(AuthFailureKind::Conflict))?;
534 let now = unix_seconds(now)?;
535 if !retiring.credential.is_valid_at(now) || state.revoked.contains(&retiring.credential.descriptor.id) {
536 return Err(rotation_error(AuthFailureKind::Unauthenticated));
537 }
538 let generation = next_generation(state.generation)?;
539 self.record_audit(CredentialAuditEvent {
540 action: CredentialAuditAction::RotationRolledBack,
541 outcome: CredentialAuditOutcome::Authorized,
542 generation,
543 credential_id: Some(retiring.credential.descriptor.id.clone()),
544 related_credential_id: Some(state.active.descriptor.id.clone()),
545 break_glass_reason: None,
546 })?;
547 let mut revoked = state.revoked.clone();
548 revoked.insert(state.active.descriptor.id.clone());
549 self.state.store(Arc::new(RotationState {
550 generation,
551 active: retiring.credential.clone(),
552 retiring: None,
553 revoked,
554 break_glass: clone_break_glass(&state.break_glass),
555 }));
556 Ok(generation)
557 }
558
559 pub fn enable_break_glass(
560 &self,
561 reason: BreakGlassReason,
562 expires_at: SystemTime,
563 now: SystemTime,
564 ) -> AuthServiceResult<u64> {
565 let _writer = self
566 .writer
567 .lock()
568 .map_err(|_| rotation_error(AuthFailureKind::Internal))?;
569 let state = self.state.load_full();
570 let break_glass = state
571 .break_glass
572 .as_ref()
573 .ok_or_else(|| rotation_error(AuthFailureKind::NotFound))?;
574 if break_glass.activation.is_some() {
575 return Err(rotation_error(AuthFailureKind::Conflict));
576 }
577 let now = unix_seconds(now)?;
578 let expires_at = unix_seconds(expires_at)?;
579 let maximum = now.saturating_add(self.maximum_break_glass_duration.as_secs());
580 if expires_at <= now
581 || expires_at > maximum
582 || expires_at > break_glass.credential.descriptor.expires_at_unix_seconds
583 {
584 return Err(rotation_error(AuthFailureKind::InvalidInput));
585 }
586 let generation = next_generation(state.generation)?;
587 self.record_audit(CredentialAuditEvent {
588 action: CredentialAuditAction::BreakGlassEnabled,
589 outcome: CredentialAuditOutcome::Authorized,
590 generation,
591 credential_id: Some(break_glass.credential.descriptor.id.clone()),
592 related_credential_id: None,
593 break_glass_reason: Some(reason),
594 })?;
595 self.state.store(Arc::new(RotationState {
596 generation,
597 active: state.active.clone(),
598 retiring: clone_retiring(&state.retiring),
599 revoked: state.revoked.clone(),
600 break_glass: Some(BreakGlassCredential {
601 credential: break_glass.credential.clone(),
602 activation: Some(BreakGlassActivation {
603 expires_at_unix_seconds: expires_at,
604 reason,
605 }),
606 }),
607 }));
608 Ok(generation)
609 }
610
611 pub fn disable_break_glass(&self) -> AuthServiceResult<u64> {
612 let _writer = self
613 .writer
614 .lock()
615 .map_err(|_| rotation_error(AuthFailureKind::Internal))?;
616 let state = self.state.load_full();
617 let break_glass = state
618 .break_glass
619 .as_ref()
620 .ok_or_else(|| rotation_error(AuthFailureKind::NotFound))?;
621 let activation = break_glass
622 .activation
623 .as_ref()
624 .ok_or_else(|| rotation_error(AuthFailureKind::Conflict))?;
625 let generation = next_generation(state.generation)?;
626 self.record_audit(CredentialAuditEvent {
627 action: CredentialAuditAction::BreakGlassDisabled,
628 outcome: CredentialAuditOutcome::Authorized,
629 generation,
630 credential_id: Some(break_glass.credential.descriptor.id.clone()),
631 related_credential_id: None,
632 break_glass_reason: Some(activation.reason),
633 })?;
634 self.state.store(Arc::new(RotationState {
635 generation,
636 active: state.active.clone(),
637 retiring: clone_retiring(&state.retiring),
638 revoked: state.revoked.clone(),
639 break_glass: Some(BreakGlassCredential {
640 credential: break_glass.credential.clone(),
641 activation: None,
642 }),
643 }));
644 Ok(generation)
645 }
646
647 pub fn verify(
649 &self,
650 credential_id: &CredentialId,
651 proof: SecretMaterial,
652 now: SystemTime,
653 ) -> AuthServiceResult<CredentialVerification> {
654 let now = unix_seconds(now)?;
655 let state = self.state.load();
656 if state.revoked.contains(credential_id) {
657 return Err(rotation_error(AuthFailureKind::Unauthenticated));
658 }
659 let (credential, source) = if state.active.descriptor.id == *credential_id {
660 (state.active.as_ref(), CredentialVerificationSource::Active)
661 } else if let Some(retiring) = state.retiring.as_ref().filter(|retiring| {
662 retiring.credential.descriptor.id == *credential_id && now < retiring.accept_until_unix_seconds
663 }) {
664 (retiring.credential.as_ref(), CredentialVerificationSource::Retiring)
665 } else if let Some(break_glass) = state
666 .break_glass
667 .as_ref()
668 .filter(|break_glass| break_glass.credential.descriptor.id == *credential_id)
669 {
670 let activation = break_glass
671 .activation
672 .as_ref()
673 .ok_or_else(|| rotation_error(AuthFailureKind::Conflict))?;
674 if now >= activation.expires_at_unix_seconds {
675 return Err(rotation_error(AuthFailureKind::Expired));
676 }
677 (
678 break_glass.credential.as_ref(),
679 CredentialVerificationSource::BreakGlass,
680 )
681 } else {
682 return Err(rotation_error(AuthFailureKind::Unauthenticated));
683 };
684 if !credential.is_valid_at(now) {
685 return Err(rotation_error(AuthFailureKind::Expired));
686 }
687 if !credential.verifies(&proof) {
688 return Err(rotation_error(AuthFailureKind::Unauthenticated));
689 }
690 Ok(CredentialVerification {
691 credential_id: credential_id.clone(),
692 generation: state.generation,
693 source,
694 })
695 }
696
697 fn audit_rejected_reload(&self) -> AuthServiceResult<()> {
698 let state = self.state.load();
699 self.record_audit(CredentialAuditEvent {
700 action: CredentialAuditAction::ReloadRejected,
701 outcome: CredentialAuditOutcome::Rejected,
702 generation: state.generation,
703 credential_id: None,
704 related_credential_id: Some(state.active.descriptor.id.clone()),
705 break_glass_reason: None,
706 })
707 }
708
709 fn record_audit(&self, event: CredentialAuditEvent) -> AuthServiceResult<()> {
710 self.audit.record(&event).map_err(|source| {
711 AuthServiceError::with_source(AuthOperation::RotateCredential, AuthFailureKind::Internal, source)
712 })
713 }
714}
715
716impl fmt::Debug for CredentialRotationManager {
717 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
718 f.debug_struct("CredentialRotationManager")
719 .field("snapshot", &self.snapshot())
720 .field("audit", &"[INJECTED]")
721 .finish()
722 }
723}
724
725fn clone_retiring(value: &Option<RetiringCredential>) -> Option<RetiringCredential> {
726 value.as_ref().map(|retiring| RetiringCredential {
727 credential: retiring.credential.clone(),
728 accept_until_unix_seconds: retiring.accept_until_unix_seconds,
729 })
730}
731
732fn clone_break_glass(value: &Option<BreakGlassCredential>) -> Option<BreakGlassCredential> {
733 value.as_ref().map(|break_glass| BreakGlassCredential {
734 credential: break_glass.credential.clone(),
735 activation: break_glass.activation.as_ref().map(|activation| BreakGlassActivation {
736 expires_at_unix_seconds: activation.expires_at_unix_seconds,
737 reason: activation.reason,
738 }),
739 })
740}
741
742fn next_generation(current: u64) -> AuthServiceResult<u64> {
743 current
744 .checked_add(1)
745 .ok_or_else(|| rotation_error(AuthFailureKind::Internal))
746}
747
748fn unix_seconds(time: SystemTime) -> AuthServiceResult<u64> {
749 time.duration_since(UNIX_EPOCH)
750 .map(|duration| duration.as_secs())
751 .map_err(|source| {
752 AuthServiceError::with_source(AuthOperation::RotateCredential, AuthFailureKind::InvalidInput, source)
753 })
754}
755
756fn rotation_error(kind: AuthFailureKind) -> AuthServiceError {
757 AuthServiceError::new(AuthOperation::RotateCredential, kind)
758}