toolkit_security/internal_auth.rs
1//! Platform-plane (workload) authentication primitives.
2//!
3//! The platform plane authenticates *which gear* is making a system-initiated
4//! call that carries **no user context** (`DirectoryService` registration,
5//! heartbeats, global GTS registration). It is deliberately separate from the
6//! tenant plane (`Authorization: Bearer <jwt>` + [`SecurityContext`]): platform
7//! calls carry an [`InternalCredential`] over the `X-ToolKit-Internal-Token`
8//! header (never `Authorization`, to avoid colliding with the user JWT) and
9//! resolve to a [`PlatformSecurityContext`] — a type **distinct** from the
10//! tenant [`SecurityContext`] that is never evaluated by the tenant
11//! `PolicyEnforcer`.
12//!
13//! This module owns only the transport-agnostic, dependency-light pieces: the
14//! credential/identity types, a neutral error, and the [`InternalAuthenticator`]
15//! authentication trait. The Axum middleware, the tonic interceptor, and the
16//! concrete K8s `TokenReview` validator live in the transport / bootstrap
17//! layers so this foundational crate stays free of `axum`, `tonic`, and `kube`.
18//!
19//! [`SecurityContext`]: crate::context::SecurityContext
20
21use std::future::Future;
22use std::path::PathBuf;
23
24use secrecy::SecretString;
25
26/// The credential a gear attaches to its system (platform-plane) calls.
27///
28/// Selected by deployment profile at the bootstrap layer. The variant set is
29/// **frozen now** to keep the API stable across phases, but only
30/// [`InternalCredential::None`] (Profile 1) and
31/// [`InternalCredential::KubeServiceAccountToken`] (Profile 3) are wired in the
32/// first phase. [`InternalCredential::BootstrapToken`] (Profile 2) and
33/// [`InternalCredential::MtlsIdentity`] (mTLS end state) are struct-only —
34/// their validation/wiring is deferred to a later phase.
35#[derive(Debug, Clone)]
36#[non_exhaustive]
37pub enum InternalCredential {
38 /// Profile 1 (in-process): no credential — the process boundary is the
39 /// trust root, so no header/metadata is attached.
40 None,
41 /// Profile 2 (single-node): an ephemeral bootstrap token minted by the
42 /// Platform Host. Struct-only in the first phase; validation deferred to P2.
43 BootstrapToken(SecretString),
44 /// Profile 3 (K8s): a projected `ServiceAccount` JWT (auto-mounted,
45 /// auto-rotated). `token_path` is the projected-volume path; `audience` is
46 /// the expected token audience (e.g. `toolkit-internal`).
47 KubeServiceAccountToken {
48 /// Path to the projected SA token file.
49 token_path: PathBuf,
50 /// Expected audience the token must be scoped to.
51 audience: String,
52 },
53 /// End state (and Profile 2 multi-node): an mTLS client identity. Struct-only
54 /// here; mTLS validation/wiring is deferred to a later phase.
55 MtlsIdentity {
56 /// Client certificate path.
57 cert: PathBuf,
58 /// Client private-key path.
59 key: PathBuf,
60 /// Trust-anchor (CA bundle) path.
61 ca: PathBuf,
62 },
63}
64
65/// Method-agnostic platform identity produced by validating an
66/// [`InternalCredential`].
67///
68/// The variant reflects *which credential* authenticated the caller; platform
69/// handlers consume a [`PlatformSecurityContext`] without branching on it. New
70/// authentication methods add a variant — hence `#[non_exhaustive]` — without
71/// changing [`PlatformSecurityContext`]'s shape.
72#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
73#[non_exhaustive]
74#[serde(tag = "type")]
75pub enum PlatformIdentity {
76 /// First phase: a validated K8s `ServiceAccount` (from a projected SA token
77 /// verified via the `TokenReview` API).
78 KubernetesServiceAccount {
79 /// Namespace the `ServiceAccount` belongs to.
80 namespace: String,
81 /// `ServiceAccount` name.
82 service_account: String,
83 /// Originating pod name, when the token review reports it.
84 pod: Option<String>,
85 },
86 /// Next phase: an mTLS + SPIFFE workload identity parsed from the X.509 SAN
87 /// (`spiffe://<trust_domain>/gear/<name>/<version>`). Reserved — not
88 /// populated in the first phase.
89 Spiffe {
90 /// SPIFFE trust domain.
91 trust_domain: String,
92 /// Workload name component of the SPIFFE ID (the gear segment of
93 /// `spiffe://<trust_domain>/gear/<name>/<version>`).
94 name: String,
95 /// Version component of the SPIFFE ID.
96 version: String,
97 },
98 /// A caller authenticated by a pre-shared secret (dev / single-node
99 /// profiles). Not tied to any deployment substrate: it exists so the
100 /// platform plane can be exercised end-to-end without Kubernetes. The
101 /// `name` is a configured label used for workload-policy decisions.
102 Shared {
103 /// Configured caller label (returned by [`PlatformIdentity::peer_name`]).
104 name: String,
105 },
106 /// A credential-free outbound plane marker, not an authenticated caller.
107 ///
108 /// Produced only by [`PlatformSecurityContext::outbound_marker`], so gear
109 /// code can satisfy a platform-plane signature without possessing a real
110 /// identity. It is never the result of validating anything, and must never
111 /// authorize anything.
112 ///
113 /// It has its own variant because it used to share [`Self::Unknown`],
114 /// leaving one value meaning two unrelated things — a locally minted marker
115 /// and a peer identity this build does not recognise — with `peer_name()`
116 /// answering `"<unknown>"` for both.
117 OutboundMarker,
118 /// Catch-all for variants introduced in a newer library version.
119 ///
120 /// Produced only by `serde::Deserialize` when the `"type"` field holds an
121 /// unrecognised value; `peer_name` returns `"<unknown>"` for it.
122 ///
123 /// It no longer doubles as the outbound marker — see
124 /// [`Self::OutboundMarker`].
125 #[serde(other)]
126 Unknown,
127}
128
129impl PlatformIdentity {
130 /// The caller's name, distilled for workload-policy decisions.
131 ///
132 /// For a [`PlatformIdentity::KubernetesServiceAccount`] this is the `ServiceAccount`
133 /// name; for [`PlatformIdentity::Spiffe`] it is the workload (gear) component
134 /// of the SPIFFE ID.
135 #[must_use]
136 pub fn peer_name(&self) -> &str {
137 match self {
138 Self::KubernetesServiceAccount {
139 service_account, ..
140 } => service_account,
141 Self::Spiffe { name, .. } | Self::Shared { name } => name,
142 Self::Unknown => "<unknown>",
143 // Deliberately distinct from `<unknown>`: this one is not a peer at
144 // all, and a log line saying so is worth more than one that looks
145 // like an unrecognised caller.
146 Self::OutboundMarker => "<outbound-marker>",
147 }
148 }
149}
150
151/// Identity for non-tenant, platform-scoped operations.
152///
153/// **Never** carries a tenant subject and is **never** passed to the tenant
154/// `PolicyEnforcer`. This is a separate type from the tenant
155/// [`SecurityContext`] by design (separation of mechanisms): "this call has no
156/// tenant" is unrepresentable-as-a-tenant rather than encoded as a nil tenant
157/// id. The wrapper is stable across authentication phases; only its
158/// [`PlatformIdentity`] changes.
159///
160/// [`SecurityContext`]: crate::context::SecurityContext
161#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
162pub struct PlatformSecurityContext {
163 identity: PlatformIdentity,
164}
165
166impl PlatformSecurityContext {
167 /// Wrap a validated [`PlatformIdentity`].
168 #[must_use]
169 pub fn new(identity: PlatformIdentity) -> Self {
170 Self { identity }
171 }
172
173 /// Construct a credential-free **outbound plane marker**.
174 ///
175 /// This is **not** a validated identity. It exists so gear-layer code (e.g.
176 /// a `PolicyEnforcer`) can call a platform-plane contract method whose
177 /// signature takes a [`PlatformSecurityContext`] *argument* for compile-time
178 /// plane selection, without possessing — or being able to forge — a real
179 /// one. The marker carries no secret and is **discarded** by the generated
180 /// client: the actual platform credential is attached below the contract
181 /// layer from the process `InternalTokenProvider`, and the receiver
182 /// re-derives the real identity by validating that wire token
183 /// (`cpt-cf-adr-two-plane-auth`).
184 ///
185 /// It MUST never be consulted for an authorization decision — its
186 /// [`PlatformIdentity`] is [`PlatformIdentity::OutboundMarker`], a variant
187 /// no validation ever produces, so a consumer that mistakenly reads it
188 /// derives neither a caller name nor a peer that could be mistaken for one.
189 #[must_use]
190 pub fn outbound_marker() -> Self {
191 Self {
192 identity: PlatformIdentity::OutboundMarker,
193 }
194 }
195
196 /// Whether this is the credential-free [outbound marker], rather than a
197 /// validated caller.
198 ///
199 /// [outbound marker]: PlatformSecurityContext::outbound_marker
200 #[must_use]
201 pub fn is_outbound_marker(&self) -> bool {
202 matches!(self.identity, PlatformIdentity::OutboundMarker)
203 }
204
205 /// The validated platform identity backing this context.
206 #[must_use]
207 pub fn identity(&self) -> &PlatformIdentity {
208 &self.identity
209 }
210
211 /// Consume the context, returning the owned [`PlatformIdentity`].
212 #[must_use]
213 pub fn into_identity(self) -> PlatformIdentity {
214 self.identity
215 }
216}
217
218/// Lightweight marker that *some* gear authenticated as a peer.
219///
220/// Distilled to the caller's name and consumed **only** by workload-policy
221/// checks (e.g. "only `flight-control` may call `DeregisterInstance`"). It is
222/// **not** a prerequisite for trusting a user context: the tenant JWT is
223/// self-authenticating and is always re-validated regardless of peer trust.
224/// It never substitutes for tenant-plane validation.
225#[derive(Debug, Clone, PartialEq, Eq)]
226pub struct PeerAuthenticated {
227 /// The authenticated caller's name.
228 pub name: String,
229}
230
231/// Runtime signal that "this listener enforces platform-plane auth".
232///
233/// Stamped onto every request handled by an active platform-plane enforcement
234/// layer — the gRPC `InternalAuthGrpcLayer` (before its exempt check, so even
235/// exempt methods carry it) and the HTTP `internal_auth_middleware`. It lets a
236/// handler distinguish two token-less cases that both lack a
237/// [`PlatformSecurityContext`]:
238///
239/// - **Marker present, no context** — an enforcing listener let an *anonymous*
240/// caller through (a permissive or exempt path). A handler that authorizes
241/// per-peer must fail closed here: an unauthenticated caller must not be
242/// treated as more privileged than an honest token holder.
243/// - **Marker absent** — no platform-plane enforcement is installed (Profile 1
244/// / in-process); there is no trust boundary to honour, so handlers fail open.
245///
246/// The marker carries no identity and grants nothing on its own; it only
247/// reports the listener's posture, so a handler never has to read another
248/// component's configuration to learn it.
249#[derive(Debug, Clone, Copy, PartialEq, Eq)]
250pub struct PlatformAuthEnforced;
251
252/// Neutral platform-plane authentication error.
253///
254/// Intentionally coarse-grained and transport-agnostic: it never carries the
255/// token or provider-specific detail, so it is safe to surface at a trust
256/// boundary. Concrete validators map their own errors into these variants.
257#[derive(Debug, thiserror::Error)]
258#[non_exhaustive]
259pub enum InternalAuthNError {
260 /// The credential was present but failed validation (bad signature, wrong
261 /// audience, expired, not authenticated by the backend, etc.).
262 #[error("invalid internal credential")]
263 InvalidToken,
264 /// The validation backend (e.g. the K8s `TokenReview` API) could not be
265 /// reached or returned a transient failure. Callers may retry or surface 503.
266 #[error("internal-auth backend unavailable")]
267 Unavailable,
268 /// Any other validation failure. The message must not contain the token or
269 /// other sensitive material.
270 #[error("internal authentication failed: {0}")]
271 Other(String),
272}
273
274/// Authenticates a raw platform-plane credential and resolves the caller's
275/// [`PlatformIdentity`].
276///
277/// The transport layer (Axum middleware / tonic interceptor) stays generic over
278/// this trait; the concrete validator (K8s `TokenReview` in the first phase) is
279/// supplied at the gear/bootstrap layer so neither `toolkit-http` nor
280/// `toolkit-transport-grpc` depend on `kube`.
281///
282/// The returned future is `Send` so the trait can be used from Axum/Tower
283/// middleware on a multi-threaded runtime.
284pub trait InternalAuthenticator: Send + Sync {
285 /// Authenticate the raw `X-ToolKit-Internal-Token` value and resolve the
286 /// caller's [`PlatformIdentity`].
287 ///
288 /// cancel-safe: this future is dropped mid-flight when a client
289 /// disconnects — it runs from middleware on an abortable task — and also
290 /// when a caller bounds it with `tokio::time::timeout`, as the caching
291 /// wrapper in this crate does. An implementation must hold no state across
292 /// the await that would be corrupted by never resuming: an abandoned call
293 /// must leave the authenticator exactly as it found it.
294 ///
295 /// # Errors
296 ///
297 /// Returns [`InternalAuthNError`] if the credential is invalid, the backend
298 /// is unavailable, or authentication otherwise fails.
299 fn authenticate(
300 &self,
301 token: &str,
302 ) -> impl Future<Output = Result<PlatformIdentity, InternalAuthNError>> + Send;
303}
304
305#[cfg(test)]
306#[cfg_attr(coverage_nightly, coverage(off))]
307mod tests {
308 use super::*;
309
310 #[test]
311 fn platform_identity_peer_name() {
312 let sa = PlatformIdentity::KubernetesServiceAccount {
313 namespace: "toolkit".to_owned(),
314 service_account: "flight-control".to_owned(),
315 pod: Some("flight-control-0".to_owned()),
316 };
317 assert_eq!(sa.peer_name(), "flight-control");
318
319 let spiffe = PlatformIdentity::Spiffe {
320 trust_domain: "example.org".to_owned(),
321 name: "mini-chat".to_owned(),
322 version: "1.0.0".to_owned(),
323 };
324 assert_eq!(spiffe.peer_name(), "mini-chat");
325 }
326
327 #[test]
328 fn an_unrecognised_identity_tag_decodes_to_unknown() {
329 // `#[serde(other)]` is what keeps a peer running a newer build from
330 // failing to decode here, and nothing exercised it: a payload whose
331 // `type` this build does not know must land on `Unknown` and report
332 // `<unknown>` rather than deserializing into some known variant.
333 let identity: PlatformIdentity =
334 serde_json::from_str(r#"{"type":"future_method"}"#).unwrap();
335
336 assert_eq!(identity, PlatformIdentity::Unknown);
337 assert_eq!(
338 identity.peer_name(),
339 "<unknown>",
340 "an unrecognised identity must not resolve to a usable caller name"
341 );
342 }
343
344 #[test]
345 fn outbound_marker_carries_no_identity() {
346 // The marker is a credential-free plane selector, so a consumer that
347 // mistakenly reads it derives no caller name.
348 let marker = PlatformSecurityContext::outbound_marker();
349 assert_eq!(marker.identity(), &PlatformIdentity::OutboundMarker);
350 assert_eq!(marker.identity().peer_name(), "<outbound-marker>");
351 assert!(marker.is_outbound_marker());
352 }
353
354 #[test]
355 fn the_outbound_marker_is_distinguishable_from_an_unrecognised_peer() {
356 // The two used to be the same value, so nothing could reject the marker
357 // specifically -- a locally minted plane selector and a peer identity
358 // from a newer build both read as `Unknown` / `<unknown>`.
359 let marker = PlatformSecurityContext::outbound_marker();
360 let unrecognised = PlatformSecurityContext::new(PlatformIdentity::Unknown);
361
362 assert_ne!(marker.identity(), unrecognised.identity());
363 assert!(marker.is_outbound_marker());
364 assert!(
365 !unrecognised.is_outbound_marker(),
366 "a peer this build does not recognise is still a peer, not our own marker"
367 );
368 }
369
370 #[test]
371 fn platform_security_context_roundtrips_serde() {
372 let ctx = PlatformSecurityContext::new(PlatformIdentity::KubernetesServiceAccount {
373 namespace: "toolkit".to_owned(),
374 service_account: "directory-service".to_owned(),
375 pod: None,
376 });
377 let json = serde_json::to_string(&ctx).unwrap();
378 let back: PlatformSecurityContext = serde_json::from_str(&json).unwrap();
379 assert_eq!(back, ctx);
380 }
381}