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