1use chrono::Utc;
6use sqlx::{PgPool, Row, postgres::PgRow};
7use tracing::debug;
8use uuid::Uuid;
9
10use super::{PermissionDto, RoleDto, UserRoleDto};
11
12#[derive(Debug)]
14#[non_exhaustive]
15pub enum RbacDbError {
16 ConnectionError(String),
18 RoleNotFound,
20 PermissionNotFound,
22 RoleDuplicate,
24 PermissionDuplicate,
26 AssignmentNotFound,
28 AssignmentDuplicate,
30 RoleInUse,
32 PermissionInUse,
34 QueryError(String),
36 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#[derive(Clone)]
62pub struct RbacDbBackend {
63 pool: PgPool,
64}
65
66impl RbacDbBackend {
67 pub const fn new(pool: PgPool) -> Self {
69 Self { pool }
70 }
71
72 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 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 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 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 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 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 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 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 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 self.get_role(role_id).await
367 }
368
369 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 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 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 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 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 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 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 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 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 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 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 async fn ensure_permission(
678 &self,
679 tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
680 resource: &str,
681 action: &str,
682 ) -> Result<Uuid, RbacDbError> {
683 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 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
714fn 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
727fn 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
736fn 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
753fn 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)] #![allow(clippy::cast_precision_loss)] #![allow(clippy::cast_sign_loss)] #![allow(clippy::cast_possible_truncation)] #![allow(clippy::cast_possible_wrap)] #![allow(clippy::missing_panics_doc)] #![allow(clippy::missing_errors_doc)] #![allow(missing_docs)] #![allow(clippy::items_after_statements)] 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}