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}