Skip to main content

koprogo_api/infrastructure/database/repositories/
role_delegation_repository_impl.rs

1//! PostgreSQL implementation of [`RoleDelegationRepository`] (Story 3.5).
2//!
3//! Persists delegated role assignments inside the existing `user_roles` table
4//! using the two new columns introduced by migration
5//! `20260605030000_extend_user_roles_delegation.sql`:
6//! - `valid_until TIMESTAMPTZ` (NULL = permanent native row)
7//! - `delegated_from_user_id UUID` (NULL = native row)
8
9use crate::application::error::AppError;
10use crate::application::ports::RoleDelegationRepository;
11use crate::domain::entities::{UserRole, UserRoleAssignment};
12use crate::infrastructure::pool::DbPool;
13use async_trait::async_trait;
14use chrono::{DateTime, Utc};
15use sqlx::Row;
16use std::str::FromStr;
17use uuid::Uuid;
18
19pub struct PostgresRoleDelegationRepository {
20    pool: DbPool,
21}
22
23impl PostgresRoleDelegationRepository {
24    pub fn new(pool: DbPool) -> Self {
25        Self { pool }
26    }
27
28    fn row_to_assignment(row: &sqlx::postgres::PgRow) -> Result<UserRoleAssignment, AppError> {
29        let role_str: String = row
30            .try_get("role")
31            .map_err(|e| AppError::Database(format!("Failed to read role: {}", e)))?;
32        let role = UserRole::from_str(&role_str)
33            .map_err(|e| AppError::Database(format!("Invalid role in DB: {}", e)))?;
34        let valid_until: Option<DateTime<Utc>> = row
35            .try_get("valid_until")
36            .map_err(|e| AppError::Database(format!("Failed to read valid_until: {}", e)))?;
37        let delegated_from_user_id: Option<Uuid> =
38            row.try_get("delegated_from_user_id").map_err(|e| {
39                AppError::Database(format!("Failed to read delegated_from_user_id: {}", e))
40            })?;
41        Ok(UserRoleAssignment {
42            id: row
43                .try_get("id")
44                .map_err(|e| AppError::Database(format!("Failed to read id: {}", e)))?,
45            user_id: row
46                .try_get("user_id")
47                .map_err(|e| AppError::Database(format!("Failed to read user_id: {}", e)))?,
48            role,
49            organization_id: row.try_get("organization_id").map_err(|e| {
50                AppError::Database(format!("Failed to read organization_id: {}", e))
51            })?,
52            is_primary: row
53                .try_get("is_primary")
54                .map_err(|e| AppError::Database(format!("Failed to read is_primary: {}", e)))?,
55            valid_until,
56            delegated_from_user_id,
57            created_at: row
58                .try_get("created_at")
59                .map_err(|e| AppError::Database(format!("Failed to read created_at: {}", e)))?,
60            updated_at: row
61                .try_get("updated_at")
62                .map_err(|e| AppError::Database(format!("Failed to read updated_at: {}", e)))?,
63        })
64    }
65}
66
67#[async_trait]
68impl RoleDelegationRepository for PostgresRoleDelegationRepository {
69    async fn save(&self, a: &UserRoleAssignment) -> Result<(), AppError> {
70        sqlx::query(
71            r#"
72            INSERT INTO user_roles (
73                id, user_id, role, organization_id, is_primary,
74                valid_until, delegated_from_user_id,
75                created_at, updated_at
76            )
77            VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
78            "#,
79        )
80        .bind(a.id)
81        .bind(a.user_id)
82        .bind(a.role.to_string())
83        .bind(a.organization_id)
84        .bind(a.is_primary)
85        .bind(a.valid_until)
86        .bind(a.delegated_from_user_id)
87        .bind(a.created_at)
88        .bind(a.updated_at)
89        .execute(&self.pool)
90        .await
91        .map_err(|e| AppError::Database(format!("Failed to save delegation: {}", e)))?;
92        Ok(())
93    }
94
95    async fn find_by_id(&self, id: Uuid) -> Result<Option<UserRoleAssignment>, AppError> {
96        let row = sqlx::query(
97            r#"
98            SELECT id, user_id, role, organization_id, is_primary,
99                   valid_until, delegated_from_user_id,
100                   created_at, updated_at
101            FROM user_roles
102            WHERE id = $1
103            "#,
104        )
105        .bind(id)
106        .fetch_optional(&self.pool)
107        .await
108        .map_err(|e| AppError::Database(format!("Failed to find delegation: {}", e)))?;
109        match row {
110            None => Ok(None),
111            Some(row) => Ok(Some(Self::row_to_assignment(&row)?)),
112        }
113    }
114
115    async fn find_active_by_user_and_role(
116        &self,
117        user_id: Uuid,
118        role: &UserRole,
119        organization_id: Option<Uuid>,
120    ) -> Result<Vec<UserRoleAssignment>, AppError> {
121        // "Active" = either permanent (`valid_until IS NULL`) or still within
122        // its validity window. NULL-safe equality on `organization_id`.
123        let rows = sqlx::query(
124            r#"
125            SELECT id, user_id, role, organization_id, is_primary,
126                   valid_until, delegated_from_user_id,
127                   created_at, updated_at
128            FROM user_roles
129            WHERE user_id = $1
130              AND role = $2
131              AND (organization_id IS NOT DISTINCT FROM $3)
132              AND (valid_until IS NULL OR valid_until > NOW())
133            ORDER BY created_at ASC
134            "#,
135        )
136        .bind(user_id)
137        .bind(role.to_string())
138        .bind(organization_id)
139        .fetch_all(&self.pool)
140        .await
141        .map_err(|e| AppError::Database(format!("Failed to load assignments: {}", e)))?;
142
143        rows.iter().map(Self::row_to_assignment).collect()
144    }
145
146    async fn list_delegations_of(
147        &self,
148        user_id: Uuid,
149    ) -> Result<Vec<UserRoleAssignment>, AppError> {
150        let rows = sqlx::query(
151            r#"
152            SELECT id, user_id, role, organization_id, is_primary,
153                   valid_until, delegated_from_user_id,
154                   created_at, updated_at
155            FROM user_roles
156            WHERE delegated_from_user_id IS NOT NULL
157              AND valid_until IS NOT NULL
158              AND valid_until > NOW()
159              AND (user_id = $1 OR delegated_from_user_id = $1)
160            ORDER BY created_at DESC
161            "#,
162        )
163        .bind(user_id)
164        .fetch_all(&self.pool)
165        .await
166        .map_err(|e| AppError::Database(format!("Failed to list delegations: {}", e)))?;
167
168        rows.iter().map(Self::row_to_assignment).collect()
169    }
170
171    async fn revoke(&self, id: Uuid) -> Result<(), AppError> {
172        // We delete the row outright — delegations are typically short-lived
173        // and the audit trail lives in `audit_log` (cf. Story 3.4 mandate
174        // pattern but adapted for the lighter delegation lifecycle).
175        // The DELETE is restricted to delegation rows (delegated_from_user_id
176        // NOT NULL) to prevent accidentally wiping a native assignment.
177        sqlx::query(
178            r#"
179            DELETE FROM user_roles
180            WHERE id = $1 AND delegated_from_user_id IS NOT NULL
181            "#,
182        )
183        .bind(id)
184        .execute(&self.pool)
185        .await
186        .map_err(|e| AppError::Database(format!("Failed to revoke delegation: {}", e)))?;
187        Ok(())
188    }
189}