Skip to main content

koprogo_api/application/use_cases/
role_delegation_use_cases.rs

1//! Story 3.5 — Temporary role delegation use cases (FR8 INV-8).
2//!
3//! A syndic (or any holder of a *native* role) may delegate their role to
4//! another user for a bounded duration. The platform enforces:
5//!
6//! - **Self-delegation forbidden** (`delegator != target`).
7//! - **Duration bounded** in (`now`, `now + MAX_DELEGATION_DAYS`].
8//! - **Anti-double-grant** (409): the target must not already hold this role
9//!   actively (native or delegated).
10//! - **Non-transitive** (403): a user that received the role through a
11//!   delegation cannot re-delegate it. Caller MUST hold the role as a *native*
12//!   assignment (`delegated_from_user_id IS NULL`).
13//!
14//! Handlers enforce the upstream "caller actually holds the role" check via
15//! the JWT role + an active assignment lookup before calling `delegate_role`.
16
17use crate::application::error::AppError;
18use crate::application::ports::RoleDelegationRepository;
19use crate::domain::entities::{UserRole, UserRoleAssignment};
20use chrono::{DateTime, Duration, Utc};
21use std::sync::Arc;
22use uuid::Uuid;
23
24/// Anti-abuse cap: no single delegation can outlive 90 days. A renewal MUST
25/// go through a fresh `delegate_role` call (audit-faithful).
26pub const MAX_DELEGATION_DAYS: i64 = 90;
27
28pub struct RoleDelegationUseCases {
29    repo: Arc<dyn RoleDelegationRepository>,
30}
31
32impl RoleDelegationUseCases {
33    pub fn new(repo: Arc<dyn RoleDelegationRepository>) -> Self {
34        Self { repo }
35    }
36
37    /// Delegate a role to another user.
38    ///
39    /// The caller (handler) MUST have already verified that `delegator_user_id`
40    /// owns the role to be delegated (via JWT + native assignment lookup);
41    /// this use-case re-checks the **non-transitive** invariant by inspecting
42    /// the persisted assignment(s).
43    pub async fn delegate_role(
44        &self,
45        delegator_user_id: Uuid,
46        target_user_id: Uuid,
47        role: UserRole,
48        organization_id: Option<Uuid>,
49        valid_until: DateTime<Utc>,
50    ) -> Result<UserRoleAssignment, AppError> {
51        // --- @security : self-delegation forbidden ----------------------
52        if delegator_user_id == target_user_id {
53            return Err(AppError::Validation(
54                "Cannot delegate a role to oneself".to_string(),
55            ));
56        }
57        if delegator_user_id.is_nil() || target_user_id.is_nil() {
58            return Err(AppError::Validation(
59                "Delegation user ids must not be nil UUIDs".to_string(),
60            ));
61        }
62
63        // --- @edge : validity window in the future and bounded ----------
64        let now = Utc::now();
65        if valid_until <= now {
66            return Err(AppError::Validation(
67                "Delegation valid_until must be strictly in the future".to_string(),
68            ));
69        }
70        let duration = valid_until - now;
71        if duration > Duration::days(MAX_DELEGATION_DAYS) {
72            return Err(AppError::Validation(format!(
73                "Delegation duration exceeds {} days (anti-abuse)",
74                MAX_DELEGATION_DAYS
75            )));
76        }
77
78        // --- @security : non-transitive — delegator must hold the role
79        //                NATIVELY (not via a prior delegation). ----------
80        let delegator_assignments = self
81            .repo
82            .find_active_by_user_and_role(delegator_user_id, &role, organization_id)
83            .await?;
84        let has_native = delegator_assignments
85            .iter()
86            .any(|a| !a.is_delegated() && a.is_currently_active());
87        if !has_native {
88            // Either the delegator has no assignment for this role at all,
89            // or the only ones they have are themselves delegations.
90            return Err(AppError::DelegationChainNotAllowed);
91        }
92
93        // --- @negative : target already holds the role actively → 409 ---
94        let target_assignments = self
95            .repo
96            .find_active_by_user_and_role(target_user_id, &role, organization_id)
97            .await?;
98        if target_assignments.iter().any(|a| a.is_currently_active()) {
99            return Err(AppError::RoleAlreadyAssigned {
100                user_id: target_user_id,
101                role: role.to_string(),
102            });
103        }
104
105        // --- Persist the delegation -------------------------------------
106        let assignment = UserRoleAssignment::new_delegated(
107            target_user_id,
108            role,
109            organization_id,
110            valid_until,
111            delegator_user_id,
112        );
113        self.repo.save(&assignment).await?;
114        Ok(assignment)
115    }
116
117    /// Manually revoke a delegation before its term. Idempotent.
118    pub async fn revoke_delegation(&self, assignment_id: Uuid) -> Result<(), AppError> {
119        let existing =
120            self.repo.find_by_id(assignment_id).await?.ok_or_else(|| {
121                AppError::NotFound(format!("Delegation {} not found", assignment_id))
122            })?;
123        if !existing.is_delegated() {
124            return Err(AppError::Validation(
125                "Assignment is not a delegation".to_string(),
126            ));
127        }
128        self.repo.revoke(assignment_id).await?;
129        Ok(())
130    }
131
132    /// List active delegations involving `user_id` (received OR granted).
133    pub async fn list_delegations_of(
134        &self,
135        user_id: Uuid,
136    ) -> Result<Vec<UserRoleAssignment>, AppError> {
137        self.repo.list_delegations_of(user_id).await
138    }
139}
140
141// ============================================================================
142// Tests — taxonomie 4 catégories obligatoire (CRITICAL.md #3)
143// ============================================================================
144
145#[cfg(test)]
146mod tests {
147    use super::*;
148    use async_trait::async_trait;
149    use std::sync::Mutex;
150
151    #[derive(Default)]
152    struct InMemoryRepo {
153        rows: Mutex<Vec<UserRoleAssignment>>,
154    }
155
156    impl InMemoryRepo {
157        fn push(&self, a: UserRoleAssignment) {
158            self.rows.lock().unwrap().push(a);
159        }
160    }
161
162    #[async_trait]
163    impl RoleDelegationRepository for InMemoryRepo {
164        async fn save(&self, a: &UserRoleAssignment) -> Result<(), AppError> {
165            self.rows.lock().unwrap().push(a.clone());
166            Ok(())
167        }
168
169        async fn find_by_id(&self, id: Uuid) -> Result<Option<UserRoleAssignment>, AppError> {
170            Ok(self
171                .rows
172                .lock()
173                .unwrap()
174                .iter()
175                .find(|a| a.id == id)
176                .cloned())
177        }
178
179        async fn find_active_by_user_and_role(
180            &self,
181            user_id: Uuid,
182            role: &UserRole,
183            organization_id: Option<Uuid>,
184        ) -> Result<Vec<UserRoleAssignment>, AppError> {
185            Ok(self
186                .rows
187                .lock()
188                .unwrap()
189                .iter()
190                .filter(|a| {
191                    a.user_id == user_id
192                        && &a.role == role
193                        && a.organization_id == organization_id
194                        && a.is_currently_active()
195                })
196                .cloned()
197                .collect())
198        }
199
200        async fn list_delegations_of(
201            &self,
202            user_id: Uuid,
203        ) -> Result<Vec<UserRoleAssignment>, AppError> {
204            Ok(self
205                .rows
206                .lock()
207                .unwrap()
208                .iter()
209                .filter(|a| {
210                    a.is_delegated()
211                        && a.is_currently_active()
212                        && (a.user_id == user_id || a.delegated_from_user_id == Some(user_id))
213                })
214                .cloned()
215                .collect())
216        }
217
218        async fn revoke(&self, id: Uuid) -> Result<(), AppError> {
219            self.rows.lock().unwrap().retain(|a| a.id != id);
220            Ok(())
221        }
222    }
223
224    fn factory() -> (Arc<InMemoryRepo>, RoleDelegationUseCases) {
225        let repo: Arc<InMemoryRepo> = Arc::new(InMemoryRepo::default());
226        let uc = RoleDelegationUseCases::new(repo.clone() as Arc<dyn RoleDelegationRepository>);
227        (repo, uc)
228    }
229
230    /// Seed a native (non-delegated) role for `user`.
231    fn seed_native(repo: &InMemoryRepo, user: Uuid, role: UserRole) {
232        repo.push(UserRoleAssignment::new(user, role, None, true));
233    }
234
235    // ---- @happy ------------------------------------------------------------
236
237    #[tokio::test]
238    async fn happy_delegate_then_list_finds_assignment() {
239        let (repo, uc) = factory();
240        let syndic = Uuid::new_v4();
241        let owner = Uuid::new_v4();
242        seed_native(&repo, syndic, UserRole::Syndic);
243
244        let valid_until = Utc::now() + Duration::days(7);
245        let delegation = uc
246            .delegate_role(syndic, owner, UserRole::Syndic, None, valid_until)
247            .await
248            .expect("delegate ok");
249
250        assert_eq!(delegation.user_id, owner);
251        assert_eq!(delegation.delegated_from_user_id, Some(syndic));
252        assert!(delegation.is_delegated());
253        assert!(delegation.is_currently_active());
254
255        // Target's view of received delegations
256        let received = uc.list_delegations_of(owner).await.unwrap();
257        assert_eq!(received.len(), 1);
258        assert_eq!(received[0].id, delegation.id);
259
260        // Delegator's view of granted delegations
261        let granted = uc.list_delegations_of(syndic).await.unwrap();
262        assert_eq!(granted.len(), 1);
263        assert_eq!(granted[0].id, delegation.id);
264    }
265
266    #[tokio::test]
267    async fn happy_revoke_delegation_removes_it() {
268        let (repo, uc) = factory();
269        let syndic = Uuid::new_v4();
270        let owner = Uuid::new_v4();
271        seed_native(&repo, syndic, UserRole::Syndic);
272
273        let valid_until = Utc::now() + Duration::days(7);
274        let d = uc
275            .delegate_role(syndic, owner, UserRole::Syndic, None, valid_until)
276            .await
277            .unwrap();
278        uc.revoke_delegation(d.id).await.unwrap();
279        assert!(uc.list_delegations_of(owner).await.unwrap().is_empty());
280    }
281
282    // ---- @edge -------------------------------------------------------------
283
284    #[tokio::test]
285    async fn edge_valid_until_exactly_now_is_rejected() {
286        let (repo, uc) = factory();
287        let syndic = Uuid::new_v4();
288        let owner = Uuid::new_v4();
289        seed_native(&repo, syndic, UserRole::Syndic);
290        // Pin valid_until far enough in the past that the use-case `now`
291        // computed during execution will be strictly later — i.e. the
292        // window is already closed at validation time.
293        let valid_until = Utc::now() - Duration::seconds(1);
294        let err = uc
295            .delegate_role(syndic, owner, UserRole::Syndic, None, valid_until)
296            .await
297            .unwrap_err();
298        assert!(matches!(err, AppError::Validation(_)));
299    }
300
301    #[tokio::test]
302    async fn edge_max_window_minus_epsilon_is_accepted() {
303        let (repo, uc) = factory();
304        let syndic = Uuid::new_v4();
305        let owner = Uuid::new_v4();
306        seed_native(&repo, syndic, UserRole::Syndic);
307        // Slightly inside the MAX_DELEGATION_DAYS cap to avoid the boundary
308        // race between caller `now` and use-case `now`.
309        let valid_until = Utc::now() + Duration::days(MAX_DELEGATION_DAYS) - Duration::seconds(5);
310        let res = uc
311            .delegate_role(syndic, owner, UserRole::Syndic, None, valid_until)
312            .await;
313        assert!(res.is_ok(), "{:?}", res.err());
314    }
315
316    // ---- @security ---------------------------------------------------------
317
318    #[tokio::test]
319    async fn security_self_delegation_is_rejected() {
320        let (repo, uc) = factory();
321        let same = Uuid::new_v4();
322        seed_native(&repo, same, UserRole::Syndic);
323        let err = uc
324            .delegate_role(
325                same,
326                same,
327                UserRole::Syndic,
328                None,
329                Utc::now() + Duration::days(7),
330            )
331            .await
332            .unwrap_err();
333        assert!(matches!(err, AppError::Validation(_)));
334    }
335
336    #[tokio::test]
337    async fn security_non_transitive_delegated_role_cannot_be_redelegated() {
338        // Setup: original syndic delegates to ownerA. OwnerA tries to
339        // re-delegate to ownerB — must fail with DelegationChainNotAllowed.
340        let (repo, uc) = factory();
341        let original_syndic = Uuid::new_v4();
342        let owner_a = Uuid::new_v4();
343        let owner_b = Uuid::new_v4();
344        seed_native(&repo, original_syndic, UserRole::Syndic);
345
346        let valid_until = Utc::now() + Duration::days(7);
347        let _ = uc
348            .delegate_role(
349                original_syndic,
350                owner_a,
351                UserRole::Syndic,
352                None,
353                valid_until,
354            )
355            .await
356            .expect("first delegation ok");
357
358        // ownerA has Syndic via delegation only — try to re-delegate.
359        let err = uc
360            .delegate_role(owner_a, owner_b, UserRole::Syndic, None, valid_until)
361            .await
362            .unwrap_err();
363        assert!(
364            matches!(err, AppError::DelegationChainNotAllowed),
365            "expected DelegationChainNotAllowed, got {:?}",
366            err
367        );
368    }
369
370    #[tokio::test]
371    async fn security_delegator_without_any_role_is_rejected() {
372        // The caller does not even hold the role. The use-case must refuse
373        // (same chain-not-allowed error — strictest possible).
374        let (_repo, uc) = factory();
375        let stranger = Uuid::new_v4();
376        let target = Uuid::new_v4();
377        let err = uc
378            .delegate_role(
379                stranger,
380                target,
381                UserRole::Syndic,
382                None,
383                Utc::now() + Duration::days(7),
384            )
385            .await
386            .unwrap_err();
387        assert!(matches!(err, AppError::DelegationChainNotAllowed));
388    }
389
390    #[tokio::test]
391    async fn security_nil_ids_are_rejected() {
392        let (_repo, uc) = factory();
393        let err = uc
394            .delegate_role(
395                Uuid::nil(),
396                Uuid::new_v4(),
397                UserRole::Syndic,
398                None,
399                Utc::now() + Duration::days(7),
400            )
401            .await
402            .unwrap_err();
403        assert!(matches!(err, AppError::Validation(_)));
404    }
405
406    // ---- @negative ---------------------------------------------------------
407
408    #[tokio::test]
409    async fn negative_duration_above_max_is_rejected() {
410        let (repo, uc) = factory();
411        let syndic = Uuid::new_v4();
412        let target = Uuid::new_v4();
413        seed_native(&repo, syndic, UserRole::Syndic);
414
415        let valid_until = Utc::now() + Duration::days(MAX_DELEGATION_DAYS + 1);
416        let err = uc
417            .delegate_role(syndic, target, UserRole::Syndic, None, valid_until)
418            .await
419            .unwrap_err();
420        assert!(matches!(err, AppError::Validation(_)));
421    }
422
423    #[tokio::test]
424    async fn negative_target_already_has_role_returns_conflict() {
425        let (repo, uc) = factory();
426        let syndic_a = Uuid::new_v4();
427        let target = Uuid::new_v4();
428        seed_native(&repo, syndic_a, UserRole::Syndic);
429        // Target already has Syndic natively → cannot re-grant via delegation.
430        seed_native(&repo, target, UserRole::Syndic);
431
432        let err = uc
433            .delegate_role(
434                syndic_a,
435                target,
436                UserRole::Syndic,
437                None,
438                Utc::now() + Duration::days(7),
439            )
440            .await
441            .unwrap_err();
442        assert!(
443            matches!(err, AppError::RoleAlreadyAssigned { .. }),
444            "expected RoleAlreadyAssigned, got {:?}",
445            err
446        );
447    }
448
449    #[tokio::test]
450    async fn negative_revoke_unknown_id_returns_not_found() {
451        let (_repo, uc) = factory();
452        let err = uc.revoke_delegation(Uuid::new_v4()).await.unwrap_err();
453        assert!(matches!(err, AppError::NotFound(_)));
454    }
455
456    #[tokio::test]
457    async fn negative_revoke_native_assignment_is_rejected() {
458        let (repo, uc) = factory();
459        let user = Uuid::new_v4();
460        let native = UserRoleAssignment::new(user, UserRole::Syndic, None, true);
461        let native_id = native.id;
462        repo.push(native);
463        // Revoking a native (non-delegated) row through this use-case is a
464        // categorical error — it should never reach this surface.
465        let err = uc.revoke_delegation(native_id).await.unwrap_err();
466        assert!(matches!(err, AppError::Validation(_)));
467    }
468}