Skip to main content

rustio_core/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 Admin-role user automatically has every permission. Staff and
15//! User roles are checked against the tables above.
16//!
17//! Permissions for a user are cached in a `DashMap<user_id, (Vec<String>, expires)>`
18//! with a 60-second TTL so hot paths don't hit the DB. A write to the
19//! permission tables calls `invalidate_user_cache(user_id)`.
20
21use std::collections::HashSet;
22use std::sync::Arc;
23use std::time::{Duration, Instant};
24
25use dashmap::DashMap;
26use once_cell::sync::Lazy;
27use sqlx::Row as SqlxRow;
28
29use crate::error::{Error, Result};
30use crate::orm::Db;
31
32use super::users::Identity;
33
34#[cfg(test)]
35use super::role::Role;
36
37/// Marker type used by the authorize! macro for fast-paths on admins.
38pub struct Superuser;
39
40#[derive(Debug, Clone)]
41pub struct Permission {
42    pub id: i64,
43    pub name: String,
44    pub description: String,
45}
46
47#[derive(Debug, thiserror::Error)]
48pub enum PermissionError {
49    #[error("permission `{0}` not found")]
50    Missing(String),
51    #[error("user not found")]
52    NoSuchUser,
53    #[error("group not found")]
54    NoSuchGroup,
55}
56
57// --- schema ---------------------------------------------------------------
58
59pub async fn init_permission_tables(db: &Db) -> Result<()> {
60    sqlx::query(
61        "CREATE TABLE IF NOT EXISTS rustio_permissions (
62            id          BIGSERIAL PRIMARY KEY,
63            name        TEXT NOT NULL UNIQUE,
64            description TEXT NOT NULL DEFAULT '',
65            created_at  TIMESTAMPTZ NOT NULL DEFAULT NOW()
66        )",
67    )
68    .execute(db.pool())
69    .await?;
70
71    sqlx::query(
72        "CREATE TABLE IF NOT EXISTS rustio_groups (
73            id          BIGSERIAL PRIMARY KEY,
74            name        TEXT NOT NULL UNIQUE,
75            description TEXT NOT NULL DEFAULT '',
76            created_at  TIMESTAMPTZ NOT NULL DEFAULT NOW()
77        )",
78    )
79    .execute(db.pool())
80    .await?;
81
82    sqlx::query(
83        "CREATE TABLE IF NOT EXISTS rustio_group_permissions (
84            group_id      BIGINT NOT NULL REFERENCES rustio_groups(id)      ON DELETE CASCADE,
85            permission_id BIGINT NOT NULL REFERENCES rustio_permissions(id) ON DELETE CASCADE,
86            PRIMARY KEY (group_id, permission_id)
87        )",
88    )
89    .execute(db.pool())
90    .await?;
91
92    sqlx::query(
93        "CREATE TABLE IF NOT EXISTS rustio_user_groups (
94            user_id  BIGINT NOT NULL REFERENCES rustio_users(id)  ON DELETE CASCADE,
95            group_id BIGINT NOT NULL REFERENCES rustio_groups(id) ON DELETE CASCADE,
96            PRIMARY KEY (user_id, group_id)
97        )",
98    )
99    .execute(db.pool())
100    .await?;
101
102    sqlx::query(
103        "CREATE TABLE IF NOT EXISTS rustio_user_permissions (
104            user_id       BIGINT NOT NULL REFERENCES rustio_users(id)       ON DELETE CASCADE,
105            permission_id BIGINT NOT NULL REFERENCES rustio_permissions(id) ON DELETE CASCADE,
106            PRIMARY KEY (user_id, permission_id)
107        )",
108    )
109    .execute(db.pool())
110    .await?;
111
112    Ok(())
113}
114
115// --- cache ----------------------------------------------------------------
116
117struct CacheEntry {
118    perms: Arc<HashSet<String>>,
119    expires: Instant,
120}
121
122static PERM_CACHE: Lazy<DashMap<i64, CacheEntry>> = Lazy::new(DashMap::new);
123
124const PERM_CACHE_TTL: Duration = Duration::from_secs(60);
125
126pub(crate) fn invalidate_user_cache(user_id: i64) {
127    PERM_CACHE.remove(&user_id);
128}
129
130fn invalidate_group_cache(db: &Db, group_id: i64) {
131    // Users in this group need their cached permission sets evicted.
132    // Fire-and-forget — the TTL will catch anything we miss.
133    let db = db.clone();
134    tokio::spawn(async move {
135        let rows = sqlx::query("SELECT user_id FROM rustio_user_groups WHERE group_id = $1")
136            .bind(group_id)
137            .fetch_all(db.pool())
138            .await
139            .unwrap_or_default();
140        for r in rows {
141            if let Ok(uid) = r.try_get::<i64, _>("user_id") {
142                invalidate_user_cache(uid);
143            }
144        }
145    });
146}
147
148// --- reads ----------------------------------------------------------------
149
150/// All permission names belonging to the given user — direct + via
151/// groups — unioned into one set. Cached for 60s.
152pub async fn permissions_for_user(db: &Db, user_id: i64) -> Result<Arc<HashSet<String>>> {
153    if let Some(e) = PERM_CACHE.get(&user_id) {
154        if e.expires > Instant::now() {
155            return Ok(e.perms.clone());
156        }
157    }
158
159    let rows = sqlx::query(
160        "SELECT DISTINCT p.name
161           FROM rustio_permissions p
162           LEFT JOIN rustio_user_permissions up ON up.permission_id = p.id
163           LEFT JOIN rustio_group_permissions gp ON gp.permission_id = p.id
164           LEFT JOIN rustio_user_groups ug ON ug.group_id = gp.group_id
165          WHERE up.user_id = $1 OR ug.user_id = $1",
166    )
167    .bind(user_id)
168    .fetch_all(db.pool())
169    .await?;
170
171    let mut set = HashSet::with_capacity(rows.len());
172    for r in rows {
173        if let Ok(name) = r.try_get::<String, _>("name") {
174            set.insert(name);
175        }
176    }
177    let arc = Arc::new(set);
178    PERM_CACHE.insert(
179        user_id,
180        CacheEntry {
181            perms: arc.clone(),
182            expires: Instant::now() + PERM_CACHE_TTL,
183        },
184    );
185    Ok(arc)
186}
187
188/// Ask "does this identity have permission X?".
189///
190/// Order of checks (load-bearing — see Phase 7a/0.5/sec2):
191/// 1. **`is_active`** — an inactive user is denied even if their role
192///    would bypass group checks. Defense-in-depth: `login_guard` already
193///    rejects inactive sessions at the panel boundary, but if a future
194///    code path calls `check_permission` without the guard, the inactive
195///    check here is the second line.
196/// 2. **`bypasses_group_checks`** — Administrator and Developer skip the
197///    M2M lookup; every other tier consults the tables.
198pub async fn check_permission(db: &Db, identity: &Identity, permission: &str) -> Result<bool> {
199    if !identity.is_active {
200        return Ok(false);
201    }
202    if identity.role.bypasses_group_checks() {
203        return Ok(true);
204    }
205    let perms = permissions_for_user(db, identity.user_id).await?;
206    Ok(perms.contains(permission))
207}
208
209// --- writes ---------------------------------------------------------------
210
211async fn permission_id(db: &Db, name: &str) -> Result<i64> {
212    // Look up first, then insert if missing — lets the caller use
213    // convenient short names without pre-seeding the table.
214    if let Some(row) = sqlx::query("SELECT id FROM rustio_permissions WHERE name = $1")
215        .bind(name)
216        .fetch_optional(db.pool())
217        .await?
218    {
219        return row.try_get("id").map_err(|e| Error::Internal(format!("{e}")));
220    }
221    let row = sqlx::query(
222        "INSERT INTO rustio_permissions (name, description)
223         VALUES ($1, $2)
224         ON CONFLICT (name) DO UPDATE SET description = rustio_permissions.description
225         RETURNING id",
226    )
227    .bind(name)
228    .bind("")
229    .fetch_one(db.pool())
230    .await?;
231    row.try_get("id").map_err(|e| Error::Internal(format!("{e}")))
232}
233
234pub async fn grant_to_user(db: &Db, user_id: i64, permission: &str) -> Result<()> {
235    let pid = permission_id(db, permission).await?;
236    sqlx::query(
237        "INSERT INTO rustio_user_permissions (user_id, permission_id)
238         VALUES ($1, $2)
239         ON CONFLICT DO NOTHING",
240    )
241    .bind(user_id)
242    .bind(pid)
243    .execute(db.pool())
244    .await?;
245    invalidate_user_cache(user_id);
246    Ok(())
247}
248
249pub async fn grant_to_group(db: &Db, group_id: i64, permission: &str) -> Result<()> {
250    let pid = permission_id(db, permission).await?;
251    sqlx::query(
252        "INSERT INTO rustio_group_permissions (group_id, permission_id)
253         VALUES ($1, $2)
254         ON CONFLICT DO NOTHING",
255    )
256    .bind(group_id)
257    .bind(pid)
258    .execute(db.pool())
259    .await?;
260    invalidate_group_cache(db, group_id);
261    Ok(())
262}
263
264pub async fn create_group(db: &Db, name: &str, description: &str) -> Result<i64> {
265    let row = sqlx::query(
266        "INSERT INTO rustio_groups (name, description)
267         VALUES ($1, $2)
268         RETURNING id",
269    )
270    .bind(name)
271    .bind(description)
272    .fetch_one(db.pool())
273    .await?;
274    row.try_get("id").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. Idempotent.
303pub async fn register_model_permissions(
304    db: &Db,
305    app: &str,
306    singular: &str,
307) -> Result<()> {
308    let actions = ["add", "change", "delete", "view"];
309    for action in actions {
310        let name = format!("{app}.{action}_{singular}");
311        let _ = permission_id(db, &name).await?;
312    }
313    Ok(())
314}
315
316// ---------------------------------------------------------------------------
317// Phase 7a/0.5/c — default group bootstrap + lazy permission attachment.
318//
319// Both functions are gated behind `RUSTIO_DEMO_MODE=1`. Without the flag
320// they're no-ops, so production deployments never accidentally seed demo
321// data. The flag itself is operator-facing (set in env, not config).
322// ---------------------------------------------------------------------------
323
324/// One row in the `DEFAULT_GROUP_SPECS` table: a group name + its
325/// permission policy expressed in terms of registered models.
326struct GroupSpec {
327    name: &'static str,
328    description: &'static str,
329    model_perms: GroupModelPerms,
330}
331
332/// How a group's permissions relate to registered admin models.
333enum GroupModelPerms {
334    /// Apply these actions to **every** registered (non-core) model.
335    /// e.g. `All(&["view"])` for read-only Auditors. Wildcard expansion
336    /// happens at attach time so adding a new model auto-extends the
337    /// group's access on next startup.
338    All(&'static [&'static str]),
339    /// Per-model rules. Each tuple is `(model_codename, actions)` where
340    /// `model_codename` is the model's `singular_name` lowercased
341    /// (`"translator"`, not `"Translator"`). Models that aren't
342    /// registered are silently skipped — the spec lists every model
343    /// the group could plausibly own; reality boots with whatever
344    /// subset is registered.
345    Specific(&'static [(&'static str, &'static [&'static str])]),
346}
347
348const DEFAULT_GROUP_SPECS: &[GroupSpec] = &[
349    GroupSpec {
350        name: "Auditors",
351        description: "Read-only access to all models",
352        model_perms: GroupModelPerms::All(&["view"]),
353    },
354    GroupSpec {
355        name: "Content Editors",
356        description: "Create, view, and edit content models",
357        model_perms: GroupModelPerms::Specific(&[
358            ("project", &["view", "add", "change"]),
359            ("language", &["view", "add", "change"]),
360        ]),
361    },
362    GroupSpec {
363        name: "HR Managers",
364        description: "Manage freelancers and their skills",
365        model_perms: GroupModelPerms::Specific(&[
366            ("translator", &["view", "add", "change", "delete"]),
367            ("skill", &["view", "add", "change", "delete"]),
368        ]),
369    },
370    GroupSpec {
371        name: "Finance",
372        description: "Manage contracts and billing",
373        model_perms: GroupModelPerms::Specific(&[
374            ("contract", &["view", "add", "change", "delete"]),
375            ("invoice", &["view", "add", "change", "delete"]),
376            ("payment", &["view", "add", "change", "delete"]),
377        ]),
378    },
379    GroupSpec {
380        name: "Project Coordinators",
381        description: "Coordinate projects and assignments",
382        model_perms: GroupModelPerms::Specific(&[
383            ("project", &["view", "add", "change", "delete"]),
384            ("assignment", &["view", "add", "change", "delete"]),
385            ("timeentry", &["view", "add", "change", "delete"]),
386        ]),
387    },
388    GroupSpec {
389        name: "System Operators",
390        description: "View and edit, no destructive operations",
391        model_perms: GroupModelPerms::All(&["view", "change"]),
392    },
393];
394
395fn demo_mode_enabled() -> bool {
396    std::env::var("RUSTIO_DEMO_MODE").as_deref() == Ok("1")
397}
398
399/// Insert each default group with `ON CONFLICT (name) DO NOTHING`.
400/// Idempotent across restarts and against admin-created groups —
401/// duplicates by name simply skip without bumping any state.
402///
403/// Gated by `RUSTIO_DEMO_MODE=1`. Without the flag this is a no-op
404/// so production deploys never accidentally seed demo data.
405pub async fn bootstrap_default_groups(db: &Db) -> Result<()> {
406    if !demo_mode_enabled() {
407        return Ok(());
408    }
409    for spec in DEFAULT_GROUP_SPECS {
410        sqlx::query(
411            "INSERT INTO rustio_groups (name, description) \
412             VALUES ($1, $2) \
413             ON CONFLICT (name) DO NOTHING",
414        )
415        .bind(spec.name)
416        .bind(spec.description)
417        .execute(db.pool())
418        .await?;
419    }
420    log::info!(
421        "RUSTIO_DEMO_MODE: ensured {} default groups exist",
422        DEFAULT_GROUP_SPECS.len()
423    );
424    Ok(())
425}
426
427/// For each default group, attach the permissions it can resolve from
428/// the currently-registered models. `All(&[…])` becomes one perm per
429/// non-core entry; `Specific(&[…])` becomes one perm per matching
430/// entry (skipping un-registered models silently).
431///
432/// Idempotent — `grant_to_group` uses `ON CONFLICT DO NOTHING` and
433/// `permission_id` upserts. Adding a new model and restarting picks
434/// up the wildcards automatically.
435///
436/// Gated by `RUSTIO_DEMO_MODE=1`.
437pub async fn lazy_attach_permissions(
438    db: &Db,
439    entries: &[crate::admin::AdminEntry],
440) -> Result<()> {
441    if !demo_mode_enabled() {
442        return Ok(());
443    }
444    let mut attached = 0usize;
445    for spec in DEFAULT_GROUP_SPECS {
446        let group_id = match find_group_id_by_name(db, spec.name).await? {
447            Some(id) => id,
448            None => continue,
449        };
450        for codename in resolve_perms(&spec.model_perms, entries) {
451            grant_to_group(db, group_id, &codename).await?;
452            attached += 1;
453        }
454    }
455    log::info!(
456        "RUSTIO_DEMO_MODE: lazy-attached {attached} permission rows across {} groups",
457        DEFAULT_GROUP_SPECS.len()
458    );
459    Ok(())
460}
461
462pub(crate) async fn find_group_id_by_name(db: &Db, name: &str) -> Result<Option<i64>> {
463    let row = sqlx::query("SELECT id FROM rustio_groups WHERE name = $1")
464        .bind(name)
465        .fetch_optional(db.pool())
466        .await?;
467    match row {
468        Some(r) => Ok(Some(
469            r.try_get::<i64, _>("id")
470                .map_err(|e| Error::Internal(format!("{e}")))?,
471        )),
472        None => Ok(None),
473    }
474}
475
476/// Expand a `GroupModelPerms` against the currently-registered admin
477/// entries into the full list of permission codenames to grant.
478///
479/// Codename format matches `register_model_permissions`:
480/// `<entry.admin_name>.<action>_<entry.singular_name.lower>`.
481///
482/// Core entries (the synthetic User) are skipped — wildcard groups
483/// don't get rights over framework infrastructure.
484fn resolve_perms(
485    spec: &GroupModelPerms,
486    entries: &[crate::admin::AdminEntry],
487) -> Vec<String> {
488    let mut out: Vec<String> = Vec::new();
489    match spec {
490        GroupModelPerms::All(actions) => {
491            for entry in entries.iter().filter(|e| !e.core) {
492                let singular = entry.singular_name.to_ascii_lowercase();
493                for action in *actions {
494                    out.push(format!("{}.{}_{}", entry.admin_name, action, singular));
495                }
496            }
497        }
498        GroupModelPerms::Specific(pairs) => {
499            for (codename, actions) in *pairs {
500                let entry = entries.iter().find(|e| {
501                    !e.core && e.singular_name.eq_ignore_ascii_case(codename)
502                });
503                if let Some(entry) = entry {
504                    let singular = entry.singular_name.to_ascii_lowercase();
505                    for action in *actions {
506                        out.push(format!("{}.{}_{}", entry.admin_name, action, singular));
507                    }
508                }
509                // Model not registered → skip silently. Wildcard groups
510                // expand whatever's there now; specifics skip what's
511                // missing.
512            }
513        }
514    }
515    out
516}
517
518#[cfg(test)]
519mod tests {
520    use super::*;
521
522    #[test]
523    fn administrator_and_developer_bypass_group_checks() {
524        // The two top tiers skip the M2M lookup. Lower tiers don't.
525        for &(role, expected) in &[
526            (Role::User, false),
527            (Role::Staff, false),
528            (Role::Supervisor, false),
529            (Role::Administrator, true),
530            (Role::Developer, true),
531        ] {
532            let id = Identity {
533                user_id: 1,
534                email: "a@b.com".into(),
535                role,
536                is_active: true,
537                is_demo: false,
538                demo_label: None,
539            };
540            assert_eq!(
541                id.role.bypasses_group_checks(),
542                expected,
543                "{role:?} should be {expected}"
544            );
545        }
546    }
547
548    #[test]
549    fn cache_ttl_is_one_minute() {
550        assert_eq!(PERM_CACHE_TTL.as_secs(), 60);
551    }
552
553    /// Phase 7a/0.5/sec3: invalidating the perm cache makes a fresh
554    /// `permissions_for_user` call read live tables. Without sec3's
555    /// fix, `do_user_edit`'s wholesale `DELETE FROM rustio_user_groups`
556    /// would leave the user passing every cached perm for up to 60s.
557    #[tokio::test]
558    #[ignore = "needs `RUSTIO_TEST_DB=1` + a running postgres (URL via RUSTIO_TEST_DATABASE_URL or default)"]
559    async fn invalidate_user_cache_drops_stale_perms() {
560        use crate::auth::create_user;
561        use crate::auth::Role as RoleAlias;
562
563        let url = std::env::var("RUSTIO_TEST_DATABASE_URL")
564            .unwrap_or_else(|_| "postgres://postgres:dev@localhost:5432/rustio_dev".into());
565        let opts = crate::orm::DbOptions {
566            max_connections: 2,
567            ..crate::orm::DbOptions::default()
568        };
569        let db = crate::orm::Db::connect_with(&url, opts).await.unwrap();
570
571        // Seed: one Staff user, one group with `posts.view_post`,
572        // user attached to group. Clean per-test by using a unique tag.
573        crate::auth::init_user_tables(&db).await.unwrap();
574        crate::auth::migrate_user_schema(&db).await.unwrap();
575        crate::auth::init_permission_tables(&db).await.unwrap();
576
577        let tag = format!("invtest_{}_{}", std::process::id(),
578            std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos());
579        let email = format!("{tag}@example.test");
580        let user_id = create_user(&db, &email, "secret-pw-123", RoleAlias::Staff).await.unwrap();
581        let group_id = create_group(&db, &tag, "tmp").await.unwrap();
582        grant_to_group(&db, group_id, "posts.view_post").await.unwrap();
583        add_user_to_group(&db, user_id, group_id).await.unwrap();
584
585        let identity = Identity {
586            user_id,
587            email: email.clone(),
588            role: RoleAlias::Staff,
589            is_active: true,
590            is_demo: false,
591            demo_label: None,
592        };
593
594        // Sanity: user has the perm via group.
595        assert!(
596            check_permission(&db, &identity, "posts.view_post").await.unwrap(),
597            "user should have view_post via group"
598        );
599
600        // Simulate `do_user_edit`'s wholesale DELETE without going
601        // through `remove_user_from_group` (which would invalidate the
602        // cache for us). This is the exact pattern that bites in
603        // production.
604        sqlx::query("DELETE FROM rustio_user_groups WHERE user_id = $1")
605            .bind(user_id)
606            .execute(db.pool())
607            .await
608            .unwrap();
609
610        // sec3's fix: explicit invalidate after the wholesale delete.
611        invalidate_user_cache(user_id);
612
613        assert!(
614            !check_permission(&db, &identity, "posts.view_post").await.unwrap(),
615            "after wholesale DELETE + invalidate, user must NOT have view_post"
616        );
617
618        // Cleanup.
619        let _ = sqlx::query("DELETE FROM rustio_users WHERE id = $1")
620            .bind(user_id)
621            .execute(db.pool())
622            .await;
623        let _ = sqlx::query("DELETE FROM rustio_groups WHERE id = $1")
624            .bind(group_id)
625            .execute(db.pool())
626            .await;
627    }
628
629    /// Phase 7a/0.5/sec2 regression: the order of checks in
630    /// `check_permission` must reject inactive users **before** the
631    /// `bypasses_group_checks` short-circuit. Otherwise an inactive
632    /// Administrator/Developer who somehow holds a session passes
633    /// every permission check.
634    #[tokio::test]
635    #[ignore = "needs `RUSTIO_TEST_DB=1` + a running postgres (URL via RUSTIO_TEST_DATABASE_URL or default)"]
636    async fn inactive_administrator_is_denied_before_bypass() {
637        let url = std::env::var("RUSTIO_TEST_DATABASE_URL")
638            .unwrap_or_else(|_| "postgres://postgres:dev@localhost:5432/rustio_dev".into());
639        let opts = crate::orm::DbOptions {
640            max_connections: 2,
641            ..crate::orm::DbOptions::default()
642        };
643        let db = crate::orm::Db::connect_with(&url, opts).await.unwrap();
644
645        let id = Identity {
646            user_id: -1, // ghost id; the inactive short-circuit fires first, so this never gets queried
647            email: "ghost@example.com".into(),
648            role: Role::Administrator,
649            is_active: false,
650            is_demo: false,
651            demo_label: None,
652        };
653        let result = check_permission(&db, &id, "any.permission").await.unwrap();
654        assert!(
655            !result,
656            "inactive Administrator must be denied; bypass must NOT fire before is_active check"
657        );
658
659        // Sanity check: same identity with is_active=true would bypass.
660        let id_active = Identity {
661            is_active: true,
662            ..id
663        };
664        assert!(
665            check_permission(&db, &id_active, "any.permission")
666                .await
667                .unwrap(),
668            "active Administrator should bypass and return true"
669        );
670    }
671
672    // ------------------------------------------------------------------
673    // Phase 7a/0.5/c — bootstrap_default_groups + lazy_attach_permissions
674    // ------------------------------------------------------------------
675
676    fn pg_url() -> String {
677        std::env::var("RUSTIO_TEST_DATABASE_URL")
678            .unwrap_or_else(|_| "postgres://postgres:dev@localhost:5432/rustio_dev".into())
679    }
680
681    async fn pg_db() -> crate::orm::Db {
682        let opts = crate::orm::DbOptions {
683            max_connections: 2,
684            ..crate::orm::DbOptions::default()
685        };
686        crate::orm::Db::connect_with(&pg_url(), opts).await.unwrap()
687    }
688
689    use crate::auth::TEST_ENV_LOCK as ENV_LOCK;
690
691    /// Reset every default group on the test DB so each test starts
692    /// from a clean slate. Cascades through user/perm M2M.
693    async fn reset_default_groups(db: &crate::orm::Db) {
694        for spec in DEFAULT_GROUP_SPECS {
695            let _ = sqlx::query("DELETE FROM rustio_groups WHERE name = $1")
696                .bind(spec.name)
697                .execute(db.pool())
698                .await;
699        }
700    }
701
702    #[tokio::test]
703    #[ignore = "needs `RUSTIO_TEST_DB=1` + a running postgres (URL via RUSTIO_TEST_DATABASE_URL or default)"]
704    async fn bootstrap_creates_six_default_groups() {
705        let _env = ENV_LOCK.lock().await;
706        let db = pg_db().await;
707        crate::auth::init_permission_tables(&db).await.unwrap();
708        reset_default_groups(&db).await;
709
710        std::env::set_var("RUSTIO_DEMO_MODE", "1");
711        bootstrap_default_groups(&db).await.unwrap();
712        std::env::remove_var("RUSTIO_DEMO_MODE");
713
714        for spec in DEFAULT_GROUP_SPECS {
715            let id = find_group_id_by_name(&db, spec.name).await.unwrap();
716            assert!(
717                id.is_some(),
718                "default group {:?} should exist after bootstrap",
719                spec.name
720            );
721        }
722
723        reset_default_groups(&db).await;
724    }
725
726    #[tokio::test]
727    #[ignore = "needs `RUSTIO_TEST_DB=1` + a running postgres (URL via RUSTIO_TEST_DATABASE_URL or default)"]
728    async fn bootstrap_idempotent_across_restarts() {
729        let _env = ENV_LOCK.lock().await;
730        let db = pg_db().await;
731        crate::auth::init_permission_tables(&db).await.unwrap();
732        reset_default_groups(&db).await;
733
734        std::env::set_var("RUSTIO_DEMO_MODE", "1");
735        bootstrap_default_groups(&db).await.unwrap();
736
737        let count_after_first: i64 =
738            sqlx::query_scalar("SELECT COUNT(*) FROM rustio_groups WHERE name = ANY($1)")
739                .bind(
740                    DEFAULT_GROUP_SPECS
741                        .iter()
742                        .map(|s| s.name.to_string())
743                        .collect::<Vec<_>>(),
744                )
745                .fetch_one(db.pool())
746                .await
747                .unwrap();
748        assert_eq!(count_after_first, DEFAULT_GROUP_SPECS.len() as i64);
749
750        bootstrap_default_groups(&db).await.unwrap();
751        let count_after_second: i64 =
752            sqlx::query_scalar("SELECT COUNT(*) FROM rustio_groups WHERE name = ANY($1)")
753                .bind(
754                    DEFAULT_GROUP_SPECS
755                        .iter()
756                        .map(|s| s.name.to_string())
757                        .collect::<Vec<_>>(),
758                )
759                .fetch_one(db.pool())
760                .await
761                .unwrap();
762        assert_eq!(
763            count_after_first, count_after_second,
764            "second bootstrap must not duplicate rows"
765        );
766
767        std::env::remove_var("RUSTIO_DEMO_MODE");
768        reset_default_groups(&db).await;
769    }
770
771    #[tokio::test]
772    #[ignore = "needs `RUSTIO_TEST_DB=1` + a running postgres (URL via RUSTIO_TEST_DATABASE_URL or default)"]
773    async fn bootstrap_skips_when_demo_mode_unset() {
774        let _env = ENV_LOCK.lock().await;
775        let db = pg_db().await;
776        crate::auth::init_permission_tables(&db).await.unwrap();
777        reset_default_groups(&db).await;
778
779        // Without the flag, bootstrap should be a no-op even though
780        // every prerequisite exists.
781        std::env::remove_var("RUSTIO_DEMO_MODE");
782        bootstrap_default_groups(&db).await.unwrap();
783
784        for spec in DEFAULT_GROUP_SPECS {
785            let id = find_group_id_by_name(&db, spec.name).await.unwrap();
786            assert!(
787                id.is_none(),
788                "default group {:?} must NOT exist when demo mode is off",
789                spec.name
790            );
791        }
792    }
793
794    /// Build a minimal `Admin` with one project entry whose
795    /// `singular_name` is `"Post"` — matches what `examples/blog`
796    /// registers, so we can exercise wildcard expansion realistically.
797    fn admin_with_post_entry() -> crate::admin::Admin {
798        // Phase 5a's `AdminEntry::for_testing` exposes the right
799        // shape without needing a real `AdminModel` impl.
800        const POST_FIELDS: &[crate::admin::AdminField] = &[];
801        let post_entry = crate::admin::AdminEntry::for_testing(
802            "posts", "Posts", "Post", "posts", POST_FIELDS, false,
803        );
804        let mut admin = crate::admin::Admin::new();
805        admin.entries.push(post_entry);
806        admin
807    }
808
809    #[tokio::test]
810    #[ignore = "needs `RUSTIO_TEST_DB=1` + a running postgres (URL via RUSTIO_TEST_DATABASE_URL or default)"]
811    async fn lazy_attach_resolves_wildcard_to_all_entries() {
812        let _env = ENV_LOCK.lock().await;
813        let db = pg_db().await;
814        crate::auth::init_permission_tables(&db).await.unwrap();
815        reset_default_groups(&db).await;
816
817        std::env::set_var("RUSTIO_DEMO_MODE", "1");
818        bootstrap_default_groups(&db).await.unwrap();
819        let admin = admin_with_post_entry();
820        register_model_permissions(&db, "posts", "post").await.unwrap();
821        lazy_attach_permissions(&db, admin.entries()).await.unwrap();
822
823        // Auditors uses `All(&["view"])`. Against an admin with one
824        // non-core entry (`Post`), it should resolve to `posts.view_post`.
825        let auditor_id = find_group_id_by_name(&db, "Auditors").await.unwrap().unwrap();
826        let perms: Vec<String> = sqlx::query_scalar(
827            "SELECT p.name FROM rustio_permissions p \
828             JOIN rustio_group_permissions gp ON gp.permission_id = p.id \
829             WHERE gp.group_id = $1",
830        )
831        .bind(auditor_id)
832        .fetch_all(db.pool())
833        .await
834        .unwrap();
835
836        assert!(
837            perms.iter().any(|p| p == "posts.view_post"),
838            "Auditors should have posts.view_post, got: {perms:?}"
839        );
840
841        std::env::remove_var("RUSTIO_DEMO_MODE");
842        reset_default_groups(&db).await;
843    }
844
845    #[tokio::test]
846    #[ignore = "needs `RUSTIO_TEST_DB=1` + a running postgres (URL via RUSTIO_TEST_DATABASE_URL or default)"]
847    async fn lazy_attach_skips_unregistered_models() {
848        let _env = ENV_LOCK.lock().await;
849        let db = pg_db().await;
850        crate::auth::init_permission_tables(&db).await.unwrap();
851        reset_default_groups(&db).await;
852
853        std::env::set_var("RUSTIO_DEMO_MODE", "1");
854        bootstrap_default_groups(&db).await.unwrap();
855        // Admin with ONLY `Post` registered — Finance, HR Managers,
856        // Project Coordinators target models that aren't registered.
857        let admin = admin_with_post_entry();
858        lazy_attach_permissions(&db, admin.entries()).await.unwrap();
859
860        let finance_id = find_group_id_by_name(&db, "Finance")
861            .await
862            .unwrap()
863            .unwrap();
864        let perms_count: i64 = sqlx::query_scalar(
865            "SELECT COUNT(*) FROM rustio_group_permissions WHERE group_id = $1",
866        )
867        .bind(finance_id)
868        .fetch_one(db.pool())
869        .await
870        .unwrap();
871        assert_eq!(
872            perms_count, 0,
873            "Finance has no perms when contract/invoice/payment aren't registered"
874        );
875
876        std::env::remove_var("RUSTIO_DEMO_MODE");
877        reset_default_groups(&db).await;
878    }
879
880    #[tokio::test]
881    #[ignore = "needs `RUSTIO_TEST_DB=1` + a running postgres (URL via RUSTIO_TEST_DATABASE_URL or default)"]
882    async fn lazy_attach_idempotent() {
883        let _env = ENV_LOCK.lock().await;
884        let db = pg_db().await;
885        crate::auth::init_permission_tables(&db).await.unwrap();
886        reset_default_groups(&db).await;
887
888        std::env::set_var("RUSTIO_DEMO_MODE", "1");
889        bootstrap_default_groups(&db).await.unwrap();
890        let admin = admin_with_post_entry();
891        register_model_permissions(&db, "posts", "post").await.unwrap();
892        lazy_attach_permissions(&db, admin.entries()).await.unwrap();
893
894        let auditor_id = find_group_id_by_name(&db, "Auditors")
895            .await
896            .unwrap()
897            .unwrap();
898        let count_after_first: i64 = sqlx::query_scalar(
899            "SELECT COUNT(*) FROM rustio_group_permissions WHERE group_id = $1",
900        )
901        .bind(auditor_id)
902        .fetch_one(db.pool())
903        .await
904        .unwrap();
905
906        // Second pass — must not duplicate.
907        lazy_attach_permissions(&db, admin.entries()).await.unwrap();
908        let count_after_second: i64 = sqlx::query_scalar(
909            "SELECT COUNT(*) FROM rustio_group_permissions WHERE group_id = $1",
910        )
911        .bind(auditor_id)
912        .fetch_one(db.pool())
913        .await
914        .unwrap();
915
916        assert_eq!(
917            count_after_first, count_after_second,
918            "lazy_attach must be idempotent — second call should change nothing"
919        );
920
921        std::env::remove_var("RUSTIO_DEMO_MODE");
922        reset_default_groups(&db).await;
923    }
924
925    /// Sanity: `demo_mode_enabled` reflects the env var. Sandbox-only;
926    /// no DB. Uses `#[tokio::test]` because `ENV_LOCK` is async-aware —
927    /// the same lock keeps env mutations from racing with the PG-gated
928    /// tests under `cargo test -- --ignored`.
929    #[tokio::test]
930    async fn demo_mode_enabled_reflects_env_var() {
931        let _env = ENV_LOCK.lock().await;
932        let prior = std::env::var("RUSTIO_DEMO_MODE").ok();
933
934        std::env::remove_var("RUSTIO_DEMO_MODE");
935        assert!(!demo_mode_enabled(), "no env → disabled");
936
937        std::env::set_var("RUSTIO_DEMO_MODE", "1");
938        assert!(demo_mode_enabled(), "RUSTIO_DEMO_MODE=1 → enabled");
939
940        std::env::set_var("RUSTIO_DEMO_MODE", "0");
941        assert!(!demo_mode_enabled(), "RUSTIO_DEMO_MODE=0 → disabled");
942
943        // Restore.
944        match prior {
945            Some(v) => std::env::set_var("RUSTIO_DEMO_MODE", v),
946            None => std::env::remove_var("RUSTIO_DEMO_MODE"),
947        }
948    }
949}