dynamic_config_server/
kubernetes.rs1use std::collections::HashMap;
17use std::hash::{Hash, Hasher};
18use std::sync::Mutex;
19use std::time::{Duration, Instant};
20
21use crate::auth::Principal;
22
23const CACHE_TTL: Duration = Duration::from_secs(60);
26
27pub struct KubernetesVerifier {
30 api: String,
32 own_token_path: std::path::PathBuf,
35 agent: ureq::Agent,
37 audience: Option<String>,
39 grants: Vec<(String, Principal)>,
41 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 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 #[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 pub fn verify(&self, presented: &str) -> Option<Principal> {
137 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 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 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}