Skip to main content

rustlavel_rbac/
tables.rs

1//! The five tables, and the migration that creates them.
2//!
3//! Written entirely through the schema builder, so the same definition is
4//! correct on PostgreSQL, MySQL and SQL Server. Nothing here emits SQL of its
5//! own.
6//!
7//! ```ignore
8//! // in the generated migration registry
9//! let mut registry: Vec<&'static dyn Migration> = vec![];
10//! registry.extend(rustlavel_rbac::migrations());
11//! ```
12
13use rustlavel_db::migration::Migration;
14use rustlavel_db::schema::{Schema, Table};
15use rustlavel_core::Result;
16
17/// The conventional table names, matching what a Laravel developer expects
18/// from `laravel-permission` closely enough to be recognisable.
19pub const ROLES: &str = "roles";
20pub const PERMISSIONS: &str = "permissions";
21pub const ROLE_PERMISSION: &str = "role_permission";
22pub const USER_ROLE: &str = "user_role";
23pub const USER_PERMISSION: &str = "user_permission";
24
25/// Where the five tables live.
26///
27/// Configurable for two reasons. An application may already own a `permissions`
28/// table and not want this one on top of it; and the framework's own
29/// integration tests run concurrently against one database, so each needs a set
30/// of its own — see [`TableNames::suffixed`].
31#[derive(Debug, Clone, PartialEq, Eq)]
32pub struct TableNames {
33    pub roles: String,
34    pub permissions: String,
35    pub role_permission: String,
36    pub user_role: String,
37    pub user_permission: String,
38}
39
40impl Default for TableNames {
41    fn default() -> Self {
42        TableNames {
43            roles: ROLES.to_string(),
44            permissions: PERMISSIONS.to_string(),
45            role_permission: ROLE_PERMISSION.to_string(),
46            user_role: USER_ROLE.to_string(),
47            user_permission: USER_PERMISSION.to_string(),
48        }
49    }
50}
51
52impl TableNames {
53    /// The conventional names with a suffix on each: `roles_a1`, `user_role_a1`.
54    ///
55    /// One test, one suffix. Two tests sharing a table would pass or fail
56    /// depending on which finished first.
57    pub fn suffixed(suffix: &str) -> Self {
58        let base = TableNames::default();
59        TableNames {
60            roles: format!("{}{suffix}", base.roles),
61            permissions: format!("{}{suffix}", base.permissions),
62            role_permission: format!("{}{suffix}", base.role_permission),
63            user_role: format!("{}{suffix}", base.user_role),
64            user_permission: format!("{}{suffix}", base.user_permission),
65        }
66    }
67
68    /// Check every name is a plain identifier.
69    ///
70    /// Done once, when a store is built, rather than on every statement: a
71    /// table name is interpolated into SQL and can never be a bound parameter.
72    pub fn validate(&self) -> Result<()> {
73        for name in self.all() {
74            rustlavel_db::validate_identifier(name)?;
75        }
76        Ok(())
77    }
78
79    /// All five, in dependency order: parents before the tables pointing at
80    /// them.
81    pub fn all(&self) -> [&str; 5] {
82        [
83            &self.roles,
84            &self.permissions,
85            &self.role_permission,
86            &self.user_role,
87            &self.user_permission,
88        ]
89    }
90}
91
92/// Create the five tables.
93///
94/// Shared by [`CreateRbacTables`] and by [`crate::Permissions::migrate`], so an
95/// application that would rather not register a migration still gets exactly
96/// the schema production has.
97pub async fn create_tables(schema: &Schema<'_>, names: &TableNames) -> Result<()> {
98    names.validate()?;
99
100    schema.create(&names.roles, define_named_table).await?;
101    schema.create(&names.permissions, define_named_table).await?;
102
103    // `foreign_id("role")` would be shorter, but it derives the parent table by
104    // pluralising the name it is given, and these parents are configurable — so
105    // the reference is spelled out. `.index()` is included by hand for the same
106    // reason: it comes free with `foreign_id` and not with `references`.
107    let roles = names.roles.clone();
108    let permissions = names.permissions.clone();
109    schema
110        .create(&names.role_permission, move |t: &mut Table| {
111            t.id();
112            t.big_integer("role_id").references(&roles, "id").cascade_on_delete().index();
113            t.big_integer("permission_id")
114                .references(&permissions, "id")
115                .cascade_on_delete()
116                .index();
117            t.timestamps();
118            // Attaching the same permission twice is not an error worth an
119            // error message; it is a row the database should refuse to hold.
120            t.unique(&["role_id", "permission_id"]);
121        })
122        .await?;
123
124    let roles = names.roles.clone();
125    schema
126        .create(&names.user_role, move |t: &mut Table| {
127            // No foreign key to a users table, deliberately. This crate does
128            // not know what an application calls its users, whether they live
129            // in this database at all, or whether the id is even a row key
130            // rather than an identifier from an upstream directory. The price
131            // is that deleting a user leaves rows behind, so the store offers
132            // `purge_user` to clean up after one.
133            t.big_integer("user_id").index();
134            t.big_integer("role_id").references(&roles, "id").cascade_on_delete().index();
135            t.timestamps();
136            t.unique(&["user_id", "role_id"]);
137        })
138        .await?;
139
140    let permissions = names.permissions.clone();
141    schema
142        .create(&names.user_permission, move |t: &mut Table| {
143            t.big_integer("user_id").index();
144            t.big_integer("permission_id")
145                .references(&permissions, "id")
146                .cascade_on_delete()
147                .index();
148            // The column that makes a direct entry able to *revoke* something a
149            // role grants. Without it a direct permission could only ever add,
150            // and "everyone in support may refund, except this one person"
151            // would have to be modelled by inventing a role for that person.
152            t.boolean("granted").default_bool(true);
153            t.timestamps();
154            t.unique(&["user_id", "permission_id"]);
155        })
156        .await?;
157
158    Ok(())
159}
160
161/// `roles` and `permissions` have the same shape, so they share a definition.
162fn define_named_table(t: &mut Table) {
163    t.id();
164    t.string("name").unique();
165    // What this role or permission is for, in words. An admin screen that can
166    // only show `billing.refund` makes the person granting it guess.
167    t.text("description").nullable();
168    t.timestamps();
169}
170
171/// Drop the five, children first so the foreign keys do not object.
172pub async fn drop_tables(schema: &Schema<'_>, names: &TableNames) -> Result<()> {
173    names.validate()?;
174
175    for table in [
176        &names.user_permission,
177        &names.user_role,
178        &names.role_permission,
179        &names.permissions,
180        &names.roles,
181    ] {
182        schema.drop(table).await?;
183    }
184    Ok(())
185}
186
187rustlavel_db::migration!(
188    CreateRbacTables,
189    "2026_09_02_000001_create_rbac_tables",
190    up: |schema| { crate::tables::create_tables(schema, &TableNames::default()).await },
191    down: |schema| { crate::tables::drop_tables(schema, &TableNames::default()).await },
192);
193
194/// The migrations this package needs, for the application's registry.
195///
196/// A `Vec` of one today. It is a `Vec` because a later release that adds a
197/// column must add a second migration rather than edit this one — an applied
198/// migration is history, and history does not get rewritten under a database
199/// that has already run it.
200pub fn migrations() -> Vec<&'static dyn Migration> {
201    vec![&CreateRbacTables]
202}
203
204#[cfg(test)]
205mod tests {
206    use super::*;
207    use rustlavel_db::dialect::{MySql, Postgres, SqlServer};
208    use rustlavel_db::schema::create_statements;
209
210    /// Render one table's DDL for one database, without a database.
211    fn ddl(dialect: &dyn rustlavel_db::Dialect, table: &str, define: fn(&mut Table)) -> String {
212        create_statements(dialect, table, define).expect("valid definition").join(";\n")
213    }
214
215    #[test]
216    fn the_default_names_are_the_conventional_ones() {
217        let names = TableNames::default();
218
219        assert_eq!(
220            names.all(),
221            ["roles", "permissions", "role_permission", "user_role", "user_permission"]
222        );
223        assert!(names.validate().is_ok());
224    }
225
226    #[test]
227    fn a_suffix_reaches_every_table() {
228        let names = TableNames::suffixed("_t9");
229
230        assert_eq!(
231            names.all(),
232            [
233                "roles_t9",
234                "permissions_t9",
235                "role_permission_t9",
236                "user_role_t9",
237                "user_permission_t9"
238            ]
239        );
240        assert!(names.validate().is_ok());
241    }
242
243    #[test]
244    fn a_table_name_that_is_not_an_identifier_is_refused() {
245        let names =
246            TableNames { roles: "roles; drop table users".to_string(), ..TableNames::default() };
247
248        let error = names.validate().expect_err("a name carrying a statement must be refused");
249        assert!(error.to_string().contains("not a valid SQL identifier"), "{error}");
250    }
251
252    #[test]
253    fn the_named_tables_render_on_all_three_databases() {
254        for dialect in [&Postgres as &dyn rustlavel_db::Dialect, &MySql, &SqlServer] {
255            let sql = ddl(dialect, "roles", define_named_table);
256
257            assert!(sql.contains("create table"), "{}: {sql}", dialect.name());
258            assert!(sql.to_lowercase().contains("unique"), "{}: {sql}", dialect.name());
259            assert!(sql.contains("description"), "{}: {sql}", dialect.name());
260        }
261    }
262
263    #[test]
264    fn the_granted_column_is_a_boolean_defaulting_to_true() {
265        // MySQL and SQL Server have no `true` literal, so the default has to
266        // come out as `1` there. Getting this wrong is a migration that fails
267        // on two databases out of three and only in production.
268        let define = |t: &mut Table| {
269            t.boolean("granted").default_bool(true);
270        };
271
272        let postgres = ddl(&Postgres, "user_permission", define);
273        assert!(postgres.contains("default true"), "{postgres}");
274
275        for dialect in [&MySql as &dyn rustlavel_db::Dialect, &SqlServer] {
276            let sql = ddl(dialect, "user_permission", define);
277            assert!(sql.contains("default 1"), "{}: {sql}", dialect.name());
278        }
279    }
280
281    #[test]
282    fn the_migration_is_named_the_way_the_cli_names_them() {
283        let name = CreateRbacTables.name();
284
285        assert_eq!(name, "2026_09_02_000001_create_rbac_tables");
286        let (date, rest) = name.split_at(11);
287        assert!(date.chars().all(|c| c.is_ascii_digit() || c == '_'), "{date}");
288        assert!(rest.starts_with("000001_"), "{rest}");
289    }
290
291    #[test]
292    fn the_registry_exposes_every_migration() {
293        let registry = migrations();
294
295        assert_eq!(registry.len(), 1);
296        assert_eq!(registry[0].name(), "2026_09_02_000001_create_rbac_tables");
297    }
298}