Skip to main content

static_tr_plugin/domain/
client.rs

1//! Client implementation for the static tenant resolver plugin.
2//!
3//! Implements `TenantResolverPluginClient` using the domain service.
4
5use async_trait::async_trait;
6use modkit_security::SecurityContext;
7use tenant_resolver_sdk::{
8    AccessOptions, TenantFilter, TenantId, TenantInfo, TenantResolverError,
9    TenantResolverPluginClient,
10};
11
12use super::service::Service;
13
14#[async_trait]
15impl TenantResolverPluginClient for Service {
16    async fn get_tenant(
17        &self,
18        _ctx: &SecurityContext,
19        id: TenantId,
20    ) -> Result<TenantInfo, TenantResolverError> {
21        self.tenants
22            .get(&id)
23            .cloned()
24            .ok_or(TenantResolverError::TenantNotFound { tenant_id: id })
25    }
26
27    async fn can_access(
28        &self,
29        ctx: &SecurityContext,
30        target: TenantId,
31        _options: Option<&AccessOptions>,
32    ) -> Result<bool, TenantResolverError> {
33        let source = ctx.tenant_id();
34
35        // First, check if target tenant exists
36        if !self.tenants.contains_key(&target) {
37            return Err(TenantResolverError::TenantNotFound { tenant_id: target });
38        }
39
40        // Self-access is always allowed
41        if source == target {
42            return Ok(true);
43        }
44
45        // Check if access rule exists
46        Ok(self.access_rules.contains(&(source, target)))
47    }
48
49    async fn get_accessible_tenants(
50        &self,
51        ctx: &SecurityContext,
52        filter: Option<&TenantFilter>,
53        _options: Option<&AccessOptions>,
54    ) -> Result<Vec<TenantInfo>, TenantResolverError> {
55        let source = ctx.tenant_id();
56
57        let mut items: Vec<TenantInfo> = Vec::new();
58
59        // Add self-tenant first if it exists and matches filter
60        if let Some(self_info) = self.tenants.get(&source)
61            && Self::matches_filter(self_info, filter)
62        {
63            items.push(self_info.clone());
64        }
65
66        // Get all targets accessible by this source
67        let accessible_ids = self.accessible_by.get(&source);
68
69        // Add accessible tenants (if any) that match the filter
70        if let Some(ids) = accessible_ids {
71            for id in ids {
72                // Skip self (already added)
73                if *id == source {
74                    continue;
75                }
76                if let Some(info) = self.tenants.get(id)
77                    && Self::matches_filter(info, filter)
78                {
79                    items.push(info.clone());
80                }
81            }
82        }
83
84        Ok(items)
85    }
86}
87
88#[cfg(test)]
89#[cfg_attr(coverage_nightly, coverage(off))]
90mod tests {
91    use super::*;
92    use crate::config::{AccessRuleConfig, StaticTrPluginConfig, TenantConfig};
93    use tenant_resolver_sdk::TenantStatus;
94    use uuid::Uuid;
95
96    // Helper to create a test tenant config
97    fn tenant(id: &str, name: &str, status: TenantStatus) -> TenantConfig {
98        TenantConfig {
99            id: Uuid::parse_str(id).unwrap(),
100            name: name.to_owned(),
101            status,
102            tenant_type: None,
103        }
104    }
105
106    // Helper to create an access rule config
107    fn access_rule(source: &str, target: &str) -> AccessRuleConfig {
108        AccessRuleConfig {
109            source: Uuid::parse_str(source).unwrap(),
110            target: Uuid::parse_str(target).unwrap(),
111        }
112    }
113
114    // Helper to create a security context for a tenant
115    fn ctx_for_tenant(tenant_id: &str) -> SecurityContext {
116        SecurityContext::builder()
117            .tenant_id(Uuid::parse_str(tenant_id).unwrap())
118            .build()
119    }
120
121    // Filter for active tenants only
122    fn active_filter() -> TenantFilter {
123        TenantFilter {
124            status: vec![TenantStatus::Active],
125            ..Default::default()
126        }
127    }
128
129    // Test UUIDs
130    const TENANT_A: &str = "11111111-1111-1111-1111-111111111111";
131    const TENANT_B: &str = "22222222-2222-2222-2222-222222222222";
132    const TENANT_C: &str = "33333333-3333-3333-3333-333333333333";
133    const NONEXISTENT: &str = "99999999-9999-9999-9999-999999999999";
134
135    // ==================== get_tenant tests ====================
136
137    #[tokio::test]
138    async fn get_tenant_existing() {
139        let cfg = StaticTrPluginConfig {
140            tenants: vec![tenant(TENANT_A, "Tenant A", TenantStatus::Active)],
141            ..Default::default()
142        };
143        let service = Service::from_config(&cfg);
144        let ctx = ctx_for_tenant(TENANT_A);
145
146        let result = service
147            .get_tenant(&ctx, Uuid::parse_str(TENANT_A).unwrap())
148            .await;
149
150        assert!(result.is_ok());
151        let info = result.unwrap();
152        assert_eq!(info.name, "Tenant A");
153        assert_eq!(info.status, TenantStatus::Active);
154    }
155
156    #[tokio::test]
157    async fn get_tenant_nonexistent() {
158        let cfg = StaticTrPluginConfig {
159            tenants: vec![tenant(TENANT_A, "Tenant A", TenantStatus::Active)],
160            ..Default::default()
161        };
162        let service = Service::from_config(&cfg);
163        let ctx = ctx_for_tenant(TENANT_A);
164        let nonexistent_id = Uuid::parse_str(NONEXISTENT).unwrap();
165
166        let result = service.get_tenant(&ctx, nonexistent_id).await;
167
168        assert!(result.is_err());
169        match result.unwrap_err() {
170            TenantResolverError::TenantNotFound { tenant_id } => {
171                assert_eq!(tenant_id, nonexistent_id);
172            }
173            other => panic!("Expected TenantNotFound, got: {other:?}"),
174        }
175    }
176
177    #[tokio::test]
178    async fn get_tenant_empty_service() {
179        let cfg = StaticTrPluginConfig::default();
180        let service = Service::from_config(&cfg);
181        let ctx = ctx_for_tenant(TENANT_A);
182        let tenant_a_id = Uuid::parse_str(TENANT_A).unwrap();
183
184        let result = service.get_tenant(&ctx, tenant_a_id).await;
185
186        assert!(result.is_err());
187        match result.unwrap_err() {
188            TenantResolverError::TenantNotFound { tenant_id } => {
189                assert_eq!(tenant_id, tenant_a_id);
190            }
191            other => panic!("Expected TenantNotFound, got: {other:?}"),
192        }
193    }
194
195    #[tokio::test]
196    async fn get_tenant_returns_any_status() {
197        // get_tenant now returns tenant regardless of status
198        let cfg = StaticTrPluginConfig {
199            tenants: vec![tenant(TENANT_A, "Tenant A", TenantStatus::Suspended)],
200            ..Default::default()
201        };
202        let service = Service::from_config(&cfg);
203        let ctx = ctx_for_tenant(TENANT_A);
204        let tenant_a_id = Uuid::parse_str(TENANT_A).unwrap();
205
206        // Returns tenant even if suspended
207        let result = service.get_tenant(&ctx, tenant_a_id).await;
208        assert!(result.is_ok());
209        assert_eq!(result.unwrap().status, TenantStatus::Suspended);
210    }
211
212    // ==================== can_access tests ====================
213
214    #[tokio::test]
215    async fn can_access_allowed() {
216        let cfg = StaticTrPluginConfig {
217            tenants: vec![
218                tenant(TENANT_A, "A", TenantStatus::Active),
219                tenant(TENANT_B, "B", TenantStatus::Active),
220            ],
221            access_rules: vec![access_rule(TENANT_A, TENANT_B)],
222            ..Default::default()
223        };
224        let service = Service::from_config(&cfg);
225        let ctx = ctx_for_tenant(TENANT_A);
226
227        let result = service
228            .can_access(&ctx, Uuid::parse_str(TENANT_B).unwrap(), None)
229            .await;
230
231        assert!(result.is_ok());
232        assert!(result.unwrap());
233    }
234
235    #[tokio::test]
236    async fn can_access_denied_no_rule() {
237        let cfg = StaticTrPluginConfig {
238            tenants: vec![
239                tenant(TENANT_A, "A", TenantStatus::Active),
240                tenant(TENANT_B, "B", TenantStatus::Active),
241            ],
242            access_rules: vec![], // No rules
243            ..Default::default()
244        };
245        let service = Service::from_config(&cfg);
246        let ctx = ctx_for_tenant(TENANT_A);
247
248        let result = service
249            .can_access(&ctx, Uuid::parse_str(TENANT_B).unwrap(), None)
250            .await;
251
252        assert!(result.is_ok());
253        assert!(!result.unwrap());
254    }
255
256    #[tokio::test]
257    async fn can_access_error_for_nonexistent_target() {
258        let cfg = StaticTrPluginConfig {
259            tenants: vec![tenant(TENANT_A, "A", TenantStatus::Active)],
260            access_rules: vec![],
261            ..Default::default()
262        };
263        let service = Service::from_config(&cfg);
264        let ctx = ctx_for_tenant(TENANT_A);
265        let nonexistent_id = Uuid::parse_str(NONEXISTENT).unwrap();
266
267        let result = service.can_access(&ctx, nonexistent_id, None).await;
268
269        assert!(result.is_err());
270        match result.unwrap_err() {
271            TenantResolverError::TenantNotFound { tenant_id } => {
272                assert_eq!(tenant_id, nonexistent_id);
273            }
274            other => panic!("Expected TenantNotFound, got: {other:?}"),
275        }
276    }
277
278    #[tokio::test]
279    async fn can_access_not_symmetric() {
280        // A can access B does NOT mean B can access A
281        let cfg = StaticTrPluginConfig {
282            tenants: vec![
283                tenant(TENANT_A, "A", TenantStatus::Active),
284                tenant(TENANT_B, "B", TenantStatus::Active),
285            ],
286            access_rules: vec![access_rule(TENANT_A, TENANT_B)], // Only A -> B
287            ..Default::default()
288        };
289        let service = Service::from_config(&cfg);
290
291        // A can access B
292        let ctx_a = ctx_for_tenant(TENANT_A);
293        let result = service
294            .can_access(&ctx_a, Uuid::parse_str(TENANT_B).unwrap(), None)
295            .await;
296        assert!(result.unwrap());
297
298        // B cannot access A (no reverse rule)
299        let ctx_b = ctx_for_tenant(TENANT_B);
300        let result = service
301            .can_access(&ctx_b, Uuid::parse_str(TENANT_A).unwrap(), None)
302            .await;
303        assert!(!result.unwrap());
304    }
305
306    #[tokio::test]
307    async fn can_access_not_transitive() {
308        // A -> B and B -> C does NOT mean A -> C
309        let cfg = StaticTrPluginConfig {
310            tenants: vec![
311                tenant(TENANT_A, "A", TenantStatus::Active),
312                tenant(TENANT_B, "B", TenantStatus::Active),
313                tenant(TENANT_C, "C", TenantStatus::Active),
314            ],
315            access_rules: vec![
316                access_rule(TENANT_A, TENANT_B), // A -> B
317                access_rule(TENANT_B, TENANT_C), // B -> C
318            ],
319            ..Default::default()
320        };
321        let service = Service::from_config(&cfg);
322        let ctx = ctx_for_tenant(TENANT_A);
323
324        // A can access B
325        let result = service
326            .can_access(&ctx, Uuid::parse_str(TENANT_B).unwrap(), None)
327            .await;
328        assert!(result.unwrap());
329
330        // A cannot access C (no direct rule)
331        let result = service
332            .can_access(&ctx, Uuid::parse_str(TENANT_C).unwrap(), None)
333            .await;
334        assert!(!result.unwrap());
335    }
336
337    #[tokio::test]
338    async fn can_access_self_allowed() {
339        // Plugin handles self-access: always allowed
340        let cfg = StaticTrPluginConfig {
341            tenants: vec![tenant(TENANT_A, "A", TenantStatus::Active)],
342            access_rules: vec![], // No explicit self-access rule needed
343            ..Default::default()
344        };
345        let service = Service::from_config(&cfg);
346        let ctx = ctx_for_tenant(TENANT_A);
347
348        // Plugin returns true for self-access
349        let result = service
350            .can_access(&ctx, Uuid::parse_str(TENANT_A).unwrap(), None)
351            .await;
352        assert!(result.unwrap());
353    }
354
355    #[tokio::test]
356    async fn can_access_allows_any_status() {
357        // can_access no longer filters by status - that's plugin policy
358        let cfg = StaticTrPluginConfig {
359            tenants: vec![
360                tenant(TENANT_A, "A", TenantStatus::Active),
361                tenant(TENANT_B, "B", TenantStatus::Suspended),
362            ],
363            access_rules: vec![access_rule(TENANT_A, TENANT_B)],
364            ..Default::default()
365        };
366        let service = Service::from_config(&cfg);
367        let ctx = ctx_for_tenant(TENANT_A);
368        let tenant_b_id = Uuid::parse_str(TENANT_B).unwrap();
369
370        // Returns true even if target is suspended (access rule exists)
371        let result = service.can_access(&ctx, tenant_b_id, None).await;
372        assert!(result.unwrap());
373    }
374
375    // ==================== get_accessible_tenants tests ====================
376
377    #[tokio::test]
378    async fn get_accessible_tenants_with_rules() {
379        let cfg = StaticTrPluginConfig {
380            tenants: vec![
381                tenant(TENANT_A, "A", TenantStatus::Active),
382                tenant(TENANT_B, "B", TenantStatus::Active),
383                tenant(TENANT_C, "C", TenantStatus::Active),
384            ],
385            access_rules: vec![
386                access_rule(TENANT_A, TENANT_B),
387                access_rule(TENANT_A, TENANT_C),
388            ],
389            ..Default::default()
390        };
391        let service = Service::from_config(&cfg);
392        let ctx = ctx_for_tenant(TENANT_A);
393
394        let result = service.get_accessible_tenants(&ctx, None, None).await;
395
396        assert!(result.is_ok());
397        let items = result.unwrap();
398        // Self-tenant A + accessible B and C
399        assert_eq!(items.len(), 3);
400
401        // Self-tenant should be first
402        assert_eq!(items[0].id, Uuid::parse_str(TENANT_A).unwrap());
403
404        let ids: Vec<_> = items.iter().map(|t| t.id).collect();
405        assert!(ids.contains(&Uuid::parse_str(TENANT_B).unwrap()));
406        assert!(ids.contains(&Uuid::parse_str(TENANT_C).unwrap()));
407    }
408
409    #[tokio::test]
410    async fn get_accessible_tenants_no_rules() {
411        let cfg = StaticTrPluginConfig {
412            tenants: vec![
413                tenant(TENANT_A, "A", TenantStatus::Active),
414                tenant(TENANT_B, "B", TenantStatus::Active),
415            ],
416            access_rules: vec![],
417            ..Default::default()
418        };
419        let service = Service::from_config(&cfg);
420        let ctx = ctx_for_tenant(TENANT_A);
421
422        let result = service.get_accessible_tenants(&ctx, None, None).await;
423
424        assert!(result.is_ok());
425        let items = result.unwrap();
426        // Only self-tenant (no cross-tenant rules)
427        assert_eq!(items.len(), 1);
428        assert_eq!(items[0].id, Uuid::parse_str(TENANT_A).unwrap());
429    }
430
431    #[tokio::test]
432    async fn get_accessible_tenants_missing_tenant_info() {
433        // Access rule references a tenant that doesn't exist in tenants list
434        let cfg = StaticTrPluginConfig {
435            tenants: vec![tenant(TENANT_A, "A", TenantStatus::Active)],
436            access_rules: vec![access_rule(TENANT_A, TENANT_B)], // B not in tenants
437            ..Default::default()
438        };
439        let service = Service::from_config(&cfg);
440        let ctx = ctx_for_tenant(TENANT_A);
441
442        let result = service.get_accessible_tenants(&ctx, None, None).await;
443
444        assert!(result.is_ok());
445        let items = result.unwrap();
446        // B is in access_rules but not in tenants, so it's skipped
447        // Only self-tenant A is returned
448        assert_eq!(items.len(), 1);
449        assert_eq!(items[0].id, Uuid::parse_str(TENANT_A).unwrap());
450    }
451
452    #[tokio::test]
453    async fn get_accessible_tenants_filtered_by_status() {
454        let cfg = StaticTrPluginConfig {
455            tenants: vec![
456                tenant(TENANT_A, "A", TenantStatus::Active),
457                tenant(TENANT_B, "B", TenantStatus::Active),
458                tenant(TENANT_C, "C", TenantStatus::Suspended),
459            ],
460            access_rules: vec![
461                access_rule(TENANT_A, TENANT_B),
462                access_rule(TENANT_A, TENANT_C),
463            ],
464            ..Default::default()
465        };
466        let service = Service::from_config(&cfg);
467        let ctx = ctx_for_tenant(TENANT_A);
468
469        // Without filter - returns self (A) plus B and C
470        let result = service.get_accessible_tenants(&ctx, None, None).await;
471        assert_eq!(result.unwrap().len(), 3);
472
473        // With active filter - returns self (A) and B (C is suspended)
474        let filter = active_filter();
475        let result = service
476            .get_accessible_tenants(&ctx, Some(&filter), None)
477            .await;
478        let items = result.unwrap();
479        assert_eq!(items.len(), 2);
480        // Self-tenant should be first
481        assert_eq!(items[0].id, Uuid::parse_str(TENANT_A).unwrap());
482        assert_eq!(items[1].id, Uuid::parse_str(TENANT_B).unwrap());
483    }
484
485    #[tokio::test]
486    async fn get_accessible_tenants_filtered_by_id() {
487        let cfg = StaticTrPluginConfig {
488            tenants: vec![
489                tenant(TENANT_A, "A", TenantStatus::Active),
490                tenant(TENANT_B, "B", TenantStatus::Active),
491                tenant(TENANT_C, "C", TenantStatus::Active),
492            ],
493            access_rules: vec![
494                access_rule(TENANT_A, TENANT_B),
495                access_rule(TENANT_A, TENANT_C),
496            ],
497            ..Default::default()
498        };
499        let service = Service::from_config(&cfg);
500        let ctx = ctx_for_tenant(TENANT_A);
501
502        // Filter by specific ID
503        let filter = TenantFilter {
504            id: vec![Uuid::parse_str(TENANT_B).unwrap()],
505            ..Default::default()
506        };
507        let result = service
508            .get_accessible_tenants(&ctx, Some(&filter), None)
509            .await;
510        let items = result.unwrap();
511        assert_eq!(items.len(), 1);
512        assert_eq!(items[0].id, Uuid::parse_str(TENANT_B).unwrap());
513    }
514}