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