Skip to main content

alibi_plugins/admin/
access.rs

1//! Permission helpers for the admin plugin.
2
3use super::AdminConfig;
4use crate::access::{AccessControl, create_access_control};
5use std::collections::HashMap;
6
7/// Built-in admin resources and actions, matching Better Auth's `defaultStatements`.
8///
9/// These declarations include `user:impersonate-admins`, which is deliberately
10/// not granted by [`admin_role`]. Declaring an action does not grant it to a role.
11pub const DEFAULT_STATEMENTS: &[(&str, &[&str])] = &[
12    (
13        "user",
14        &[
15            "create",
16            "list",
17            "set-role",
18            "ban",
19            "impersonate",
20            "impersonate-admins",
21            "delete",
22            "set-password",
23            "set-email",
24            "get",
25            "update",
26        ],
27    ),
28    ("session", &["list", "revoke", "delete"]),
29];
30
31/// Create application-side access control from [`DEFAULT_STATEMENTS`].
32#[must_use]
33pub fn default_ac() -> AccessControl {
34    create_access_control(
35        DEFAULT_STATEMENTS
36            .iter()
37            .map(|(resource, actions)| {
38                (
39                    (*resource).to_owned(),
40                    actions.iter().map(|action| (*action).to_owned()).collect(),
41                )
42            })
43            .collect(),
44    )
45}
46
47/// Role-based permission grants for the admin plugin.
48#[derive(Debug, Clone, Default, PartialEq, Eq)]
49pub struct RolePermissions {
50    /// Resource name -> allowed actions.
51    pub permissions: HashMap<String, Vec<String>>,
52}
53
54impl RolePermissions {
55    /// Create an empty role definition.
56    #[must_use]
57    pub fn new() -> Self {
58        Self::default()
59    }
60
61    /// Allow a set of actions for one resource.
62    /// Replaces that resource's grants while preserving other resources.
63    #[must_use]
64    pub fn allow<I, S>(mut self, resource: impl Into<String>, actions: I) -> Self
65    where
66        I: IntoIterator<Item = S>,
67        S: Into<String>,
68    {
69        _ = self.permissions.insert(
70            resource.into(),
71            actions.into_iter().map(Into::into).collect(),
72        );
73        self
74    }
75
76    fn allows(&self, requested: &HashMap<String, Vec<String>>) -> bool {
77        use crate::access::{Connector, authorize};
78        authorize(
79            |resource| self.permissions.get(resource).map(Vec::as_slice),
80            requested
81                .iter()
82                .map(|(resource, actions)| (resource.as_str(), actions.as_slice(), Connector::And)),
83            Connector::And,
84        )
85        .success()
86    }
87}
88
89/// Create the built-in admin role, ready to extend with [`RolePermissions::allow`].
90///
91/// Grants every action in [`DEFAULT_STATEMENTS`] except `user:impersonate-admins`.
92#[must_use]
93pub fn admin_role() -> RolePermissions {
94    DEFAULT_STATEMENTS
95        .iter()
96        .fold(RolePermissions::new(), |role, (resource, actions)| {
97            role.allow(
98                *resource,
99                actions
100                    .iter()
101                    .copied()
102                    .filter(|action| *resource != "user" || *action != "impersonate-admins"),
103            )
104        })
105}
106
107/// Create the built-in user role, which grants no admin permissions.
108#[must_use]
109pub fn user_role() -> RolePermissions {
110    RolePermissions::new()
111}
112
113/// Create the built-in role map used when [`AdminConfig::roles`] is unset.
114///
115/// Extend a role before passing the map to the plugin:
116///
117/// ```
118/// use alibi_plugins::admin::{AdminPlugin, admin_role, default_roles};
119///
120/// let mut roles = default_roles();
121/// roles.insert("admin".into(), admin_role().allow("job", ["read", "manage"]));
122/// let plugin = AdminPlugin::new().roles(roles);
123/// ```
124#[must_use]
125pub fn default_roles() -> HashMap<String, RolePermissions> {
126    HashMap::from([
127        ("admin".to_owned(), admin_role()),
128        ("user".to_owned(), user_role()),
129    ])
130}
131
132fn configured_roles(config: &AdminConfig) -> HashMap<String, RolePermissions> {
133    config.roles.clone().unwrap_or_else(default_roles)
134}
135
136fn role_names<'a>(role: Option<&'a str>, default_role: &'a str) -> Vec<&'a str> {
137    role.filter(|role| !role.is_empty())
138        .or_else(|| (!default_role.is_empty()).then_some(default_role))
139        .unwrap_or("user")
140        .split(',')
141        .collect()
142}
143
144pub(super) fn is_admin_user_id(user_id: Option<&str>, config: &AdminConfig) -> bool {
145    user_id.is_some_and(|user_id| {
146        config
147            .admin_user_ids
148            .as_ref()
149            .is_some_and(|ids| ids.iter().any(|id| id == user_id))
150    })
151}
152
153pub(super) fn has_permission(
154    user_id: Option<&str>,
155    role: Option<&str>,
156    config: &AdminConfig,
157    requested: &HashMap<String, Vec<String>>,
158) -> bool {
159    if is_admin_user_id(user_id, config) {
160        return true;
161    }
162
163    let roles = configured_roles(config);
164    role_names(role, &config.default_role)
165        .into_iter()
166        .filter_map(|role| roles.get(role))
167        .any(|role| role.allows(requested))
168}
169
170pub(super) fn is_admin_role(role: Option<&str>, config: &AdminConfig) -> bool {
171    role_names(role, &config.default_role)
172        .into_iter()
173        .any(|role| {
174            config.admin_roles.as_ref().map_or_else(
175                || role == "admin",
176                |admins| admins.iter().any(|admin| admin.trim() == role),
177            )
178        })
179}