Skip to main content

koprogo_api/domain/plateforme/
user_role_assignment.rs

1use chrono::{DateTime, Utc};
2use serde::{Deserialize, Serialize};
3use uuid::Uuid;
4
5use super::user::UserRole;
6
7/// Represents an assignment of a role to a user within an optional organization scope.
8///
9/// # Story 3.5 — Temporary role delegation (FR8 INV-8)
10///
11/// Two optional fields turn a permanent native assignment into a time-bounded
12/// delegated assignment:
13///
14/// - `valid_until = None` ⇒ permanent / native role (legacy behaviour).
15/// - `valid_until = Some(t)` ⇒ delegated assignment that auto-expires at `t`.
16/// - `delegated_from_user_id = Some(delegator)` ⇒ trail of who delegated the
17///   role; used by [`crate::application::use_cases::RoleDelegationUseCases`]
18///   to enforce the **non-transitive** invariant (a user cannot re-delegate a
19///   role that was itself delegated to them).
20#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
21pub struct UserRoleAssignment {
22    pub id: Uuid,
23    pub user_id: Uuid,
24    pub role: UserRole,
25    pub organization_id: Option<Uuid>,
26    pub is_primary: bool,
27    /// Story 3.5: end of validity. `None` = permanent native role.
28    #[serde(default)]
29    pub valid_until: Option<DateTime<Utc>>,
30    /// Story 3.5: source of the delegation. `None` = native role.
31    /// `Some(uid)` = `uid` granted this role for a bounded duration.
32    #[serde(default)]
33    pub delegated_from_user_id: Option<Uuid>,
34    pub created_at: DateTime<Utc>,
35    pub updated_at: DateTime<Utc>,
36}
37
38impl UserRoleAssignment {
39    /// Creates a new permanent (native) role assignment.
40    ///
41    /// `valid_until` and `delegated_from_user_id` default to `None`. Use
42    /// [`UserRoleAssignment::new_delegated`] for a time-bounded delegation.
43    pub fn new(
44        user_id: Uuid,
45        role: UserRole,
46        organization_id: Option<Uuid>,
47        is_primary: bool,
48    ) -> Self {
49        let now = Utc::now();
50        Self {
51            id: Uuid::new_v4(),
52            user_id,
53            role,
54            organization_id,
55            is_primary,
56            valid_until: None,
57            delegated_from_user_id: None,
58            created_at: now,
59            updated_at: now,
60        }
61    }
62
63    /// Creates a new delegated role assignment (Story 3.5).
64    ///
65    /// A delegated assignment is never the primary role of the target user —
66    /// keeping the user's native primary intact for `users.role` consistency.
67    pub fn new_delegated(
68        user_id: Uuid,
69        role: UserRole,
70        organization_id: Option<Uuid>,
71        valid_until: DateTime<Utc>,
72        delegated_from_user_id: Uuid,
73    ) -> Self {
74        let now = Utc::now();
75        Self {
76            id: Uuid::new_v4(),
77            user_id,
78            role,
79            organization_id,
80            is_primary: false,
81            valid_until: Some(valid_until),
82            delegated_from_user_id: Some(delegated_from_user_id),
83            created_at: now,
84            updated_at: now,
85        }
86    }
87
88    pub fn set_primary(&mut self, primary: bool) {
89        self.is_primary = primary;
90        self.updated_at = Utc::now();
91    }
92
93    // === Story 3.5 helpers =================================================
94
95    /// True if this assignment is a delegated (non-native) role.
96    pub fn is_delegated(&self) -> bool {
97        self.delegated_from_user_id.is_some()
98    }
99
100    /// True if the assignment carries a `valid_until` and it is in the past.
101    ///
102    /// A permanent native role (`valid_until = None`) is never expired.
103    pub fn is_expired(&self) -> bool {
104        self.is_expired_at(Utc::now())
105    }
106
107    /// Testable variant of `is_expired` taking an explicit reference time.
108    pub fn is_expired_at(&self, t: DateTime<Utc>) -> bool {
109        match self.valid_until {
110            None => false,
111            Some(until) => t >= until,
112        }
113    }
114
115    /// True if the assignment authorises actions right now.
116    ///
117    /// Native (permanent) assignments are always active. Delegated
118    /// assignments are active iff their window has not elapsed.
119    pub fn is_currently_active(&self) -> bool {
120        !self.is_expired()
121    }
122}
123
124#[cfg(test)]
125mod tests {
126    use super::*;
127    use chrono::Duration;
128
129    // ------------------------------------------------------------------
130    // Legacy tests (kept for retrocompat)
131    // ------------------------------------------------------------------
132
133    #[test]
134    fn test_new_assignment_defaults() {
135        let user_id = Uuid::new_v4();
136        let assignment = UserRoleAssignment::new(user_id, UserRole::Syndic, None, true);
137
138        assert_eq!(assignment.user_id, user_id);
139        assert_eq!(assignment.role, UserRole::Syndic);
140        assert!(assignment.is_primary);
141        assert!(assignment.organization_id.is_none());
142        // Story 3.5: native role => not delegated, not expirable.
143        assert!(!assignment.is_delegated());
144        assert!(assignment.valid_until.is_none());
145        assert!(assignment.is_currently_active());
146    }
147
148    #[test]
149    fn test_set_primary_updates_timestamp() {
150        let mut assignment =
151            UserRoleAssignment::new(Uuid::new_v4(), UserRole::Accountant, None, false);
152        let original_updated_at = assignment.updated_at;
153
154        assignment.set_primary(true);
155
156        assert!(assignment.is_primary);
157        assert!(
158            assignment.updated_at > original_updated_at,
159            "Updated_at should change when toggling primary flag"
160        );
161    }
162
163    // ------------------------------------------------------------------
164    // Story 3.5 — delegation helpers — 4 categories
165    // ------------------------------------------------------------------
166
167    // --- @happy --------------------------------------------------------
168
169    #[test]
170    fn happy_new_delegated_assignment_is_currently_active() {
171        let user_id = Uuid::new_v4();
172        let delegator = Uuid::new_v4();
173        let valid_until = Utc::now() + Duration::days(7);
174        let a = UserRoleAssignment::new_delegated(
175            user_id,
176            UserRole::Syndic,
177            None,
178            valid_until,
179            delegator,
180        );
181
182        assert!(a.is_currently_active());
183        assert!(!a.is_expired());
184        assert!(a.is_delegated());
185        assert_eq!(a.delegated_from_user_id, Some(delegator));
186        assert_eq!(a.valid_until, Some(valid_until));
187        assert!(
188            !a.is_primary,
189            "delegated assignment must never be the user's primary role"
190        );
191    }
192
193    #[test]
194    fn happy_native_assignment_is_not_delegated() {
195        let a = UserRoleAssignment::new(Uuid::new_v4(), UserRole::Owner, None, true);
196        assert!(!a.is_delegated());
197        assert!(a.is_currently_active());
198        assert!(!a.is_expired());
199    }
200
201    // --- @edge ---------------------------------------------------------
202
203    #[test]
204    fn edge_just_before_valid_until_is_active() {
205        let now = Utc::now();
206        let valid_until = now + Duration::milliseconds(500);
207        let a = UserRoleAssignment::new_delegated(
208            Uuid::new_v4(),
209            UserRole::Syndic,
210            None,
211            valid_until,
212            Uuid::new_v4(),
213        );
214        // 100ms before the boundary: still active.
215        assert!(!a.is_expired_at(now + Duration::milliseconds(100)));
216        assert!(a.is_currently_active());
217    }
218
219    #[test]
220    fn edge_at_or_after_valid_until_is_expired() {
221        let valid_until = Utc::now() + Duration::milliseconds(10);
222        let a = UserRoleAssignment::new_delegated(
223            Uuid::new_v4(),
224            UserRole::Syndic,
225            None,
226            valid_until,
227            Uuid::new_v4(),
228        );
229        // Exactly at valid_until is considered expired (half-open window).
230        assert!(a.is_expired_at(valid_until));
231        // 1ms after: expired.
232        assert!(a.is_expired_at(valid_until + Duration::milliseconds(1)));
233    }
234
235    // --- @security -----------------------------------------------------
236
237    #[test]
238    fn security_delegated_flag_is_preserved_through_serde_roundtrip() {
239        let delegator = Uuid::new_v4();
240        let a = UserRoleAssignment::new_delegated(
241            Uuid::new_v4(),
242            UserRole::Syndic,
243            None,
244            Utc::now() + Duration::days(3),
245            delegator,
246        );
247        let json = serde_json::to_string(&a).expect("serialize ok");
248        let back: UserRoleAssignment = serde_json::from_str(&json).expect("deserialize ok");
249        // Critical for the @security non-transitive invariant: the delegation
250        // trail MUST survive persistence + transport unchanged.
251        assert_eq!(back.delegated_from_user_id, Some(delegator));
252        assert_eq!(back.valid_until, a.valid_until);
253        assert!(back.is_delegated());
254    }
255
256    #[test]
257    fn security_native_role_serde_keeps_none_delegation() {
258        let a = UserRoleAssignment::new(Uuid::new_v4(), UserRole::Owner, None, true);
259        let json = serde_json::to_string(&a).expect("serialize ok");
260        let back: UserRoleAssignment = serde_json::from_str(&json).expect("deserialize ok");
261        // A native role must never be silently flagged as delegated by serde
262        // defaults — protects the @security invariant in the upstream check.
263        assert!(back.delegated_from_user_id.is_none());
264        assert!(back.valid_until.is_none());
265        assert!(!back.is_delegated());
266    }
267
268    // --- @negative -----------------------------------------------------
269
270    #[test]
271    fn negative_expired_delegation_is_not_currently_active() {
272        // Construct an already-expired delegation by bypassing the helper —
273        // simulating a row read from DB whose `valid_until` is in the past.
274        let now = Utc::now();
275        let a = UserRoleAssignment {
276            id: Uuid::new_v4(),
277            user_id: Uuid::new_v4(),
278            role: UserRole::Syndic,
279            organization_id: None,
280            is_primary: false,
281            valid_until: Some(now - Duration::seconds(1)),
282            delegated_from_user_id: Some(Uuid::new_v4()),
283            created_at: now - Duration::days(1),
284            updated_at: now - Duration::days(1),
285        };
286        assert!(a.is_expired());
287        assert!(!a.is_currently_active());
288    }
289}