Skip to main content

dynamic_config_server/
kubernetes.rs

1//! Kubernetes authentication: the caller's bearer token is a projected
2//! service-account token, and the API server's TokenReview says whose.
3//!
4//! This is the server-side half of the organisation's identity-first
5//! policy: a pod presents the identity Kubernetes already gave it, the
6//! server asks the API server "is this real, and who is it", and the
7//! grants map `namespace:serviceaccount` names to applications. **No
8//! distributed client tokens at all** — nothing to mint, rotate, or
9//! leak; revoking access is deleting the ServiceAccount or the grant.
10//!
11//! One review per unseen token, then a short cache: projected tokens
12//! rotate on the kubelet's schedule (minutes to hours), so a sixty-
13//! second cache absorbs the request rate without ever holding a
14//! verdict long after the token could have been revoked.
15
16use std::collections::HashMap;
17use std::hash::{Hash, Hasher};
18use std::sync::Mutex;
19use std::time::{Duration, Instant};
20
21use crate::auth::Principal;
22
23/// How long a TokenReview verdict is reused before the API server is
24/// asked again.
25const CACHE_TTL: Duration = Duration::from_secs(60);
26
27/// The reviewer: where the API server is, how this server authenticates
28/// to it, and who is granted what.
29pub struct KubernetesVerifier {
30    /// `https://host:port`, from the in-cluster environment (or a test).
31    api: String,
32    /// This server's OWN service-account token, presented to the API
33    /// server. Read per call: it is projected too, and rotates.
34    own_token_path: std::path::PathBuf,
35    /// The cluster CA, absent only under `#[cfg(test)]` plumbing.
36    agent: ureq::Agent,
37    /// Audience the token must carry, when the deployment pins one.
38    audience: Option<String>,
39    /// `namespace:serviceaccount` → the principal it becomes.
40    grants: Vec<(String, Principal)>,
41    /// Verdicts by token hash — the token itself is never stored.
42    cache: Mutex<HashMap<u64, (Option<Principal>, Instant)>>,
43}
44
45impl std::fmt::Debug for KubernetesVerifier {
46    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
47        f.debug_struct("KubernetesVerifier")
48            .field("api", &self.api)
49            .field("grants", &self.grants.len())
50            .finish_non_exhaustive()
51    }
52}
53
54impl KubernetesVerifier {
55    /// The in-cluster reviewer: API server address from the environment
56    /// Kubernetes injects, trust from the mounted cluster CA, identity
57    /// from the mounted service-account token.
58    ///
59    /// # Errors
60    ///
61    /// Outside a cluster (no `KUBERNETES_SERVICE_HOST`, no mounted CA) —
62    /// at startup, where the refusal names what is missing, not at the
63    /// first request.
64    pub fn in_cluster(
65        audience: Option<String>,
66        grants: Vec<(String, Principal)>,
67    ) -> Result<Self, String> {
68        let host = std::env::var("KUBERNETES_SERVICE_HOST")
69            .map_err(|_| "auth.kubernetes is enabled, but KUBERNETES_SERVICE_HOST is not set — this server is not running in a cluster".to_owned())?;
70        let port = std::env::var("KUBERNETES_SERVICE_PORT").unwrap_or_else(|_| "443".to_owned());
71
72        let ca = std::fs::read_to_string("/var/run/secrets/kubernetes.io/serviceaccount/ca.crt")
73            .map_err(|error| format!("auth.kubernetes: reading the cluster CA: {error}"))?;
74        let mut roots = Vec::new();
75
76        for item in ureq::tls::parse_pem(ca.as_bytes()) {
77            match item {
78                Ok(ureq::tls::PemItem::Certificate(certificate)) => roots.push(certificate),
79                Ok(_) => {}
80                Err(_) => {
81                    return Err("auth.kubernetes: the cluster CA is not PEM".to_owned());
82                }
83            }
84        }
85
86        if roots.is_empty() {
87            return Err("auth.kubernetes: the cluster CA held no certificate".to_owned());
88        }
89
90        let agent: ureq::Agent = ureq::Agent::config_builder()
91            .tls_config(
92                ureq::tls::TlsConfig::builder()
93                    .root_certs(ureq::tls::RootCerts::new_with_certs(&roots))
94                    .build(),
95            )
96            .timeout_global(Some(Duration::from_secs(10)))
97            .build()
98            .into();
99
100        Ok(Self {
101            api: format!("https://{host}:{port}"),
102            own_token_path: "/var/run/secrets/kubernetes.io/serviceaccount/token".into(),
103            agent,
104            audience,
105            grants,
106            cache: Mutex::new(HashMap::new()),
107        })
108    }
109
110    /// A reviewer pointed at an arbitrary endpoint with default trust —
111    /// what the mock-backed tests use; never constructed in production.
112    #[cfg(test)]
113    #[must_use]
114    pub fn for_tests(
115        api: String,
116        own_token_path: std::path::PathBuf,
117        grants: Vec<(String, Principal)>,
118    ) -> Self {
119        Self {
120            api,
121            own_token_path,
122            agent: ureq::Agent::config_builder()
123                .timeout_global(Some(Duration::from_secs(5)))
124                .build()
125                .into(),
126            audience: None,
127            grants,
128            cache: Mutex::new(HashMap::new()),
129        }
130    }
131
132    /// Who `presented` is, if the API server vouches for it AND a grant
133    /// names it. `None` is both "not a valid token" and "valid but not
134    /// granted" — the caller's error stays 401 either way, and the
135    /// distinction lives in this server's log, not the response.
136    pub fn verify(&self, presented: &str) -> Option<Principal> {
137        // The token never lands in the map; its keyed 64-bit hash does.
138        // SipHash with a per-process random key: not forgeable from
139        // outside, and a collision is lottery odds against yourself.
140        let key = {
141            let mut hasher = std::collections::hash_map::DefaultHasher::new();
142            presented.hash(&mut hasher);
143            hasher.finish()
144        };
145
146        if let Some((verdict, at)) = self
147            .cache
148            .lock()
149            .unwrap_or_else(std::sync::PoisonError::into_inner)
150            .get(&key)
151        {
152            if at.elapsed() < CACHE_TTL {
153                return verdict.clone();
154            }
155        }
156
157        let verdict = self.review(presented);
158
159        self.cache
160            .lock()
161            .unwrap_or_else(std::sync::PoisonError::into_inner)
162            .insert(key, (verdict.clone(), Instant::now()));
163
164        verdict
165    }
166
167    fn review(&self, presented: &str) -> Option<Principal> {
168        let own = std::fs::read_to_string(&self.own_token_path).ok()?;
169
170        let mut spec = serde_json::json!({ "token": presented });
171
172        if let Some(audience) = &self.audience {
173            spec["audiences"] = serde_json::json!([audience]);
174        }
175
176        let body = serde_json::json!({
177            "apiVersion": "authentication.k8s.io/v1",
178            "kind": "TokenReview",
179            "spec": spec,
180        });
181
182        let response: serde_json::Value = self
183            .agent
184            .post(format!(
185                "{}/apis/authentication.k8s.io/v1/tokenreviews",
186                self.api
187            ))
188            .header("authorization", format!("Bearer {}", own.trim()))
189            .send_json(&body)
190            .ok()?
191            .body_mut()
192            .read_json()
193            .ok()?;
194
195        if response["status"]["authenticated"] != serde_json::Value::Bool(true) {
196            return None;
197        }
198
199        // "system:serviceaccount:<namespace>:<name>" — anything else
200        // (a user, a node) is authenticated but not OUR vocabulary.
201        let username = response["status"]["user"]["username"].as_str()?;
202        let subject = username.strip_prefix("system:serviceaccount:")?;
203
204        self.grants
205            .iter()
206            .find(|(granted, _)| granted == subject)
207            .map(|(_, principal)| principal.clone())
208    }
209}
210
211#[cfg(test)]
212mod tests {
213    use super::*;
214
215    #[test]
216    fn a_granted_service_account_becomes_its_principal() {
217        let server = tiny_http::Server::http("127.0.0.1:0").expect("binds");
218        let api = format!("http://{}", server.server_addr());
219
220        let handle = std::thread::spawn(move || {
221            let request = server.recv().expect("a review arrives");
222
223            assert_eq!(request.url(), "/apis/authentication.k8s.io/v1/tokenreviews");
224
225            let response = serde_json::json!({
226                "status": {
227                    "authenticated": true,
228                    "user": { "username": "system:serviceaccount:shop:billing" },
229                },
230            });
231
232            request
233                .respond(tiny_http::Response::from_string(response.to_string()))
234                .expect("responds");
235        });
236
237        let token_file = tempfile::NamedTempFile::new().expect("a file");
238        std::fs::write(token_file.path(), "own-token").expect("written");
239
240        let verifier = KubernetesVerifier::for_tests(
241            api,
242            token_file.path().to_path_buf(),
243            vec![(
244                "shop:billing".to_owned(),
245                Principal::new("shop/billing", ["shop"]),
246            )],
247        );
248
249        let principal = verifier.verify("some-projected-token").expect("granted");
250
251        assert_eq!(principal.name(), "shop/billing");
252        assert!(principal.may_read("shop"));
253
254        // The second ask is the cache, not a second review — the mock
255        // accepted exactly one request and the thread has ended.
256        handle.join().expect("one review");
257        assert!(verifier.verify("some-projected-token").is_some());
258    }
259
260    #[test]
261    fn an_ungranted_or_unauthenticated_token_is_nobody() {
262        let server = tiny_http::Server::http("127.0.0.1:0").expect("binds");
263        let api = format!("http://{}", server.server_addr());
264
265        let handle = std::thread::spawn(move || {
266            for _ in 0..2 {
267                let request = server.recv().expect("a review arrives");
268                let response = serde_json::json!({
269                    "status": {
270                        "authenticated": true,
271                        "user": { "username": "system:serviceaccount:other:nobody" },
272                    },
273                });
274
275                request
276                    .respond(tiny_http::Response::from_string(response.to_string()))
277                    .expect("responds");
278            }
279        });
280
281        let token_file = tempfile::NamedTempFile::new().expect("a file");
282        std::fs::write(token_file.path(), "own-token").expect("written");
283
284        let verifier = KubernetesVerifier::for_tests(
285            api,
286            token_file.path().to_path_buf(),
287            vec![(
288                "shop:billing".to_owned(),
289                Principal::new("shop/billing", ["shop"]),
290            )],
291        );
292
293        assert!(
294            verifier.verify("token-one").is_none(),
295            "valid but ungranted"
296        );
297        assert!(verifier.verify("token-two").is_none());
298        handle.join().expect("two reviews");
299    }
300}