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