Skip to main content

bitwarden_pam/rotation/
models.rs

1use bitwarden_api_api::models::{
2    PamAccessConnectorStatus as ApiAccessConnectorStatus, PamRotationAttemptResponseModel,
3    PamRotationAttemptStatus as ApiRotationAttemptStatus, PamRotationJobResponseModel,
4    PamRotationJobStatus as ApiRotationJobStatus, PamRotationSource as ApiRotationSource,
5    PamRotationSyncState as ApiRotationSyncState,
6    PamSessionTerminationOutcome as ApiSessionTerminationOutcome,
7    PamTargetSystemMethod as ApiTargetSystemMethod, PamTargetSystemStatus as ApiTargetSystemStatus,
8};
9use bitwarden_core::require;
10use bitwarden_pam_sealed::{
11    PasswordPolicyError, SealedPasswordPolicy, SealedTargetSystemKind, SealedTargetSystemMethod,
12};
13use chrono::{DateTime, Utc};
14use serde::{Deserialize, Serialize};
15
16use super::error::RotationError;
17use crate::{AccessConnectorId, RotationAttemptId, RotationConfigId, RotationJobId};
18
19/// Lifecycle state of an access connector. Removing a connector for good is a delete, not a
20/// status.
21#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq)]
22#[bitwarden_ffi::wasm_record]
23#[serde(rename_all = "snake_case")]
24pub enum AccessConnectorStatus {
25    /// The connector may authenticate and claim rotation jobs.
26    Enabled,
27    /// The connector cannot authenticate or claim jobs. Its credential is kept so it can be
28    /// re-enabled.
29    Disabled,
30    /// A status this SDK version does not recognize. Kept as a distinct variant so listing
31    /// connectors never fails against a newer server.
32    Unknown,
33}
34
35impl From<ApiAccessConnectorStatus> for AccessConnectorStatus {
36    fn from(status: ApiAccessConnectorStatus) -> Self {
37        match status {
38            ApiAccessConnectorStatus::Enabled => Self::Enabled,
39            ApiAccessConnectorStatus::Disabled => Self::Disabled,
40            ApiAccessConnectorStatus::__Unknown(_) => Self::Unknown,
41        }
42    }
43}
44
45/// How a target system's credential is rotated.
46#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq)]
47#[bitwarden_ffi::wasm_record]
48#[serde(rename_all = "snake_case")]
49pub enum TargetSystemMethod {
50    /// A connector writes the new secret directly into the target system.
51    Automatic,
52    /// An operator applies the new credential out-of-band; the config records the event.
53    Manual,
54    /// A method this SDK version does not recognize.
55    Unknown,
56}
57
58impl From<ApiTargetSystemMethod> for TargetSystemMethod {
59    fn from(method: ApiTargetSystemMethod) -> Self {
60        match method {
61            ApiTargetSystemMethod::Automatic => Self::Automatic,
62            ApiTargetSystemMethod::Manual => Self::Manual,
63            ApiTargetSystemMethod::__Unknown(_) => Self::Unknown,
64        }
65    }
66}
67
68impl TryFrom<TargetSystemMethod> for ApiTargetSystemMethod {
69    type Error = RotationError;
70
71    fn try_from(method: TargetSystemMethod) -> Result<Self, Self::Error> {
72        match method {
73            TargetSystemMethod::Automatic => Ok(Self::Automatic),
74            TargetSystemMethod::Manual => Ok(Self::Manual),
75            // `Unknown` carries no wire value to send back.
76            TargetSystemMethod::Unknown => Err(RotationError::UnrecognizedVariant),
77        }
78    }
79}
80
81/// The technology a target system represents. Only meaningful for
82/// [`Automatic`](TargetSystemMethod::Automatic) systems; a manual rotation has no integration.
83/// Lives only in the sealed blob.
84#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq)]
85#[bitwarden_ffi::wasm_record]
86#[serde(rename_all = "snake_case")]
87pub enum TargetSystemKind {
88    /// Entra ID / Azure AD service-account password rotation.
89    Entra,
90    /// An operator-supplied script run by the connector.
91    CustomScript,
92    /// On-premises Active Directory account password rotation. Distinct from
93    /// [`Entra`](TargetSystemKind::Entra), which is Entra ID / Azure AD.
94    ActiveDirectory,
95    /// A kind this SDK version does not recognize.
96    Unknown,
97}
98
99impl From<SealedTargetSystemMethod> for TargetSystemMethod {
100    fn from(method: SealedTargetSystemMethod) -> Self {
101        match method {
102            SealedTargetSystemMethod::Automatic => Self::Automatic,
103            SealedTargetSystemMethod::Manual => Self::Manual,
104        }
105    }
106}
107
108impl From<SealedTargetSystemKind> for TargetSystemKind {
109    fn from(kind: SealedTargetSystemKind) -> Self {
110        match kind {
111            SealedTargetSystemKind::Entra => Self::Entra,
112            SealedTargetSystemKind::CustomScript => Self::CustomScript,
113            SealedTargetSystemKind::ActiveDirectory => Self::ActiveDirectory,
114        }
115    }
116}
117
118impl TryFrom<TargetSystemKind> for SealedTargetSystemKind {
119    type Error = RotationError;
120
121    fn try_from(kind: TargetSystemKind) -> Result<Self, Self::Error> {
122        match kind {
123            TargetSystemKind::Entra => Ok(Self::Entra),
124            TargetSystemKind::CustomScript => Ok(Self::CustomScript),
125            TargetSystemKind::ActiveDirectory => Ok(Self::ActiveDirectory),
126            TargetSystemKind::Unknown => Err(RotationError::UnrecognizedVariant),
127        }
128    }
129}
130
131/// Lifecycle state of a target system.
132#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq)]
133#[bitwarden_ffi::wasm_record]
134#[serde(rename_all = "snake_case")]
135pub enum TargetSystemStatus {
136    /// The target system accepts rotation jobs.
137    Active,
138    /// No new rotation jobs are dispatched; jobs already in flight run to completion.
139    Disabled,
140    /// Registered but not yet sealed, left by a failed [`TargetSystemsClient::create`].
141    /// [`TargetSystemsClient::update`] finishes sealing; [`TargetSystemsClient::delete`]
142    /// discards it.
143    ///
144    /// [`TargetSystemsClient::create`]: super::TargetSystemsClient::create
145    /// [`TargetSystemsClient::update`]: super::TargetSystemsClient::update
146    /// [`TargetSystemsClient::delete`]: super::TargetSystemsClient::delete
147    PendingSeal,
148    /// A status this SDK version does not recognize.
149    Unknown,
150}
151
152impl From<ApiTargetSystemStatus> for TargetSystemStatus {
153    fn from(status: ApiTargetSystemStatus) -> Self {
154        match status {
155            ApiTargetSystemStatus::Active => Self::Active,
156            ApiTargetSystemStatus::Disabled => Self::Disabled,
157            ApiTargetSystemStatus::PendingSeal => Self::PendingSeal,
158            ApiTargetSystemStatus::__Unknown(_) => Self::Unknown,
159        }
160    }
161}
162
163/// What triggered a rotation job.
164#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq)]
165#[bitwarden_ffi::wasm_record]
166#[serde(rename_all = "snake_case")]
167pub enum RotationSource {
168    /// The cron schedule fired.
169    Scheduled,
170    /// An operator asked for a rotation now.
171    OnDemand,
172    /// An access lease ended on a config with `rotate_on_access_end` set.
173    AccessEnd,
174    /// A source this SDK version does not recognize.
175    Unknown,
176}
177
178impl From<ApiRotationSource> for RotationSource {
179    fn from(source: ApiRotationSource) -> Self {
180        match source {
181            ApiRotationSource::Scheduled => Self::Scheduled,
182            ApiRotationSource::OnDemand => Self::OnDemand,
183            ApiRotationSource::AccessEnd => Self::AccessEnd,
184            ApiRotationSource::__Unknown(_) => Self::Unknown,
185        }
186    }
187}
188
189/// Overall status of a rotation job.
190#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq)]
191#[bitwarden_ffi::wasm_record]
192#[serde(rename_all = "snake_case")]
193pub enum RotationJobStatus {
194    /// Queued and waiting for a connector to claim it.
195    Pending,
196    /// A connector is executing the job.
197    Claimed,
198    /// An attempt succeeded and updated the vault cipher.
199    Succeeded,
200    /// The job exhausted its retry budget.
201    Failed,
202    /// The job passed its deadline without a successful attempt.
203    TimedOut,
204    /// A status this SDK version does not recognize.
205    Unknown,
206}
207
208impl From<ApiRotationJobStatus> for RotationJobStatus {
209    fn from(status: ApiRotationJobStatus) -> Self {
210        match status {
211            ApiRotationJobStatus::Pending => Self::Pending,
212            ApiRotationJobStatus::Claimed => Self::Claimed,
213            ApiRotationJobStatus::Succeeded => Self::Succeeded,
214            ApiRotationJobStatus::Failed => Self::Failed,
215            ApiRotationJobStatus::TimedOut => Self::TimedOut,
216            ApiRotationJobStatus::__Unknown(_) => Self::Unknown,
217        }
218    }
219}
220
221/// Per-attempt outcome within a rotation job.
222#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq)]
223#[bitwarden_ffi::wasm_record]
224#[serde(rename_all = "snake_case")]
225pub enum RotationAttemptStatus {
226    /// The connector is actively running this attempt.
227    Executing,
228    /// The target system accepted the new credential and the vault cipher was updated.
229    Rotated,
230    /// The attempt failed; see [`RotationAttempt::failure_reason`].
231    Errored,
232    /// The job was released or timed out mid-attempt; this does not count against the retry
233    /// budget.
234    Abandoned,
235    /// A status this SDK version does not recognize.
236    Unknown,
237}
238
239impl From<ApiRotationAttemptStatus> for RotationAttemptStatus {
240    fn from(status: ApiRotationAttemptStatus) -> Self {
241        match status {
242            ApiRotationAttemptStatus::Executing => Self::Executing,
243            ApiRotationAttemptStatus::Rotated => Self::Rotated,
244            ApiRotationAttemptStatus::Errored => Self::Errored,
245            ApiRotationAttemptStatus::Abandoned => Self::Abandoned,
246            ApiRotationAttemptStatus::__Unknown(_) => Self::Unknown,
247        }
248    }
249}
250
251/// Whether a failed rotation attempt left the target system's credential changed.
252#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq)]
253#[bitwarden_ffi::wasm_record]
254#[serde(rename_all = "snake_case")]
255pub enum RotationSyncState {
256    /// The target's credential is unchanged, so the vault still matches it.
257    TargetUnchanged,
258    /// The target accepted the new credential but the vault was not updated, so they disagree.
259    TargetUpdated,
260    /// The target-system call may or may not have applied (network error or timeout). No vault
261    /// write was attempted, so the vault and the target may disagree until the next rotation.
262    Indeterminate,
263    /// A state this SDK version does not recognize.
264    Unknown,
265}
266
267impl From<ApiRotationSyncState> for RotationSyncState {
268    fn from(state: ApiRotationSyncState) -> Self {
269        match state {
270            ApiRotationSyncState::TargetUnchanged => Self::TargetUnchanged,
271            ApiRotationSyncState::TargetUpdated => Self::TargetUpdated,
272            ApiRotationSyncState::Indeterminate => Self::Indeterminate,
273            ApiRotationSyncState::__Unknown(_) => Self::Unknown,
274        }
275    }
276}
277
278impl TryFrom<RotationSyncState> for ApiRotationSyncState {
279    type Error = RotationError;
280
281    fn try_from(state: RotationSyncState) -> Result<Self, Self::Error> {
282        match state {
283            RotationSyncState::TargetUnchanged => Ok(Self::TargetUnchanged),
284            RotationSyncState::TargetUpdated => Ok(Self::TargetUpdated),
285            RotationSyncState::Indeterminate => Ok(Self::Indeterminate),
286            RotationSyncState::Unknown => Err(RotationError::UnrecognizedVariant),
287        }
288    }
289}
290
291/// Whether the connector terminated the account's active sessions after rotating.
292#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq)]
293#[bitwarden_ffi::wasm_record]
294#[serde(rename_all = "snake_case")]
295pub enum SessionTerminationOutcome {
296    /// Session termination was not requested for this config.
297    NotRequested,
298    /// The sessions were terminated.
299    Terminated,
300    /// Termination was requested but failed. The rotation itself still succeeded.
301    TermFailed,
302    /// An outcome this SDK version does not recognize.
303    Unknown,
304}
305
306impl From<ApiSessionTerminationOutcome> for SessionTerminationOutcome {
307    fn from(outcome: ApiSessionTerminationOutcome) -> Self {
308        match outcome {
309            ApiSessionTerminationOutcome::NotRequested => Self::NotRequested,
310            ApiSessionTerminationOutcome::Terminated => Self::Terminated,
311            ApiSessionTerminationOutcome::TermFailed => Self::TermFailed,
312            ApiSessionTerminationOutcome::__Unknown(_) => Self::Unknown,
313        }
314    }
315}
316
317impl TryFrom<SessionTerminationOutcome> for ApiSessionTerminationOutcome {
318    type Error = RotationError;
319
320    fn try_from(outcome: SessionTerminationOutcome) -> Result<Self, Self::Error> {
321        match outcome {
322            SessionTerminationOutcome::NotRequested => Ok(Self::NotRequested),
323            SessionTerminationOutcome::Terminated => Ok(Self::Terminated),
324            SessionTerminationOutcome::TermFailed => Ok(Self::TermFailed),
325            SessionTerminationOutcome::Unknown => Err(RotationError::UnrecognizedVariant),
326        }
327    }
328}
329
330/// Why a rotation attempt failed, as the connector reports it. On the wire it is a snake_case
331/// token, which the server stores as the start of [`RotationAttempt::failure_reason`].
332#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq)]
333#[bitwarden_ffi::wasm_record]
334#[serde(rename_all = "snake_case")]
335pub enum RotationFailureCode {
336    /// No active session at the start of execution (terminal session state).
337    NoActiveSession,
338    /// Target-system credentials could not be resolved from the configured resolver.
339    CredentialsUnresolved,
340    /// The password policy received from the server is invalid or cannot be satisfied.
341    InvalidPolicy,
342    /// The target-system kind is not supported by this connector build.
343    UnsupportedKind,
344    /// The target system rejected the rotation (e.g. wrong account, policy violation at the
345    /// target).
346    TargetRejected,
347    /// The target system could not be reached (network or connectivity error).
348    TargetUnreachable,
349    /// Verification of the rotated credential failed after the rotation step.
350    VerificationFailed,
351    /// A custom script exited with a failure code.
352    ScriptFailed,
353    /// A custom script exceeded its configured timeout.
354    ScriptTimeout,
355    /// The server rejected the cipher write (e.g. revision-date conflict).
356    CipherWriteRejected,
357    /// Encrypting the updated cipher data failed.
358    CipherEncryptFailed,
359    /// Sealed-data verification failed; see [`WorkSnapshot::verify`](crate::WorkSnapshot::verify).
360    SealedDataRejected,
361    /// An unexpected internal error occurred.
362    Internal,
363    /// A code this SDK version does not recognize.
364    Unknown,
365}
366
367impl RotationFailureCode {
368    /// The wire token the server stores for this code, at most 100 characters. Refuses
369    /// [`Unknown`](Self::Unknown), which has no token to send.
370    pub fn as_token(self) -> Result<&'static str, RotationError> {
371        match self {
372            Self::NoActiveSession => Ok("no_active_session"),
373            Self::CredentialsUnresolved => Ok("credentials_unresolved"),
374            Self::InvalidPolicy => Ok("invalid_policy"),
375            Self::UnsupportedKind => Ok("unsupported_kind"),
376            Self::TargetRejected => Ok("target_rejected"),
377            Self::TargetUnreachable => Ok("target_unreachable"),
378            Self::VerificationFailed => Ok("verification_failed"),
379            Self::ScriptFailed => Ok("script_failed"),
380            Self::ScriptTimeout => Ok("script_timeout"),
381            Self::CipherWriteRejected => Ok("cipher_write_rejected"),
382            Self::CipherEncryptFailed => Ok("cipher_encrypt_failed"),
383            Self::SealedDataRejected => Ok("sealed_data_rejected"),
384            Self::Internal => Ok("internal"),
385            Self::Unknown => Err(RotationError::UnrecognizedVariant),
386        }
387    }
388
389    /// Parses a wire token; an unrecognized token, including `"unknown"`, is
390    /// [`Unknown`](Self::Unknown).
391    pub fn from_token(token: &str) -> Self {
392        match token {
393            "no_active_session" => Self::NoActiveSession,
394            "credentials_unresolved" => Self::CredentialsUnresolved,
395            "invalid_policy" => Self::InvalidPolicy,
396            "unsupported_kind" => Self::UnsupportedKind,
397            "target_rejected" => Self::TargetRejected,
398            "target_unreachable" => Self::TargetUnreachable,
399            "verification_failed" => Self::VerificationFailed,
400            "script_failed" => Self::ScriptFailed,
401            "script_timeout" => Self::ScriptTimeout,
402            "cipher_write_rejected" => Self::CipherWriteRejected,
403            "cipher_encrypt_failed" => Self::CipherEncryptFailed,
404            "sealed_data_rejected" => Self::SealedDataRejected,
405            "internal" => Self::Internal,
406            _ => Self::Unknown,
407        }
408    }
409}
410
411/// Password generation policy for a target system. For [`Manual`](TargetSystemMethod::Manual)
412/// systems it is guidance for the operator that nothing enforces.
413/// Lives only in the sealed blob.
414#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
415#[bitwarden_ffi::wasm_record]
416#[serde(rename_all = "camelCase")]
417pub struct PasswordPolicy {
418    /// Minimum length of a generated credential.
419    pub min_length: u32,
420    /// Maximum length of a generated credential.
421    pub max_length: u32,
422    /// Whether generated credentials may include `A-Z`.
423    pub include_uppercase: bool,
424    /// Whether generated credentials may include `a-z`.
425    pub include_lowercase: bool,
426    /// Whether generated credentials may include `0-9`.
427    pub include_digits: bool,
428    /// Whether generated credentials may include punctuation.
429    pub include_symbols: bool,
430}
431
432impl PasswordPolicy {
433    /// Validates that the generator can produce a password for this policy. The access connector
434    /// applies the same check, so an unsatisfiable policy is caught while editing rather than at
435    /// rotation.
436    pub(crate) fn validate(&self) -> Result<(), PasswordPolicyError> {
437        SealedPasswordPolicy::from(self.clone()).validate()
438    }
439}
440
441impl From<SealedPasswordPolicy> for PasswordPolicy {
442    fn from(policy: SealedPasswordPolicy) -> Self {
443        Self {
444            min_length: policy.min_length,
445            max_length: policy.max_length,
446            include_uppercase: policy.include_uppercase,
447            include_lowercase: policy.include_lowercase,
448            include_digits: policy.include_digits,
449            include_symbols: policy.include_symbols,
450        }
451    }
452}
453
454impl From<PasswordPolicy> for SealedPasswordPolicy {
455    fn from(policy: PasswordPolicy) -> Self {
456        Self {
457            min_length: policy.min_length,
458            max_length: policy.max_length,
459            include_uppercase: policy.include_uppercase,
460            include_lowercase: policy.include_lowercase,
461            include_digits: policy.include_digits,
462            include_symbols: policy.include_symbols,
463        }
464    }
465}
466
467/// One connector's execution of the rotation sequence for a job.
468#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
469#[bitwarden_ffi::wasm_record]
470#[serde(rename_all = "camelCase")]
471pub struct RotationAttempt {
472    /// The attempt's unique identifier.
473    pub id: RotationAttemptId,
474    /// The job this attempt belongs to.
475    pub job_id: RotationJobId,
476    /// The connector that ran this attempt. `None` on a payload the server did not attribute.
477    pub claimed_by_access_connector_id: Option<AccessConnectorId>,
478    /// Current execution state of the attempt.
479    pub status: RotationAttemptStatus,
480    /// Operator-facing failure reason, set for an errored attempt. Reported by the connector and
481    /// never raw target output; treat it as untrusted text for display only.
482    pub failure_reason: Option<String>,
483    /// The code at the start of [`failure_reason`](Self::failure_reason). `None` when there is no
484    /// reason; [`Unknown`](RotationFailureCode::Unknown) when the code is not one this SDK
485    /// recognizes.
486    pub failure_code: Option<RotationFailureCode>,
487    /// Whether the vault cipher was written with the rotated credential.
488    pub cipher_updated: bool,
489    /// Whether a failure left the target's credential changed. `None` unless the attempt errored.
490    pub sync_state: Option<RotationSyncState>,
491    /// Whether active sessions were terminated after rotating. `None` unless the attempt rotated.
492    pub session_termination: Option<SessionTerminationOutcome>,
493    /// The connector's start time for this attempt (UTC).
494    pub started_at: DateTime<Utc>,
495    /// The attempt's resolution time (UTC), or `None` while still executing.
496    pub ended_at: Option<DateTime<Utc>>,
497}
498
499impl TryFrom<PamRotationAttemptResponseModel> for RotationAttempt {
500    type Error = RotationError;
501
502    fn try_from(response: PamRotationAttemptResponseModel) -> Result<Self, Self::Error> {
503        Ok(Self {
504            id: RotationAttemptId::new(require!(response.id)),
505            job_id: RotationJobId::new(require!(response.job_id)),
506            claimed_by_access_connector_id: response
507                .claimed_by_access_connector_id
508                .map(AccessConnectorId::new),
509            status: require!(response.status).into(),
510            failure_code: response
511                .failure_reason
512                .as_deref()
513                .map(failure_code_from_reason),
514            failure_reason: response.failure_reason,
515            cipher_updated: response.cipher_updated.unwrap_or(false),
516            sync_state: response.sync_state.map(Into::into),
517            session_termination: response.session_termination.map(Into::into),
518            started_at: require!(response.creation_date).parse()?,
519            ended_at: response
520                .resolved_date
521                .map(|date| date.parse())
522                .transpose()?,
523        })
524    }
525}
526
527/// The server builds the reason as `ErrorCode` or `ErrorCode: Detail`.
528fn failure_code_from_reason(reason: &str) -> RotationFailureCode {
529    let token = reason.split_once(": ").map_or(reason, |(code, _)| code);
530    RotationFailureCode::from_token(token)
531}
532
533/// One dispatch of the rotation workflow for a config. Each claim records an attempt, so a retried
534/// or released job carries several.
535#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
536#[bitwarden_ffi::wasm_record]
537#[serde(rename_all = "camelCase")]
538pub struct RotationJob {
539    /// The job's unique identifier.
540    pub id: RotationJobId,
541    /// The rotation config this job was dispatched for.
542    pub rotation_config_id: RotationConfigId,
543    /// What triggered the job.
544    pub source: RotationSource,
545    /// Current lifecycle state of the job.
546    pub status: RotationJobStatus,
547    /// The connector holding the job; `None` while unclaimed.
548    pub claimed_by_access_connector_id: Option<AccessConnectorId>,
549    /// The job's claim time (UTC), or `None` while still pending.
550    pub claimed_at: Option<DateTime<Utc>>,
551    /// The job's queued time (UTC).
552    pub created_at: DateTime<Utc>,
553    /// The earliest a connector may claim this job again after a released or failed attempt (UTC).
554    pub next_claimable_at: Option<DateTime<Utc>>,
555    /// The job's claim deadline (UTC); past this it is considered timed out.
556    pub expires_at: Option<DateTime<Utc>>,
557    /// The job's attempts, oldest first.
558    pub attempts: Vec<RotationAttempt>,
559}
560
561impl TryFrom<PamRotationJobResponseModel> for RotationJob {
562    type Error = RotationError;
563
564    fn try_from(response: PamRotationJobResponseModel) -> Result<Self, Self::Error> {
565        Ok(Self {
566            id: RotationJobId::new(require!(response.id)),
567            rotation_config_id: RotationConfigId::new(require!(response.rotation_config_id)),
568            source: require!(response.source).into(),
569            status: require!(response.status).into(),
570            claimed_by_access_connector_id: response
571                .claimed_by_access_connector_id
572                .map(AccessConnectorId::new),
573            claimed_at: response.claimed_at.map(|date| date.parse()).transpose()?,
574            created_at: require!(response.creation_date).parse()?,
575            next_claimable_at: response
576                .next_claimable_at
577                .map(|date| date.parse())
578                .transpose()?,
579            expires_at: response.expires_at.map(|date| date.parse()).transpose()?,
580            attempts: response
581                .attempts
582                .unwrap_or_default()
583                .into_iter()
584                .map(RotationAttempt::try_from)
585                .collect::<Result<Vec<_>, _>>()?,
586        })
587    }
588}
589
590#[cfg(test)]
591mod tests {
592    use super::*;
593
594    const KNOWN_CODES: [(RotationFailureCode, &str); 13] = [
595        (RotationFailureCode::NoActiveSession, "no_active_session"),
596        (
597            RotationFailureCode::CredentialsUnresolved,
598            "credentials_unresolved",
599        ),
600        (RotationFailureCode::InvalidPolicy, "invalid_policy"),
601        (RotationFailureCode::UnsupportedKind, "unsupported_kind"),
602        (RotationFailureCode::TargetRejected, "target_rejected"),
603        (RotationFailureCode::TargetUnreachable, "target_unreachable"),
604        (
605            RotationFailureCode::VerificationFailed,
606            "verification_failed",
607        ),
608        (RotationFailureCode::ScriptFailed, "script_failed"),
609        (RotationFailureCode::ScriptTimeout, "script_timeout"),
610        (
611            RotationFailureCode::CipherWriteRejected,
612            "cipher_write_rejected",
613        ),
614        (
615            RotationFailureCode::CipherEncryptFailed,
616            "cipher_encrypt_failed",
617        ),
618        (
619            RotationFailureCode::SealedDataRejected,
620            "sealed_data_rejected",
621        ),
622        (RotationFailureCode::Internal, "internal"),
623    ];
624
625    #[test]
626    fn sync_state_maps_to_api_values() {
627        let pairs = [
628            (RotationSyncState::TargetUnchanged, 0),
629            (RotationSyncState::TargetUpdated, 1),
630            (RotationSyncState::Indeterminate, 2),
631        ];
632        for (state, expected) in pairs {
633            let api = ApiRotationSyncState::try_from(state).unwrap();
634            assert_eq!(api.as_i64(), expected, "{state:?}");
635        }
636    }
637
638    #[test]
639    fn sync_state_unknown_is_refused() {
640        assert!(matches!(
641            ApiRotationSyncState::try_from(RotationSyncState::Unknown),
642            Err(RotationError::UnrecognizedVariant)
643        ));
644    }
645
646    #[test]
647    fn session_termination_maps_to_api_values() {
648        let pairs = [
649            (SessionTerminationOutcome::NotRequested, 0),
650            (SessionTerminationOutcome::Terminated, 1),
651            (SessionTerminationOutcome::TermFailed, 2),
652        ];
653        for (outcome, expected) in pairs {
654            let api = ApiSessionTerminationOutcome::try_from(outcome).unwrap();
655            assert_eq!(api.as_i64(), expected, "{outcome:?}");
656        }
657    }
658
659    #[test]
660    fn session_termination_unknown_is_refused() {
661        assert!(matches!(
662            ApiSessionTerminationOutcome::try_from(SessionTerminationOutcome::Unknown),
663            Err(RotationError::UnrecognizedVariant)
664        ));
665    }
666
667    #[test]
668    fn failure_code_tokens_round_trip() {
669        for (code, token) in KNOWN_CODES {
670            assert_eq!(code.as_token().unwrap(), token, "{code:?}");
671            assert_eq!(RotationFailureCode::from_token(token), code);
672            assert_eq!(
673                serde_json::to_string(&code).unwrap(),
674                format!("\"{token}\""),
675                "{code:?}"
676            );
677        }
678    }
679
680    #[test]
681    fn failure_code_tokens_are_bounded_snake_case() {
682        for (code, _) in KNOWN_CODES {
683            let token = code.as_token().unwrap();
684            assert!(token.len() <= 100, "{code:?}");
685            assert!(
686                token.chars().all(|c| c.is_ascii_lowercase() || c == '_'),
687                "{code:?}"
688            );
689        }
690    }
691
692    #[test]
693    fn failure_code_unknown_is_refused() {
694        assert!(matches!(
695            RotationFailureCode::Unknown.as_token(),
696            Err(RotationError::UnrecognizedVariant)
697        ));
698    }
699
700    #[test]
701    fn failure_code_unrecognized_token_is_unknown() {
702        for token in ["", "from_the_future", "unknown", "Internal"] {
703            assert_eq!(
704                RotationFailureCode::from_token(token),
705                RotationFailureCode::Unknown,
706                "{token:?}"
707            );
708        }
709    }
710
711    fn attempt_response(failure_reason: Option<&str>) -> PamRotationAttemptResponseModel {
712        PamRotationAttemptResponseModel {
713            id: Some(uuid::Uuid::new_v4()),
714            job_id: Some(uuid::Uuid::new_v4()),
715            status: Some(ApiRotationAttemptStatus::Errored),
716            failure_reason: failure_reason.map(str::to_owned),
717            creation_date: Some("2026-01-01T00:00:00Z".to_owned()),
718            ..Default::default()
719        }
720    }
721
722    #[test]
723    fn attempt_failure_code_from_code_alone() {
724        let attempt = RotationAttempt::try_from(attempt_response(Some("script_timeout"))).unwrap();
725        assert_eq!(
726            attempt.failure_code,
727            Some(RotationFailureCode::ScriptTimeout)
728        );
729        assert_eq!(attempt.failure_reason.as_deref(), Some("script_timeout"));
730    }
731
732    #[test]
733    fn attempt_failure_code_from_code_and_detail() {
734        let attempt = RotationAttempt::try_from(attempt_response(Some(
735            "target_rejected: HTTP 403: forbidden",
736        )))
737        .unwrap();
738        assert_eq!(
739            attempt.failure_code,
740            Some(RotationFailureCode::TargetRejected)
741        );
742        assert_eq!(
743            attempt.failure_reason.as_deref(),
744            Some("target_rejected: HTTP 403: forbidden")
745        );
746    }
747
748    #[test]
749    fn attempt_failure_code_unrecognized() {
750        let attempt =
751            RotationAttempt::try_from(attempt_response(Some("from_the_future: detail"))).unwrap();
752        assert_eq!(attempt.failure_code, Some(RotationFailureCode::Unknown));
753    }
754
755    #[test]
756    fn attempt_failure_code_none_without_reason() {
757        let attempt = RotationAttempt::try_from(attempt_response(None)).unwrap();
758        assert_eq!(attempt.failure_code, None);
759    }
760
761    #[test]
762    fn every_named_kind_round_trips_through_the_sealed_kind() {
763        for kind in [
764            TargetSystemKind::Entra,
765            TargetSystemKind::CustomScript,
766            TargetSystemKind::ActiveDirectory,
767        ] {
768            let sealed = SealedTargetSystemKind::try_from(kind).unwrap();
769            assert_eq!(TargetSystemKind::from(sealed), kind);
770        }
771    }
772
773    #[test]
774    fn unknown_kind_cannot_be_sealed() {
775        assert!(matches!(
776            SealedTargetSystemKind::try_from(TargetSystemKind::Unknown),
777            Err(RotationError::UnrecognizedVariant)
778        ));
779    }
780
781    #[test]
782    fn kinds_serialize_to_snake_case_names() {
783        for (kind, name) in [
784            (TargetSystemKind::Entra, "entra"),
785            (TargetSystemKind::CustomScript, "custom_script"),
786            (TargetSystemKind::ActiveDirectory, "active_directory"),
787            (TargetSystemKind::Unknown, "unknown"),
788        ] {
789            assert_eq!(serde_json::to_value(kind).unwrap(), name);
790            assert_eq!(
791                serde_json::from_value::<TargetSystemKind>(name.into()).unwrap(),
792                kind
793            );
794        }
795    }
796}