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;
5
6use crate::config::StaticCredStorePluginConfig;
7
8/// Pre-built secret entry for O(1) lookup.
9#[domain_model]
10pub struct SecretEntry {
11    pub value: SecretValue,
12    pub owner_id: OwnerId,
13    pub sharing: SharingMode,
14    pub owner_tenant_id: TenantId,
15}
16
17/// Static credstore service.
18///
19/// Stores secrets in a two-level `HashMap<TenantId, HashMap<SecretRef, SecretEntry>>`
20/// built at init from YAML configuration.
21#[domain_model]
22pub struct Service {
23    secrets: HashMap<TenantId, HashMap<SecretRef, SecretEntry>>,
24}
25
26impl Service {
27    /// Create a service from plugin configuration.
28    ///
29    /// Validates each secret key via `SecretRef::new` and builds the lookup map.
30    ///
31    /// # Errors
32    ///
33    /// Returns an error if any configured key fails `SecretRef` validation.
34    pub fn from_config(cfg: &StaticCredStorePluginConfig) -> anyhow::Result<Self> {
35        let mut secrets: HashMap<TenantId, HashMap<SecretRef, SecretEntry>> = HashMap::new();
36
37        for entry in &cfg.secrets {
38            let key = SecretRef::new(&entry.key)?;
39            let secret_entry = SecretEntry {
40                value: SecretValue::from(entry.value.as_str()),
41                owner_id: entry.owner_id,
42                sharing: entry.sharing,
43                owner_tenant_id: entry.tenant_id,
44            };
45            let tenant_map = secrets.entry(entry.tenant_id).or_default();
46            if tenant_map.contains_key(&key) {
47                anyhow::bail!(
48                    "duplicate secret key '{}' for tenant {}",
49                    entry.key,
50                    entry.tenant_id
51                );
52            }
53            tenant_map.insert(key, secret_entry);
54        }
55
56        Ok(Self { secrets })
57    }
58
59    /// Look up a secret by tenant ID and key.
60    #[must_use]
61    pub fn get(&self, tenant_id: TenantId, key: &SecretRef) -> Option<&SecretEntry> {
62        self.secrets.get(&tenant_id)?.get(key)
63    }
64}
65
66#[cfg(test)]
67#[cfg_attr(coverage_nightly, coverage(off))]
68mod tests {
69    use super::*;
70    use crate::config::SecretConfig;
71    use uuid::Uuid;
72
73    fn tenant_a() -> Uuid {
74        Uuid::parse_str("11111111-1111-1111-1111-111111111111").unwrap()
75    }
76
77    fn tenant_b() -> Uuid {
78        Uuid::parse_str("22222222-2222-2222-2222-222222222222").unwrap()
79    }
80
81    fn owner() -> Uuid {
82        Uuid::parse_str("33333333-3333-3333-3333-333333333333").unwrap()
83    }
84
85    fn cfg_with_single_secret() -> StaticCredStorePluginConfig {
86        StaticCredStorePluginConfig {
87            secrets: vec![SecretConfig {
88                tenant_id: tenant_a(),
89                owner_id: owner(),
90                key: "openai_api_key".to_owned(),
91                value: "sk-test-123".to_owned(),
92                sharing: SharingMode::Tenant,
93            }],
94            ..StaticCredStorePluginConfig::default()
95        }
96    }
97
98    #[test]
99    fn from_config_rejects_invalid_secret_ref() {
100        let cfg = StaticCredStorePluginConfig {
101            secrets: vec![SecretConfig {
102                tenant_id: tenant_a(),
103                owner_id: owner(),
104                key: "invalid:key".to_owned(),
105                value: "value".to_owned(),
106                sharing: SharingMode::Tenant,
107            }],
108            ..StaticCredStorePluginConfig::default()
109        };
110
111        let result = Service::from_config(&cfg);
112        assert!(result.is_err());
113    }
114
115    #[test]
116    fn get_returns_secret_for_matching_tenant_and_key() {
117        let service = Service::from_config(&cfg_with_single_secret()).unwrap();
118        let key = SecretRef::new("openai_api_key").unwrap();
119
120        let entry = service.get(tenant_a(), &key);
121        assert!(entry.is_some());
122
123        let entry = entry.unwrap();
124        assert_eq!(entry.value.as_bytes(), b"sk-test-123");
125        assert_eq!(entry.owner_id, owner());
126        assert_eq!(entry.owner_tenant_id, tenant_a());
127        assert_eq!(entry.sharing, SharingMode::Tenant);
128    }
129
130    #[test]
131    fn get_returns_none_for_different_tenant() {
132        let service = Service::from_config(&cfg_with_single_secret()).unwrap();
133        let key = SecretRef::new("openai_api_key").unwrap();
134
135        let entry = service.get(tenant_b(), &key);
136        assert!(entry.is_none());
137    }
138
139    #[test]
140    fn get_returns_none_for_missing_key() {
141        let service = Service::from_config(&cfg_with_single_secret()).unwrap();
142        let key = SecretRef::new("missing").unwrap();
143
144        let entry = service.get(tenant_a(), &key);
145        assert!(entry.is_none());
146    }
147
148    #[test]
149    fn from_config_rejects_duplicate_key_for_same_tenant() {
150        let secret = SecretConfig {
151            tenant_id: tenant_a(),
152            owner_id: owner(),
153            key: "openai_api_key".to_owned(),
154            value: "sk-first".to_owned(),
155            sharing: SharingMode::Tenant,
156        };
157        let cfg = StaticCredStorePluginConfig {
158            secrets: vec![
159                secret.clone(),
160                SecretConfig {
161                    value: "sk-second".to_owned(),
162                    ..secret
163                },
164            ],
165            ..StaticCredStorePluginConfig::default()
166        };
167
168        match Service::from_config(&cfg) {
169            Ok(_) => panic!("expected error for duplicate key"),
170            Err(e) => {
171                let msg = e.to_string();
172                assert!(msg.contains("duplicate"), "expected 'duplicate' in: {msg}");
173                assert!(
174                    msg.contains("openai_api_key"),
175                    "expected key name in: {msg}"
176                );
177                assert!(
178                    msg.contains(&tenant_a().to_string()),
179                    "expected tenant id in: {msg}"
180                );
181            }
182        }
183    }
184
185    #[test]
186    fn from_config_with_empty_secrets_returns_none_for_any_lookup() {
187        let cfg = StaticCredStorePluginConfig::default();
188        let service = Service::from_config(&cfg).unwrap();
189        let key = SecretRef::new("any-key").unwrap();
190        assert!(
191            service.get(tenant_a(), &key).is_none(),
192            "empty config must return None for any lookup"
193        );
194    }
195}