Skip to main content

alibi_core/store/
adapter.rs

1//! Backend-neutral pieces shared by the bundled persistence adapters.
2//!
3//! Everything here is independent of the driver: hook sequencing after a
4//! committed transaction, the error a vetoing hook produces, raw configured
5//! field values, and timestamp parsing for string-dated plugin rows.
6
7use crate::error::{AuthError, AuthResult};
8use crate::schema::AuthSchema;
9use crate::store::{DatabaseHookContext, DatabaseHooks, HookBackend};
10use crate::utils::json::JsValue;
11use crate::verification::VerificationSnapshot;
12use chrono::{DateTime, Utc};
13use std::sync::Arc;
14
15/// The error returned when a `before_*` hook cancels an operation.
16#[must_use]
17pub fn cancelled_by_hook(operation: &str) -> AuthError {
18    AuthError::forbidden(format!("{operation} cancelled by database hook"))
19}
20
21/// Preserve an ordinary application callback exception as an empty HTTP 500.
22/// Explicit API errors retain their public status and body.
23#[must_use]
24pub fn callback_error(error: AuthError) -> AuthError {
25    match error {
26        AuthError::Internal(_) => AuthError::CallbackFailure(Box::new(error)),
27        other => other,
28    }
29}
30
31/// Parse an RFC 3339 timestamp of a string-dated plugin field.
32///
33/// # Errors
34///
35/// Returns a bad-request error naming `field` when the text is not RFC 3339.
36pub fn parse_rfc3339(value: &str, field: &str) -> AuthResult<DateTime<Utc>> {
37    DateTime::parse_from_rfc3339(value)
38        .map(|dt| dt.with_timezone(&Utc))
39        .map_err(|_| AuthError::bad_request(format!("Invalid RFC 3339 timestamp for {field}")))
40}
41
42/// [`parse_rfc3339`] over an optional value.
43///
44/// # Errors
45///
46/// Returns a bad-request error naming `field` when the text is not RFC 3339.
47pub fn parse_optional_rfc3339(
48    value: Option<&str>,
49    field: &str,
50) -> AuthResult<Option<DateTime<Utc>>> {
51    value.map(|inner| parse_rfc3339(inner, field)).transpose()
52}
53
54/// An after-hook deferred until the enclosing auth transaction commits.
55pub enum AfterHook<S: AuthSchema> {
56    UserCreated(S::User),
57    UserUpdated(S::User),
58    AccountCreated(S::Account),
59    AccountUpdated(S::Account),
60    SessionCreated(S::Session),
61    SessionUpdated(S::Session),
62    SessionUpdateMissing(String),
63    SessionDeleted(S::Session),
64    SessionRetirement {
65        works: Vec<Box<dyn crate::store::SessionDeletionWork<S>>>,
66        context: Arc<crate::AuthContext<S>>,
67    },
68    VerificationCreated(S::Verification),
69    VerificationRecordCreated(VerificationSnapshot),
70}
71
72impl<S: AuthSchema> AfterHook<S> {
73    /// Invoke the matching callback on one hook.
74    ///
75    /// # Errors
76    ///
77    /// Returns the hook's error.
78    pub async fn dispatch<B: HookBackend>(
79        &self,
80        hook: &dyn DatabaseHooks<S, B>,
81        ctx: &DatabaseHookContext<'_, B>,
82    ) -> AuthResult<()> {
83        match self {
84            Self::UserCreated(user) => hook.after_create_user(user, ctx).await,
85            Self::UserUpdated(user) => hook
86                .after_update_user(user, ctx)
87                .await
88                .map_err(callback_error),
89            Self::AccountCreated(account) => hook.after_create_account(account, ctx).await,
90            Self::AccountUpdated(account) => hook.after_update_account(account, ctx).await,
91            Self::SessionCreated(session) => hook
92                .after_create_session(session, ctx)
93                .await
94                .map_err(callback_error),
95            Self::SessionUpdated(session) => hook.after_update_session(session, ctx).await,
96            Self::SessionUpdateMissing(token) => {
97                hook.after_update_session_missing(token, ctx).await
98            }
99            Self::SessionDeleted(session) => hook.after_delete_session(session, ctx).await,
100            Self::SessionRetirement { .. } => Ok(()),
101            Self::VerificationCreated(verification) => {
102                hook.after_create_verification(verification, ctx).await
103            }
104            Self::VerificationRecordCreated(snapshot) => {
105                hook.after_create_verification_record(snapshot, ctx).await
106            }
107        }
108    }
109}
110
111/// After-hooks recorded by writes inside an auth transaction.
112///
113/// Rollback drops the queue. After commit, [`run`](Self::run) replays every
114/// event in operation order, invoking each registered hook per event; a hook
115/// error is returned to the caller after the auth writes have committed.
116pub struct AfterHookQueue<S: AuthSchema>(tokio::sync::Mutex<Vec<AfterHook<S>>>);
117
118impl<S: AuthSchema> Default for AfterHookQueue<S> {
119    fn default() -> Self {
120        Self::new()
121    }
122}
123
124impl<S: AuthSchema> AfterHookQueue<S> {
125    #[must_use]
126    pub const fn new() -> Self {
127        Self(tokio::sync::Mutex::const_new(Vec::new()))
128    }
129
130    pub async fn push(&self, event: AfterHook<S>) {
131        self.0.lock().await.push(event);
132    }
133    /// Keep plugin retirement after the selected row's application after-hooks.
134    pub async fn attach_session_retirement(
135        &self,
136        session_id: &str,
137        works: Vec<Box<dyn crate::store::SessionDeletionWork<S>>>,
138        context: Arc<crate::AuthContext<S>>,
139    ) {
140        use crate::AuthSession as _;
141        let mut events = self.0.lock().await;
142        if let Some(index) = events.iter().rposition(|event| matches!(event, AfterHook::SessionDeleted(session) if session.id().as_ref() == session_id)) {
143            events.insert(index + 1, AfterHook::SessionRetirement { works, context });
144        }
145    }
146
147    /// Run every queued event against `hooks`, in order.
148    ///
149    /// # Errors
150    ///
151    /// Returns the first hook error.
152    pub async fn run<B: HookBackend>(
153        self,
154        hooks: &[Arc<dyn DatabaseHooks<S, B>>],
155        ctx: &DatabaseHookContext<'_, B>,
156    ) -> AuthResult<()> {
157        self.run_with_policy(hooks, ctx, crate::store::AfterCommitErrorPolicy::Propagate)
158            .await
159    }
160    /// Run queued hooks, optionally reporting errors after durable commit.
161    ///
162    /// # Errors
163    /// Returns hook errors when propagation is selected.
164    pub async fn run_with_policy<B: HookBackend>(
165        self,
166        hooks: &[Arc<dyn DatabaseHooks<S, B>>],
167        ctx: &DatabaseHookContext<'_, B>,
168        policy: crate::store::AfterCommitErrorPolicy,
169    ) -> AuthResult<()> {
170        for event in self.0.into_inner() {
171            if let AfterHook::SessionRetirement { works, context } = event {
172                for work in works {
173                    if let Err(error) = work.complete(&context).await {
174                        if policy == crate::store::AfterCommitErrorPolicy::Propagate {
175                            return Err(error);
176                        }
177                        tracing::error!("Committed session retirement failed");
178                    }
179                }
180                continue;
181            }
182            for hook in hooks {
183                if let Err(error) = event.dispatch(hook.as_ref(), ctx).await {
184                    if policy == crate::store::AfterCommitErrorPolicy::Propagate {
185                        return Err(error);
186                    }
187                    tracing::error!("Committed transaction after-hook failed");
188                }
189            }
190        }
191        Ok(())
192    }
193}
194
195/// A configured additional-field value before backend coercion.
196///
197/// JavaScript numbers that are finite integers within `i64` range bind as
198/// integers so SQLite keeps INTEGER affinity; `-0` and fractions stay doubles.
199#[derive(Clone, Debug, PartialEq)]
200pub enum RawFieldValue {
201    Null,
202    Bool(bool),
203    Integer(i64),
204    Number(f64),
205    Text(String),
206    Json(serde_json::Value),
207}
208
209impl RawFieldValue {
210    /// # Errors
211    ///
212    /// Returns an error if an object or array cannot be serialized as JSON.
213    #[expect(
214        clippy::as_conversions,
215        clippy::cast_possible_truncation,
216        reason = "the range guard above makes the integer coercion exact"
217    )]
218    pub fn from_js(value: &JsValue) -> Result<Self, serde_json::Error> {
219        Ok(match value {
220            JsValue::Null => Self::Null,
221            JsValue::Bool(value) => Self::Bool(*value),
222            JsValue::Number(value)
223                if value.is_finite()
224                    && value.fract() == 0.0
225                    && !(*value == 0.0 && value.is_sign_negative())
226                    && (-9_223_372_036_854_776_000.0..9_223_372_036_854_776_000.0)
227                        .contains(value) =>
228            {
229                Self::Integer(*value as i64)
230            }
231            JsValue::Number(value) => Self::Number(*value),
232            JsValue::String(value) => Self::Text(value.clone()),
233            JsValue::Date(value) => {
234                Self::Text(value.to_rfc3339_opts(chrono::SecondsFormat::Millis, true))
235            }
236            JsValue::Array(_) | JsValue::Object(_) => Self::Json(value.to_json_value()?),
237        })
238    }
239}