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:
272/// Idempotent. A second call with the same `name` returns the
273/// existing group's id; the stored `description` is preserved
274/// (first-write-wins). Mirrors the `permission_id` upsert idiom
275/// in this module.
276pub async fn create_group(db: &Db, name: &str, description: &str) -> Result<i64> {
277    let row = sqlx::query(
278        "INSERT INTO rustio_groups (name, description)
279         VALUES ($1, $2)
280         ON CONFLICT (name) DO UPDATE SET description = rustio_groups.description
281         RETURNING id",
282    )
283    .bind(name)
284    .bind(description)
285    .fetch_one(db.pool())
286    .await?;
287    row.try_get("id")
288        .map_err(|e| Error::Internal(format!("{e}")))
289}
290
291// public:
292pub async fn add_user_to_group(db: &Db, user_id: i64, group_id: i64) -> Result<()> {
293    sqlx::query(
294        "INSERT INTO rustio_user_groups (user_id, group_id)
295         VALUES ($1, $2)
296         ON CONFLICT DO NOTHING",
297    )
298    .bind(user_id)
299    .bind(group_id)
300    .execute(db.pool())
301    .await?;
302    invalidate_user_cache(user_id);
303    Ok(())
304}
305
306// public:
307pub async fn remove_user_from_group(db: &Db, user_id: i64, group_id: i64) -> Result<()> {
308    sqlx::query("DELETE FROM rustio_user_groups WHERE user_id = $1 AND group_id = $2")
309        .bind(user_id)
310        .bind(group_id)
311        .execute(db.pool())
312        .await?;
313    invalidate_user_cache(user_id);
314    Ok(())
315}
316
317// public:
318/// For an admin model named `posts`, register the canonical four
319/// permissions: `add_post`, `change_post`, `delete_post`, `view_post`.
320/// Idempotent.
321pub async fn register_model_permissions(db: &Db, app: &str, singular: &str) -> Result<()> {
322    let actions = ["add", "change", "delete", "view"];
323    for action in actions {
324        let name = format!("{app}.{action}_{singular}");
325        let _ = permission_id(db, &name).await?;
326    }
327    Ok(())
328}
329
330#[cfg(test)]
331mod tests {
332    use super::*;
333
334    #[test]
335    fn administrator_and_developer_bypass_group_checks() {
336        // The two top tiers skip the M2M lookup. Lower tiers don't.
337        for &(role, expected) in &[
338            (Role::User, false),
339            (Role::Staff, false),
340            (Role::Supervisor, false),
341            (Role::Administrator, true),
342            (Role::Developer, true),
343        ] {
344            let id = Identity {
345                user_id: 1,
346                email: "a@b.com".into(),
347                role,
348                is_active: true,
349                is_demo: false,
350                demo_label: None,
351                must_change_password: false,
352                mfa_enabled: false,
353                trust_level: crate::auth::SessionTrust::Authenticated,
354            };
355            assert_eq!(
356                id.role.bypasses_group_checks(),
357                expected,
358                "{role:?} should be {expected}"
359            );
360        }
361    }
362
363    #[test]
364    fn cache_ttl_is_one_minute() {
365        assert_eq!(PERM_CACHE_TTL.as_secs(), 60);
366    }
367}