Skip to main content

static_credstore_plugin/domain/
service.rs

1use std::collections::HashMap;
2
3use credstore_sdk::{OwnerId, SecretRef, SecretValue, SharingMode, TenantId};
4use modkit_macros::domain_model;
5use modkit_security::SecurityContext;
6use uuid::Uuid;
7
8use crate::config::StaticCredStorePluginConfig;
9
10/// Pre-built secret entry for O(1) lookup.
11#[domain_model]
12pub struct SecretEntry {
13    pub value: SecretValue,
14    pub sharing: SharingMode,
15    pub owner_id: OwnerId,
16    pub owner_tenant_id: TenantId,
17}
18
19/// Static credstore service.
20///
21/// Secrets are stored in four maps based on their resolved `SharingMode`
22/// and whether a `tenant_id` is present:
23///
24/// - **`Private`**: keyed by `(TenantId, OwnerId, SecretRef)` — accessible only
25///   when both tenant and subject match.
26/// - **`Tenant`**: keyed by `(TenantId, SecretRef)` — accessible by any subject
27///   within the matching tenant.
28/// - **`Shared`**: keyed by `(TenantId, SecretRef)` — tenant-scoped but
29///   accessible by descendant tenants via hierarchical resolution in the
30///   gateway. The plugin stores them per-tenant; walk-up is the gateway's job.
31/// - **Global**: keyed by `SecretRef` only — no `tenant_id`; returned as
32///   fallback for any caller. Not a `SharingMode` variant; it is an
33///   operational shortcut specific to the static plugin.
34///
35/// Lookup order: **Private → Tenant → Shared → Global** (most specific first).
36#[domain_model]
37#[allow(clippy::struct_field_names)]
38pub struct Service {
39    private_secrets: HashMap<(TenantId, OwnerId, SecretRef), SecretEntry>,
40    tenant_secrets: HashMap<(TenantId, SecretRef), SecretEntry>,
41    shared_secrets: HashMap<(TenantId, SecretRef), SecretEntry>,
42    global_secrets: HashMap<SecretRef, SecretEntry>,
43}
44
45impl Service {
46    /// Create a service from plugin configuration.
47    ///
48    /// Validates each secret key via `SecretRef::new` and builds the lookup maps.
49    ///
50    /// # Errors
51    ///
52    /// Returns an error if:
53    /// - any configured key fails `SecretRef` validation
54    /// - duplicate keys within the same sharing scope
55    /// - a global secret has an explicit sharing mode other than `Shared`
56    /// - a secret without `owner_id` has an explicit `SharingMode::Private`
57    /// - `tenant_id` or `owner_id` is an explicit nil UUID
58    /// - `owner_id` is set without `tenant_id`
59    pub fn from_config(cfg: &StaticCredStorePluginConfig) -> anyhow::Result<Self> {
60        let mut private_secrets: HashMap<(TenantId, OwnerId, SecretRef), SecretEntry> =
61            HashMap::new();
62        let mut tenant_secrets: HashMap<(TenantId, SecretRef), SecretEntry> = HashMap::new();
63        let mut shared_secrets: HashMap<(TenantId, SecretRef), SecretEntry> = HashMap::new();
64        let mut global_secrets: HashMap<SecretRef, SecretEntry> = HashMap::new();
65
66        for entry in &cfg.secrets {
67            if entry.tenant_id == Some(Uuid::nil()) {
68                anyhow::bail!("secret '{}': tenant_id must not be nil UUID", entry.key);
69            }
70            if entry.owner_id == Some(Uuid::nil()) {
71                anyhow::bail!("secret '{}': owner_id must not be nil UUID", entry.key);
72            }
73
74            if entry.tenant_id.is_none() && entry.owner_id.is_some() {
75                anyhow::bail!(
76                    "secret '{}': owner_id cannot be set without tenant_id",
77                    entry.key
78                );
79            }
80
81            let sharing = entry.resolve_sharing();
82
83            if entry.owner_id.is_some() && sharing != SharingMode::Private {
84                anyhow::bail!(
85                    "secret '{}': owner_id is only valid for private sharing mode, \
86                     but resolved sharing is {sharing:?}",
87                    entry.key
88                );
89            }
90
91            if entry.owner_id.is_none() && sharing == SharingMode::Private {
92                anyhow::bail!(
93                    "secret '{}' with sharing mode 'private' requires an explicit owner_id",
94                    entry.key
95                );
96            }
97
98            let key = SecretRef::new(&entry.key)?;
99
100            match (sharing, entry.tenant_id) {
101                (SharingMode::Shared, None) => {
102                    // Global secret: no tenant_id, accessible by any caller.
103                    let secret_entry = SecretEntry {
104                        value: SecretValue::from(entry.value.as_str()),
105                        sharing,
106                        owner_id: OwnerId::nil(),
107                        owner_tenant_id: TenantId::nil(),
108                    };
109                    if global_secrets.contains_key(&key) {
110                        anyhow::bail!("duplicate global secret key '{}'", entry.key);
111                    }
112                    global_secrets.insert(key, secret_entry);
113                }
114                (SharingMode::Shared, Some(raw_tenant_id)) => {
115                    // Shared secret: tenant-scoped, visible to descendants
116                    // via gateway hierarchical resolution.
117                    let tenant_id = TenantId(raw_tenant_id);
118                    let secret_entry = SecretEntry {
119                        value: SecretValue::from(entry.value.as_str()),
120                        sharing,
121                        owner_id: OwnerId::nil(),
122                        owner_tenant_id: tenant_id,
123                    };
124                    let map_key = (tenant_id, key);
125                    if shared_secrets.contains_key(&map_key) {
126                        anyhow::bail!(
127                            "duplicate shared secret key '{}' for tenant {}",
128                            entry.key,
129                            tenant_id
130                        );
131                    }
132                    shared_secrets.insert(map_key, secret_entry);
133                }
134                (SharingMode::Tenant, _) => {
135                    let tenant_id = TenantId(entry.tenant_id.ok_or_else(|| {
136                        anyhow::anyhow!(
137                            "secret '{}': tenant sharing mode requires tenant_id",
138                            entry.key
139                        )
140                    })?);
141                    let secret_entry = SecretEntry {
142                        value: SecretValue::from(entry.value.as_str()),
143                        sharing,
144                        owner_id: OwnerId::nil(),
145                        owner_tenant_id: tenant_id,
146                    };
147                    let map_key = (tenant_id, key);
148                    if tenant_secrets.contains_key(&map_key) {
149                        anyhow::bail!(
150                            "duplicate tenant secret key '{}' for tenant {}",
151                            entry.key,
152                            tenant_id
153                        );
154                    }
155                    tenant_secrets.insert(map_key, secret_entry);
156                }
157                (SharingMode::Private, _) => {
158                    let tenant_id = TenantId(entry.tenant_id.ok_or_else(|| {
159                        anyhow::anyhow!(
160                            "secret '{}': private sharing mode requires tenant_id",
161                            entry.key
162                        )
163                    })?);
164                    // owner_id is guaranteed Some by the validation above.
165                    let owner_id = OwnerId(entry.owner_id.ok_or_else(|| {
166                        anyhow::anyhow!(
167                            "secret '{}': private sharing mode requires owner_id",
168                            entry.key
169                        )
170                    })?);
171                    let secret_entry = SecretEntry {
172                        value: SecretValue::from(entry.value.as_str()),
173                        sharing,
174                        owner_id,
175                        owner_tenant_id: tenant_id,
176                    };
177                    let map_key = (tenant_id, owner_id, key);
178                    if private_secrets.contains_key(&map_key) {
179                        anyhow::bail!(
180                            "duplicate private secret key '{}' for tenant {} owner {}",
181                            entry.key,
182                            tenant_id,
183                            owner_id
184                        );
185                    }
186                    private_secrets.insert(map_key, secret_entry);
187                }
188            }
189        }
190
191        Ok(Self {
192            private_secrets,
193            tenant_secrets,
194            shared_secrets,
195            global_secrets,
196        })
197    }
198
199    /// Look up a secret using the caller's security context.
200    ///
201    /// Lookup order: **Private → Tenant → Shared → Global** (most specific first).
202    #[must_use]
203    pub fn get(&self, ctx: &SecurityContext, key: &SecretRef) -> Option<&SecretEntry> {
204        let tenant_id = TenantId(ctx.subject_tenant_id());
205        let subject_id = OwnerId(ctx.subject_id());
206
207        self.private_secrets
208            .get(&(tenant_id, subject_id, key.clone()))
209            .or_else(|| self.tenant_secrets.get(&(tenant_id, key.clone())))
210            .or_else(|| self.shared_secrets.get(&(tenant_id, key.clone())))
211            .or_else(|| self.global_secrets.get(key))
212    }
213}
214
215#[cfg(test)]
216#[cfg_attr(coverage_nightly, coverage(off))]
217mod tests {
218    use super::*;
219    use crate::config::SecretConfig;
220    use uuid::Uuid;
221
222    fn tenant_a() -> Uuid {
223        Uuid::parse_str("11111111-1111-1111-1111-111111111111").unwrap()
224    }
225
226    fn tenant_b() -> Uuid {
227        Uuid::parse_str("22222222-2222-2222-2222-222222222222").unwrap()
228    }
229
230    fn owner_a() -> Uuid {
231        Uuid::parse_str("33333333-3333-3333-3333-333333333333").unwrap()
232    }
233
234    fn owner_b() -> Uuid {
235        Uuid::parse_str("44444444-4444-4444-4444-444444444444").unwrap()
236    }
237
238    fn ctx(tenant_id: Uuid, subject_id: Uuid) -> SecurityContext {
239        SecurityContext::builder()
240            .subject_id(subject_id)
241            .subject_tenant_id(tenant_id)
242            .build()
243            .unwrap()
244    }
245
246    /// Default config: `tenant_a` + `owner_a` → Private secret.
247    fn cfg_with_single_secret() -> StaticCredStorePluginConfig {
248        StaticCredStorePluginConfig {
249            secrets: vec![SecretConfig {
250                tenant_id: Some(tenant_a()),
251                owner_id: Some(owner_a()),
252                key: "openai_api_key".to_owned(),
253                value: "sk-test-123".to_owned(),
254                sharing: None,
255            }],
256            ..StaticCredStorePluginConfig::default()
257        }
258    }
259
260    #[test]
261    fn from_config_rejects_invalid_secret_ref() {
262        let cfg = StaticCredStorePluginConfig {
263            secrets: vec![SecretConfig {
264                tenant_id: Some(tenant_a()),
265                owner_id: Some(owner_a()),
266                key: "invalid:key".to_owned(),
267                value: "value".to_owned(),
268                sharing: None,
269            }],
270            ..StaticCredStorePluginConfig::default()
271        };
272
273        let result = Service::from_config(&cfg);
274        assert!(result.is_err());
275    }
276
277    // --- Private secret lookup ---
278
279    #[test]
280    fn private_secret_returned_for_matching_tenant_and_owner() {
281        let service = Service::from_config(&cfg_with_single_secret()).unwrap();
282        let key = SecretRef::new("openai_api_key").unwrap();
283
284        let entry = service.get(&ctx(tenant_a(), owner_a()), &key).unwrap();
285        assert_eq!(entry.value.as_bytes(), b"sk-test-123");
286        assert_eq!(entry.owner_id, OwnerId(owner_a()));
287        assert_eq!(entry.owner_tenant_id, TenantId(tenant_a()));
288        assert_eq!(entry.sharing, SharingMode::Private);
289    }
290
291    #[test]
292    fn private_secret_not_returned_for_different_owner() {
293        let service = Service::from_config(&cfg_with_single_secret()).unwrap();
294        let key = SecretRef::new("openai_api_key").unwrap();
295
296        assert!(service.get(&ctx(tenant_a(), owner_b()), &key).is_none());
297    }
298
299    #[test]
300    fn private_secret_not_returned_for_different_tenant() {
301        let service = Service::from_config(&cfg_with_single_secret()).unwrap();
302        let key = SecretRef::new("openai_api_key").unwrap();
303
304        assert!(service.get(&ctx(tenant_b(), owner_a()), &key).is_none());
305    }
306
307    #[test]
308    fn get_returns_none_for_missing_key() {
309        let service = Service::from_config(&cfg_with_single_secret()).unwrap();
310        let key = SecretRef::new("missing").unwrap();
311
312        assert!(service.get(&ctx(tenant_a(), owner_a()), &key).is_none());
313    }
314
315    #[test]
316    fn from_config_with_empty_secrets_returns_none() {
317        let cfg = StaticCredStorePluginConfig::default();
318        let service = Service::from_config(&cfg).unwrap();
319        let key = SecretRef::new("any-key").unwrap();
320        assert!(service.get(&ctx(tenant_a(), owner_a()), &key).is_none());
321    }
322
323    // --- Tenant secret lookup ---
324
325    #[test]
326    fn tenant_secret_returned_for_any_subject_in_same_tenant() {
327        let cfg = StaticCredStorePluginConfig {
328            secrets: vec![SecretConfig {
329                tenant_id: Some(tenant_a()),
330                owner_id: None,
331                key: "team_key".to_owned(),
332                value: "team-val".to_owned(),
333                sharing: None,
334            }],
335            ..StaticCredStorePluginConfig::default()
336        };
337        let service = Service::from_config(&cfg).unwrap();
338        let key = SecretRef::new("team_key").unwrap();
339
340        let e1 = service.get(&ctx(tenant_a(), owner_a()), &key).unwrap();
341        assert_eq!(e1.value.as_bytes(), b"team-val");
342        assert_eq!(e1.sharing, SharingMode::Tenant);
343
344        let e2 = service.get(&ctx(tenant_a(), owner_b()), &key).unwrap();
345        assert_eq!(e2.value.as_bytes(), b"team-val");
346
347        assert!(service.get(&ctx(tenant_b(), owner_a()), &key).is_none());
348    }
349
350    // --- Global secret lookup ---
351
352    #[test]
353    fn global_secret_returned_for_any_tenant_and_subject() {
354        let cfg = StaticCredStorePluginConfig {
355            secrets: vec![SecretConfig {
356                tenant_id: None,
357                owner_id: None,
358                key: "global_key".to_owned(),
359                value: "global-val".to_owned(),
360                sharing: None,
361            }],
362            ..StaticCredStorePluginConfig::default()
363        };
364        let service = Service::from_config(&cfg).unwrap();
365        let key = SecretRef::new("global_key").unwrap();
366
367        let e1 = service.get(&ctx(tenant_a(), owner_a()), &key).unwrap();
368        assert_eq!(e1.value.as_bytes(), b"global-val");
369        assert_eq!(e1.sharing, SharingMode::Shared);
370
371        let e2 = service.get(&ctx(tenant_b(), owner_b()), &key).unwrap();
372        assert_eq!(e2.value.as_bytes(), b"global-val");
373    }
374
375    // --- Shared (tenant-scoped) secret lookup ---
376
377    #[test]
378    fn shared_secret_returned_only_for_owning_tenant() {
379        let cfg = StaticCredStorePluginConfig {
380            secrets: vec![SecretConfig {
381                tenant_id: Some(tenant_a()),
382                owner_id: None,
383                key: "shared_key".to_owned(),
384                value: "shared-val".to_owned(),
385                sharing: Some(SharingMode::Shared),
386            }],
387            ..StaticCredStorePluginConfig::default()
388        };
389        let service = Service::from_config(&cfg).unwrap();
390        let key = SecretRef::new("shared_key").unwrap();
391
392        // Same tenant — accessible
393        let e = service.get(&ctx(tenant_a(), owner_a()), &key).unwrap();
394        assert_eq!(e.value.as_bytes(), b"shared-val");
395        assert_eq!(e.sharing, SharingMode::Shared);
396        assert_eq!(e.owner_tenant_id, TenantId(tenant_a()));
397
398        // Different tenant — not accessible at plugin level
399        // (gateway walk-up would call the plugin with parent tenant_id)
400        assert!(service.get(&ctx(tenant_b(), owner_a()), &key).is_none());
401    }
402
403    // --- Lookup precedence: Private > Tenant > Shared > Global ---
404
405    #[test]
406    fn private_takes_precedence_over_tenant_shared_and_global() {
407        let cfg = StaticCredStorePluginConfig {
408            secrets: vec![
409                SecretConfig {
410                    tenant_id: None,
411                    owner_id: None,
412                    key: "k".to_owned(),
413                    value: "global-val".to_owned(),
414                    sharing: None,
415                },
416                SecretConfig {
417                    tenant_id: Some(tenant_a()),
418                    owner_id: None,
419                    key: "k".to_owned(),
420                    value: "shared-val".to_owned(),
421                    sharing: Some(SharingMode::Shared),
422                },
423                SecretConfig {
424                    tenant_id: Some(tenant_a()),
425                    owner_id: None,
426                    key: "k".to_owned(),
427                    value: "tenant-val".to_owned(),
428                    sharing: None,
429                },
430                SecretConfig {
431                    tenant_id: Some(tenant_a()),
432                    owner_id: Some(owner_a()),
433                    key: "k".to_owned(),
434                    value: "private-val".to_owned(),
435                    sharing: None,
436                },
437            ],
438            ..StaticCredStorePluginConfig::default()
439        };
440        let service = Service::from_config(&cfg).unwrap();
441        let key = SecretRef::new("k").unwrap();
442
443        // owner_a in tenant_a → Private
444        let e = service.get(&ctx(tenant_a(), owner_a()), &key).unwrap();
445        assert_eq!(e.value.as_bytes(), b"private-val");
446        assert_eq!(e.sharing, SharingMode::Private);
447
448        // owner_b in tenant_a → Tenant (no private match)
449        let e = service.get(&ctx(tenant_a(), owner_b()), &key).unwrap();
450        assert_eq!(e.value.as_bytes(), b"tenant-val");
451        assert_eq!(e.sharing, SharingMode::Tenant);
452
453        // tenant_b → Global (no private, tenant, or shared match)
454        let e = service.get(&ctx(tenant_b(), owner_a()), &key).unwrap();
455        assert_eq!(e.value.as_bytes(), b"global-val");
456        assert_eq!(e.sharing, SharingMode::Shared);
457    }
458
459    #[test]
460    fn tenant_takes_precedence_over_shared_and_global() {
461        let cfg = StaticCredStorePluginConfig {
462            secrets: vec![
463                SecretConfig {
464                    tenant_id: None,
465                    owner_id: None,
466                    key: "k".to_owned(),
467                    value: "global-val".to_owned(),
468                    sharing: None,
469                },
470                SecretConfig {
471                    tenant_id: Some(tenant_a()),
472                    owner_id: None,
473                    key: "k".to_owned(),
474                    value: "shared-val".to_owned(),
475                    sharing: Some(SharingMode::Shared),
476                },
477                SecretConfig {
478                    tenant_id: Some(tenant_a()),
479                    owner_id: None,
480                    key: "k".to_owned(),
481                    value: "tenant-val".to_owned(),
482                    sharing: None,
483                },
484            ],
485            ..StaticCredStorePluginConfig::default()
486        };
487        let service = Service::from_config(&cfg).unwrap();
488        let key = SecretRef::new("k").unwrap();
489
490        let e = service.get(&ctx(tenant_a(), owner_a()), &key).unwrap();
491        assert_eq!(e.value.as_bytes(), b"tenant-val");
492
493        let e = service.get(&ctx(tenant_b(), owner_a()), &key).unwrap();
494        assert_eq!(e.value.as_bytes(), b"global-val");
495    }
496
497    #[test]
498    fn shared_takes_precedence_over_global() {
499        let cfg = StaticCredStorePluginConfig {
500            secrets: vec![
501                SecretConfig {
502                    tenant_id: None,
503                    owner_id: None,
504                    key: "k".to_owned(),
505                    value: "global-val".to_owned(),
506                    sharing: None,
507                },
508                SecretConfig {
509                    tenant_id: Some(tenant_a()),
510                    owner_id: None,
511                    key: "k".to_owned(),
512                    value: "shared-val".to_owned(),
513                    sharing: Some(SharingMode::Shared),
514                },
515            ],
516            ..StaticCredStorePluginConfig::default()
517        };
518        let service = Service::from_config(&cfg).unwrap();
519        let key = SecretRef::new("k").unwrap();
520
521        // tenant_a has a shared secret → takes precedence over global
522        let e = service.get(&ctx(tenant_a(), owner_a()), &key).unwrap();
523        assert_eq!(e.value.as_bytes(), b"shared-val");
524        assert_eq!(e.sharing, SharingMode::Shared);
525
526        // tenant_b has no shared secret → falls through to global
527        let e = service.get(&ctx(tenant_b(), owner_a()), &key).unwrap();
528        assert_eq!(e.value.as_bytes(), b"global-val");
529    }
530
531    // --- Duplicate key validation ---
532
533    #[test]
534    fn from_config_rejects_duplicate_private_key() {
535        let secret = SecretConfig {
536            tenant_id: Some(tenant_a()),
537            owner_id: Some(owner_a()),
538            key: "dup".to_owned(),
539            value: "v1".to_owned(),
540            sharing: None,
541        };
542        let cfg = StaticCredStorePluginConfig {
543            secrets: vec![
544                secret.clone(),
545                SecretConfig {
546                    value: "v2".to_owned(),
547                    ..secret
548                },
549            ],
550            ..StaticCredStorePluginConfig::default()
551        };
552
553        match Service::from_config(&cfg) {
554            Ok(_) => panic!("expected error for duplicate private key"),
555            Err(e) => {
556                let err = e.to_string();
557                assert!(err.contains("duplicate"), "expected 'duplicate' in: {err}");
558                assert!(err.contains("dup"), "expected key name in: {err}");
559            }
560        }
561    }
562
563    #[test]
564    fn from_config_rejects_duplicate_tenant_key() {
565        let cfg = StaticCredStorePluginConfig {
566            secrets: vec![
567                SecretConfig {
568                    tenant_id: Some(tenant_a()),
569                    owner_id: None,
570                    key: "dup".to_owned(),
571                    value: "v1".to_owned(),
572                    sharing: None,
573                },
574                SecretConfig {
575                    tenant_id: Some(tenant_a()),
576                    owner_id: None,
577                    key: "dup".to_owned(),
578                    value: "v2".to_owned(),
579                    sharing: None,
580                },
581            ],
582            ..StaticCredStorePluginConfig::default()
583        };
584
585        match Service::from_config(&cfg) {
586            Ok(_) => panic!("expected error for duplicate tenant key"),
587            Err(e) => {
588                let err = e.to_string();
589                assert!(err.contains("duplicate"), "expected 'duplicate' in: {err}");
590            }
591        }
592    }
593
594    #[test]
595    fn from_config_rejects_duplicate_global_key() {
596        let cfg = StaticCredStorePluginConfig {
597            secrets: vec![
598                SecretConfig {
599                    tenant_id: None,
600                    owner_id: None,
601                    key: "dup".to_owned(),
602                    value: "v1".to_owned(),
603                    sharing: None,
604                },
605                SecretConfig {
606                    tenant_id: None,
607                    owner_id: None,
608                    key: "dup".to_owned(),
609                    value: "v2".to_owned(),
610                    sharing: None,
611                },
612            ],
613            ..StaticCredStorePluginConfig::default()
614        };
615
616        match Service::from_config(&cfg) {
617            Ok(_) => panic!("expected error for duplicate global key"),
618            Err(e) => {
619                let err = e.to_string();
620                assert!(err.contains("duplicate"), "expected 'duplicate' in: {err}");
621            }
622        }
623    }
624
625    #[test]
626    fn from_config_rejects_duplicate_shared_key() {
627        let cfg = StaticCredStorePluginConfig {
628            secrets: vec![
629                SecretConfig {
630                    tenant_id: Some(tenant_a()),
631                    owner_id: None,
632                    key: "dup".to_owned(),
633                    value: "v1".to_owned(),
634                    sharing: Some(SharingMode::Shared),
635                },
636                SecretConfig {
637                    tenant_id: Some(tenant_a()),
638                    owner_id: None,
639                    key: "dup".to_owned(),
640                    value: "v2".to_owned(),
641                    sharing: Some(SharingMode::Shared),
642                },
643            ],
644            ..StaticCredStorePluginConfig::default()
645        };
646
647        match Service::from_config(&cfg) {
648            Ok(_) => panic!("expected error for duplicate shared key"),
649            Err(e) => {
650                let err = e.to_string();
651                assert!(err.contains("duplicate"), "expected 'duplicate' in: {err}");
652            }
653        }
654    }
655
656    // --- Config validation ---
657
658    #[test]
659    fn from_config_rejects_non_shared_global_secret() {
660        for mode in [SharingMode::Private, SharingMode::Tenant] {
661            let cfg = StaticCredStorePluginConfig {
662                secrets: vec![SecretConfig {
663                    tenant_id: None,
664                    owner_id: None,
665                    key: "global_key".to_owned(),
666                    value: "val".to_owned(),
667                    sharing: Some(mode),
668                }],
669                ..StaticCredStorePluginConfig::default()
670            };
671
672            assert!(
673                Service::from_config(&cfg).is_err(),
674                "expected error for global secret with {mode:?} sharing"
675            );
676        }
677    }
678
679    #[test]
680    fn from_config_rejects_private_without_owner_id() {
681        let cfg = StaticCredStorePluginConfig {
682            secrets: vec![SecretConfig {
683                tenant_id: Some(tenant_a()),
684                owner_id: None,
685                key: "private_key".to_owned(),
686                value: "val".to_owned(),
687                sharing: Some(SharingMode::Private),
688            }],
689            ..StaticCredStorePluginConfig::default()
690        };
691
692        match Service::from_config(&cfg) {
693            Ok(_) => panic!("expected error for private without owner_id"),
694            Err(e) => {
695                let err = e.to_string();
696                assert!(err.contains("requires an explicit owner_id"), "got: {err}");
697            }
698        }
699    }
700
701    #[test]
702    fn from_config_rejects_owner_id_without_tenant_id() {
703        let cfg = StaticCredStorePluginConfig {
704            secrets: vec![SecretConfig {
705                tenant_id: None,
706                owner_id: Some(owner_a()),
707                key: "bad_key".to_owned(),
708                value: "val".to_owned(),
709                sharing: None,
710            }],
711            ..StaticCredStorePluginConfig::default()
712        };
713
714        match Service::from_config(&cfg) {
715            Ok(_) => panic!("expected error for owner_id without tenant_id"),
716            Err(e) => {
717                let err = e.to_string();
718                assert!(
719                    err.contains("owner_id cannot be set without tenant_id"),
720                    "got: {err}"
721                );
722            }
723        }
724    }
725
726    #[test]
727    fn from_config_rejects_owner_id_for_non_private() {
728        for mode in [SharingMode::Tenant, SharingMode::Shared] {
729            let cfg = StaticCredStorePluginConfig {
730                secrets: vec![SecretConfig {
731                    tenant_id: Some(tenant_a()),
732                    owner_id: Some(owner_a()),
733                    key: "bad_key".to_owned(),
734                    value: "val".to_owned(),
735                    sharing: Some(mode),
736                }],
737                ..StaticCredStorePluginConfig::default()
738            };
739
740            match Service::from_config(&cfg) {
741                Ok(_) => panic!("expected error for owner_id with {mode:?} sharing"),
742                Err(e) => {
743                    let err = e.to_string();
744                    assert!(
745                        err.contains("owner_id is only valid for private sharing mode"),
746                        "got: {err}"
747                    );
748                }
749            }
750        }
751    }
752
753    #[test]
754    fn from_config_accepts_shared_with_tenant_id() {
755        let cfg = StaticCredStorePluginConfig {
756            secrets: vec![SecretConfig {
757                tenant_id: Some(tenant_a()),
758                owner_id: None,
759                key: "k".to_owned(),
760                value: "v".to_owned(),
761                sharing: Some(SharingMode::Shared),
762            }],
763            ..StaticCredStorePluginConfig::default()
764        };
765
766        let service = Service::from_config(&cfg).unwrap();
767        let key = SecretRef::new("k").unwrap();
768        let e = service.get(&ctx(tenant_a(), owner_a()), &key).unwrap();
769        assert_eq!(e.sharing, SharingMode::Shared);
770        assert_eq!(e.owner_tenant_id, TenantId(tenant_a()));
771    }
772
773    #[test]
774    fn from_config_rejects_nil_tenant_id() {
775        let cfg = StaticCredStorePluginConfig {
776            secrets: vec![SecretConfig {
777                tenant_id: Some(Uuid::nil()),
778                owner_id: Some(owner_a()),
779                key: "k".to_owned(),
780                value: "v".to_owned(),
781                sharing: None,
782            }],
783            ..StaticCredStorePluginConfig::default()
784        };
785
786        match Service::from_config(&cfg) {
787            Ok(_) => panic!("expected error for nil tenant_id"),
788            Err(e) => {
789                let err = e.to_string();
790                assert!(err.contains("tenant_id must not be nil UUID"), "got: {err}");
791            }
792        }
793    }
794
795    #[test]
796    fn from_config_rejects_nil_owner_id() {
797        let cfg = StaticCredStorePluginConfig {
798            secrets: vec![SecretConfig {
799                tenant_id: Some(tenant_a()),
800                owner_id: Some(Uuid::nil()),
801                key: "k".to_owned(),
802                value: "v".to_owned(),
803                sharing: None,
804            }],
805            ..StaticCredStorePluginConfig::default()
806        };
807
808        match Service::from_config(&cfg) {
809            Ok(_) => panic!("expected error for nil owner_id"),
810            Err(e) => {
811                let err = e.to_string();
812                assert!(err.contains("owner_id must not be nil UUID"), "got: {err}");
813            }
814        }
815    }
816
817    // --- Sharing mode defaults ---
818
819    #[test]
820    fn default_sharing_is_shared_for_global() {
821        let cfg = StaticCredStorePluginConfig {
822            secrets: vec![SecretConfig {
823                tenant_id: None,
824                owner_id: None,
825                key: "g".to_owned(),
826                value: "v".to_owned(),
827                sharing: None,
828            }],
829            ..StaticCredStorePluginConfig::default()
830        };
831        let service = Service::from_config(&cfg).unwrap();
832        let key = SecretRef::new("g").unwrap();
833        assert_eq!(
834            service
835                .get(&ctx(tenant_a(), owner_a()), &key)
836                .unwrap()
837                .sharing,
838            SharingMode::Shared
839        );
840    }
841
842    #[test]
843    fn default_sharing_is_tenant_for_scoped_without_owner() {
844        let cfg = StaticCredStorePluginConfig {
845            secrets: vec![SecretConfig {
846                tenant_id: Some(tenant_a()),
847                owner_id: None,
848                key: "t".to_owned(),
849                value: "v".to_owned(),
850                sharing: None,
851            }],
852            ..StaticCredStorePluginConfig::default()
853        };
854        let service = Service::from_config(&cfg).unwrap();
855        let key = SecretRef::new("t").unwrap();
856        assert_eq!(
857            service
858                .get(&ctx(tenant_a(), owner_a()), &key)
859                .unwrap()
860                .sharing,
861            SharingMode::Tenant
862        );
863    }
864
865    #[test]
866    fn default_sharing_is_private_for_scoped_with_owner() {
867        let cfg = StaticCredStorePluginConfig {
868            secrets: vec![SecretConfig {
869                tenant_id: Some(tenant_a()),
870                owner_id: Some(owner_a()),
871                key: "p".to_owned(),
872                value: "v".to_owned(),
873                sharing: None,
874            }],
875            ..StaticCredStorePluginConfig::default()
876        };
877        let service = Service::from_config(&cfg).unwrap();
878        let key = SecretRef::new("p").unwrap();
879        assert_eq!(
880            service
881                .get(&ctx(tenant_a(), owner_a()), &key)
882                .unwrap()
883                .sharing,
884            SharingMode::Private
885        );
886    }
887
888    #[test]
889    fn explicit_sharing_overrides_default() {
890        // tenant_id + no owner_id defaults to Tenant; override to Shared.
891        let cfg = StaticCredStorePluginConfig {
892            secrets: vec![SecretConfig {
893                tenant_id: Some(tenant_a()),
894                owner_id: None,
895                key: "k".to_owned(),
896                value: "v".to_owned(),
897                sharing: Some(SharingMode::Shared),
898            }],
899            ..StaticCredStorePluginConfig::default()
900        };
901        let service = Service::from_config(&cfg).unwrap();
902        let key = SecretRef::new("k").unwrap();
903        assert_eq!(
904            service
905                .get(&ctx(tenant_a(), owner_a()), &key)
906                .unwrap()
907                .sharing,
908            SharingMode::Shared
909        );
910    }
911
912    // --- Same key in different scopes ---
913
914    #[test]
915    fn allows_same_key_in_different_tenants() {
916        let cfg = StaticCredStorePluginConfig {
917            secrets: vec![
918                SecretConfig {
919                    tenant_id: Some(tenant_a()),
920                    owner_id: None,
921                    key: "api_key".to_owned(),
922                    value: "val-a".to_owned(),
923                    sharing: None,
924                },
925                SecretConfig {
926                    tenant_id: Some(tenant_b()),
927                    owner_id: None,
928                    key: "api_key".to_owned(),
929                    value: "val-b".to_owned(),
930                    sharing: None,
931                },
932            ],
933            ..StaticCredStorePluginConfig::default()
934        };
935        let service = Service::from_config(&cfg).unwrap();
936        let key = SecretRef::new("api_key").unwrap();
937
938        assert_eq!(
939            service
940                .get(&ctx(tenant_a(), owner_a()), &key)
941                .unwrap()
942                .value
943                .as_bytes(),
944            b"val-a"
945        );
946        assert_eq!(
947            service
948                .get(&ctx(tenant_b(), owner_a()), &key)
949                .unwrap()
950                .value
951                .as_bytes(),
952            b"val-b"
953        );
954    }
955
956    #[test]
957    fn same_key_across_all_four_scopes() {
958        let cfg = StaticCredStorePluginConfig {
959            secrets: vec![
960                SecretConfig {
961                    tenant_id: None,
962                    owner_id: None,
963                    key: "k".to_owned(),
964                    value: "global".to_owned(),
965                    sharing: None,
966                },
967                SecretConfig {
968                    tenant_id: Some(tenant_a()),
969                    owner_id: None,
970                    key: "k".to_owned(),
971                    value: "shared".to_owned(),
972                    sharing: Some(SharingMode::Shared),
973                },
974                SecretConfig {
975                    tenant_id: Some(tenant_a()),
976                    owner_id: None,
977                    key: "k".to_owned(),
978                    value: "tenant".to_owned(),
979                    sharing: None,
980                },
981                SecretConfig {
982                    tenant_id: Some(tenant_a()),
983                    owner_id: Some(owner_a()),
984                    key: "k".to_owned(),
985                    value: "private".to_owned(),
986                    sharing: None,
987                },
988            ],
989            ..StaticCredStorePluginConfig::default()
990        };
991
992        assert!(Service::from_config(&cfg).is_ok());
993    }
994}