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