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