Skip to content

Commit afe7a1d

Browse files
author
Derek
committed
fix: add SensitiveString type, ConfigReloader registry hook, redaction tests
SensitiveString provides compile-time safe secret handling — Serialize always outputs ***REDACTED***, only .expose() reveals the value. Three-layer secret protection: 1. #[serde(skip_serializing)] — field absent entirely 2. Heuristic auto-redaction — pattern matching on field names 3. SensitiveString type — value always redacted regardless of name ConfigReloader.with_registry_update(key) connects hot-reload to the registry so listeners get notified on config changes. 19 registry tests + 12 sensitive string tests cover all redaction guarantees including deep nesting, arrays, case insensitivity, and a full string-scan leak check.
1 parent fa880a6 commit afe7a1d

5 files changed

Lines changed: 549 additions & 7 deletions

File tree

TODO.md

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -103,14 +103,14 @@ what config keys exist, their types, defaults, or descriptions.
103103
- [x] `registry::sections()` — list all registered sections
104104
- [x] `registry::dump_effective()` — JSON map of effective values
105105
- [x] `registry::dump_defaults()` — JSON map of defaults (via `T::default()`)
106-
- [x] Redaction via `#[serde(skip_serializing)]` on sensitive fields
106+
- [x] Heuristic auto-redaction (password, secret, token, key, credential, auth, private, cert, encryption)
107+
- [x] `#[serde(skip_serializing)]` as additional layer for fields that should never appear
107108
- [x] expression, memory, version_check, scaling, grpc, secrets wired with `from_cascade()` auto-register
108109
- [x] Modules without defaults (tiered_sink, http_server, kafka, spool, dlq) use `unmarshal_key_registered` from downstream apps
109-
- [ ] Health/admin endpoint integration — `/config` endpoint (redacted)
110-
- [ ] Change notification (opt-in) — consumers CAN subscribe to config reload events
111-
- Opt-in: modules that need hot-reload subscribe; others keep `OnceLock` (init-once)
112-
- `registry.on_change("expression", |new| { ... })` for subscribers
113-
- Integrate with existing `SharedConfig<T>` / `ConfigReloader` from `config-reload` feature
110+
- [x] `/config` admin endpoint (opt-in via `enable_config_endpoint`) — returns redacted effective + defaults JSON
111+
- [x] Change notification (opt-in) — `registry::on_change(key, callback)` + `registry::update()`
112+
- Modules that need hot-reload subscribe; others keep `OnceLock` (init-once)
113+
- [ ] Wire `ConfigReloader` to call `registry::update()` on reload (connect the plumbing)
114114
- [ ] Migrate all dfe-* and hyperi-* apps to `unmarshal_key_registered` pattern
115115
- [ ] Align hyperi-pylib with same registry pattern
116116

src/config/mod.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,7 @@
7979
pub mod env_compat;
8080
pub mod flat_env;
8181
pub mod registry;
82+
pub mod sensitive;
8283

8384
#[cfg(feature = "config-reload")]
8485
pub mod reloader;

src/config/registry.rs

