Skip to main content

fraiseql_server/api/rbac_management/
db_backend.rs

1//! RBAC Database Backend
2//!
3//! PostgreSQL-backed operations for role and permission management.
4
5use chrono::Utc;
6use sqlx::{PgPool, Row, postgres::PgRow};
7use tracing::debug;
8use uuid::Uuid;
9
10use super::{PermissionDto, RoleDto, UserRoleDto};
11
12/// Error type for RBAC database operations.
13#[derive(Debug)]
14#[non_exhaustive]
15pub enum RbacDbError {
16    /// Database connection error.
17    ConnectionError(String),
18    /// Role not found.
19    RoleNotFound,
20    /// Permission not found.
21    PermissionNotFound,
22    /// Role already exists.
23    RoleDuplicate,
24    /// Permission already exists.
25    PermissionDuplicate,
26    /// User role assignment not found.
27    AssignmentNotFound,
28    /// Assignment already exists.
29    AssignmentDuplicate,
30    /// Role has active assignments.
31    RoleInUse,
32    /// Permission has active assignments.
33    PermissionInUse,
34    /// Database query error.
35    QueryError(String),
36    /// Transaction error.
37    TransactionError(String),
38}
39
40impl std::fmt::Display for RbacDbError {
41    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
42        match self {
43            Self::ConnectionError(msg) => write!(f, "Connection error: {msg}"),
44            Self::RoleNotFound => write!(f, "Role not found"),
45            Self::PermissionNotFound => write!(f, "Permission not found"),
46            Self::RoleDuplicate => write!(f, "Role already exists"),
47            Self::PermissionDuplicate => write!(f, "Permission already exists"),
48            Self::AssignmentNotFound => write!(f, "Assignment not found"),
49            Self::AssignmentDuplicate => write!(f, "Assignment already exists"),
50            Self::RoleInUse => write!(f, "Role has active assignments"),
51            Self::PermissionInUse => write!(f, "Permission has active assignments"),
52            Self::QueryError(msg) => write!(f, "Query error: {msg}"),
53            Self::TransactionError(msg) => write!(f, "Transaction error: {msg}"),
54        }
55    }
56}
57
58impl std::error::Error for RbacDbError {}
59
60/// Database backend for RBAC operations.
61#[derive(Clone)]
62pub struct RbacDbBackend {
63    pool: PgPool,
64}
65
66impl RbacDbBackend {
67    /// Create a new RBAC database backend from a connection pool.
68    #[must_use]
69    pub const fn new(pool: PgPool) -> Self {
70        Self { pool }
71    }
72
73    /// Ensure the RBAC database schema exists.
74    ///
75    /// Creates all required tables and indexes if they don't already exist.
76    /// This operation is idempotent.
77    ///
78    /// # Errors
79    ///
80    /// Returns `RbacDbError::QueryError` if the schema creation SQL fails.
81    pub async fn ensure_schema(&self) -> Result<(), RbacDbError> {
82        sqlx::raw_sql(
83            "CREATE TABLE IF NOT EXISTS fraiseql_roles (
84                id UUID PRIMARY KEY,
85                name TEXT NOT NULL,
86                description TEXT,
87                tenant_id UUID,
88                created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
89                updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
90                UNIQUE(name, COALESCE(tenant_id, '00000000-0000-0000-0000-000000000000'::uuid))
91            );
92
93            CREATE TABLE IF NOT EXISTS fraiseql_permissions (
94                id UUID PRIMARY KEY,
95                resource TEXT NOT NULL,
96                action TEXT NOT NULL,
97                description TEXT,
98                created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
99                UNIQUE(resource, action)
100            );
101
102            CREATE TABLE IF NOT EXISTS fraiseql_role_permissions (
103                role_id UUID REFERENCES fraiseql_roles(id) ON DELETE CASCADE,
104                permission_id UUID REFERENCES fraiseql_permissions(id) ON DELETE CASCADE,
105                PRIMARY KEY (role_id, permission_id)
106            );
107
108            CREATE TABLE IF NOT EXISTS fraiseql_user_roles (
109                user_id TEXT NOT NULL,
110                role_id UUID REFERENCES fraiseql_roles(id) ON DELETE CASCADE,
111                tenant_id UUID,
112                assigned_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
113                PRIMARY KEY (user_id, role_id)
114            );
115
116            CREATE INDEX IF NOT EXISTS idx_fraiseql_roles_tenant
117                ON fraiseql_roles(tenant_id);
118            CREATE INDEX IF NOT EXISTS idx_fraiseql_user_roles_user
119                ON fraiseql_user_roles(user_id);
120            CREATE INDEX IF NOT EXISTS idx_fraiseql_user_roles_role
121                ON fraiseql_user_roles(role_id);",
122        )
123        .execute(&self.pool)
124        .await
125        .map_err(|e| RbacDbError::QueryError(format!("Schema creation failed: {e}")))?;
126
127        debug!("RBAC schema ensured");
128        Ok(())
129    }
130
131    // =========================================================================
132    // Role Operations
133    // =========================================================================
134
135    /// Create a new role with associated permissions.
136    ///
137    /// Permissions are specified as `"resource:action"` strings. Each permission
138    /// is created if it doesn't already exist, then linked to the role.
139    ///
140    /// # Errors
141    ///
142    /// Returns `RbacDbError::QueryError` if `tenant_id` is not a valid UUID.
143    /// Returns `RbacDbError::ConnectionError` if a transaction cannot be started.
144    /// Returns `RbacDbError::RoleDuplicate` if a role with the same name already exists.
145    /// Returns `RbacDbError::QueryError` if any database operation fails.
146    /// Returns `RbacDbError::TransactionError` if the transaction cannot be committed.
147    pub async fn create_role(
148        &self,
149        name: &str,
150        description: Option<&str>,
151        permissions: Vec<String>,
152        tenant_id: Option<&str>,
153    ) -> Result<RoleDto, RbacDbError> {
154        let role_id = Uuid::new_v4();
155        let now = Utc::now();
156        let tenant_uuid = tenant_id
157            .map(|tid| {
158                Uuid::parse_str(tid)
159                    .map_err(|_| RbacDbError::QueryError("Invalid tenant ID".into()))
160            })
161            .transpose()?;
162
163        let mut tx = self
164            .pool
165            .begin()
166            .await
167            .map_err(|e| RbacDbError::ConnectionError(e.to_string()))?;
168
169        // Insert role
170        sqlx::query(
171            "INSERT INTO fraiseql_roles (id, name, description, tenant_id, created_at, updated_at)
172             VALUES ($1, $2, $3, $4, $5, $5)",
173        )
174        .bind(role_id)
175        .bind(name)
176        .bind(description)
177        .bind(tenant_uuid)
178        .bind(now)
179        .execute(&mut *tx)
180        .await
181        .map_err(|e| {
182            if is_unique_violation(&e) {
183                RbacDbError::RoleDuplicate
184            } else {
185                RbacDbError::QueryError(e.to_string())
186            }
187        })?;
188
189        // Create or find permissions, then link to role
190        for perm_str in &permissions {
191            let (resource, action) = parse_permission(perm_str)?;
192            let perm_id = self.ensure_permission(&mut tx, resource, action).await?;
193            sqlx::query(
194                "INSERT INTO fraiseql_role_permissions (role_id, permission_id)
195                 VALUES ($1, $2) ON CONFLICT DO NOTHING",
196            )
197            .bind(role_id)
198            .bind(perm_id)
199            .execute(&mut *tx)
200            .await
201            .map_err(|e| RbacDbError::QueryError(e.to_string()))?;
202        }
203
204        tx.commit().await.map_err(|e| RbacDbError::TransactionError(e.to_string()))?;
205
206        Ok(RoleDto {
207            id: role_id.to_string(),
208            name: name.to_string(),
209            description: description.map(String::from),
210            permissions,
211            tenant_id: tenant_uuid.map(|u| u.to_string()),
212            created_at: now.to_rfc3339(),
213            updated_at: now.to_rfc3339(),
214        })
215    }
216
217    /// Get role by ID with its associated permissions.
218    ///
219    /// # Errors
220    ///
221    /// Returns `RbacDbError::QueryError` if `role_id` is not a valid UUID or the query fails.
222    /// Returns `RbacDbError::RoleNotFound` if no role with the given ID exists.
223    pub async fn get_role(&self, role_id: &str) -> Result<RoleDto, RbacDbError> {
224        let role_uuid = Uuid::parse_str(role_id)
225            .map_err(|_| RbacDbError::QueryError("Invalid role ID".into()))?;
226
227        let row = sqlx::query(
228            "SELECT id, name, description, tenant_id, created_at, updated_at
229             FROM fraiseql_roles WHERE id = $1",
230        )
231        .bind(role_uuid)
232        .fetch_optional(&self.pool)
233        .await
234        .map_err(|e| RbacDbError::QueryError(e.to_string()))?
235        .ok_or(RbacDbError::RoleNotFound)?;
236
237        let permissions = self.get_role_permissions(role_uuid).await?;
238
239        Ok(role_dto_from_row(&row, permissions))
240    }
241
242    /// List roles with optional tenant filtering and pagination.
243    ///
244    /// # Errors
245    ///
246    /// Returns `RbacDbError::QueryError` if `tenant_id` is not a valid UUID or the query fails.
247    pub async fn list_roles(
248        &self,
249        tenant_id: Option<&str>,
250        limit: u32,
251        offset: u32,
252    ) -> Result<Vec<RoleDto>, RbacDbError> {
253        let tenant_uuid = tenant_id
254            .map(|tid| {
255                Uuid::parse_str(tid)
256                    .map_err(|_| RbacDbError::QueryError("Invalid tenant ID".into()))
257            })
258            .transpose()?;
259
260        let rows = if let Some(tid) = tenant_uuid {
261            sqlx::query(
262                "SELECT id, name, description, tenant_id, created_at, updated_at
263                 FROM fraiseql_roles WHERE tenant_id = $1
264                 ORDER BY name LIMIT $2 OFFSET $3",
265            )
266            .bind(tid)
267            .bind(i64::from(limit))
268            .bind(i64::from(offset))
269            .fetch_all(&self.pool)
270            .await
271        } else {
272            sqlx::query(
273                "SELECT id, name, description, tenant_id, created_at, updated_at
274                 FROM fraiseql_roles
275                 ORDER BY name LIMIT $1 OFFSET $2",
276            )
277            .bind(i64::from(limit))
278            .bind(i64::from(offset))
279            .fetch_all(&self.pool)
280            .await
281        }
282        .map_err(|e| RbacDbError::QueryError(e.to_string()))?;
283
284        let mut roles = Vec::with_capacity(rows.len());
285        for row in &rows {
286            let id: Uuid = row.get("id");
287            let permissions = self.get_role_permissions(id).await?;
288            roles.push(role_dto_from_row(row, permissions));
289        }
290        Ok(roles)
291    }
292
293    /// Update an existing role's name, description, and permissions.
294    ///
295    /// # Errors
296    ///
297    /// Returns `RbacDbError::QueryError` if `role_id` is not a valid UUID.
298    /// Returns `RbacDbError::ConnectionError` if a transaction cannot be started.
299    /// Returns `RbacDbError::RoleDuplicate` if the new name conflicts with an existing role.
300    /// Returns `RbacDbError::RoleNotFound` if no role with the given ID exists.
301    /// Returns `RbacDbError::QueryError` if any database operation fails.
302    /// Returns `RbacDbError::TransactionError` if the transaction cannot be committed.
303    pub async fn update_role(
304        &self,
305        role_id: &str,
306        name: &str,
307        description: Option<&str>,
308        permissions: Vec<String>,
309    ) -> Result<RoleDto, RbacDbError> {
310        let role_uuid = Uuid::parse_str(role_id)
311            .map_err(|_| RbacDbError::QueryError("Invalid role ID".into()))?;
312        let now = Utc::now();
313
314        let mut tx = self
315            .pool
316            .begin()
317            .await
318            .map_err(|e| RbacDbError::ConnectionError(e.to_string()))?;
319
320        // Update role metadata
321        let result = sqlx::query(
322            "UPDATE fraiseql_roles SET name = $1, description = $2, updated_at = $3
323             WHERE id = $4",
324        )
325        .bind(name)
326        .bind(description)
327        .bind(now)
328        .bind(role_uuid)
329        .execute(&mut *tx)
330        .await
331        .map_err(|e| {
332            if is_unique_violation(&e) {
333                RbacDbError::RoleDuplicate
334            } else {
335                RbacDbError::QueryError(e.to_string())
336            }
337        })?;
338
339        if result.rows_affected() == 0 {
340            return Err(RbacDbError::RoleNotFound);
341        }
342
343        // Replace permissions: delete existing, add new
344        sqlx::query("DELETE FROM fraiseql_role_permissions WHERE role_id = $1")
345            .bind(role_uuid)
346            .execute(&mut *tx)
347            .await
348            .map_err(|e| RbacDbError::QueryError(e.to_string()))?;
349
350        for perm_str in &permissions {
351            let (resource, action) = parse_permission(perm_str)?;
352            let perm_id = self.ensure_permission(&mut tx, resource, action).await?;
353            sqlx::query(
354                "INSERT INTO fraiseql_role_permissions (role_id, permission_id)
355                 VALUES ($1, $2) ON CONFLICT DO NOTHING",
356            )
357            .bind(role_uuid)
358            .bind(perm_id)
359            .execute(&mut *tx)
360            .await
361            .map_err(|e| RbacDbError::QueryError(e.to_string()))?;
362        }
363
364        tx.commit().await.map_err(|e| RbacDbError::TransactionError(e.to_string()))?;
365
366        // Fetch the updated role to get tenant_id and timestamps
367        self.get_role(role_id).await
368    }
369
370    /// Delete a role by ID (cascades to `role_permissions` and `user_roles`).
371    ///
372    /// # Errors
373    ///
374    /// Returns `RbacDbError::QueryError` if `role_id` is not a valid UUID or the query fails.
375    /// Returns `RbacDbError::RoleNotFound` if no role with the given ID exists.
376    pub async fn delete_role(&self, role_id: &str) -> Result<(), RbacDbError> {
377        let role_uuid = Uuid::parse_str(role_id)
378            .map_err(|_| RbacDbError::QueryError("Invalid role ID".into()))?;
379
380        let result = sqlx::query("DELETE FROM fraiseql_roles WHERE id = $1")
381            .bind(role_uuid)
382            .execute(&self.pool)
383            .await
384            .map_err(|e| RbacDbError::QueryError(e.to_string()))?;
385
386        if result.rows_affected() == 0 {
387            return Err(RbacDbError::RoleNotFound);
388        }
389        Ok(())
390    }
391
392    // =========================================================================
393    // Permission Operations
394    // =========================================================================
395
396    /// Create a new permission.
397    ///
398    /// # Errors
399    ///
400    /// Returns `RbacDbError::PermissionDuplicate` if a permission with the same resource and action
401    /// already exists. Returns `RbacDbError::QueryError` if the database insert fails.
402    pub async fn create_permission(
403        &self,
404        resource: &str,
405        action: &str,
406        description: Option<&str>,
407    ) -> Result<PermissionDto, RbacDbError> {
408        let perm_id = Uuid::new_v4();
409        let now = Utc::now();
410
411        sqlx::query(
412            "INSERT INTO fraiseql_permissions (id, resource, action, description, created_at)
413             VALUES ($1, $2, $3, $4, $5)",
414        )
415        .bind(perm_id)
416        .bind(resource)
417        .bind(action)
418        .bind(description)
419        .bind(now)
420        .execute(&self.pool)
421        .await
422        .map_err(|e| {
423            if is_unique_violation(&e) {
424                RbacDbError::PermissionDuplicate
425            } else {
426                RbacDbError::QueryError(e.to_string())
427            }
428        })?;
429
430        Ok(PermissionDto {
431            id:          perm_id.to_string(),
432            resource:    resource.to_string(),
433            action:      action.to_string(),
434            description: description.map(String::from),
435            created_at:  now.to_rfc3339(),
436        })
437    }
438
439    /// List all permissions.
440    ///
441    /// # Errors
442    ///
443    /// Returns `RbacDbError::QueryError` if the database query fails.
444    pub async fn list_permissions(&self) -> Result<Vec<PermissionDto>, RbacDbError> {
445        let rows = sqlx::query(
446            "SELECT id, resource, action, description, created_at
447             FROM fraiseql_permissions ORDER BY resource, action",
448        )
449        .fetch_all(&self.pool)
450        .await
451        .map_err(|e| RbacDbError::QueryError(e.to_string()))?;
452
453        Ok(rows.iter().map(permission_dto_from_row).collect())
454    }
455
456    /// Get a permission by ID.
457    ///
458    /// # Errors
459    ///
460    /// Returns `RbacDbError::QueryError` if `permission_id` is not a valid UUID or the query fails.
461    /// Returns `RbacDbError::PermissionNotFound` if no permission with the given ID exists.
462    pub async fn get_permission(&self, permission_id: &str) -> Result<PermissionDto, RbacDbError> {
463        let perm_uuid = Uuid::parse_str(permission_id)
464            .map_err(|_| RbacDbError::QueryError("Invalid permission ID".into()))?;
465
466        let row = sqlx::query(
467            "SELECT id, resource, action, description, created_at
468             FROM fraiseql_permissions WHERE id = $1",
469        )
470        .bind(perm_uuid)
471        .fetch_optional(&self.pool)
472        .await
473        .map_err(|e| RbacDbError::QueryError(e.to_string()))?
474        .ok_or(RbacDbError::PermissionNotFound)?;
475
476        Ok(permission_dto_from_row(&row))
477    }
478
479    /// Delete a permission by ID.
480    ///
481    /// # Errors
482    ///
483    /// Returns `RbacDbError::QueryError` if `permission_id` is not a valid UUID or the query fails.
484    /// Returns `RbacDbError::PermissionInUse` if the permission is referenced by one or more roles.
485    /// Returns `RbacDbError::PermissionNotFound` if no permission with the given ID exists.
486    pub async fn delete_permission(&self, permission_id: &str) -> Result<(), RbacDbError> {
487        let perm_uuid = Uuid::parse_str(permission_id)
488            .map_err(|_| RbacDbError::QueryError("Invalid permission ID".into()))?;
489
490        // Check if permission is referenced by any role
491        let count: i64 = sqlx::query_scalar(
492            "SELECT COUNT(*) FROM fraiseql_role_permissions WHERE permission_id = $1",
493        )
494        .bind(perm_uuid)
495        .fetch_one(&self.pool)
496        .await
497        .map_err(|e| RbacDbError::QueryError(e.to_string()))?;
498
499        if count > 0 {
500            return Err(RbacDbError::PermissionInUse);
501        }
502
503        let result = sqlx::query("DELETE FROM fraiseql_permissions WHERE id = $1")
504            .bind(perm_uuid)
505            .execute(&self.pool)
506            .await
507            .map_err(|e| RbacDbError::QueryError(e.to_string()))?;
508
509        if result.rows_affected() == 0 {
510            return Err(RbacDbError::PermissionNotFound);
511        }
512        Ok(())
513    }
514
515    // =========================================================================
516    // User-Role Assignment Operations
517    // =========================================================================
518
519    /// Assign a role to a user.
520    ///
521    /// # Errors
522    ///
523    /// Returns `RbacDbError::QueryError` if `role_id` or `tenant_id` is not a valid UUID.
524    /// Returns `RbacDbError::RoleNotFound` if no role with the given ID exists.
525    /// Returns `RbacDbError::AssignmentDuplicate` if the user already has this role.
526    /// Returns `RbacDbError::QueryError` if the database insert fails.
527    pub async fn assign_role_to_user(
528        &self,
529        user_id: &str,
530        role_id: &str,
531        tenant_id: Option<&str>,
532    ) -> Result<UserRoleDto, RbacDbError> {
533        let role_uuid = Uuid::parse_str(role_id)
534            .map_err(|_| RbacDbError::QueryError("Invalid role ID".into()))?;
535        let tenant_uuid = tenant_id
536            .map(|tid| {
537                Uuid::parse_str(tid)
538                    .map_err(|_| RbacDbError::QueryError("Invalid tenant ID".into()))
539            })
540            .transpose()?;
541        let now = Utc::now();
542
543        // Verify role exists
544        let role_exists: bool =
545            sqlx::query_scalar("SELECT EXISTS(SELECT 1 FROM fraiseql_roles WHERE id = $1)")
546                .bind(role_uuid)
547                .fetch_one(&self.pool)
548                .await
549                .map_err(|e| RbacDbError::QueryError(e.to_string()))?;
550
551        if !role_exists {
552            return Err(RbacDbError::RoleNotFound);
553        }
554
555        sqlx::query(
556            "INSERT INTO fraiseql_user_roles (user_id, role_id, tenant_id, assigned_at)
557             VALUES ($1, $2, $3, $4)",
558        )
559        .bind(user_id)
560        .bind(role_uuid)
561        .bind(tenant_uuid)
562        .bind(now)
563        .execute(&self.pool)
564        .await
565        .map_err(|e| {
566            if is_unique_violation(&e) {
567                RbacDbError::AssignmentDuplicate
568            } else {
569                RbacDbError::QueryError(e.to_string())
570            }
571        })?;
572
573        Ok(UserRoleDto {
574            user_id:     user_id.to_string(),
575            role_id:     role_id.to_string(),
576            tenant_id:   tenant_uuid.map(|u| u.to_string()),
577            assigned_at: now.to_rfc3339(),
578        })
579    }
580
581    /// List all role assignments for a user.
582    ///
583    /// # Errors
584    ///
585    /// Returns `RbacDbError::QueryError` if the database query fails.
586    pub async fn list_user_roles(&self, user_id: &str) -> Result<Vec<UserRoleDto>, RbacDbError> {
587        let rows = sqlx::query(
588            "SELECT user_id, role_id, tenant_id, assigned_at
589             FROM fraiseql_user_roles WHERE user_id = $1
590             ORDER BY assigned_at",
591        )
592        .bind(user_id)
593        .fetch_all(&self.pool)
594        .await
595        .map_err(|e| RbacDbError::QueryError(e.to_string()))?;
596
597        Ok(rows
598            .iter()
599            .map(|row| {
600                let role_id: Uuid = row.get("role_id");
601                let tenant_id: Option<Uuid> = row.get("tenant_id");
602                let assigned_at: chrono::DateTime<Utc> = row.get("assigned_at");
603                UserRoleDto {
604                    user_id:     row.get::<String, _>("user_id"),
605                    role_id:     role_id.to_string(),
606                    tenant_id:   tenant_id.map(|u| u.to_string()),
607                    assigned_at: assigned_at.to_rfc3339(),
608                }
609            })
610            .collect())
611    }
612
613    /// Revoke a role from a user.
614    ///
615    /// # Errors
616    ///
617    /// Returns `RbacDbError::QueryError` if `role_id` is not a valid UUID or the query fails.
618    /// Returns `RbacDbError::AssignmentNotFound` if the user does not have this role assigned.
619    pub async fn revoke_role_from_user(
620        &self,
621        user_id: &str,
622        role_id: &str,
623    ) -> Result<(), RbacDbError> {
624        let role_uuid = Uuid::parse_str(role_id)
625            .map_err(|_| RbacDbError::QueryError("Invalid role ID".into()))?;
626
627        let result =
628            sqlx::query("DELETE FROM fraiseql_user_roles WHERE user_id = $1 AND role_id = $2")
629                .bind(user_id)
630                .bind(role_uuid)
631                .execute(&self.pool)
632                .await
633                .map_err(|e| RbacDbError::QueryError(e.to_string()))?;
634
635        if result.rows_affected() == 0 {
636            return Err(RbacDbError::AssignmentNotFound);
637        }
638        Ok(())
639    }
640
641    // =========================================================================
642    // Internal Helpers
643    // =========================================================================
644
645    /// Get the `"resource:action"` permission strings for a role.
646    ///
647    /// # Errors
648    ///
649    /// Returns [`RbacDbError::QueryError`] if the database query fails.
650    async fn get_role_permissions(&self, role_id: Uuid) -> Result<Vec<String>, RbacDbError> {
651        let rows = sqlx::query(
652            "SELECT p.resource, p.action
653             FROM fraiseql_permissions p
654             JOIN fraiseql_role_permissions rp ON rp.permission_id = p.id
655             WHERE rp.role_id = $1
656             ORDER BY p.resource, p.action",
657        )
658        .bind(role_id)
659        .fetch_all(&self.pool)
660        .await
661        .map_err(|e| RbacDbError::QueryError(e.to_string()))?;
662
663        Ok(rows
664            .iter()
665            .map(|r| {
666                let resource: String = r.get("resource");
667                let action: String = r.get("action");
668                format!("{resource}:{action}")
669            })
670            .collect())
671    }
672
673    /// Find or create a permission, returning its UUID.
674    ///
675    /// # Errors
676    ///
677    /// Returns [`RbacDbError::QueryError`] if the SELECT or INSERT query fails.
678    async fn ensure_permission(
679        &self,
680        tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
681        resource: &str,
682        action: &str,
683    ) -> Result<Uuid, RbacDbError> {
684        // Try to find existing
685        let existing: Option<Uuid> = sqlx::query_scalar(
686            "SELECT id FROM fraiseql_permissions WHERE resource = $1 AND action = $2",
687        )
688        .bind(resource)
689        .bind(action)
690        .fetch_optional(&mut **tx)
691        .await
692        .map_err(|e| RbacDbError::QueryError(e.to_string()))?;
693
694        if let Some(id) = existing {
695            return Ok(id);
696        }
697
698        // Create new
699        let id = Uuid::new_v4();
700        sqlx::query(
701            "INSERT INTO fraiseql_permissions (id, resource, action, created_at)
702             VALUES ($1, $2, $3, NOW())",
703        )
704        .bind(id)
705        .bind(resource)
706        .bind(action)
707        .execute(&mut **tx)
708        .await
709        .map_err(|e| RbacDbError::QueryError(e.to_string()))?;
710
711        Ok(id)
712    }
713}
714
715/// Parse a `"resource:action"` string into its components.
716///
717/// # Errors
718///
719/// Returns [`RbacDbError::QueryError`] if the string does not contain a `:` separator.
720pub(crate) fn parse_permission(perm: &str) -> Result<(&str, &str), RbacDbError> {
721    perm.split_once(':').ok_or_else(|| {
722        RbacDbError::QueryError(format!(
723            "Invalid permission format '{perm}': expected 'resource:action'"
724        ))
725    })
726}
727
728/// Check if a sqlx error is a unique constraint violation.
729fn is_unique_violation(e: &sqlx::Error) -> bool {
730    if let sqlx::Error::Database(db_err) = e {
731        db_err.code().as_deref() == Some("23505")
732    } else {
733        false
734    }
735}
736
737/// Convert a database row to a `RoleDto`.
738fn role_dto_from_row(row: &PgRow, permissions: Vec<String>) -> RoleDto {
739    let id: Uuid = row.get("id");
740    let tenant_id: Option<Uuid> = row.get("tenant_id");
741    let created_at: chrono::DateTime<Utc> = row.get("created_at");
742    let updated_at: chrono::DateTime<Utc> = row.get("updated_at");
743    RoleDto {
744        id: id.to_string(),
745        name: row.get("name"),
746        description: row.get("description"),
747        permissions,
748        tenant_id: tenant_id.map(|u| u.to_string()),
749        created_at: created_at.to_rfc3339(),
750        updated_at: updated_at.to_rfc3339(),
751    }
752}
753
754/// Convert a database row to a `PermissionDto`.
755fn permission_dto_from_row(row: &PgRow) -> PermissionDto {
756    let id: Uuid = row.get("id");
757    let created_at: chrono::DateTime<Utc> = row.get("created_at");
758    PermissionDto {
759        id:          id.to_string(),
760        resource:    row.get("resource"),
761        action:      row.get("action"),
762        description: row.get("description"),
763        created_at:  created_at.to_rfc3339(),
764    }
765}