Skip to main content

better_auth_api/plugins/admin/
permission_api.rs

1//! Typed Admin permission checks for application code.
2//!
3//! These checks reuse the role map, default role, and `admin_user_ids` that the registered
4//! [`AdminPlugin`](super::AdminPlugin) uses for `POST /admin/has-permission`. They add no routes.
5
6use super::AdminConfig;
7use super::access::has_permission;
8use better_auth_core::wire::{SessionView, UserView};
9use better_auth_core::{
10    AuthContext, AuthError, AuthRequest, AuthSchema, HttpMethod, NativeRequest,
11};
12use std::collections::HashMap;
13
14/// Failure of an Admin permission check.
15///
16/// A denial from a trusted query is `Ok(false)`. A denial from
17/// [`AdminPermissionApi::authorize_request`] is [`AdminPermissionError::Forbidden`].
18/// Infrastructure failures are never reported as a denial or as a grant.
19#[derive(Debug, thiserror::Error)]
20pub enum AdminPermissionError {
21    /// `AdminPlugin` is not registered on this auth instance.
22    #[error("AdminPlugin is not registered")]
23    PluginNotRegistered,
24    /// The request names no resource, or names a resource without an action.
25    #[error("a permission check requires at least one action for each resource")]
26    EmptyPermissions,
27    /// The user ID or role to evaluate is empty.
28    #[error("user id or role is required")]
29    MissingSubject,
30    /// No stored user has the requested ID.
31    #[error("user not found")]
32    UserNotFound,
33    /// The headers do not identify a valid session.
34    ///
35    /// This covers a missing or invalid credential, and an expired, revoked, or orphaned session.
36    #[error("the request is not authenticated")]
37    Unauthenticated,
38    /// The authenticated user's current roles do not grant every requested action.
39    #[error("the authenticated user lacks the requested permissions")]
40    Forbidden,
41    /// The store, the configuration, or another internal operation failed.
42    #[error("the permission check could not complete: {0}")]
43    Internal(#[source] AuthError),
44}
45
46/// Session that passed one [`AdminPermissionApi::authorize_request`] check.
47///
48/// Only that method creates this value. It holds the user and session read from the store for
49/// that check. Use it for the operation that requested the check. Do not cache it: a later
50/// revocation or role change does not update it.
51#[derive(Debug)]
52pub struct AuthorizedSession {
53    user: UserView,
54    session: SessionView,
55}
56
57impl AuthorizedSession {
58    /// User whose stored roles granted the requested permissions.
59    pub fn user(&self) -> &UserView {
60        &self.user
61    }
62
63    /// Session that authenticated the request.
64    pub fn session(&self) -> &SessionView {
65        &self.session
66    }
67
68    /// Return the user and the session.
69    pub fn into_parts(self) -> (UserView, SessionView) {
70        (self.user, self.session)
71    }
72}
73
74/// Permission checks bound to the configuration of the registered `AdminPlugin`.
75///
76/// Every check requires at least one resource and at least one action per resource. All
77/// requested actions must be granted by one of the evaluated roles.
78pub struct AdminPermissionApi<'a, S: AuthSchema> {
79    config: &'a AdminConfig,
80    context: &'a AuthContext<S>,
81}
82
83impl<'a, S: AuthSchema> AdminPermissionApi<'a, S> {
84    /// Bind to the configuration that `AdminPlugin` published during initialization.
85    pub fn from_context(context: &'a AuthContext<S>) -> Result<Self, AdminPermissionError> {
86        let config = context
87            .extensions
88            .get::<AdminConfig>()
89            .ok_or(AdminPermissionError::PluginNotRegistered)?;
90        Ok(Self { config, context })
91    }
92
93    /// Evaluate a role string without identifying a user.
94    ///
95    /// This is not authentication. Use it only for a role that trusted server code already
96    /// holds. Never pass a role that a client supplied. `role` may hold several roles separated
97    /// by commas. Unknown roles grant nothing. `admin_user_ids` never applies because no user
98    /// is identified.
99    pub fn role_has_permission(
100        &self,
101        role: &str,
102        permissions: &HashMap<String, Vec<String>>,
103    ) -> Result<bool, AdminPermissionError> {
104        require_permissions(permissions)?;
105        if role.is_empty() {
106            return Err(AdminPermissionError::MissingSubject);
107        }
108        Ok(has_permission(None, Some(role), self.config, permissions))
109    }
110
111    /// Evaluate the current stored roles of a user from trusted server code.
112    ///
113    /// This reads the user on each call and does not check any session. The caller must
114    /// authenticate the operator separately. A user without a role uses the configured default
115    /// role. A user listed in `admin_user_ids` passes every check.
116    ///
117    /// A dynamic base URL needs a configured fallback for this requestless operation.
118    pub async fn user_has_permission(
119        &self,
120        user_id: &str,
121        permissions: &HashMap<String, Vec<String>>,
122    ) -> Result<bool, AdminPermissionError> {
123        require_permissions(permissions)?;
124        if user_id.is_empty() {
125            return Err(AdminPermissionError::MissingSubject);
126        }
127        let user = self
128            .context
129            .with_native_context(NativeRequest::default(), |context| async move {
130                context.database.get_user_by_id(user_id).await
131            })
132            .await
133            .map_err(AdminPermissionError::Internal)?
134            .ok_or(AdminPermissionError::UserNotFound)?;
135        Ok(has_permission(
136            user.id.as_str(),
137            user.role.as_deref(),
138            self.config,
139            permissions,
140        ))
141    }
142
143    /// Authenticate the caller from request headers, then require the permissions.
144    ///
145    /// The session and the user come from the framework session store on every call. Request
146    /// bodies and client claims never select the user or the role. With a database or secondary
147    /// storage, this read skips the cookie cache. A revoked session or a removed role therefore
148    /// takes effect on the next call. The check does not return refreshed session cookies.
149    pub async fn authorize_request(
150        &self,
151        headers: &HashMap<String, String>,
152        permissions: &HashMap<String, Vec<String>>,
153    ) -> Result<AuthorizedSession, AdminPermissionError> {
154        require_permissions(permissions)?;
155        let (user, session) = self
156            .context
157            .with_native_context(
158                NativeRequest {
159                    request: None,
160                    headers: Some(headers),
161                },
162                |context| async move {
163                    let request = AuthRequest::new(HttpMethod::Post, "/admin/has-permission")
164                        .with_optional_headers(Some(headers.clone()));
165                    let mut hook_context =
166                        better_auth_core::RequestHookContext::from_request(&request)?;
167                    hook_context.body = better_auth_core::FieldValue::from_json(
168                        serde_json::json!({ "permissions": permissions }),
169                    )?;
170                    better_auth_core::with_request_hook_context_value(hook_context, async {
171                        context.require_authoritative_session(&request).await
172                    })
173                    .await
174                },
175            )
176            .await
177            .map_err(session_error)?;
178        if has_permission(
179            user.id.as_str(),
180            user.role.as_deref(),
181            self.config,
182            permissions,
183        ) {
184            Ok(AuthorizedSession { user, session })
185        } else {
186            Err(AdminPermissionError::Forbidden)
187        }
188    }
189}
190
191fn require_permissions(
192    permissions: &HashMap<String, Vec<String>>,
193) -> Result<(), AdminPermissionError> {
194    if permissions.is_empty() || permissions.values().any(Vec::is_empty) {
195        Err(AdminPermissionError::EmptyPermissions)
196    } else {
197        Ok(())
198    }
199}
200
201fn session_error(error: AuthError) -> AdminPermissionError {
202    match error {
203        AuthError::SessionNotFound | AuthError::UserNotFound => {
204            AdminPermissionError::Unauthenticated
205        }
206        error if error.status_code() == 401 => AdminPermissionError::Unauthenticated,
207        error => AdminPermissionError::Internal(error),
208    }
209}