Lines changed: 275 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -154,6 +154,14 @@ pub fn dump_defaults() -> JsonValue {
154154
///
155155
/// Any JSON field whose name (lowercased) contains one of these
156156
/// substrings will have its value replaced with `"***REDACTED***"`.
157+
/// Field name patterns that trigger automatic redaction.
158+
///
159+
/// Any JSON field whose name (lowercased) contains one of these
160+
/// substrings will have its value replaced with `"***REDACTED***"`.
161+
///
162+
/// This is a safety net — the primary protection is [`SensitiveString`]
163+
/// on the field type (compile-time safe). This heuristic catches fields
164+
/// that developers forgot to mark as sensitive.
157165
const SENSITIVE_PATTERNS: &[&str] = &[
158166
"password",
159167
"secret",
@@ -164,6 +172,8 @@ const SENSITIVE_PATTERNS: &[&str] = &[
164172
"private",
165173
"cert",
166174
"encryption",
175+
"connection_string",
176+
"dsn",
167177
];
168178

169179
const REDACTED: &str = "***REDACTED***";
@@ -531,6 +541,271 @@ mod tests {
531541
assert_eq!(get_section("fresh").unwrap().effective["enabled"], true);
532542
}
533543

544+
// ── Redaction test structs (module-level to avoid items_after_statements) ──
545+
546+
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, Default)]
547+
struct MixedCase {
548+
#[serde(rename = "Password")]
549+
password_upper: String,
550+
#[serde(rename = "API_TOKEN")]
551+
token_upper: String,
552+
#[serde(rename = "mySecret")]
553+
secret_camel: String,
554+
}
555+
556+
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, Default)]
557+
struct DeepNested {
558+
level1: Level1,
559+
}
560+
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, Default)]
561+
struct Level1 {
562+
level2: Level2,
563+
name: String,
564+
}
565+
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, Default)]
566+
struct Level2 {
567+
api_token: String,
568+
db_password: String,
569+
port: u16,
570+
}
571+
572+
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, Default)]
573+
struct WithArray {
574+
items: Vec<ArrayItem>,
575+
}
576+
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, Default)]
577+
struct ArrayItem {
578+
name: String,
579+
secret_key: String,
580+
}
581+
582+
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
583+
struct WithDefaultSecret {
584+
api_token: String,
585+
host: String,
586+
}
587+
impl Default for WithDefaultSecret {
588+
fn default() -> Self {
589+
Self {
590+
api_token: "default-placeholder-token".into(),
591+
host: "localhost".into(),
592+
}
593+
}
594+
}
595+
596+
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, Default)]
597+
struct DoubleProtected {
598+
#[serde(skip_serializing)]
599+
#[allow(dead_code)]
600+
hidden_secret: String,
601+
visible_token: String,
602+
normal: String,
603+
}
604+
605+
// ── Redaction guarantee tests ──────────────────────────────
606+
607+
/// Config struct that exercises ALL sensitive field name patterns.
608+
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, Default)]
609+
struct AllSensitivePatterns {
610+
// Each SENSITIVE_PATTERNS entry must be covered
611+
my_password: String,
612+
db_secret: String,
613+
api_token: String,
614+
encryption_key: String,
615+
aws_credential: String,
616+
oauth_auth_code: String,
617+
private_data: String,
618+
tls_cert_path: String,
619+
// Non-sensitive controls (must NOT be redacted)
620+
hostname: String,
621+
port: u16,
622+
enabled: bool,
623+
timeout_ms: u64,
624+
}
625+
626+
#[test]
627+
fn redaction_covers_all_sensitive_patterns() {
628+
serial_test!();
629+
630+
let config = AllSensitivePatterns {
631+
my_password: "pass123".into(),
632+
db_secret: "sec456".into(),
633+
api_token: "tok789".into(),
634+
encryption_key: "key012".into(),
635+
aws_credential: "cred345".into(),
636+
oauth_auth_code: "auth678".into(),
637+
private_data: "priv901".into(),
638+
tls_cert_path: "/etc/tls/cert.pem".into(),
639+
hostname: "db.prod.internal".into(),
640+
port: 5432,
641+
enabled: true,
642+
timeout_ms: 30000,
643+
};
644+
register::<AllSensitivePatterns>("all_patterns", &config);
645+
646+
let dump = dump_effective();
647+
let section = &dump["all_patterns"];
648+
649+
// Every sensitive field MUST be redacted
650+
assert_eq!(section["my_password"], REDACTED, "password pattern missed");
651+
assert_eq!(section["db_secret"], REDACTED, "secret pattern missed");
652+
assert_eq!(section["api_token"], REDACTED, "token pattern missed");
653+
assert_eq!(section["encryption_key"], REDACTED, "key pattern missed");
654+
assert_eq!(
655+
section["aws_credential"], REDACTED,
656+
"credential pattern missed"
657+
);
658+
assert_eq!(section["oauth_auth_code"], REDACTED, "auth pattern missed");
659+
assert_eq!(section["private_data"], REDACTED, "private pattern missed");
660+
assert_eq!(section["tls_cert_path"], REDACTED, "cert pattern missed");
661+
662+
// Non-sensitive fields MUST be preserved
663+
assert_eq!(section["hostname"], "db.prod.internal");
664+
assert_eq!(section["port"], 5432);
665+
assert_eq!(section["enabled"], true);
666+
assert_eq!(section["timeout_ms"], 30000);
667+
}
668+
669+
#[test]
670+
fn redaction_is_case_insensitive() {
671+
serial_test!();
672+
673+
let config = MixedCase {
674+
password_upper: "visible_if_broken".into(),
675+
token_upper: "visible_if_broken".into(),
676+
secret_camel: "visible_if_broken".into(),
677+
};
678+
register::<MixedCase>("case_test", &config);
679+
680+
let dump = dump_effective();
681+
let section = &dump["case_test"];
682+
683+
assert_eq!(section["Password"], REDACTED);
684+
assert_eq!(section["API_TOKEN"], REDACTED);
685+
assert_eq!(section["mySecret"], REDACTED);
686+
}
687+
688+
#[test]
689+
fn redaction_handles_deeply_nested_secrets() {
690+
serial_test!();
691+
692+
let config = DeepNested {
693+
level1: Level1 {
694+
level2: Level2 {
695+
api_token: "deep_secret_1".into(),
696+
db_password: "deep_secret_2".into(),
697+
port: 3306,
698+
},
699+
name: "safe_value".into(),
700+
},
701+
};
702+
register::<DeepNested>("deep", &config);
703+
704+
let dump = dump_effective();
705+
assert_eq!(dump["deep"]["level1"]["level2"]["api_token"], REDACTED);
706+
assert_eq!(dump["deep"]["level1"]["level2"]["db_password"], REDACTED);
707+
assert_eq!(dump["deep"]["level1"]["level2"]["port"], 3306);
708+
assert_eq!(dump["deep"]["level1"]["name"], "safe_value");
709+
}
710+
711+
#[test]
712+
fn redaction_handles_arrays_with_sensitive_objects() {
713+
serial_test!();
714+
715+
let config = WithArray {
716+
items: vec![
717+
ArrayItem {
718+
name: "item1".into(),
719+
secret_key: "sk_1".into(),
720+
},
721+
ArrayItem {
722+
name: "item2".into(),
723+
secret_key: "sk_2".into(),
724+
},
725+
],
726+
};
727+
register::<WithArray>("array_test", &config);
728+
729+
let dump = dump_effective();
730+
let items = dump["array_test"]["items"].as_array().unwrap();
731+
for item in items {
732+
assert_eq!(item["secret_key"], REDACTED);
733+
assert_ne!(item["name"], REDACTED); // name should be preserved
734+
}
735+
}
736+
737+
#[test]
738+
fn no_secret_values_in_redacted_dump_string() {
739+
serial_test!();
740+
741+
let secrets = [
742+
"hunter2",
743+
"sk_live_abc123",
744+
"super_s3cret!",
745+
"my-private-key-data",
746+
];
747+
748+
let config = AllSensitivePatterns {
749+
my_password: secrets[0].into(),
750+
db_secret: secrets[1].into(),
751+
api_token: secrets[2].into(),
752+
encryption_key: secrets[3].into(),
753+
..Default::default()
754+
};
755+
register::<AllSensitivePatterns>("leak_check", &config);
756+
757+
// Serialise the full dump to a string and scan for ANY secret value
758+
let dump = dump_effective();
759+
let dump_str = serde_json::to_string(&dump).unwrap();
760+
761+
for secret in &secrets {
762+
assert!(
763+
!dump_str.contains(secret),
764+
"SECRET LEAKED in dump_effective(): '{secret}' found in output"
765+
);
766+
}
767+
}
768+
769+
#[test]
770+
fn defaults_dump_also_redacted() {
771+
serial_test!();
772+
773+
register::<WithDefaultSecret>("default_secrets", &WithDefaultSecret::default());
774+
775+
let dump = dump_defaults();
776+
assert_eq!(dump["default_secrets"]["api_token"], REDACTED);
777+
assert_eq!(dump["default_secrets"]["host"], "localhost");
778+
}
779+
780+
#[test]
781+
fn skip_serializing_plus_heuristic_double_protection() {
782+
serial_test!();
783+
784+
let config = DoubleProtected {
785+
hidden_secret: "should_not_appear".into(),
786+
visible_token: "should_be_redacted".into(),
787+
normal: "visible".into(),
788+
};
789+
register::<DoubleProtected>("double", &config);
790+
791+
let dump = dump_effective();
792+
let section = &dump["double"];
793+
794+
// skip_serializing: field absent entirely
795+
assert!(section.get("hidden_secret").is_none());
796+
// heuristic: field present but redacted
797+
assert_eq!(section["visible_token"], REDACTED);
798+
// normal: preserved
799+
assert_eq!(section["normal"], "visible");
800+
801+
// String scan: neither secret should appear
802+
let dump_str = serde_json::to_string(&dump).unwrap();
803+
assert!(!dump_str.contains("should_not_appear"));
804+
assert!(!dump_str.contains("should_be_redacted"));
805+
}
806+
807+
// ── Change notification ─────────────────────────────────────
808+
534809
#[test]
535810
fn multiple_listeners_on_same_key() {
536811
serial_test!();

src/config/reloader.rs

Lines changed: 42 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -190,11 +190,15 @@ impl Default for ReloaderConfig {
190190
///
191191
/// On each trigger, calls `reload_fn` to load new config, `validate_fn` to
192192
/// validate, then updates the `SharedConfig<T>` if valid.
193+
/// Callback invoked after a successful reload with the new config value.
194+
type PostReloadHook<T> = Arc<dyn Fn(&T) + Send + Sync>;
195+
193196
pub struct ConfigReloader<T: Clone + Send + Sync + 'static> {
194197
config: ReloaderConfig,
195198
shared: SharedConfig<T>,
196199
reload_fn: Arc<dyn Fn() -> Result<T, BoxError> + Send + Sync>,
197200
validate_fn: Arc<dyn Fn(&T) -> Result<(), BoxError> + Send + Sync>,
201+
post_reload_hooks: Vec<PostReloadHook<T>>,
198202
}
199203

200204
impl<T: Clone + Send + Sync + 'static> ConfigReloader<T> {
@@ -215,9 +219,41 @@ impl<T: Clone + Send + Sync + 'static> ConfigReloader<T> {
215219
shared,
216220
reload_fn: Arc::new(reload_fn),
217221
validate_fn: Arc::new(validate_fn),
222+
post_reload_hooks: Vec::new(),
218223
}
219224
}
220225

