Skip to main content

toolkit_security/
internal_auth_config.rs

1//! Declarative configuration for the platform (internal) authentication plane.
2//!
3//! [`InternalAuthConfig`] is the single, transport-agnostic config surface used
4//! by every participant in the platform plane:
5//!
6//! - **`OoP` gears** (`oop_http.internal_auth`) — selects the *inbound* HTTP
7//!   validator for the gear's own routes **and** the *outbound* credential the
8//!   gear attaches to its `DirectoryService` calls.
9//! - **The `gear-orchestrator`** — selects the *inbound* gRPC validator that
10//!   protects the `DirectoryService` RPCs.
11//! - **The `api-gateway`** (`gateway_proxy.internal_auth`) — selects the
12//!   *outbound* credential attached to the edge's `DirectoryService` polls.
13//!
14//! Two providers are supported:
15//!
16//! - [`InternalAuthConfig::SharedSecret`] — a single pre-shared token. No
17//!   Kubernetes required; ideal for local demos, single-node deployments, and
18//!   tests. Both the inbound validator and the outbound credential are derived
19//!   from the same `secret`.
20//! - [`InternalAuthConfig::Kube`] — a projected `ServiceAccount` token
21//!   (Profile 3). Inbound validation uses the Kubernetes `TokenReview` API
22//!   (built in a layer that can depend on `kube`); the outbound credential is
23//!   read (and rotated) from `token_path`.
24//!
25//! This crate builds only the dependency-light shared-secret validator
26//! ([`build_authenticator`](InternalAuthConfig::build_authenticator)); the
27//! `TokenReview` validator and the outbound interceptor are constructed by the
28//! `kube` / transport layers, which read the accessors here.
29
30use std::path::{Path, PathBuf};
31
32use secrecy::SecretString;
33use serde::{Deserialize, Serialize};
34
35use crate::authenticator::DynInternalAuthenticator;
36use crate::shared_secret::{InvalidSharedSecret, SharedSecretInternalAuthenticator};
37
38/// Default caller label assigned to a validated shared-secret peer.
39pub const DEFAULT_INTERNAL_PEER_NAME: &str = "toolkit-internal";
40
41/// Platform-plane authentication provider selection.
42///
43/// Serialized with an internal `provider` tag, e.g.
44///
45/// ```yaml
46/// internal_auth:
47///   provider: shared_secret
48///   secret: "dev-internal-token"
49///   peer_name: "hello"
50/// ```
51///
52/// or
53///
54/// ```yaml
55/// internal_auth:
56///   provider: kube
57///   audiences: ["toolkit-internal"]
58///   token_path: /var/run/secrets/tokens/toolkit-internal
59/// ```
60#[derive(Clone, Deserialize)]
61#[serde(tag = "provider", rename_all = "snake_case")]
62pub enum InternalAuthConfig {
63    /// A single pre-shared secret (dev / single-node). See the [module
64    /// docs](self).
65    SharedSecret {
66        /// The shared token accepted (inbound) and attached (outbound).
67        ///
68        /// A `SecretString` rather than a `String` so redaction is structural:
69        /// it zeroizes on drop and renders as `[REDACTED]` in any `{:?}` sink,
70        /// instead of depending on the hand-written `Debug` and `Serialize`
71        /// impls below staying correct as fields are added.
72        secret: SecretString,
73        /// Caller label assigned to validated peers (inbound side only).
74        #[serde(default = "default_peer_name")]
75        peer_name: String,
76    },
77    /// A projected Kubernetes `ServiceAccount` token (Profile 3).
78    Kube {
79        /// Expected token audiences for `TokenReview` (inbound).
80        ///
81        /// **Required, and must not be empty.** An empty list disables audience
82        /// binding twice over in `toolkit-k8s-auth`: no audience is sent to the
83        /// API server, *and* the client-side comparison against the response is
84        /// skipped. Any `ServiceAccount` token the API server accepts — from
85        /// any workload in the cluster, issued for any audience — would then
86        /// authenticate as a platform peer.
87        ///
88        /// It used to default to empty, so a config that simply omitted the
89        /// field got that silently.
90        #[serde(deserialize_with = "non_empty_audiences")]
91        audiences: Vec<String>,
92        /// Projected-token path to read + rotate for outbound calls. When
93        /// absent, no outbound credential is attached (inbound-only).
94        #[serde(default)]
95        token_path: Option<PathBuf>,
96    },
97}
98
99fn default_peer_name() -> String {
100    DEFAULT_INTERNAL_PEER_NAME.to_owned()
101}
102
103/// Deserialize `audiences`, refusing an absent or empty list.
104///
105/// Failing here rather than at first use is deliberate: an unbound audience is
106/// a cluster-wide authentication weakness, and a service that comes up and
107/// accepts every `ServiceAccount` token is far worse than one that refuses to
108/// start with a config error naming the field.
109fn non_empty_audiences<'de, D>(deserializer: D) -> Result<Vec<String>, D::Error>
110where
111    D: serde::Deserializer<'de>,
112{
113    use serde::de::Error as _;
114
115    let audiences = Vec::<String>::deserialize(deserializer)?;
116    if audiences.is_empty() {
117        return Err(D::Error::custom(
118            "internal_auth provider=kube requires a non-empty `audiences` list; an empty one \
119             disables audience verification and accepts any ServiceAccount token in the cluster",
120        ));
121    }
122    Ok(audiences)
123}
124
125/// Manual [`Debug`] that never renders the shared secret. The derived impl would
126/// print `secret` verbatim, leaking the platform-plane credential into any
127/// `{:?}` sink (config tracing, panic messages, error context). All other
128/// fields — including `peer_name` and the `Kube` variant — are shown as-is.
129impl std::fmt::Debug for InternalAuthConfig {
130    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
131        match self {
132            Self::SharedSecret { peer_name, .. } => f
133                .debug_struct("SharedSecret")
134                .field("secret", &"<redacted>")
135                .field("peer_name", peer_name)
136                .finish(),
137            Self::Kube {
138                audiences,
139                token_path,
140            } => f
141                .debug_struct("Kube")
142                .field("audiences", audiences)
143                .field("token_path", token_path)
144                .finish(),
145        }
146    }
147}
148
149/// Manual [`Serialize`] that never emits the shared secret in plaintext.
150///
151/// A derived `Serialize` would write `secret` verbatim, leaking the
152/// platform-plane credential whenever a containing config is serialized — most
153/// notably `AppConfig::to_yaml` behind `--print-config`. Instead the secret is
154/// replaced with a `<redacted>` placeholder; every other field (and the
155/// internally-tagged `provider` shape) is preserved so the output still round-
156/// trips structurally.
157///
158/// This is safe because the config is only ever *deserialized* to obtain the
159/// real secret; serialization is used for diagnostics, never to transmit the
160/// credential.
161impl Serialize for InternalAuthConfig {
162    fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
163        use serde::ser::SerializeMap;
164        match self {
165            Self::SharedSecret { peer_name, .. } => {
166                let mut map = serializer.serialize_map(Some(3))?;
167                map.serialize_entry("provider", "shared_secret")?;
168                map.serialize_entry("secret", "<redacted>")?;
169                map.serialize_entry("peer_name", peer_name)?;
170                map.end()
171            }
172            Self::Kube {
173                audiences,
174                token_path,
175            } => {
176                let mut map = serializer.serialize_map(Some(3))?;
177                map.serialize_entry("provider", "kube")?;
178                map.serialize_entry("audiences", audiences)?;
179                map.serialize_entry("token_path", token_path)?;
180                map.end()
181            }
182        }
183    }
184}
185
186/// Why a platform-plane authenticator could not be built.
187#[derive(Debug, thiserror::Error)]
188#[non_exhaustive]
189pub enum InvalidInternalAuth {
190    /// The configured shared secret is unusable.
191    #[error(transparent)]
192    SharedSecret(#[from] InvalidSharedSecret),
193    /// `provider: kube` was configured with no audiences. An empty list
194    /// disables audience verification on both sides — nothing is sent to the
195    /// API server, and the client-side check against the response is skipped —
196    /// so any `ServiceAccount` token the cluster issues would authenticate.
197    #[error(
198        "internal_auth provider=kube requires a non-empty `audiences` list; an empty one \
199         disables audience verification and accepts any ServiceAccount token in the cluster"
200    )]
201    EmptyKubeAudiences,
202}
203
204/// What [`InternalAuthConfig::build_authenticator`] could do with the config.
205///
206/// An `Option` conflated two unrelated answers on `None`: "this provider needs
207/// no validator" and "this provider's validator must be built by a layer that
208/// can depend on `kube`". Nothing in the type said which, so all three callers
209/// re-derived it with `is_kube()` afterwards and each had to remember to fail
210/// on the fallthrough — a caller that forgot would have run an unauthenticated
211/// platform plane.
212#[derive(Debug)]
213pub enum BuiltAuthenticator {
214    /// The validator was built here.
215    Built(DynInternalAuthenticator),
216    /// The provider needs a backend this crate cannot construct. Build it with
217    /// [`kube_audiences`](InternalAuthConfig::kube_audiences), via a layer that
218    /// depends on `kube` (e.g. `toolkit-k8s-auth`).
219    RequiresExternalBackend,
220}
221
222impl InternalAuthConfig {
223    /// Build the **inbound** validator when it can be constructed without a
224    /// heavier backend.
225    ///
226    /// # Errors
227    ///
228    /// Returns [`InvalidInternalAuth::SharedSecret`] when the configured shared
229    /// secret is unusable — empty, or the redaction placeholder from a
230    /// serialized config. Returns [`InvalidInternalAuth::EmptyKubeAudiences`]
231    /// when `provider: kube` has no configured audiences.
232    pub fn build_authenticator(&self) -> Result<BuiltAuthenticator, InvalidInternalAuth> {
233        match self {
234            Self::SharedSecret { secret, peer_name } => {
235                let auth =
236                    SharedSecretInternalAuthenticator::try_new(secret.clone(), peer_name.clone())?;
237                Ok(BuiltAuthenticator::Built(DynInternalAuthenticator::new(
238                    auth,
239                )))
240            }
241            // Checked here as well as during deserialization. The variant's
242            // fields are public, so a config built in code never passes through
243            // serde — and every caller reaches this before it reads
244            // `kube_audiences`, which makes it the last point where an unbound
245            // validator can still be refused.
246            Self::Kube { audiences, .. } if audiences.is_empty() => {
247                Err(InvalidInternalAuth::EmptyKubeAudiences)
248            }
249            Self::Kube { .. } => Ok(BuiltAuthenticator::RequiresExternalBackend),
250        }
251    }
252
253    /// The static outbound credential for the shared-secret provider, if any.
254    #[must_use]
255    pub fn shared_secret(&self) -> Option<SecretString> {
256        match self {
257            Self::SharedSecret { secret, .. } => Some(secret.clone()),
258            Self::Kube { .. } => None,
259        }
260    }
261
262    /// Whether this config selects the Kubernetes provider.
263    #[must_use]
264    pub fn is_kube(&self) -> bool {
265        matches!(self, Self::Kube { .. })
266    }
267
268    /// The configured `TokenReview` audiences for the Kubernetes provider.
269    #[must_use]
270    pub fn kube_audiences(&self) -> Option<&[String]> {
271        match self {
272            Self::Kube { audiences, .. } => Some(audiences),
273            Self::SharedSecret { .. } => None,
274        }
275    }
276
277    /// The projected-token path for the Kubernetes provider's outbound
278    /// credential, if configured.
279    #[must_use]
280    pub fn kube_token_path(&self) -> Option<&Path> {
281        match self {
282            Self::Kube { token_path, .. } => token_path.as_deref(),
283            Self::SharedSecret { .. } => None,
284        }
285    }
286}
287
288#[cfg(test)]
289#[cfg_attr(coverage_nightly, coverage(off))]
290mod tests {
291    use super::*;
292    use crate::internal_auth::{InternalAuthNError, InternalAuthenticator, PlatformIdentity};
293    use secrecy::ExposeSecret;
294
295    #[test]
296    fn debug_redacts_shared_secret_but_keeps_other_fields() {
297        let cfg = InternalAuthConfig::SharedSecret {
298            secret: SecretString::from("super-secret-token"),
299            peer_name: "hello".to_owned(),
300        };
301        let rendered = format!("{cfg:?}");
302        assert!(
303            !rendered.contains("super-secret-token"),
304            "secret must never appear in Debug output: {rendered}"
305        );
306        assert!(rendered.contains("<redacted>"), "expected redaction marker");
307        assert!(rendered.contains("hello"), "peer_name must be preserved");
308
309        // The Kube variant carries no secret; all fields render normally.
310        let kube = InternalAuthConfig::Kube {
311            audiences: vec!["toolkit-internal".to_owned()],
312            token_path: Some(PathBuf::from("/var/run/secrets/tokens/t")),
313        };
314        let rendered = format!("{kube:?}");
315        assert!(rendered.contains("toolkit-internal"));
316        assert!(rendered.contains("/var/run/secrets/tokens/t"));
317    }
318
319    #[test]
320    fn serialize_redacts_shared_secret_but_keeps_other_fields() {
321        let cfg = InternalAuthConfig::SharedSecret {
322            secret: SecretString::from("super-secret-token"),
323            peer_name: "hello".to_owned(),
324        };
325        let json = serde_json::to_string(&cfg).expect("serialize");
326        assert!(
327            !json.contains("super-secret-token"),
328            "secret must never be serialized in plaintext: {json}"
329        );
330        assert!(json.contains("<redacted>"), "expected redaction marker");
331        assert!(json.contains("hello"), "peer_name must be preserved");
332        assert!(json.contains("shared_secret"), "provider tag preserved");
333
334        // The redacted form still deserializes structurally (tag + fields).
335        let round: InternalAuthConfig = serde_json::from_str(&json).expect("round-trip");
336        assert!(matches!(round, InternalAuthConfig::SharedSecret { .. }));
337
338        // The Kube variant carries no secret; all fields serialize normally.
339        let kube = InternalAuthConfig::Kube {
340            audiences: vec!["toolkit-internal".to_owned()],
341            token_path: Some(PathBuf::from("/var/run/secrets/tokens/t")),
342        };
343        let json = serde_json::to_string(&kube).expect("serialize");
344        assert!(json.contains("kube"));
345        assert!(json.contains("toolkit-internal"));
346        assert!(json.contains("/var/run/secrets/tokens/t"));
347    }
348
349    #[tokio::test]
350    async fn deserializes_shared_secret_with_default_peer_name() {
351        let cfg: InternalAuthConfig = serde_json::from_value(serde_json::json!({
352            "provider": "shared_secret",
353            "secret": "s"
354        }))
355        .unwrap();
356        match &cfg {
357            InternalAuthConfig::SharedSecret { secret, peer_name } => {
358                assert_eq!(secret.expose_secret(), "s");
359                assert_eq!(peer_name, DEFAULT_INTERNAL_PEER_NAME);
360            }
361            InternalAuthConfig::Kube { .. } => panic!("expected shared_secret"),
362        }
363        assert!(!cfg.is_kube());
364
365        // Authenticate through what was built, rather than asserting only that
366        // something was: `is_some()` passes even if `secret` and `peer_name`
367        // were wired to the authenticator the wrong way round.
368        let BuiltAuthenticator::Built(authenticator) =
369            cfg.build_authenticator().expect("a valid secret")
370        else {
371            panic!("shared_secret builds its validator here");
372        };
373        let identity = authenticator
374            .authenticate("s")
375            .await
376            .expect("the configured secret must authenticate");
377        assert_eq!(
378            identity,
379            PlatformIdentity::Shared {
380                name: DEFAULT_INTERNAL_PEER_NAME.to_owned()
381            },
382            "the caller must be labelled with the configured peer name"
383        );
384
385        let rejected = authenticator.authenticate("not-the-secret").await;
386        assert!(
387            matches!(rejected, Err(InternalAuthNError::InvalidToken)),
388            "a wrong secret must be rejected, got {rejected:?}"
389        );
390    }
391
392    #[test]
393    fn deserializes_kube_with_token_path() {
394        let cfg: InternalAuthConfig = serde_json::from_value(serde_json::json!({
395            "provider": "kube",
396            "audiences": ["toolkit-internal"],
397            "token_path": "/var/run/secrets/tokens/toolkit-internal"
398        }))
399        .unwrap();
400        assert!(cfg.is_kube());
401        assert_eq!(
402            cfg.kube_audiences(),
403            Some(&["toolkit-internal".to_owned()][..])
404        );
405        assert_eq!(
406            cfg.kube_token_path().map(Path::to_owned),
407            Some(PathBuf::from("/var/run/secrets/tokens/toolkit-internal"))
408        );
409        // Kube inbound validator is built elsewhere (needs kube), and the type
410        // says so rather than returning a bare `None` the caller has to
411        // interpret.
412        assert!(matches!(
413            cfg.build_authenticator(),
414            Ok(BuiltAuthenticator::RequiresExternalBackend)
415        ));
416        assert!(cfg.shared_secret().is_none());
417    }
418
419    #[test]
420    fn kube_built_in_code_with_no_audiences_is_refused() {
421        // The serde check does not cover this: the variant's fields are public,
422        // so a config constructed in code never passes through deserialization.
423        let unbound = InternalAuthConfig::Kube {
424            audiences: Vec::new(),
425            token_path: None,
426        };
427        assert!(matches!(
428            unbound.build_authenticator(),
429            Err(InvalidInternalAuth::EmptyKubeAudiences)
430        ));
431
432        // With an audience it is buildable again, by the layer that owns kube.
433        let bound = InternalAuthConfig::Kube {
434            audiences: vec!["toolkit-internal".to_owned()],
435            token_path: None,
436        };
437        assert!(matches!(
438            bound.build_authenticator(),
439            Ok(BuiltAuthenticator::RequiresExternalBackend)
440        ));
441    }
442
443    #[test]
444    fn kube_without_audiences_is_refused() {
445        // An omitted list used to default to empty, which disables audience
446        // binding entirely -- any ServiceAccount token in the cluster would
447        // authenticate as a platform peer. Refusing the config is the point:
448        // failing to start beats starting unbound.
449        let err =
450            serde_json::from_value::<InternalAuthConfig>(serde_json::json!({ "provider": "kube" }))
451                .expect_err("kube without audiences must not deserialize");
452        assert!(
453            err.to_string().contains("audiences"),
454            "the error must name the field: {err}"
455        );
456
457        let err = serde_json::from_value::<InternalAuthConfig>(
458            serde_json::json!({ "provider": "kube", "audiences": [] }),
459        )
460        .expect_err("an explicitly empty list is the same weakness");
461        assert!(err.to_string().contains("audiences"), "got: {err}");
462    }
463
464    #[test]
465    fn kube_with_audiences_and_no_token_path_is_inbound_only() {
466        let cfg: InternalAuthConfig = serde_json::from_value(
467            serde_json::json!({ "provider": "kube", "audiences": ["toolkit-internal"] }),
468        )
469        .unwrap();
470
471        assert!(cfg.is_kube());
472        assert_eq!(
473            cfg.kube_token_path(),
474            None,
475            "no token path means inbound-only: nothing is minted outbound"
476        );
477    }
478
479    #[test]
480    fn rejects_an_unknown_provider() {
481        // A typo in `provider` must fail the config rather than silently
482        // selecting a default plane.
483        let result: Result<InternalAuthConfig, _> =
484            serde_json::from_value(serde_json::json!({ "provider": "kubernetes" }));
485        assert!(
486            result.is_err(),
487            "an unrecognised provider must not deserialize"
488        );
489    }
490
491    #[test]
492    fn shared_secret_accessors_return_none_for_kube_fields() {
493        let cfg = InternalAuthConfig::SharedSecret {
494            secret: SecretString::from("s"),
495            peer_name: "hello".to_owned(),
496        };
497        // Outbound credential is the shared secret itself.
498        assert_eq!(
499            cfg.shared_secret().map(|s| s.expose_secret().to_owned()),
500            Some("s".to_owned())
501        );
502        // Kube-only accessors are None for the shared-secret provider.
503        assert!(cfg.kube_audiences().is_none());
504        assert!(cfg.kube_token_path().is_none());
505    }
506}