This is an automated email from the ASF dual-hosted git repository.
hubcio pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/iggy.git
The following commit(s) were added to refs/heads/master by this push:
new f900e7abe docs(connectors): fix the inverted serde_secret redaction
claim (#3803)
f900e7abe is described below
commit f900e7abe55d62115ac9c8675039fc07017267f5
Author: Maxim Levkov <[email protected]>
AuthorDate: Mon Aug 10 03:05:34 2026 -0700
docs(connectors): fix the inverted serde_secret redaction claim (#3803)
---
.claude/skills/connectors-overview/SKILL.md | 26 ++++++--
core/common/src/utils/serde_secret.rs | 95 ++++++++++++++++++++++++++---
2 files changed, 109 insertions(+), 12 deletions(-)
diff --git a/.claude/skills/connectors-overview/SKILL.md
b/.claude/skills/connectors-overview/SKILL.md
index 91ff86ec7..230ad5354 100644
--- a/.claude/skills/connectors-overview/SKILL.md
+++ b/.claude/skills/connectors-overview/SKILL.md
@@ -96,14 +96,24 @@ The connectors codebase is intentionally repetitive across
plugins. Cross-plugin
### Secrets
-Any credential-bearing field (connection strings, API keys, bearer tokens, AWS
keys) must be `SecretString` from the `secrecy` crate, with the workspace serde
wrapper applied so `Debug` and serialization both redact. Runtime exposes
plugin configs over the `/stats` HTTP surface via serialization - plain
`String` leaks the secret to anyone who can hit the endpoint. Plain `String`
for a credential is a review-blocker. Pattern (from
`sinks/postgres_sink/src/lib.rs::PostgresSinkConfig`):
+Any credential-bearing field (connection strings, API keys, bearer tokens, AWS
keys) must be `SecretString` from the `secrecy` crate. Plain `String` for a
credential is a review-blocker: `SecretString` redacts on `Debug`, so it is
what keeps a credential out of a log line that formats the whole config.
+
+**`serde_secret::serialize_secret` EXPOSES the secret. It does not redact.**
It calls `expose_secret()` and writes the plaintext. `SecretString`
deliberately has no `Serialize` impl, and that absence is the protection - so
adding `serialize_with` is what *unblocks* the derive and turns a compile-time
guarantee into plaintext output. Use it only where the plaintext is the point:
a wire payload, a persisted config, an API response that exposes credentials by
design.
+
+So the default for a plugin config struct is **derive `Deserialize`, but not
`Serialize`**. `Deserialize` is required: the SDK glue deserializes the config
into the plugin's own struct (`sdk/src/{sink,source}.rs` call
`serde_json::from_str::<C>` under a `DeserializeOwned` bound).
+
+What never happens is the return trip. The runtime holds plugin configuration
as a `serde_json::Value` - parsed from TOML, posted as JSON to the control API,
or injected by env var - and hands that across the FFI, so nothing
re-serializes the plugin's struct. Leaving `Serialize` off makes that
compiler-enforced instead of convention-enforced
(`sources/http_source/src/lib.rs::HttpSourceConfig` does this, and comments the
omission so nobody adds it back).
+
+Pattern:
```rust
use secrecy::{ExposeSecret, SecretString};
-#[derive(Debug, Clone, Serialize, Deserialize)]
+// `Deserialize` only. Nothing re-serializes a plugin config, and leaving
+// `Serialize` off is what makes the credential unserializable rather than
+// merely un-serialized.
+#[derive(Debug, Clone, Deserialize)]
pub struct MyConfig {
- #[serde(serialize_with = "iggy_common::serde_secret::serialize_secret")]
pub connection_string: SecretString,
}
@@ -113,7 +123,13 @@ let pool = PgPoolOptions::new()
.await?;
```
-In-tree uses: `sinks/{postgres,mongodb,elasticsearch,influxdb,delta}_sink`,
`sources/{postgres,elasticsearch,influxdb}_source`.
+If a config struct genuinely needs `Serialize`,
`serde_secret::serialize_redacted` (and `serialize_optional_redacted`) write
`[REDACTED]` in place of the value. Reach for `serialize_secret` only when the
caller must get the real thing back. The sinks and sources listed below predate
that helper and use the exposing one; the annotation is inert today, but it is
not the protection it looks like.
+
+Note that none of this protects the credential from the runtime's own control
API, which returns plugin configuration verbatim - see #3802. Plugin-side
annotations are inert there because the runtime never routes through them.
+
+Plugin-side uses of the exposing helpers:
`sinks/{postgres,mongodb,elasticsearch,influxdb,s3,surrealdb}_sink`,
`sources/{postgres,elasticsearch,influxdb}_source`.
+
+That list is plugin-side only, not an inventory of every caller in the tree,
and the others are not all mistakes: `runtime/src/api/config.rs` puts
`serialize_secret` on `HttpConfig::api_key` (inert for the same reason), and
several `core/common` wire-payload types (login, create-user, change-password,
PAT) use these helpers by design, because there the credential *is* the payload.
### Errors
@@ -193,7 +209,7 @@ Each implemented in at least one in-tree plugin or runtime
path.
| `flume::unbounded()` channel |
`runtime/src/source.rs::spawn_source_handler` / `source_forwarding_loop`
| MPSC handoff from SDK async task to runtime loop |
| `tokio::sync::watch::channel(())` |
`sdk/src/{sink,source}.rs`, `runtime/src/sink.rs`, `runtime/src/manager/*`
| One-shot shutdown broadcast |
| `dashmap::DashMap` |
`runtime/src/manager/sink.rs`, `source.rs::SOURCE_SENDERS`, SDK `INSTANCES`
| Lock-free concurrent keyed access |
-| `secrecy::SecretString` + `iggy_common::serde_secret::serialize_secret` |
`sinks/postgres_sink::PostgresSinkConfig::connection_string`
| Auto-redact on Debug/Display + serialization |
+| `secrecy::SecretString` + `iggy_common::serde_secret::serialize_secret` |
`sinks/postgres_sink::PostgresSinkConfig::connection_string`
| `Debug` redacts; `serialize_secret` EXPOSES |
## Drop accounting
diff --git a/core/common/src/utils/serde_secret.rs
b/core/common/src/utils/serde_secret.rs
index 7f8bd2feb..b10492e43 100644
--- a/core/common/src/utils/serde_secret.rs
+++ b/core/common/src/utils/serde_secret.rs
@@ -17,22 +17,42 @@
//! Serde serialization helpers for `SecretString` fields.
//!
-//! `SecretString` intentionally does not implement `Serialize` to prevent
-//! accidental secret exposure. These helpers are for fields that **must** be
-//! serialized (e.g., wire protocol payloads, persisted TOML configs, API
-//! responses that already expose credentials by design).
+//! `SecretString` intentionally does not implement `Serialize`, and that
+//! absence is the protection: a struct holding one cannot derive `Serialize`
+//! at all. Adding `serialize_with` is therefore what *unblocks* the derive, so
+//! reaching for a helper here is a decision to serialize a credential, never a
+//! way to avoid it.
+//!
+//! [`serialize_secret`] and [`serialize_optional_secret`] write the plaintext.
+//! Use them only where the plaintext is the point: wire protocol payloads,
+//! persisted configs, API responses that expose credentials by design.
//!
-//! Usage:
//! ```ignore
//! #[serde(serialize_with = "crate::utils::serde_secret::serialize_secret")]
//! pub password: SecretString,
//! ```
//!
-//! Do **not** add `serialize_with` to fields that should remain redacted in
-//! serialized output — rely on `SecretString`'s default behavior instead.
+//! [`serialize_redacted`] and [`serialize_optional_redacted`] write
+//! [`REDACTED`] in place of the value, for a struct that must be serializable
+//! for unrelated reasons but whose credential no reader is entitled to.
+//!
+//! **Redacted output is not a config.** Deserializing it hands back the
literal
+//! [`REDACTED`] as the secret, silently, so a redact-then-reload round trip
+//! replaces the credential with the placeholder instead of failing. Nothing
+//! in-tree can reach that today: these helpers have no consumers, and the one
+//! persist/reload path round-trips a raw `serde_json::Value` rather than a
+//! typed struct. If a consumer ever needs the round trip closed mechanically,
+//! the shape that cannot be half-applied is a newtype owning both directions,
+//! not a paired `deserialize_with` that a caller can forget to add.
+//!
+//! If neither applies, leave `serialize_with` off and let the missing impl
keep
+//! the field unserializable.
use secrecy::{ExposeSecret, SecretString};
+/// Placeholder written in place of a redacted secret.
+pub const REDACTED: &str = "[REDACTED]";
+
pub fn serialize_secret<S: serde::Serializer>(
secret: &SecretString,
serializer: S,
@@ -50,6 +70,28 @@ pub fn serialize_optional_secret<S: serde::Serializer>(
}
}
+/// Writes [`REDACTED`] instead of the secret.
+pub fn serialize_redacted<S: serde::Serializer>(
+ _secret: &SecretString,
+ serializer: S,
+) -> Result<S::Ok, S::Error> {
+ serializer.serialize_str(REDACTED)
+}
+
+/// Writes [`REDACTED`] instead of the secret, keeping `None` distinguishable.
+///
+/// Whether a credential is configured at all is not itself a secret, and
+/// collapsing `Some` to `null` would tell a reader the field is unset.
+pub fn serialize_optional_redacted<S: serde::Serializer>(
+ secret: &Option<SecretString>,
+ serializer: S,
+) -> Result<S::Ok, S::Error> {
+ match secret {
+ Some(_) => serializer.serialize_some(REDACTED),
+ None => serializer.serialize_none(),
+ }
+}
+
#[cfg(test)]
mod tests {
use super::*;
@@ -101,4 +143,43 @@ mod tests {
let json = serde_json::to_string(&s).unwrap();
assert_eq!(json, r#"{"token":null}"#);
}
+
+ #[derive(Serialize)]
+ struct WithRedactedSecret {
+ #[serde(serialize_with = "serialize_redacted")]
+ password: SecretString,
+ }
+
+ #[derive(Serialize)]
+ struct WithOptionalRedactedSecret {
+ #[serde(serialize_with = "serialize_optional_redacted")]
+ token: Option<SecretString>,
+ }
+
+ #[test]
+ fn serialize_redacted_replaces_value_in_json() {
+ let s = WithRedactedSecret {
+ password: SecretString::from("my_password"),
+ };
+ let json = serde_json::to_string(&s).unwrap();
+ assert_eq!(json, r#"{"password":"[REDACTED]"}"#);
+ assert!(!json.contains("my_password"));
+ }
+
+ #[test]
+ fn serialize_optional_redacted_keeps_some_distinguishable_from_none() {
+ let present = WithOptionalRedactedSecret {
+ token: Some(SecretString::from("tok_123")),
+ };
+ let absent = WithOptionalRedactedSecret { token: None };
+
+ let present_json = serde_json::to_string(&present).unwrap();
+ assert_eq!(present_json, r#"{"token":"[REDACTED]"}"#);
+ assert!(!present_json.contains("tok_123"));
+ assert_eq!(
+ serde_json::to_string(&absent).unwrap(),
+ r#"{"token":null}"#,
+ "a configured credential must not read as an unset one"
+ );
+ }
}