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/// The genuine logical endpoint call and its optional original HTTP request.
92/// Headerless server operations have an endpoint and no transport request.
93#[derive(Debug, Clone)]
94pub struct UserValidationContext {
95    pub request: Option<RequestHookContext>,
96    pub endpoint: Option<crate::endpoint::EndpointCall>,
97}
98
99#[async_trait]
100pub trait UserInfoValidator: Send + Sync {
101    /// Observe the actual endpoint and optional transport request before
102    /// database creation hooks or provider account/session writes.
103    /// Exceptions are a generic 403 denial.
104    async fn validate(
105        &self,
106        data: &mut UserValidationData,
107        context: &UserValidationContext,
108    ) -> AuthResult<Option<UserValidationRejection>>;
109}
110
111fn denial(code: &str, message: &str) -> AuthError {
112    AuthError::Api {
113        status: 403,
114        code: Some(code.into()),
115        message: message.into(),
116    }
117}
118
119fn assert_source(source: Option<&UserValidationSource>) -> AuthResult<()> {
120    let Some(source) = source.filter(|source| !source.method.is_empty()) else {
121        return Err(denial(
122            "validation_source_missing",
123            "User validation source is required",
124        ));
125    };
126    if source.method == "oauth"
127        && source
128            .oauth
129            .as_ref()
130            .is_none_or(|provider| provider.provider_id.is_empty())
131    {
132        return Err(denial(
133            "validation_source_missing",
134            "OAuth user validation source requires oauth.providerId",
135        ));
136    }
137    if matches!(source.method.as_str(), "sso-oidc" | "sso-saml")
138        && source
139            .sso
140            .as_ref()
141            .is_none_or(|provider| provider.provider_id.is_empty())
142    {
143        return Err(denial(
144            "validation_source_missing",
145            "SSO user validation source requires sso.providerId",
146        ));
147    }
148    Ok(())
149}
150
151/// Apply configured validation against fresh provider identity at a returning
152/// sign-in or account-link boundary. Creation is owned by the store wrapper.
153pub async fn validate_user_info(
154    config: &AuthConfig,
155    data: &mut UserValidationData,
156) -> AuthResult<()> {
157    let Some(validator) = &config.user_validation else {
158        return Ok(());
159    };
160    assert_source(Some(&data.source))?;
161    let context = UserValidationContext {
162        request: current_request_hook_context(),
163        endpoint: crate::endpoint::current_endpoint_call_context(),
164    };
165    if context.request.is_none() && context.endpoint.is_none() {
166        return Err(denial(
167            "validation_context_missing",
168            "User validation requires an endpoint context",
169        ));
170    }
171    match validator.validate(data, &context).await {
172        Ok(Some(rejection)) if !rejection.error.is_empty() => {
173            let message = rejection
174                .error_description
175                .as_deref()
176                .filter(|value| !value.is_empty())
177                .unwrap_or(&rejection.error);
178            Err(denial(&rejection.error, message))
179        }
180        Ok(_) => Ok(()),
181        Err(_) => Err(denial("validation_failed", "User validation failed")),
182    }
183}
184
185/// A creation candidate already normalized and admitted by the configured
186/// identity policy. Adapters persist its trusted mutations without normalizing
187/// it again, and retain their ordinary database hooks.
188pub struct PreparedUserCreation {
189    data: CreateUser,
190    defaults: crate::store::UserCreationDefaults,
191}
192
193impl PreparedUserCreation {
194    #[must_use]
195    pub fn into_data(self) -> CreateUser {
196        self.data
197    }
198
199    /// Adapters run their before hooks on the candidate, then apply these model
200    /// defaults immediately before insertion.
201    #[must_use]
202    pub fn into_parts(self) -> (CreateUser, crate::store::UserCreationDefaults) {
203        (self.data, self.defaults)
204    }
205
206    pub(crate) fn from_data(data: CreateUser) -> Self {
207        Self {
208            data,
209            defaults: crate::store::UserCreationDefaults::default(),
210        }
211    }
212
213    pub(crate) fn with_defaults(mut self, defaults: crate::store::UserCreationDefaults) -> Self {
214        self.defaults = defaults;
215        self
216    }
217}
218
219pub(crate) async fn prepare_creation(
220    config: &AuthConfig,
221    mut user: CreateUser,
222    source: Option<UserValidationSource>,
223) -> AuthResult<PreparedUserCreation> {
224    assert_source(source.as_ref())?;
225    let mut source = source.ok_or_else(|| {
226        denial(
227            "validation_source_missing",
228            "User validation source is required",
229        )
230    })?;
231    source.action = UserValidationAction::CreateUser;
232    let now = Utc::now();
233    _ = user.created_at.get_or_insert(now);
234    _ = user.updated_at.get_or_insert(now);
235    user.email = user
236        .email
237        .map(|email| crate::utils::normalize_user_email(&email));
238    let mut data = UserValidationData { user, source };
239    validate_user_info(config, &mut data).await?;
240    if data.user.created_at.is_none() || data.user.updated_at.is_none() {
241        return Err(AuthError::NotImplemented(
242            "Identity policy removal of creation timestamps is not supported".into(),
243        ));
244    }
245    Ok(PreparedUserCreation::from_data(data.user))
246}