Skip to main content

rustio_admin/auth/
permissions.rs

1//! Granular permissions with groups.
2//!
3//! Data model:
4//!   rustio_permissions         (id, name, description)
5//!   rustio_groups              (id, name, description)
6//!   rustio_group_permissions   (group_id, permission_id)
7//!   rustio_user_groups         (user_id, group_id)
8//!   rustio_user_permissions    (user_id, permission_id)    -- direct grants
9//!
10//! Permission naming convention: `<app>.<action>_<model>`, e.g.
11//! `posts.add_post`, `posts.change_post`, `posts.delete_post`,
12//! `posts.view_post`.
13//!
14//! An Administrator-or-higher role automatically has every permission
15//! (see `Role::bypasses_group_checks`). Lower tiers are checked against
16//! the tables above.
17//!
18//! Permissions for a user are cached in a `DashMap<user_id, …>` with a
19//! 60-second TTL so hot paths don't hit the DB. A write to the
20//! permission tables calls `invalidate_user_cache(user_id)`.
21
22use std::collections::HashSet;
23use std::sync::Arc;
24use std::time::{Duration, Instant};
25
26use dashmap::DashMap;
27use once_cell::sync::Lazy;
28use sqlx::Row as SqlxRow;
29
30use crate::error::{Error, Result};
31use crate::orm::Db;
32
33use super::users::Identity;
34
35#[cfg(test)]
36use super::role::Role;
37
38// public:
39/// Marker type used by the admin's authorize macro for fast-paths on admins.
40pub struct Superuser;
41
42// public:
43#[derive(Debug, Clone)]
44pub struct Permission {
45    pub id: i64,
46    pub name: String,
47    pub description: String,
48}
49
50// public:
51#[derive(Debug, thiserror::Error)]
52pub enum PermissionError {
53    #[error("permission `{0}` not found")]
54    Missing(String),
55    #[error("user not found")]
56    NoSuchUser,
57    #[error("group not found")]
58    NoSuchGroup,
59}
60
61// --- schema ---------------------------------------------------------------
62
63// public:
64pub async fn init_permission_tables(db: &Db) -> Result<()> {
65    sqlx::query(
66        "CREATE TABLE IF NOT EXISTS rustio_permissions (
67            id          BIGSERIAL PRIMARY KEY,
68            name        TEXT NOT NULL UNIQUE,
69            description TEXT NOT NULL DEFAULT '',
70            created_at  TIMESTAMPTZ NOT NULL DEFAULT NOW()
71        )",
72    )
73    .execute(db.pool())
74    .await?;
75
76    sqlx::query(
77        "CREATE TABLE IF NOT EXISTS rustio_groups (
78            id          BIGSERIAL PRIMARY KEY,
79            name        TEXT NOT NULL UNIQUE,
80            description TEXT NOT NULL DEFAULT '',
81            created_at  TIMESTAMPTZ NOT NULL DEFAULT NOW()
82        )",
83    )
84    .execute(db.pool())
85    .await?;
86
87    sqlx::query(
88        "CREATE TABLE IF NOT EXISTS rustio_group_permissions (
89            group_id      BIGINT NOT NULL REFERENCES rustio_groups(id)      ON DELETE CASCADE,
90            permission_id BIGINT NOT NULL REFERENCES rustio_permissions(id) ON DELETE CASCADE,
91            PRIMARY KEY (group_id, permission_id)
92        )",
93    )
94    .execute(db.pool())
95    .await?;
96
97    sqlx::query(
98        "CREATE TABLE IF NOT EXISTS rustio_user_groups (
99            user_id  BIGINT NOT NULL REFERENCES rustio_users(id)  ON DELETE CASCADE,
100            group_id BIGINT NOT NULL REFERENCES rustio_groups(id) ON DELETE CASCADE,
101            PRIMARY KEY (user_id, group_id)
102        )",
103    )
104    .execute(db.pool())
105    .await?;
106
107    sqlx::query(
108        "CREATE TABLE IF NOT EXISTS rustio_user_permissions (
109            user_id       BIGINT NOT NULL REFERENCES rustio_users(id)       ON DELETE CASCADE,
110            permission_id BIGINT NOT NULL REFERENCES rustio_permissions(id) ON DELETE CASCADE,
111            PRIMARY KEY (user_id, permission_id)
112        )",
113    )
114    .execute(db.pool())
115    .await?;
116
117    Ok(())
118}
119
120// --- cache ----------------------------------------------------------------
121
122struct CacheEntry {
123    perms: Arc<HashSet<String>>,
124    expires: Instant,
125}
126
127static PERM_CACHE: Lazy<DashMap<i64, CacheEntry>> = Lazy::new(DashMap::new);
128
129const PERM_CACHE_TTL: Duration = Duration::from_secs(60);
130
131pub(crate) fn invalidate_user_cache(user_id: i64) {
132    PERM_CACHE.remove(&user_id);
133}
134
135fn invalidate_group_cache(db: &Db, group_id: i64) {
136    // Users in this group need their cached permission sets evicted.
137    // Fire-and-forget — the TTL will catch anything we miss.
138    let db = db.clone();
139    tokio::spawn(async move {
140        let rows = sqlx::query("SELECT user_id FROM rustio_user_groups WHERE group_id = $1")
141            .bind(group_id)
142            .fetch_all(db.pool())
143            .await
144            .unwrap_or_default();
145        for r in rows {
146            if let Ok(uid) = r.try_get::<i64, _>("user_id") {
147                invalidate_user_cache(uid);
148            }
149        }
150    });
151}
152
153// --- reads ----------------------------------------------------------------
154
155// public:
156/// All permission names belonging to the given user — direct + via
157/// groups — unioned into one set. Cached for 60s.
158pub async fn permissions_for_user(db: &Db, user_id: i64) -> Result<Arc<HashSet<String>>> {
159    if let Some(e) = PERM_CACHE.get(&user_id) {
160        if e.expires > Instant::now() {
161            return Ok(e.perms.clone());
162        }
163    }
164
165    let rows = sqlx::query(
166        "SELECT DISTINCT p.name
167           FROM rustio_permissions p
168           LEFT JOIN rustio_user_permissions up ON up.permission_id = p.id
169           LEFT JOIN rustio_group_permissions gp ON gp.permission_id = p.id
170           LEFT JOIN rustio_user_groups ug ON ug.group_id = gp.group_id
171          WHERE up.user_id = $1 OR ug.user_id = $1",
172    )
173    .bind(user_id)
174    .fetch_all(db.pool())
175    .await?;
176
177    let mut set = HashSet::with_capacity(rows.len());
178    for r in rows {
179        if let Ok(name) = r.try_get::<String, _>("name") {
180            set.insert(name);
181        }
182    }
183    let arc = Arc::new(set);
184    PERM_CACHE.insert(
185        user_id,
186        CacheEntry {
187            perms: arc.clone(),
188            expires: Instant::now() + PERM_CACHE_TTL,
189        },
190    );
191    Ok(arc)
192}
193
194// public:
195/// Ask "does this identity have permission X?".
196///
197/// Order of checks (load-bearing):
198/// 1. **`is_active`** — an inactive user is denied even if their role
199///    would bypass group checks.
200/// 2. **`bypasses_group_checks`** — Administrator and Developer skip
201///    the M2M lookup; every other tier consults the tables.
202pub async fn check_permission(db: &Db, identity: &Identity, permission: &str) -> Result<bool> {
203    if !identity.is_active {
204        return Ok(false);
205    }
206    if identity.role.bypasses_group_checks() {
207        return Ok(true);
208    }
209    let perms = permissions_for_user(db, identity.user_id).await?;
210    Ok(perms.contains(permission))
211}
212
213// --- writes ---------------------------------------------------------------
214
215async fn permission_id(db: &Db, name: &str) -> Result<i64> {
216    if let Some(row) = sqlx::query("SELECT id FROM rustio_permissions WHERE name = $1")
217        .bind(name)
218        .fetch_optional(db.pool())
219        .await?
220    {
221        return row
222            .try_get("id")
223            .map_err(|e| Error::Internal(format!("{e}")));
224    }
225    let row = sqlx::query(
226        "INSERT INTO rustio_permissions (name, description)
227         VALUES ($1, $2)
228         ON CONFLICT (name) DO UPDATE SET description = rustio_permissions.description
229         RETURNING id",
230    )
231    .bind(name)
232    .bind("")
233    .fetch_one(db.pool())
234    .await?;
235    row.try_get("id")
236        .map_err(|e| Error::Internal(format!("{e}")))
237}
238
239// public:
240pub async fn grant_to_user(db: &Db, user_id: i64, permission: &str) -> Result<()> {
241    let pid = permission_id(db, permission).await?;
242    sqlx::query(
243        "INSERT INTO rustio_user_permissions (user_id, permission_id)
244         VALUES ($1, $2)
245         ON CONFLICT DO NOTHING",
246    )
247    .bind(user_id)
248    .bind(pid)
249    .execute(db.pool())
250    .await?;
251    invalidate_user_cache(user_id);
252    Ok(())
253}
254
255// public:
256pub async fn grant_to_group(db: &Db, group_id: i64, permission: &str) -> Result<()> {
257    let pid = permission_id(db, permission).await?;
258    sqlx::query(
259        "INSERT INTO rustio_group_permissions (group_id, permission_id)
260         VALUES ($1, $2)
261         ON CONFLICT DO NOTHING",
262    )
263    .bind(group_id)
264    .bind(pid)
265    .execute(db.pool())
266    .await?;
267    invalidate_group_cache(db, group_id);
268    Ok(())
269}
270
271// public:
272pub async fn create_group(db: &Db, name: &str, description: &str) -> Result<i64> {
273    let row = sqlx::query(
274        "INSERT INTO rustio_groups (name, description)
275         VALUES ($1, $2)
276         RETURNING id",
277    )
278    .bind(name)
279    .bind(description)
280    .fetch_one(db.pool())
281    .await?;
282    row.try_get("id")
283        .map_err(|e| Error::Internal(format!("{e}")))
284}
285
286// public:
287pub async fn add_user_to_group(db: &Db, user_id: i64, group_id: i64) -> Result<()> {
288    sqlx::query(
289        "INSERT INTO rustio_user_groups (user_id, group_id)
290         VALUES ($1, $2)
291         ON CONFLICT DO NOTHING",
292    )
293    .bind(user_id)
294    .bind(group_id)
295    .execute(db.pool())
296    .await?;
297    invalidate_user_cache(user_id);
298    Ok(())
299}
300
301// public:
302pub async fn remove_user_from_group(db: &Db, user_id: i64, group_id: i64) -> Result<()> {
303    sqlx::query("DELETE FROM rustio_user_groups WHERE user_id = $1 AND group_id = $2")
304        .bind(user_id)
305        .bind(group_id)
306        .execute(db.pool())
307        .await?;
308    invalidate_user_cache(user_id);
309    Ok(())
310}
311
312// public:
313/// For an admin model named `posts`, register the canonical four
314/// permissions: `add_post`, `change_post`, `delete_post`, `view_post`.
315/// Idempotent.
316pub async fn register_model_permissions(db: &Db, app: &str, singular: &str) -> Result<()> {
317    let actions = ["add", "change", "delete", "view"];
318    for action in actions {
319        let name = format!("{app}.{action}_{singular}");
320        let _ = permission_id(db, &name).await?;
321    }
322    Ok(())
323}
324
325#[cfg(test)]
326mod tests {
327    use super::*;
328
329    #[test]
330    fn administrator_and_developer_bypass_group_checks() {
331        // The two top tiers skip the M2M lookup. Lower tiers don't.
332        for &(role, expected) in &[
333            (Role::User, false),
334            (Role::Staff, false),
335            (Role::Supervisor, false),
336            (Role::Administrator, true),
337            (Role::Developer, true),
338        ] {
339            let id = Identity {
340                user_id: 1,
341                email: "a@b.com".into(),
342                role,
343                is_active: true,
344                is_demo: false,
345                demo_label: None,
346                must_change_password: false,
347                mfa_enabled: false,
348                trust_level: crate::auth::SessionTrust::Authenticated,
349            };
350            assert_eq!(
351                id.role.bypasses_group_checks(),
352                expected,
353                "{role:?} should be {expected}"
354            );
355        }
356    }
357
358    #[test]
359    fn cache_ttl_is_one_minute() {
360        assert_eq!(PERM_CACHE_TTL.as_secs(), 60);
361    }
362}