226+
/// Add a hook that runs after each successful reload.
227+
///
228+
/// Use this to connect to the config registry:
229+
///
230+
/// ```rust,no_run
231+
/// # use hyperi_rustlib::config::reloader::ConfigReloader;
232+
/// # use hyperi_rustlib::config::registry;
233+
/// // reloader.with_registry_update("my_app");
234+
/// ```
235+
#[must_use]
236+
pub fn with_post_reload_hook(mut self, hook: impl Fn(&T) + Send + Sync + 'static) -> Self {
237+
self.post_reload_hooks.push(Arc::new(hook));
238+
self
239+
}
240+
241+
/// Connect to the config registry: after each successful reload,
242+
/// call `registry::update()` so listeners are notified and the
243+
/// registry reflects the new effective config.
244+
///
245+
/// Requires `T: Serialize + Default`.
246+
#[must_use]
247+
pub fn with_registry_update(self, key: &str) -> Self
248+
where
249+
T: serde::Serialize + Default,
250+
{
251+
let key = key.to_string();
252+
self.with_post_reload_hook(move |config| {
253+
super::registry::update::<T>(&key, config);
254+
})
255+
}
256+
221257
/// Start the reload loop in a background task.
222258
///
223259
/// Returns a `JoinHandle` that can be used to abort the reloader.
@@ -418,9 +454,14 @@ impl<T: Clone + Send + Sync + 'static> ConfigReloader<T> {
418454
}
419455

420456
let old_version = self.shared.version();
421-
self.shared.update(new_config);
457+
self.shared.update(new_config.clone());
422458
let new_version = self.shared.version();
423459

460+
// Run post-reload hooks (registry update, etc.)
461+
for hook in &self.post_reload_hooks {
462+
hook(&new_config);
463+
}
464+
424465
#[cfg(feature = "metrics")]
425466
metrics::counter!("config_reloads_total", "result" => "success").increment(1);
426467

0 commit comments

Comments
 (0)