Skip to main content

reddb_server/runtime/
impl_config_secret.rs

1//! Runtime config / secret / KV resolution.
2//!
3//! Extracted verbatim from `impl_core.rs` (impl_core slice 7/10, issue #1628).
4//! Houses the config-tier readers, secret encrypt/decrypt pipeline, and vault
5//! KV accessors:
6//!
7//! - **Free helpers** — `seed_storage_deploy_config`, `show_secrets_allows_key`,
8//!   `secret_sql_value_to_string`, `insert_config_json_path`,
9//!   `insert_config_json_segments`, `show_config_json_result`.
10//! - **Methods** — `vault_kv_get`, `vault_kv_try_set`, `secret_aes_key`,
11//!   `config_bool`, `binary_document_body_enabled`, `config_u64`, `config_f64`,
12//!   `config_string`, `secret_auto_encrypt`.
13use super::*;
14
15pub(crate) fn seed_storage_deploy_config(
16    store: &crate::storage::UnifiedStore,
17    selection: crate::storage::StorageProfileSelection,
18) {
19    store.set_config_tree(
20        "storage.deploy",
21        &crate::json!({
22            "profile": selection.deploy_profile.as_str(),
23            "packaging": selection.packaging.as_str(),
24            "preset": selection.preset_name(),
25            "replica_count": selection.replica_count,
26            "managed_backup": selection.managed_backup,
27            "wal_retention": selection.wal_retention,
28        }),
29    );
30}
31
32pub(crate) fn show_secrets_allows_key(key: &str) -> bool {
33    ![
34        "red.secret.",
35        "red.config.",
36        "red.iam.",
37        "red.acl.",
38        "red.vault.",
39    ]
40    .iter()
41    .any(|prefix| key.starts_with(prefix))
42}
43
44pub(crate) fn secret_sql_value_to_string(value: &Value) -> RedDBResult<String> {
45    match value {
46        Value::Text(s) => Ok(s.to_string()),
47        Value::Integer(n) => Ok(n.to_string()),
48        Value::UnsignedInteger(n) => Ok(n.to_string()),
49        Value::Float(n) => Ok(n.to_string()),
50        Value::Boolean(b) => Ok(b.to_string()),
51        Value::Null => Err(RedDBError::Query(
52            "SET SECRET key = NULL deletes the secret; use DELETE SECRET for explicit deletes"
53                .to_string(),
54        )),
55        Value::Password(_) | Value::Secret(_) => Err(RedDBError::Query(
56            "SET SECRET accepts plain scalar literals; PASSWORD() and SECRET() are for typed columns"
57                .to_string(),
58        )),
59        _ => Err(RedDBError::Query(format!(
60            "SET SECRET does not support value type {:?} yet",
61            value.data_type()
62        ))),
63    }
64}
65
66#[cfg(test)]
67mod tests {
68    use super::*;
69
70    #[test]
71    fn show_secrets_allows_only_user_managed_keys() {
72        assert!(!show_secrets_allows_key("red.secret.aes_key"));
73        assert!(!show_secrets_allows_key(
74            "red.secret.ai.providers.anthropic.tokens.default"
75        ));
76        assert!(!show_secrets_allows_key("red.config.ai.default.provider"));
77        assert!(show_secrets_allows_key("acme.key"));
78    }
79
80    #[test]
81    fn typed_config_readers_preserve_cold_values_and_live_updates() {
82        let directory = tempfile::tempdir().expect("config fixture");
83        let runtime = RedDBRuntime::with_options(
84            RedDBOptions::persistent(directory.path().join("config.rdb"))
85                .with_memory_budget(32 * 1024 * 1024)
86                .with_bounded_disk_residency(true),
87        )
88        .expect("runtime");
89        let store = runtime.db().store();
90        store
91            .try_set_config_tree(
92                "probe",
93                &crate::json!({"boolean": false, "integer": 42, "float": 1.5, "string": "saved"}),
94            )
95            .expect("persist configuration");
96        store
97            .try_set_config_tree("queue.max_wait_ms", &crate::json!(1))
98            .expect("queue wait limit");
99        store.persist().expect("checkpoint");
100        let manager = store
101            .get_collection("red.config")
102            .expect("config collection");
103        assert!(manager.has_backing(), "fixture must read persisted rows");
104        assert!(
105            manager.query_all(|_| true).is_empty(),
106            "fixture must be cold"
107        );
108        assert_eq!(
109            store.try_get_config("probe.integer").unwrap(),
110            Some(Value::UnsignedInteger(42))
111        );
112        assert!(!runtime.config_bool("probe.boolean", true));
113        assert_eq!(runtime.config_u64("probe.integer", 9), 42);
114        assert_eq!(runtime.config_f64("probe.float", 9.0), 1.5);
115        assert_eq!(runtime.config_string("probe.string", "default"), "saved");
116        assert!(runtime.redwire_queue_wait_cap_check(5).is_err());
117        store
118            .try_set_config_tree("probe.integer", &crate::json!(73))
119            .expect("live replacement");
120        assert_eq!(runtime.config_u64("probe.integer", 9), 73);
121        assert_eq!(runtime.config_u64("probe.missing", 9), 9);
122    }
123
124    #[test]
125    fn boot_defaults_and_overlay_preserve_cold_operator_configuration() {
126        let directory = tempfile::tempdir().expect("config fixture");
127        let runtime = RedDBRuntime::with_options(
128            RedDBOptions::persistent(directory.path().join("config.rdb"))
129                .with_memory_budget(32 * 1024 * 1024)
130                .with_bounded_disk_residency(true),
131        )
132        .expect("runtime");
133        let store = runtime.db().store();
134        store
135            .try_set_config_tree("concurrency.locking.enabled", &crate::json!(false))
136            .expect("operator configuration");
137        store.persist().expect("checkpoint");
138        super::super::config_matrix::heal_critical_keys(store.as_ref());
139        assert_eq!(
140            store.try_get_config("concurrency.locking.enabled").unwrap(),
141            Some(Value::Boolean(false))
142        );
143        let overlay = directory.path().join("overlay.json");
144        std::fs::write(&overlay, r#"{"concurrency":{"locking":{"enabled":true}}}"#)
145            .expect("overlay fixture");
146        assert_eq!(
147            super::super::config_overlay::apply_config_file(
148                store.as_ref(),
149                overlay.to_str().unwrap()
150            ),
151            0
152        );
153        assert_eq!(
154            store.try_get_config("concurrency.locking.enabled").unwrap(),
155            Some(Value::Boolean(false))
156        );
157    }
158
159    #[test]
160    fn fresh_boot_never_persists_a_redundant_red_config_prefix() {
161        let runtime = RedDBRuntime::in_memory().expect("runtime");
162        let manager = runtime
163            .db()
164            .store()
165            .get_collection("red.config")
166            .expect("red.config");
167        let mut invalid = Vec::new();
168        manager.for_each_entity(|entity| {
169            if let Some(Value::Text(key)) =
170                entity.data.as_row().and_then(|row| row.get_field("key"))
171            {
172                if key.starts_with("red.") {
173                    invalid.push(key.to_string());
174                }
175            }
176            true
177        });
178        assert!(
179            invalid.is_empty(),
180            "fresh red.config contains redundant prefixes: {invalid:?}"
181        );
182    }
183
184    #[test]
185    fn sql_config_surfaces_reject_redundant_red_prefixes() {
186        let runtime = RedDBRuntime::in_memory().expect("runtime");
187        for query in ["SET CONFIG red.auth.enabled = true", "SHOW CONFIG red.auth"] {
188            let error = match runtime.execute_query(query) {
189                Ok(_) => panic!("legacy config key unexpectedly accepted: {query}"),
190                Err(error) => error,
191            };
192            assert!(
193                error
194                    .to_string()
195                    .contains("omit the redundant `red.` prefix"),
196                "unexpected error for {query}: {error}"
197            );
198        }
199    }
200}
201
202pub(crate) fn insert_config_json_path(
203    root: &mut crate::serde_json::Value,
204    path: &str,
205    value: crate::serde_json::Value,
206) {
207    let segments: Vec<&str> = path
208        .split('.')
209        .filter(|segment| !segment.is_empty())
210        .collect();
211    insert_config_json_segments(root, &segments, value);
212}
213
214fn insert_config_json_segments(
215    root: &mut crate::serde_json::Value,
216    segments: &[&str],
217    value: crate::serde_json::Value,
218) {
219    if segments.is_empty() {
220        *root = value;
221        return;
222    }
223
224    if !matches!(root, crate::serde_json::Value::Object(_)) {
225        *root = crate::serde_json::Value::Object(crate::serde_json::Map::new());
226    }
227
228    let crate::serde_json::Value::Object(map) = root else {
229        return;
230    };
231    if segments.len() == 1 {
232        map.insert(segments[0].to_string(), value);
233        return;
234    }
235    let entry = map
236        .entry(segments[0].to_string())
237        .or_insert_with(|| crate::serde_json::Value::Object(crate::serde_json::Map::new()));
238    insert_config_json_segments(entry, &segments[1..], value);
239}
240
241pub(crate) fn show_config_json_result(
242    query: &str,
243    mode: reddb_rql::modes::QueryMode,
244    prefix: &Option<String>,
245    value: crate::serde_json::Value,
246) -> RuntimeQueryResult {
247    let mut result = UnifiedResult::with_columns(vec!["key".into(), "value".into()]);
248    let mut record = UnifiedRecord::new();
249    record.set(
250        "key",
251        prefix
252            .as_ref()
253            .map(|key| Value::text(key.clone()))
254            .unwrap_or(Value::Null),
255    );
256    record.set("value", Value::Json(value.to_string_compact().into_bytes()));
257    result.push(record);
258    RuntimeQueryResult {
259        query: query.to_string(),
260        mode,
261        statement: "show_config_json",
262        engine: "runtime-config",
263        result,
264        affected_rows: 0,
265        statement_type: "select",
266        bookmark: None,
267        notice: None,
268    }
269}
270
271impl RedDBRuntime {
272    pub(crate) fn set_internal_provider_secret(
273        &self,
274        key: &str,
275        value: Option<String>,
276    ) -> RedDBResult<()> {
277        self.reject_vault_transaction()?;
278        if super::execution_context::current_tenant().is_some() {
279            return Err(RedDBError::Query(
280                "system provider credentials are platform-scoped".into(),
281            ));
282        }
283        let auth =
284            self.inner.auth_store.read().clone().ok_or_else(|| {
285                RedDBError::Query("secret storage requires an enabled vault".into())
286            })?;
287        self.check_secret_write_privilege(&auth, key)?;
288        match value {
289            Some(value) => auth.vault_kv_try_set(key.into(), value),
290            None => auth.vault_kv_try_delete(key).map(|_| ()),
291        }
292        .map_err(|err| RedDBError::Query(err.to_string()))?;
293        self.invalidate_result_cache();
294        Ok(())
295    }
296
297    /// Read a vault KV secret from the configured AuthStore, if present.
298    pub fn vault_kv_get(&self, key: &str) -> Option<String> {
299        self.inner
300            .auth_store
301            .read()
302            .as_ref()
303            .and_then(|store| store.vault_kv_get(key))
304    }
305
306    /// Write a vault KV secret and fail if the encrypted vault write is
307    /// unavailable or cannot be made durable.
308    pub fn vault_kv_try_set(&self, key: String, value: String) -> RedDBResult<()> {
309        let store = self.inner.auth_store.read().clone().ok_or_else(|| {
310            RedDBError::Query("secret storage requires an enabled, unsealed vault".to_string())
311        })?;
312        store
313            .vault_kv_try_set(key, value)
314            .map_err(|err| RedDBError::Query(err.to_string()))
315    }
316
317    /// Returns the vault AES key (`red.secret.aes_key`) if an auth
318    /// store is wired and a key has been generated. Used by the
319    /// `Value::Secret` encrypt/decrypt pipeline.
320    pub(crate) fn secret_aes_key(&self) -> Option<[u8; 32]> {
321        let guard = self.inner.auth_store.read();
322        guard.as_ref().and_then(|s| s.vault_secret_key())
323    }
324
325    /// Resolve a boolean flag from `red.config`. Defaults to `default`
326    /// when the key is missing or not coercible. If the same key has
327    /// been written multiple times (SET CONFIG appends new rows), the
328    /// most recent entity wins. Env-var overrides
329    /// (`REDDB_<UP_DOTTED>`) take highest precedence.
330    pub(crate) fn config_bool(&self, key: &str, default: bool) -> bool {
331        if let Some(raw) = self.inner.env_config_overrides.get(key) {
332            if let Some(reddb_types::Value::Boolean(b)) =
333                crate::runtime::config_overlay::coerce_env_value(key, raw)
334            {
335                return b;
336            }
337        }
338        match self.inner.db.store().get_config(key) {
339            Some(Value::Boolean(value)) => value,
340            Some(Value::Text(value)) => matches!(value.as_ref(), "true" | "TRUE" | "True" | "1"),
341            Some(Value::Integer(value)) => value != 0,
342            _ => default,
343        }
344    }
345
346    /// Whether DOCUMENT writes should store the body as the native binary
347    /// container (PRD-1398, ADR-0063). On by default after the production
348    /// cutover. Reads decode the container transparently regardless of this
349    /// flag.
350    pub(crate) fn binary_document_body_enabled(&self) -> bool {
351        self.config_bool("storage.binary_document_body", true)
352    }
353
354    pub(crate) fn config_u64(&self, key: &str, default: u64) -> u64 {
355        if let Some(raw) = self.inner.env_config_overrides.get(key) {
356            if let Some(Value::UnsignedInteger(value)) =
357                crate::runtime::config_overlay::coerce_env_value(key, raw)
358            {
359                return value;
360            }
361        }
362        match self.inner.db.store().get_config(key) {
363            Some(Value::Integer(value)) => value as u64,
364            Some(Value::UnsignedInteger(value)) => value,
365            Some(Value::Text(value)) => value.parse::<u64>().unwrap_or(default),
366            _ => default,
367        }
368    }
369
370    pub(crate) fn config_f64(&self, key: &str, default: f64) -> f64 {
371        if let Some(raw) = self.inner.env_config_overrides.get(key) {
372            if let Ok(value) = raw.parse::<f64>() {
373                return value;
374            }
375        }
376        match self.inner.db.store().get_config(key) {
377            Some(Value::Float(value)) => value,
378            Some(Value::Integer(value)) => value as f64,
379            Some(Value::UnsignedInteger(value)) => value as f64,
380            Some(Value::Text(value)) => value.parse::<f64>().unwrap_or(default),
381            _ => default,
382        }
383    }
384
385    pub(crate) fn config_string(&self, key: &str, default: &str) -> String {
386        if let Some(raw) = self.inner.env_config_overrides.get(key) {
387            return raw.clone();
388        }
389        match self.inner.db.store().get_config(key) {
390            Some(Value::Text(value)) => value.to_string(),
391            _ => default.to_string(),
392        }
393    }
394
395    /// Whether `SECRET('...')` literals should be encrypted with the
396    /// vault AES key on INSERT. Default `true`.
397    pub(crate) fn secret_auto_encrypt(&self) -> bool {
398        self.config_bool("secret.auto_encrypt", true)
399    }
400}