use chrono::Utc;
use serde::{Deserialize, Serialize};
use sqlx::{PgPool, Row, postgres::PgRow};
use tracing::debug;
use uuid::Uuid;
use super::{PermissionDto, RoleDto, UserRoleDto};
#[derive(Debug)]
#[non_exhaustive]
pub enum RbacDbError {
ConnectionError(String),
InvalidInput(String),
RoleNotFound,
PermissionNotFound,
RoleDuplicate,
PermissionDuplicate,
AssignmentNotFound,
AssignmentDuplicate,
PermissionInUse,
QueryError(String),
TransactionError(String),
}
impl std::fmt::Display for RbacDbError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::ConnectionError(msg) => write!(f, "Connection error: {msg}"),
Self::InvalidInput(msg) => write!(f, "{msg}"),
Self::RoleNotFound => write!(f, "Role not found"),
Self::PermissionNotFound => write!(f, "Permission not found"),
Self::RoleDuplicate => write!(f, "Role already exists"),
Self::PermissionDuplicate => write!(f, "Permission already exists"),
Self::AssignmentNotFound => write!(f, "Assignment not found"),
Self::AssignmentDuplicate => write!(f, "Assignment already exists"),
Self::PermissionInUse => write!(f, "Permission has active assignments"),
Self::QueryError(msg) => write!(f, "Query error: {msg}"),
Self::TransactionError(msg) => write!(f, "Transaction error: {msg}"),
}
}
}
impl std::error::Error for RbacDbError {}
const AUDIT_PREDICATE: &str = "($1::text IS NULL OR user_id = $1)
AND ($2::uuid IS NULL OR role_id = $2)
AND ($3::text IS NULL OR event_type = $3)
AND ($4::uuid IS NULL OR tenant_id = $4)
AND ($5::timestamptz IS NULL OR occurred_at >= $5)
AND ($6::timestamptz IS NULL OR occurred_at <= $6)";
fn parse_uuid(what: &str, value: &str) -> Result<Uuid, RbacDbError> {
Uuid::parse_str(value)
.map_err(|_| RbacDbError::InvalidInput(format!("Invalid {what}: '{value}' is not a UUID")))
}
fn parse_opt_uuid(what: &str, value: Option<&str>) -> Result<Option<Uuid>, RbacDbError> {
value.map(|v| parse_uuid(what, v)).transpose()
}
#[derive(Clone)]
pub struct RbacDbBackend {
pool: PgPool,
}
impl RbacDbBackend {
#[must_use]
pub const fn new(pool: PgPool) -> Self {
Self { pool }
}
pub async fn ensure_schema(&self) -> Result<(), RbacDbError> {
sqlx::raw_sql(
"CREATE TABLE IF NOT EXISTS fraiseql_roles (
id UUID PRIMARY KEY,
name TEXT NOT NULL,
description TEXT,
tenant_id UUID,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE UNIQUE INDEX IF NOT EXISTS idx_fraiseql_roles_name_tenant
ON fraiseql_roles (
name,
COALESCE(tenant_id, '00000000-0000-0000-0000-000000000000'::uuid)
);
CREATE TABLE IF NOT EXISTS fraiseql_permissions (
id UUID PRIMARY KEY,
resource TEXT NOT NULL,
action TEXT NOT NULL,
description TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE(resource, action)
);
CREATE TABLE IF NOT EXISTS fraiseql_role_permissions (
role_id UUID REFERENCES fraiseql_roles(id) ON DELETE CASCADE,
permission_id UUID REFERENCES fraiseql_permissions(id) ON DELETE CASCADE,
PRIMARY KEY (role_id, permission_id)
);
CREATE TABLE IF NOT EXISTS fraiseql_user_roles (
user_id TEXT NOT NULL,
role_id UUID REFERENCES fraiseql_roles(id) ON DELETE CASCADE,
tenant_id UUID,
assigned_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
PRIMARY KEY (user_id, role_id)
);
CREATE TABLE IF NOT EXISTS fraiseql_permission_audit (
id UUID PRIMARY KEY,
occurred_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
event_type TEXT NOT NULL,
role_id UUID,
role_name TEXT,
user_id TEXT,
permission_id UUID,
tenant_id UUID,
details JSONB NOT NULL DEFAULT '{}'::jsonb
);
CREATE INDEX IF NOT EXISTS idx_fraiseql_roles_tenant
ON fraiseql_roles(tenant_id);
CREATE INDEX IF NOT EXISTS idx_fraiseql_user_roles_user
ON fraiseql_user_roles(user_id);
CREATE INDEX IF NOT EXISTS idx_fraiseql_user_roles_role
ON fraiseql_user_roles(role_id);
CREATE INDEX IF NOT EXISTS idx_fraiseql_permission_audit_time
ON fraiseql_permission_audit(occurred_at DESC);
CREATE INDEX IF NOT EXISTS idx_fraiseql_permission_audit_user
ON fraiseql_permission_audit(user_id);
CREATE INDEX IF NOT EXISTS idx_fraiseql_permission_audit_role
ON fraiseql_permission_audit(role_id);",
)
.execute(&self.pool)
.await
.map_err(|e| RbacDbError::QueryError(format!("Schema creation failed: {e}")))?;
debug!("RBAC schema ensured");
Ok(())
}
pub async fn create_role(
&self,
name: &str,
description: Option<&str>,
permissions: Vec<String>,
tenant_id: Option<&str>,
) -> Result<RoleDto, RbacDbError> {
let role_id = Uuid::new_v4();
let now = Utc::now();
let tenant_uuid = parse_opt_uuid("tenant ID", tenant_id)?;
let mut tx = self
.pool
.begin()
.await
.map_err(|e| RbacDbError::ConnectionError(e.to_string()))?;
sqlx::query(
"INSERT INTO fraiseql_roles (id, name, description, tenant_id, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, $5)",
)
.bind(role_id)
.bind(name)
.bind(description)
.bind(tenant_uuid)
.bind(now)
.execute(&mut *tx)
.await
.map_err(|e| {
if is_unique_violation(&e) {
RbacDbError::RoleDuplicate
} else {
RbacDbError::QueryError(e.to_string())
}
})?;
for perm_str in &permissions {
let (resource, action) = parse_permission(perm_str)?;
let perm_id = self.ensure_permission(&mut tx, resource, action).await?;
sqlx::query(
"INSERT INTO fraiseql_role_permissions (role_id, permission_id)
VALUES ($1, $2) ON CONFLICT DO NOTHING",
)
.bind(role_id)
.bind(perm_id)
.execute(&mut *tx)
.await
.map_err(|e| RbacDbError::QueryError(e.to_string()))?;
}
record_audit(
&mut tx,
&AuditRecord {
event_type: AuditEventType::RoleCreated,
role_id: Some(role_id),
role_name: Some(name),
user_id: None,
permission_id: None,
tenant_id: tenant_uuid,
details: serde_json::json!({ "permissions": permissions }),
},
)
.await?;
tx.commit().await.map_err(|e| RbacDbError::TransactionError(e.to_string()))?;
Ok(RoleDto {
id: role_id.to_string(),
name: name.to_string(),
description: description.map(String::from),
permissions,
tenant_id: tenant_uuid.map(|u| u.to_string()),
created_at: now.to_rfc3339(),
updated_at: now.to_rfc3339(),
})
}
pub async fn get_role(&self, role_id: &str) -> Result<RoleDto, RbacDbError> {
let role_uuid = parse_uuid("role ID", role_id)?;
let row = sqlx::query(
"SELECT id, name, description, tenant_id, created_at, updated_at
FROM fraiseql_roles WHERE id = $1",
)
.bind(role_uuid)
.fetch_optional(&self.pool)
.await
.map_err(|e| RbacDbError::QueryError(e.to_string()))?
.ok_or(RbacDbError::RoleNotFound)?;
let permissions = self.get_role_permissions(role_uuid).await?;
Ok(role_dto_from_row(&row, permissions))
}
pub async fn list_roles(
&self,
tenant_id: Option<&str>,
limit: u32,
offset: u32,
) -> Result<Page<RoleDto>, RbacDbError> {
let tenant_uuid = parse_opt_uuid("tenant ID", tenant_id)?;
let rows = sqlx::query(
"SELECT id, name, description, tenant_id, created_at, updated_at
FROM fraiseql_roles
WHERE $1::uuid IS NULL OR tenant_id = $1
ORDER BY name LIMIT $2 OFFSET $3",
)
.bind(tenant_uuid)
.bind(i64::from(limit))
.bind(i64::from(offset))
.fetch_all(&self.pool)
.await
.map_err(|e| RbacDbError::QueryError(e.to_string()))?;
let total: i64 = sqlx::query_scalar(
"SELECT COUNT(*) FROM fraiseql_roles WHERE $1::uuid IS NULL OR tenant_id = $1",
)
.bind(tenant_uuid)
.fetch_one(&self.pool)
.await
.map_err(|e| RbacDbError::QueryError(e.to_string()))?;
let mut roles = Vec::with_capacity(rows.len());
for row in &rows {
let id: Uuid = row.get("id");
let permissions = self.get_role_permissions(id).await?;
roles.push(role_dto_from_row(row, permissions));
}
Ok(Page::new(roles, total, limit, offset))
}
pub async fn update_role(
&self,
role_id: &str,
name: &str,
description: Option<&str>,
permissions: Vec<String>,
) -> Result<RoleDto, RbacDbError> {
let role_uuid = parse_uuid("role ID", role_id)?;
let now = Utc::now();
let mut tx = self
.pool
.begin()
.await
.map_err(|e| RbacDbError::ConnectionError(e.to_string()))?;
let result = sqlx::query(
"UPDATE fraiseql_roles SET name = $1, description = $2, updated_at = $3
WHERE id = $4",
)
.bind(name)
.bind(description)
.bind(now)
.bind(role_uuid)
.execute(&mut *tx)
.await
.map_err(|e| {
if is_unique_violation(&e) {
RbacDbError::RoleDuplicate
} else {
RbacDbError::QueryError(e.to_string())
}
})?;
if result.rows_affected() == 0 {
return Err(RbacDbError::RoleNotFound);
}
sqlx::query("DELETE FROM fraiseql_role_permissions WHERE role_id = $1")
.bind(role_uuid)
.execute(&mut *tx)
.await
.map_err(|e| RbacDbError::QueryError(e.to_string()))?;
for perm_str in &permissions {
let (resource, action) = parse_permission(perm_str)?;
let perm_id = self.ensure_permission(&mut tx, resource, action).await?;
sqlx::query(
"INSERT INTO fraiseql_role_permissions (role_id, permission_id)
VALUES ($1, $2) ON CONFLICT DO NOTHING",
)
.bind(role_uuid)
.bind(perm_id)
.execute(&mut *tx)
.await
.map_err(|e| RbacDbError::QueryError(e.to_string()))?;
}
record_audit(
&mut tx,
&AuditRecord {
event_type: AuditEventType::RoleUpdated,
role_id: Some(role_uuid),
role_name: Some(name),
user_id: None,
permission_id: None,
tenant_id: None,
details: serde_json::json!({ "permissions": permissions }),
},
)
.await?;
tx.commit().await.map_err(|e| RbacDbError::TransactionError(e.to_string()))?;
self.get_role(role_id).await
}
pub async fn delete_role(&self, role_id: &str) -> Result<(), RbacDbError> {
let role_uuid = parse_uuid("role ID", role_id)?;
let mut tx = self
.pool
.begin()
.await
.map_err(|e| RbacDbError::ConnectionError(e.to_string()))?;
let name: Option<String> =
sqlx::query_scalar("SELECT name FROM fraiseql_roles WHERE id = $1")
.bind(role_uuid)
.fetch_optional(&mut *tx)
.await
.map_err(|e| RbacDbError::QueryError(e.to_string()))?;
let Some(name) = name else {
return Err(RbacDbError::RoleNotFound);
};
let result = sqlx::query("DELETE FROM fraiseql_roles WHERE id = $1")
.bind(role_uuid)
.execute(&mut *tx)
.await
.map_err(|e| RbacDbError::QueryError(e.to_string()))?;
if result.rows_affected() == 0 {
return Err(RbacDbError::RoleNotFound);
}
record_audit(
&mut tx,
&AuditRecord {
event_type: AuditEventType::RoleDeleted,
role_id: Some(role_uuid),
role_name: Some(&name),
user_id: None,
permission_id: None,
tenant_id: None,
details: serde_json::Value::Object(serde_json::Map::new()),
},
)
.await?;
tx.commit().await.map_err(|e| RbacDbError::TransactionError(e.to_string()))?;
Ok(())
}
pub async fn create_permission(
&self,
resource: &str,
action: &str,
description: Option<&str>,
) -> Result<PermissionDto, RbacDbError> {
let perm_id = Uuid::new_v4();
let now = Utc::now();
let mut tx = self
.pool
.begin()
.await
.map_err(|e| RbacDbError::ConnectionError(e.to_string()))?;
sqlx::query(
"INSERT INTO fraiseql_permissions (id, resource, action, description, created_at)
VALUES ($1, $2, $3, $4, $5)",
)
.bind(perm_id)
.bind(resource)
.bind(action)
.bind(description)
.bind(now)
.execute(&mut *tx)
.await
.map_err(|e| {
if is_unique_violation(&e) {
RbacDbError::PermissionDuplicate
} else {
RbacDbError::QueryError(e.to_string())
}
})?;
record_audit(
&mut tx,
&AuditRecord {
event_type: AuditEventType::PermissionCreated,
role_id: None,
role_name: None,
user_id: None,
permission_id: Some(perm_id),
tenant_id: None,
details: serde_json::json!({ "resource": resource, "action": action }),
},
)
.await?;
tx.commit().await.map_err(|e| RbacDbError::TransactionError(e.to_string()))?;
Ok(PermissionDto {
id: perm_id.to_string(),
resource: resource.to_string(),
action: action.to_string(),
description: description.map(String::from),
created_at: now.to_rfc3339(),
})
}
pub async fn list_permissions(
&self,
limit: u32,
offset: u32,
) -> Result<Page<PermissionDto>, RbacDbError> {
let rows = sqlx::query(
"SELECT id, resource, action, description, created_at
FROM fraiseql_permissions ORDER BY resource, action LIMIT $1 OFFSET $2",
)
.bind(i64::from(limit))
.bind(i64::from(offset))
.fetch_all(&self.pool)
.await
.map_err(|e| RbacDbError::QueryError(e.to_string()))?;
let total: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM fraiseql_permissions")
.fetch_one(&self.pool)
.await
.map_err(|e| RbacDbError::QueryError(e.to_string()))?;
Ok(Page::new(
rows.iter().map(permission_dto_from_row).collect(),
total,
limit,
offset,
))
}
pub async fn get_permission(&self, permission_id: &str) -> Result<PermissionDto, RbacDbError> {
let perm_uuid = parse_uuid("permission ID", permission_id)?;
let row = sqlx::query(
"SELECT id, resource, action, description, created_at
FROM fraiseql_permissions WHERE id = $1",
)
.bind(perm_uuid)
.fetch_optional(&self.pool)
.await
.map_err(|e| RbacDbError::QueryError(e.to_string()))?
.ok_or(RbacDbError::PermissionNotFound)?;
Ok(permission_dto_from_row(&row))
}
pub async fn delete_permission(&self, permission_id: &str) -> Result<(), RbacDbError> {
let perm_uuid = parse_uuid("permission ID", permission_id)?;
let mut tx = self
.pool
.begin()
.await
.map_err(|e| RbacDbError::ConnectionError(e.to_string()))?;
let count: i64 = sqlx::query_scalar(
"SELECT COUNT(*) FROM fraiseql_role_permissions WHERE permission_id = $1",
)
.bind(perm_uuid)
.fetch_one(&mut *tx)
.await
.map_err(|e| RbacDbError::QueryError(e.to_string()))?;
if count > 0 {
return Err(RbacDbError::PermissionInUse);
}
let result = sqlx::query("DELETE FROM fraiseql_permissions WHERE id = $1")
.bind(perm_uuid)
.execute(&mut *tx)
.await
.map_err(|e| RbacDbError::QueryError(e.to_string()))?;
if result.rows_affected() == 0 {
return Err(RbacDbError::PermissionNotFound);
}
record_audit(
&mut tx,
&AuditRecord {
event_type: AuditEventType::PermissionDeleted,
role_id: None,
role_name: None,
user_id: None,
permission_id: Some(perm_uuid),
tenant_id: None,
details: serde_json::Value::Object(serde_json::Map::new()),
},
)
.await?;
tx.commit().await.map_err(|e| RbacDbError::TransactionError(e.to_string()))?;
Ok(())
}
pub async fn assign_role_to_user(
&self,
user_id: &str,
role_id: &str,
tenant_id: Option<&str>,
) -> Result<UserRoleDto, RbacDbError> {
let role_uuid = parse_uuid("role ID", role_id)?;
let tenant_uuid = parse_opt_uuid("tenant ID", tenant_id)?;
let now = Utc::now();
let mut tx = self
.pool
.begin()
.await
.map_err(|e| RbacDbError::ConnectionError(e.to_string()))?;
let role_name: Option<String> =
sqlx::query_scalar("SELECT name FROM fraiseql_roles WHERE id = $1")
.bind(role_uuid)
.fetch_optional(&mut *tx)
.await
.map_err(|e| RbacDbError::QueryError(e.to_string()))?;
let Some(role_name) = role_name else {
return Err(RbacDbError::RoleNotFound);
};
sqlx::query(
"INSERT INTO fraiseql_user_roles (user_id, role_id, tenant_id, assigned_at)
VALUES ($1, $2, $3, $4)",
)
.bind(user_id)
.bind(role_uuid)
.bind(tenant_uuid)
.bind(now)
.execute(&mut *tx)
.await
.map_err(|e| {
if is_unique_violation(&e) {
RbacDbError::AssignmentDuplicate
} else {
RbacDbError::QueryError(e.to_string())
}
})?;
record_audit(
&mut tx,
&AuditRecord {
event_type: AuditEventType::RoleAssigned,
role_id: Some(role_uuid),
role_name: Some(&role_name),
user_id: Some(user_id),
permission_id: None,
tenant_id: tenant_uuid,
details: serde_json::Value::Object(serde_json::Map::new()),
},
)
.await?;
tx.commit().await.map_err(|e| RbacDbError::TransactionError(e.to_string()))?;
Ok(UserRoleDto {
user_id: user_id.to_string(),
role_id: role_id.to_string(),
tenant_id: tenant_uuid.map(|u| u.to_string()),
assigned_at: now.to_rfc3339(),
})
}
pub async fn list_user_roles(
&self,
user_id: &str,
tenant_id: Option<&str>,
limit: u32,
offset: u32,
) -> Result<Page<UserRoleDto>, RbacDbError> {
let tenant_uuid = parse_opt_uuid("tenant ID", tenant_id)?;
let rows = sqlx::query(
"SELECT user_id, role_id, tenant_id, assigned_at
FROM fraiseql_user_roles
WHERE user_id = $1 AND ($2::uuid IS NULL OR tenant_id = $2)
ORDER BY assigned_at LIMIT $3 OFFSET $4",
)
.bind(user_id)
.bind(tenant_uuid)
.bind(i64::from(limit))
.bind(i64::from(offset))
.fetch_all(&self.pool)
.await
.map_err(|e| RbacDbError::QueryError(e.to_string()))?;
let total: i64 = sqlx::query_scalar(
"SELECT COUNT(*) FROM fraiseql_user_roles
WHERE user_id = $1 AND ($2::uuid IS NULL OR tenant_id = $2)",
)
.bind(user_id)
.bind(tenant_uuid)
.fetch_one(&self.pool)
.await
.map_err(|e| RbacDbError::QueryError(e.to_string()))?;
let items = rows
.iter()
.map(|row| {
let role_id: Uuid = row.get("role_id");
let tenant_id: Option<Uuid> = row.get("tenant_id");
let assigned_at: chrono::DateTime<Utc> = row.get("assigned_at");
UserRoleDto {
user_id: row.get::<String, _>("user_id"),
role_id: role_id.to_string(),
tenant_id: tenant_id.map(|u| u.to_string()),
assigned_at: assigned_at.to_rfc3339(),
}
})
.collect();
Ok(Page::new(items, total, limit, offset))
}
pub async fn revoke_role_from_user(
&self,
user_id: &str,
role_id: &str,
) -> Result<(), RbacDbError> {
let role_uuid = parse_uuid("role ID", role_id)?;
let mut tx = self
.pool
.begin()
.await
.map_err(|e| RbacDbError::ConnectionError(e.to_string()))?;
let result =
sqlx::query("DELETE FROM fraiseql_user_roles WHERE user_id = $1 AND role_id = $2")
.bind(user_id)
.bind(role_uuid)
.execute(&mut *tx)
.await
.map_err(|e| RbacDbError::QueryError(e.to_string()))?;
if result.rows_affected() == 0 {
return Err(RbacDbError::AssignmentNotFound);
}
record_audit(
&mut tx,
&AuditRecord {
event_type: AuditEventType::RoleRevoked,
role_id: Some(role_uuid),
role_name: None,
user_id: Some(user_id),
permission_id: None,
tenant_id: None,
details: serde_json::Value::Object(serde_json::Map::new()),
},
)
.await?;
tx.commit().await.map_err(|e| RbacDbError::TransactionError(e.to_string()))?;
Ok(())
}
pub async fn query_audit(
&self,
filter: &AuditFilter<'_>,
) -> Result<Page<AuditEventDto>, RbacDbError> {
let role_uuid = parse_opt_uuid("role ID", filter.role_id)?;
let tenant_uuid = parse_opt_uuid("tenant ID", filter.tenant_id)?;
let rows = sqlx::query(&format!(
"SELECT id, occurred_at, event_type, role_id, role_name, user_id,
permission_id, tenant_id, details
FROM fraiseql_permission_audit
WHERE {AUDIT_PREDICATE}
ORDER BY occurred_at, id LIMIT $7 OFFSET $8"
))
.bind(filter.user_id)
.bind(role_uuid)
.bind(filter.event_type)
.bind(tenant_uuid)
.bind(filter.start_time)
.bind(filter.end_time)
.bind(i64::from(filter.limit))
.bind(i64::from(filter.offset))
.fetch_all(&self.pool)
.await
.map_err(|e| RbacDbError::QueryError(e.to_string()))?;
let total: i64 = sqlx::query_scalar(&format!(
"SELECT COUNT(*) FROM fraiseql_permission_audit WHERE {AUDIT_PREDICATE}"
))
.bind(filter.user_id)
.bind(role_uuid)
.bind(filter.event_type)
.bind(tenant_uuid)
.bind(filter.start_time)
.bind(filter.end_time)
.fetch_one(&self.pool)
.await
.map_err(|e| RbacDbError::QueryError(e.to_string()))?;
let items = rows.iter().map(audit_dto_from_row).collect();
Ok(Page::new(items, total, filter.limit, filter.offset))
}
async fn get_role_permissions(&self, role_id: Uuid) -> Result<Vec<String>, RbacDbError> {
let rows = sqlx::query(
"SELECT p.resource, p.action
FROM fraiseql_permissions p
JOIN fraiseql_role_permissions rp ON rp.permission_id = p.id
WHERE rp.role_id = $1
ORDER BY p.resource, p.action",
)
.bind(role_id)
.fetch_all(&self.pool)
.await
.map_err(|e| RbacDbError::QueryError(e.to_string()))?;
Ok(rows
.iter()
.map(|r| {
let resource: String = r.get("resource");
let action: String = r.get("action");
format!("{resource}:{action}")
})
.collect())
}
async fn ensure_permission(
&self,
tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
resource: &str,
action: &str,
) -> Result<Uuid, RbacDbError> {
let existing: Option<Uuid> = sqlx::query_scalar(
"SELECT id FROM fraiseql_permissions WHERE resource = $1 AND action = $2",
)
.bind(resource)
.bind(action)
.fetch_optional(&mut **tx)
.await
.map_err(|e| RbacDbError::QueryError(e.to_string()))?;
if let Some(id) = existing {
return Ok(id);
}
let id = Uuid::new_v4();
sqlx::query(
"INSERT INTO fraiseql_permissions (id, resource, action, created_at)
VALUES ($1, $2, $3, NOW())",
)
.bind(id)
.bind(resource)
.bind(action)
.execute(&mut **tx)
.await
.map_err(|e| RbacDbError::QueryError(e.to_string()))?;
Ok(id)
}
}
pub(crate) fn parse_permission(perm: &str) -> Result<(&str, &str), RbacDbError> {
perm.split_once(':').ok_or_else(|| {
RbacDbError::InvalidInput(format!(
"Invalid permission format '{perm}': expected 'resource:action'"
))
})
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Page<T> {
pub items: Vec<T>,
pub total: u64,
pub limit: u32,
pub offset: u32,
pub has_more: bool,
}
impl<T> Page<T> {
fn new(items: Vec<T>, total: i64, limit: u32, offset: u32) -> Self {
let total = u64::try_from(total).unwrap_or(0);
let seen = u64::from(offset).saturating_add(items.len() as u64);
Self {
has_more: seen < total,
items,
total,
limit,
offset,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[non_exhaustive]
pub enum AuditEventType {
RoleCreated,
RoleUpdated,
RoleDeleted,
PermissionCreated,
PermissionDeleted,
RoleAssigned,
RoleRevoked,
}
impl AuditEventType {
pub const ALL: [Self; 7] = [
Self::RoleCreated,
Self::RoleUpdated,
Self::RoleDeleted,
Self::PermissionCreated,
Self::PermissionDeleted,
Self::RoleAssigned,
Self::RoleRevoked,
];
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Self::RoleCreated => "role_created",
Self::RoleUpdated => "role_updated",
Self::RoleDeleted => "role_deleted",
Self::PermissionCreated => "permission_created",
Self::PermissionDeleted => "permission_deleted",
Self::RoleAssigned => "role_assigned",
Self::RoleRevoked => "role_revoked",
}
}
}
struct AuditRecord<'a> {
event_type: AuditEventType,
role_id: Option<Uuid>,
role_name: Option<&'a str>,
user_id: Option<&'a str>,
permission_id: Option<Uuid>,
tenant_id: Option<Uuid>,
details: serde_json::Value,
}
async fn record_audit(
tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
record: &AuditRecord<'_>,
) -> Result<(), RbacDbError> {
sqlx::query(
"INSERT INTO fraiseql_permission_audit
(id, occurred_at, event_type, role_id, role_name, user_id,
permission_id, tenant_id, details)
VALUES ($1, NOW(), $2, $3, $4, $5, $6, $7, $8)",
)
.bind(Uuid::new_v4())
.bind(record.event_type.as_str())
.bind(record.role_id)
.bind(record.role_name)
.bind(record.user_id)
.bind(record.permission_id)
.bind(record.tenant_id)
.bind(&record.details)
.execute(&mut **tx)
.await
.map_err(|e| RbacDbError::QueryError(format!("Audit write failed: {e}")))?;
Ok(())
}
#[derive(Debug, Clone, Copy)]
pub struct AuditFilter<'a> {
pub user_id: Option<&'a str>,
pub role_id: Option<&'a str>,
pub event_type: Option<&'a str>,
pub tenant_id: Option<&'a str>,
pub start_time: Option<chrono::DateTime<Utc>>,
pub end_time: Option<chrono::DateTime<Utc>>,
pub limit: u32,
pub offset: u32,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AuditEventDto {
pub id: String,
pub occurred_at: String,
pub event_type: String,
pub role_id: Option<String>,
pub role_name: Option<String>,
pub user_id: Option<String>,
pub permission_id: Option<String>,
pub tenant_id: Option<String>,
pub details: serde_json::Value,
}
fn audit_dto_from_row(row: &PgRow) -> AuditEventDto {
let id: Uuid = row.get("id");
let occurred_at: chrono::DateTime<Utc> = row.get("occurred_at");
let role_id: Option<Uuid> = row.get("role_id");
let permission_id: Option<Uuid> = row.get("permission_id");
let tenant_id: Option<Uuid> = row.get("tenant_id");
AuditEventDto {
id: id.to_string(),
occurred_at: occurred_at.to_rfc3339(),
event_type: row.get("event_type"),
role_id: role_id.map(|u| u.to_string()),
role_name: row.get("role_name"),
user_id: row.get("user_id"),
permission_id: permission_id.map(|u| u.to_string()),
tenant_id: tenant_id.map(|u| u.to_string()),
details: row.get("details"),
}
}
fn is_unique_violation(e: &sqlx::Error) -> bool {
if let sqlx::Error::Database(db_err) = e {
db_err.code().as_deref() == Some("23505")
} else {
false
}
}
fn role_dto_from_row(row: &PgRow, permissions: Vec<String>) -> RoleDto {
let id: Uuid = row.get("id");
let tenant_id: Option<Uuid> = row.get("tenant_id");
let created_at: chrono::DateTime<Utc> = row.get("created_at");
let updated_at: chrono::DateTime<Utc> = row.get("updated_at");
RoleDto {
id: id.to_string(),
name: row.get("name"),
description: row.get("description"),
permissions,
tenant_id: tenant_id.map(|u| u.to_string()),
created_at: created_at.to_rfc3339(),
updated_at: updated_at.to_rfc3339(),
}
}
fn permission_dto_from_row(row: &PgRow) -> PermissionDto {
let id: Uuid = row.get("id");
let created_at: chrono::DateTime<Utc> = row.get("created_at");
PermissionDto {
id: id.to_string(),
resource: row.get("resource"),
action: row.get("action"),
description: row.get("description"),
created_at: created_at.to_rfc3339(),
}
}