Skip to main content

alibi_core/
user_validation.rs

1//! Application identity policy at the endpoint's actual admission boundary.
2use crate::config::AuthConfig;
3use crate::error::{AuthError, AuthResult};
4use crate::hooks::{RequestHookContext, current_request_hook_context};
5use crate::types::CreateUser;
6use async_trait::async_trait;
7use chrono::Utc;
8
9/// The identity operation being admitted. Non-provider returning sign-ins do
10/// not invoke this policy because they do not introduce new identity data.
11#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
12#[serde(rename_all = "kebab-case")]
13pub enum UserValidationAction {
14    #[default]
15    CreateUser,
16    LinkAccount,
17    SignIn,
18}
19
20/// Provider identity before application profile mapping.
21#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
22#[serde(rename_all = "camelCase")]
23pub struct UserValidationProvider {
24    pub provider_id: String,
25    /// Only a provider's actual object response is supplied. Arrays and scalar
26    /// responses have no profile record.
27    #[serde(skip_serializing_if = "Option::is_none")]
28    pub profile: Option<serde_json::Value>,
29}
30
31/// Identity provenance supplied by the endpoint, never by its request body.
32/// The string method permits application-defined identity extensions.
33#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
34#[serde(rename_all = "camelCase")]
35pub struct UserValidationSource {
36    #[serde(default)]
37    pub method: String,
38    #[serde(default)]
39    pub action: UserValidationAction,
40    #[serde(skip_serializing_if = "Option::is_none")]
41    pub oauth: Option<UserValidationProvider>,
42    #[serde(skip_serializing_if = "Option::is_none")]
43    pub sso: Option<UserValidationProvider>,
44}
45
46impl UserValidationSource {
47    #[must_use]
48    pub fn creation(method: impl Into<String>) -> Self {
49        Self {
50            method: method.into(),
51            action: UserValidationAction::CreateUser,
52            oauth: None,
53            sso: None,
54        }
55    }
56
57    #[must_use]
58    pub fn oauth(
59        provider_id: impl Into<String>,
60        profile: &serde_json::Value,
61        action: UserValidationAction,
62    ) -> Self {
63        Self {
64            method: "oauth".into(),
65            action,
66            oauth: Some(UserValidationProvider {
67                provider_id: provider_id.into(),
68                profile: profile.is_object().then(|| profile.clone()),
69            }),
70            sso: None,
71        }
72    }
73}
74
75/// Mutable trusted candidate. Creation mutations are persisted; provider
76/// sign-in/link mutations affect only this validation copy.
77#[derive(Debug, Clone)]
78pub struct UserValidationData {
79    pub user: CreateUser,
80    pub source: UserValidationSource,
81}
82
83/// A policy denial. An empty error is an admission, and an empty description
84/// falls back to the error code, matching the callback's public contract.
85#[derive(Debug, Clone)]
86pub struct UserValidationRejection {
87    pub error: String,
88    pub error_description: Option<String>,
89}
90
91#[async_trait]
92pub trait UserInfoValidator: Send + Sync {
93    /// Observe the actual endpoint request (including original body bytes and
94    /// its typed parsed body extensions), before database creation hooks or
95    /// provider account/session writes. Exceptions are a generic 403 denial.
96    async fn validate(
97        &self,
98        data: &mut UserValidationData,
99        request: &RequestHookContext,
100    ) -> AuthResult<Option<UserValidationRejection>>;
101}
102
103fn denial(code: &str, message: &str) -> AuthError {
104    AuthError::Api {
105        status: 403,
106        code: Some(code.into()),
107        message: message.into(),
108    }
109}
110
111fn assert_source(source: Option<&UserValidationSource>) -> AuthResult<()> {
112    let Some(source) = source.filter(|source| !source.method.is_empty()) else {
113        return Err(denial(
114            "validation_source_missing",
115            "User validation source is required",
116        ));
117    };
118    if source.method == "oauth"
119        && source
120            .oauth
121            .as_ref()
122            .is_none_or(|provider| provider.provider_id.is_empty())
123    {
124        return Err(denial(
125            "validation_source_missing",
126            "OAuth user validation source requires oauth.providerId",
127        ));
128    }
129    if matches!(source.method.as_str(), "sso-oidc" | "sso-saml")
130        && source
131            .sso
132            .as_ref()
133            .is_none_or(|provider| provider.provider_id.is_empty())
134    {
135        return Err(denial(
136            "validation_source_missing",
137            "SSO user validation source requires sso.providerId",
138        ));
139    }
140    Ok(())
141}
142
143/// Apply configured validation against fresh provider identity at a returning
144/// sign-in or account-link boundary. Creation is owned by the store wrapper.
145pub async fn validate_user_info(
146    config: &AuthConfig,
147    data: &mut UserValidationData,
148) -> AuthResult<()> {
149    let Some(validator) = &config.user_validation else {
150        return Ok(());
151    };
152    assert_source(Some(&data.source))?;
153    let request = current_request_hook_context().ok_or_else(|| {
154        denial(
155            "validation_context_missing",
156            "User validation requires an endpoint context",
157        )
158    })?;
159    match validator.validate(data, &request).await {
160        Ok(Some(rejection)) if !rejection.error.is_empty() => {
161            let message = rejection
162                .error_description
163                .as_deref()
164                .filter(|value| !value.is_empty())
165                .unwrap_or(&rejection.error);
166            Err(denial(&rejection.error, message))
167        }
168        Ok(_) => Ok(()),
169        Err(_) => Err(denial("validation_failed", "User validation failed")),
170    }
171}
172
173/// A creation candidate already normalized and admitted by the configured
174/// identity policy. Adapters persist its trusted mutations without normalizing
175/// it again, and retain their ordinary database hooks.
176pub struct PreparedUserCreation {
177    data: CreateUser,
178    defaults: crate::store::UserCreationDefaults,
179}
180
181impl PreparedUserCreation {
182    #[must_use]
183    pub fn into_data(self) -> CreateUser {
184        self.data
185    }
186
187    /// Adapters run their before hooks on the candidate, then apply these model
188    /// defaults immediately before insertion.
189    #[must_use]
190    pub fn into_parts(self) -> (CreateUser, crate::store::UserCreationDefaults) {
191        (self.data, self.defaults)
192    }
193
194    pub(crate) fn from_data(data: CreateUser) -> Self {
195        Self {
196            data,
197            defaults: crate::store::UserCreationDefaults::default(),
198        }
199    }
200
201    pub(crate) fn with_defaults(mut self, defaults: crate::store::UserCreationDefaults) -> Self {
202        self.defaults = defaults;
203        self
204    }
205}
206
207pub(crate) async fn prepare_creation(
208    config: &AuthConfig,
209    mut user: CreateUser,
210    source: Option<UserValidationSource>,
211) -> AuthResult<PreparedUserCreation> {
212    assert_source(source.as_ref())?;
213    let mut source = source.ok_or_else(|| {
214        denial(
215            "validation_source_missing",
216            "User validation source is required",
217        )
218    })?;
219    source.action = UserValidationAction::CreateUser;
220    let now = Utc::now();
221    _ = user.created_at.get_or_insert(now);
222    _ = user.updated_at.get_or_insert(now);
223    user.email = user
224        .email
225        .map(|email| crate::utils::normalize_user_email(&email));
226    let mut data = UserValidationData { user, source };
227    validate_user_info(config, &mut data).await?;
228    if data.user.created_at.is_none() || data.user.updated_at.is_none() {
229        return Err(AuthError::NotImplemented(
230            "Identity policy removal of creation timestamps is not supported".into(),
231        ));
232    }
233    Ok(PreparedUserCreation::from_data(data.user))
234}