Skip to main content

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}