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 #[must_use]
69 pub const fn new(pool: PgPool) -> Self {
70 Self { pool }
71 }
72
73 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 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 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 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 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 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 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 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 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 self.get_role(role_id).await
368 }
369
370 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 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 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 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 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 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 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 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 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 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 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 async fn ensure_permission(
679 &self,
680 tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
681 resource: &str,
682 action: &str,
683 ) -> Result<Uuid, RbacDbError> {
684 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 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
715pub(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
728fn 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
737fn 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
754fn 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}