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// public:
331/// The three structural permission groups every fresh database is
332/// seeded with (PR 2.2 / `DESIGN_PERMISSIONS.md`).
333///
334/// - `administrator` — full system access.
335/// - `editor` — create / read / update on content models only.
336/// - `viewer` — read-only on content models.
337///
338/// **These are structural defaults, not demo data.** Group names
339/// MUST exactly match the `--role` values accepted by `rustio user
340/// create` so a developer's role choice and the group their account
341/// gets dropped into are the same string. The CLI's lockstep test
342/// (`crates/rustio-admin-cli/src/user.rs`) fails CI if either side
343/// drifts.
344pub const DEFAULT_GROUP_NAMES: [&str; 3] = ["administrator", "editor", "viewer"];
345
346// public:
347/// Seed the three structural permission groups on a fresh database.
348///
349/// Idempotent: calls `create_group` (ON CONFLICT (name) DO UPDATE
350/// description) for each name. Safe to invoke on every boot.
351///
352/// **Guard (PR 2.2 doctrine):** the seed is SKIPPED when the
353/// `rustio_groups` table already contains any group name NOT in
354/// [`DEFAULT_GROUP_NAMES`]. An existing project that has built its
355/// own group structure on 0.20.x is never silently re-shaped by
356/// upgrading to 0.21.0; only databases that are either fresh or
357/// already match the default set get the seed applied.
358pub async fn seed_default_groups(db: &Db) -> Result<()> {
359    let foreign_count: i64 = sqlx::query_scalar(
360        "SELECT COUNT(*) FROM rustio_groups
361         WHERE name NOT IN ('administrator', 'editor', 'viewer')",
362    )
363    .fetch_one(db.pool())
364    .await
365    .map_err(|e| Error::Internal(format!("seed_default_groups guard: {e}")))?;
366    if foreign_count > 0 {
367        // Project has user-defined groups; respect that and skip.
368        return Ok(());
369    }
370    create_group(db, "administrator", "Full system access.").await?;
371    create_group(
372        db,
373        "editor",
374        "Create / read / update on content models only. No user, group, settings, or framework-admin actions.",
375    )
376    .await?;
377    create_group(db, "viewer", "Read-only access to content models.").await?;
378    Ok(())
379}
380
381// public:
382/// Per-model permission grants for the seeded default groups
383/// (PR 2.2 / `DESIGN_PERMISSIONS.md`). Called by
384/// [`crate::admin::Admin::seed_permissions`] after the four CRUD
385/// permissions are registered for `<app>.<singular>`. Each grant
386/// is idempotent (`grant_to_group` uses ON CONFLICT DO NOTHING);
387/// missing groups (because [`seed_default_groups`] was skipped by
388/// the user-defined-groups guard) cause silent no-ops, not errors.
389///
390/// Grant matrix:
391///
392/// |              | `add` | `change` | `delete` | `view` |
393/// |--------------|-------|----------|----------|--------|
394/// | administrator | ✓     | ✓        | ✓        | ✓      |
395/// | editor        | ✓     | ✓        |          | ✓      |
396/// | viewer        |       |          |          | ✓      |
397///
398/// `editor` deliberately lacks `delete` — destructive operations
399/// belong to administrators by default. Projects that want
400/// editor-level delete access either grant `<app>.delete_<model>`
401/// to the `editor` group explicitly via the admin permission-matrix
402/// UI, or move those users to `administrator`.
403pub async fn grant_model_to_default_groups(db: &Db, app: &str, singular: &str) -> Result<()> {
404    // Look up the three group IDs. Missing => skip (user-defined
405    // groups guard fired; the default set isn't installed on this
406    // database, so per-model grants would have nowhere to land).
407    let admin_id = group_id_by_name(db, "administrator").await?;
408    let editor_id = group_id_by_name(db, "editor").await?;
409    let viewer_id = group_id_by_name(db, "viewer").await?;
410
411    let add = format!("{app}.add_{singular}");
412    let change = format!("{app}.change_{singular}");
413    let delete = format!("{app}.delete_{singular}");
414    let view = format!("{app}.view_{singular}");
415
416    if let Some(id) = admin_id {
417        grant_to_group(db, id, &add).await?;
418        grant_to_group(db, id, &change).await?;
419        grant_to_group(db, id, &delete).await?;
420        grant_to_group(db, id, &view).await?;
421    }
422    if let Some(id) = editor_id {
423        grant_to_group(db, id, &add).await?;
424        grant_to_group(db, id, &change).await?;
425        grant_to_group(db, id, &view).await?;
426        // No delete — see grant matrix above.
427    }
428    if let Some(id) = viewer_id {
429        grant_to_group(db, id, &view).await?;
430    }
431    Ok(())
432}
433
434/// Look up a group ID by name. Returns Ok(None) when the group
435/// doesn't exist (intentional: callers want graceful no-op on
436/// missing-default-groups, not error propagation).
437async fn group_id_by_name(db: &Db, name: &str) -> Result<Option<i64>> {
438    let id: Option<i64> = sqlx::query_scalar("SELECT id FROM rustio_groups WHERE name = $1")
439        .bind(name)
440        .fetch_optional(db.pool())
441        .await
442        .map_err(|e| Error::Internal(format!("group_id_by_name({name}): {e}")))?;
443    Ok(id)
444}
445
446#[cfg(test)]
447mod tests {
448    use super::*;
449
450    #[test]
451    fn administrator_and_developer_bypass_group_checks() {
452        // The two top tiers skip the M2M lookup. Lower tiers don't.
453        for &(role, expected) in &[
454            (Role::User, false),
455            (Role::Staff, false),
456            (Role::Supervisor, false),
457            (Role::Administrator, true),
458            (Role::Developer, true),
459        ] {
460            let id = Identity {
461                user_id: 1,
462                email: "a@b.com".into(),
463                role,
464                is_active: true,
465                is_demo: false,
466                demo_label: None,
467                must_change_password: false,
468                mfa_enabled: false,
469                trust_level: crate::auth::SessionTrust::Authenticated,
470            };
471            assert_eq!(
472                id.role.bypasses_group_checks(),
473                expected,
474                "{role:?} should be {expected}"
475            );
476        }
477    }
478
479    #[test]
480    fn cache_ttl_is_one_minute() {
481        assert_eq!(PERM_CACHE_TTL.as_secs(), 60);
482    }
483}