Skip to main content

static_credstore_plugin/
config.rs

1use serde::Deserialize;
2use uuid::Uuid;
3
4use credstore_sdk::SharingMode;
5
6/// Plugin configuration.
7#[derive(Debug, Clone, Deserialize, modkit_macros::ExpandVars)]
8#[serde(default, deny_unknown_fields)]
9pub struct StaticCredStorePluginConfig {
10    /// Vendor name for GTS instance registration.
11    pub vendor: String,
12
13    /// Plugin priority (lower = higher priority).
14    pub priority: i16,
15
16    /// Static secrets served by this plugin.
17    #[expand_vars]
18    pub secrets: Vec<SecretConfig>,
19}
20
21impl Default for StaticCredStorePluginConfig {
22    fn default() -> Self {
23        Self {
24            vendor: "hyperspot".to_owned(),
25            priority: 100,
26            secrets: Vec::new(),
27        }
28    }
29}
30
31/// A single secret entry in the plugin configuration.
32#[derive(Clone, Deserialize, modkit_macros::ExpandVars)]
33#[serde(deny_unknown_fields)]
34pub struct SecretConfig {
35    /// Tenant that owns this secret.
36    ///
37    /// - `None` → **global** secret, accessible by any tenant (uses
38    ///   `SharingMode::Shared` on the wire but stored in a separate
39    ///   global map in the static plugin).
40    /// - `Some` with `SharingMode::Shared` → **shared** secret scoped to
41    ///   this tenant, visible to descendants via gateway hierarchy walk-up.
42    /// - `Some` with `SharingMode::Tenant` → **tenant** secret, visible
43    ///   only within this tenant.
44    ///
45    /// `owner_id` cannot be set without `tenant_id`.
46    pub tenant_id: Option<Uuid>,
47
48    /// Owner (subject) of this secret.
49    ///
50    /// **Only valid for `Private` sharing mode.** When set, the secret is
51    /// keyed by `(tenant_id, owner_id, key)` and matched against
52    /// `SecurityContext::subject_id()` at lookup time.
53    ///
54    /// Requires `tenant_id` to be set. Rejected at init if the resolved
55    /// sharing mode is not `Private`.
56    ///
57    /// For `Tenant`/`Shared`/global secrets, `owner_id` must be `None`;
58    /// the returned `SecretMetadata::owner_id` is filled from
59    /// `SecurityContext::subject_id()` of the caller.
60    pub owner_id: Option<Uuid>,
61
62    /// Secret reference key (validated as `SecretRef` at init).
63    pub key: String,
64
65    /// Secret value (plaintext string, converted to bytes at init).
66    #[expand_vars]
67    pub value: String,
68
69    /// Sharing mode for this secret.
70    /// When `None`, inferred from `tenant_id`/`owner_id`:
71    /// - `tenant_id=None` → `Shared`
72    /// - `tenant_id=Some`, `owner_id=None` → `Tenant`
73    /// - `tenant_id=Some`, `owner_id=Some` → `Private`
74    pub sharing: Option<SharingMode>,
75}
76
77impl SecretConfig {
78    /// Resolve the effective sharing mode from the explicit value or the
79    /// `tenant_id`/`owner_id` combination.
80    #[must_use]
81    pub fn resolve_sharing(&self) -> SharingMode {
82        self.sharing
83            .unwrap_or(match (self.tenant_id, self.owner_id) {
84                (None, _) => SharingMode::Shared,
85                (Some(_), None) => SharingMode::Tenant,
86                (Some(_), Some(_)) => SharingMode::Private,
87            })
88    }
89}
90
91impl core::fmt::Debug for SecretConfig {
92    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
93        f.debug_struct("SecretConfig")
94            .field("tenant_id", &self.tenant_id)
95            .field("owner_id", &self.owner_id)
96            .field("key", &self.key)
97            .field("value", &"<redacted>")
98            .field("sharing", &self.resolve_sharing())
99            .finish()
100    }
101}
102
103#[cfg(test)]
104#[cfg_attr(coverage_nightly, coverage(off))]
105mod tests {
106    use super::*;
107
108    #[test]
109    fn config_defaults_are_applied() {
110        let yaml = r#"
111secrets:
112  - tenant_id: "00000000-0000-0000-0000-000000000001"
113    owner_id: "00000000-0000-0000-0000-000000000002"
114    key: "openai_api_key"
115    value: "sk-test-123"
116"#;
117
118        let cfg: StaticCredStorePluginConfig = serde_saphyr::from_str(yaml).unwrap();
119
120        assert_eq!(cfg.vendor, "hyperspot");
121        assert_eq!(cfg.priority, 100);
122        assert_eq!(cfg.secrets.len(), 1);
123        assert!(cfg.secrets[0].sharing.is_none());
124        assert_eq!(cfg.secrets[0].resolve_sharing(), SharingMode::Private);
125    }
126
127    #[test]
128    fn config_allows_omitted_tenant_and_owner() {
129        let yaml = r#"
130secrets:
131  - key: "global_api_key"
132    value: "sk-global"
133"#;
134
135        let cfg: StaticCredStorePluginConfig = serde_saphyr::from_str(yaml).unwrap();
136        assert_eq!(cfg.secrets.len(), 1);
137        assert!(cfg.secrets[0].tenant_id.is_none());
138        assert!(cfg.secrets[0].owner_id.is_none());
139        assert!(cfg.secrets[0].sharing.is_none());
140        assert_eq!(cfg.secrets[0].resolve_sharing(), SharingMode::Shared);
141    }
142
143    #[test]
144    fn config_allows_partial_tenant_only() {
145        let yaml = r#"
146secrets:
147  - tenant_id: "00000000-0000-0000-0000-000000000001"
148    key: "scoped_key"
149    value: "val"
150"#;
151
152        let cfg: StaticCredStorePluginConfig = serde_saphyr::from_str(yaml).unwrap();
153        assert!(cfg.secrets[0].tenant_id.is_some());
154        assert!(cfg.secrets[0].owner_id.is_none());
155        assert!(cfg.secrets[0].sharing.is_none());
156        assert_eq!(cfg.secrets[0].resolve_sharing(), SharingMode::Tenant);
157    }
158
159    #[test]
160    fn config_explicit_sharing_overrides_default() {
161        // tenant_id + no owner_id defaults to Tenant; override to Shared.
162        let yaml = r#"
163secrets:
164  - tenant_id: "00000000-0000-0000-0000-000000000001"
165    key: "key"
166    value: "val"
167    sharing: "shared"
168"#;
169
170        let cfg: StaticCredStorePluginConfig = serde_saphyr::from_str(yaml).unwrap();
171        assert_eq!(cfg.secrets[0].sharing, Some(SharingMode::Shared));
172        assert_eq!(cfg.secrets[0].resolve_sharing(), SharingMode::Shared);
173    }
174
175    #[test]
176    fn config_rejects_unknown_fields() {
177        let yaml = r#"
178vendor: "hyperspot"
179priority: 100
180unexpected: true
181"#;
182
183        let parsed: Result<StaticCredStorePluginConfig, _> = serde_saphyr::from_str(yaml);
184        assert!(parsed.is_err());
185    }
186
187    #[test]
188    fn config_allows_empty_secrets() {
189        let parsed: Result<StaticCredStorePluginConfig, _> = serde_saphyr::from_str("{}");
190        assert!(parsed.is_ok());
191
192        let cfg = match parsed {
193            Ok(cfg) => cfg,
194            Err(e) => panic!("failed to parse config: {e}"),
195        };
196        assert!(cfg.secrets.is_empty());
197        assert_eq!(cfg.vendor, "hyperspot");
198        assert_eq!(cfg.priority, 100);
199    }
200}