Skip to main content

adminx_rbac/
lib.rs

1// adminx-rbac/src/lib.rs
2//
3// Pluggable, DB-backed RBAC for adminx. Register it and every resource route is
4// gated by `(role, action, resource)` grants read from the database and cached
5// in memory; leave it out and adminx uses its built-in per-resource role list.
6//
7// Storage-agnostic: grants are read and written through adminx-core's `Storage`
8// trait, so the same crate works over SeaORM (SQL) or MongoDB. The one asymmetry
9// is table creation — see `migrate_sql`.
10//
11// ## Startup order
12//
13// ```ignore
14// adminx_seaorm::init(&db_url).await?;                  // 1. storage
15// adminx_core::seed(adminx_rbac::migrate_sql()).await?; // 2. SQL tables (SQL backends only)
16// adminx_rbac::init(vec![                               // 3. seed defaults + load cache + register
17//     Ability::role("admin").can_manage_all(),
18//     Ability::role("editor").can("update", "posts").can("publish", "posts"),
19//     Ability::role("viewer").can_read_all(),
20// ]).await?;
21// configure_auth(AuthConfig { /* ... */ });             // 4. turn auth on
22// register_resource(Box::new(MyResource));
23// adminx_rbac::register_resources();                    // 5. roles/permissions editors (optional)
24// ```
25
26mod ability;
27mod authorizer;
28mod resources;
29mod schema;
30
31pub use ability::{Ability, ANY_RESOURCE, MANAGE};
32pub use authorizer::DbAuthorizer;
33pub use resources::{PermissionResource, RoleResource};
34
35use adminx_core::storage::{storage, QueryOptions, StorageError};
36use once_cell::sync::OnceCell;
37use serde_json::{Map, Value};
38
39/// Kept so `reload()` can reach the same cache that was registered as the global
40/// authorizer.
41static RBAC: OnceCell<DbAuthorizer> = OnceCell::new();
42
43/// Seed the `abilities` into the DB if the permission table is empty, load all
44/// grants into the cache, and register the authorizer with adminx-core. Call
45/// after storage is set (and, on SQL, after running [`migrate_sql`]).
46///
47/// Idempotent-ish: seeding only happens when the table is empty, so restarts and
48/// panel edits are preserved — the database is authoritative once populated.
49pub async fn init(abilities: Vec<Ability>) -> Result<(), StorageError> {
50    seed(&abilities).await?;
51    let authz = DbAuthorizer::new();
52    authz.reload().await?;
53    // Ignore a second init: mirrors set_authorizer/set_storage's set-once policy.
54    let _ = RBAC.set(authz.clone());
55    adminx_core::set_authorizer(Box::new(authz));
56    Ok(())
57}
58
59/// Re-read grants from the database into the cache. Called automatically after a
60/// permission edit via the panel; also available for programmatic edits. A no-op
61/// if [`init`] was never called.
62pub async fn reload() -> Result<(), StorageError> {
63    match RBAC.get() {
64        Some(a) => a.reload().await,
65        None => Ok(()),
66    }
67}
68
69/// SQL `CREATE TABLE IF NOT EXISTS` statements for the RBAC tables. Run once on a
70/// SQL backend via `adminx_core::seed(adminx_rbac::migrate_sql())`. Mongo needs
71/// nothing (collections auto-create). See `schema` for the dialect note.
72pub fn migrate_sql() -> &'static [&'static str] {
73    schema::SQL
74}
75
76/// Register the in-panel editors for roles and permissions (admin-only). Optional
77/// — omit it if you manage grants only in code/SQL.
78pub fn register_resources() {
79    adminx_core::register_resource(Box::new(RoleResource));
80    adminx_core::register_resource(Box::new(PermissionResource));
81}
82
83/// Insert the ability block's grants only when `adminx_permissions` is empty, so
84/// the code block bootstraps a fresh DB but never overwrites runtime edits.
85async fn seed(abilities: &[Ability]) -> Result<(), StorageError> {
86    let probe = QueryOptions {
87        page: 1,
88        per_page: 1,
89        sort_by: None,
90        sort_desc: false,
91        filters: Vec::new(),
92    };
93    let existing = storage().list("adminx_permissions", &probe).await?;
94    if existing.total > 0 || !existing.rows.is_empty() {
95        tracing::info!(
96            "adminx-rbac: {} permission row(s) already present; skipping seed",
97            existing.total.max(existing.rows.len() as u64)
98        );
99        return Ok(());
100    }
101
102    for ab in abilities {
103        // Role metadata is best-effort — a duplicate name just means the row is
104        // already there; the grants below are what actually matter.
105        let mut role_row = Map::new();
106        role_row.insert("name".into(), Value::String(ab.role_name().to_string()));
107        if let Err(e) = storage().create("adminx_roles", role_row).await {
108            tracing::debug!("adminx-rbac: role '{}' not inserted ({e:?})", ab.role_name());
109        }
110        for row in ab.permission_rows() {
111            storage().create("adminx_permissions", row).await?;
112        }
113    }
114    tracing::info!("adminx-rbac: seeded default abilities for {} role(s)", abilities.len());
115    Ok(())
116}