1use 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
37pub 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
57pub 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
115struct 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 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
148pub 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
188pub 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
209async fn permission_id(db: &Db, name: &str) -> Result<i64> {
212 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
301pub 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
316struct GroupSpec {
327 name: &'static str,
328 description: &'static str,
329 model_perms: GroupModelPerms,
330}
331
332enum GroupModelPerms {
334 All(&'static [&'static str]),
339 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
399pub 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
427pub 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
476fn 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 }
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 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 #[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 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 assert!(
596 check_permission(&db, &identity, "posts.view_post").await.unwrap(),
597 "user should have view_post via group"
598 );
599
600 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 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 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 #[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, 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 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 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 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 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 fn admin_with_post_entry() -> crate::admin::Admin {
798 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 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 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 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 #[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 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}