API: a Mutation's own token authorizes requests about it

241f400 · gmorell · 2026-11-09 15:26

6 files +170 -64
Message
{commit_body(@commit)}

Files changed

modified README.md
+22 −6
@@ -158,10 +158,24 @@ resource.customizations.health.neiam.org_Mutation: |
158 158 The operator serves an HTTP API to create, list and delete Mutations and
159 159 their variants.
160 160
161 −The API is off until `MUTANT_API_TOKEN` is set (`operator.yml` reads it from
162 −the `mutantops-api` secret). Every `/api` request then needs
163 −`Authorization: Bearer <token>`. `MUTANT_API_INSECURE=true` serves it without
164 −auth. `MUTANT_API_ADDR` sets the listen address (default `0.0.0.0:8080`).
161 +Every `/api` request needs `Authorization: Bearer <token>`, and which token
162 +depends on the request:
163 +
164 +- **A Mutation's own token** (`token` in its `<name>-secret`) allows getting
165 + that Mutation and listing, adding and removing its variants, and nothing
166 + else. An app in the Mutation's namespace reads it from that Secret, so it
167 + can manage its own Mutation's variants without credentials for anything
168 + else. The operator only accepts a Secret owned by the Mutation, so one
169 + created by someone else does not count.
170 +- **The admin token** (`MUTANT_API_TOKEN`, optional; `operator.yml` reads it
171 + from the `mutantops-api` secret) also allows listing every Mutation in a
172 + namespace and creating or deleting a Mutation. Without it, those answer
173 + 401.
174 +
175 +A wrong token and a Mutation that does not exist both answer 401, so one
176 +Mutation's token cannot probe for others. `MUTANT_API_INSECURE=true` serves
177 +the API without auth, for local development. `MUTANT_API_ADDR` sets the
178 +listen address (default `0.0.0.0:8080`).
165 179
166 180 ```text
167 181 GET /api/v1/namespaces/{ns}/mutations
@@ -182,7 +196,7 @@ Mutation's `resourceVersion`, so none is lost.
182 196 ```sh
183 197 kubectl -n mutants create secret generic mutantops-api --from-literal=token="$(openssl rand -hex 32)"
184 198 kubectl -n mutants rollout restart deploy/mutantops-operator
185 −TOKEN=$(kubectl -n mutants get secret mutantops-api -o jsonpath='{.data.token}' | base64 -d)
199 +TOKEN=$(kubectl -n mutants get secret mutantops-api -o jsonpath='{.data.token}' | base64 -d) # admin
186 200 API=http://mutantops-api.mutants.svc # or: kubectl -n mutants port-forward svc/mutantops-api 8080:80
187 201 NS=$API/api/v1/namespaces/default
188 202 AUTH="Authorization: Bearer $TOKEN"
@@ -190,7 +204,9 @@ AUTH="Authorization: Bearer $TOKEN"
190 204 # create a Mutation from a base Deployment (deployment.json)
191 205 jq -n --slurpfile d deployment.json '{name: "web", deployment: $d[0], variants: []}' |
192 206 curl -H "$AUTH" -H 'Content-Type: application/json' -d @- $NS/mutations
193 −# add and remove variants
207 +# add and remove variants: the admin token works, and so does web's own
208 +WEB=$(kubectl -n default get secret web-secret -o jsonpath='{.data.token}' | base64 -d)
209 +AUTH="Authorization: Bearer $WEB"
194 210 curl -H "$AUTH" -H 'Content-Type: application/json' \
195 211 -d '{"label":"beta","env":{"FEATURE_X":"on"},"instances":2}' $NS/mutations/web/variants
196 212 curl -X DELETE -H "$AUTH" $NS/mutations/web/variants/beta
modified operator.yml
+3 −1
@@ -29,7 +29,9 @@ spec:
29 29 fieldPath: metadata.name
30 30 - name: OPERATOR_NAME
31 31 value: "mutantops-operator"
32 − # The HTTP API stays off until this is set. Create the secret with:
32 + # Optional admin token for the HTTP API: listing, creating and deleting
33 + # Mutations. A Mutation's own variants only need its own token, from
34 + # its generated <name>-secret. Create the secret with:
33 35 # kubectl -n mutants create secret generic mutantops-api \
34 36 # --from-literal=token="$(openssl rand -hex 32)"
35 37 - name: MUTANT_API_TOKEN
modified src/api.rs
+137 −47
@@ -13,6 +13,17 @@
13 13 //! DELETE /api/v1/namespaces/{ns}/mutations/{name}/variants/{label}
14 14 //! ```
15 15 //!
16 +//! ## Authorization
17 +//!
18 +//! Requests carry `Authorization: Bearer <token>`. A request about one
19 +//! Mutation -- getting it, listing, adding or removing its variants -- is
20 +//! allowed with that Mutation's own token: the `token` in the
21 +//! `<name>-secret` the operator generated for it, in its namespace. An app
22 +//! reads that Secret from its own namespace and can then manage that one
23 +//! Mutation's variants, and nothing else. Listing every Mutation in a
24 +//! namespace, and creating or deleting a Mutation, take the admin token
25 +//! (`MUTANT_API_TOKEN`), as does anything when no Mutation token applies.
26 +//!
16 27 //! A Mutation is a Kubernetes object, so the operator keeps no state of its
17 28 //! own. One synced by Argo CD cannot be deleted here, since the next sync
18 29 //! would put it back, and its variants are `readOnly` too unless it carries
@@ -20,19 +31,19 @@
20 31 //! API the variants.
21 32
22 33 use crate::crd::{
23 − insert_variant, is_gitops_managed, remove_variant, validate_base, validate_label, validate_variants,
24 − variants_editable,
34 + insert_variant, is_gitops_managed, remove_variant, secret_name, validate_base, validate_label,
35 + validate_variants, variants_editable, SECRET_TOKEN_KEY,
25 36 Mutation, MutationSpec, Variant, VariantState, VariantStatus,
26 37 };
27 38 use axum::{
28 − extract::{Path, Request, State},
29 − http::{header, StatusCode},
30 − middleware::{self, Next},
39 + extract::{Path, State},
40 + http::{header, HeaderMap, StatusCode},
31 41 response::{IntoResponse, Response},
32 42 routing::get,
33 43 Json, Router,
34 44 };
35 45 use k8s_openapi::api::apps::v1::Deployment;
46 +use k8s_openapi::api::core::v1::Secret;
36 47 use kube::{
37 48 api::{Api, DeleteParams, ListParams, Patch, PatchParams, PostParams},
38 49 Client, ResourceExt,
@@ -49,13 +60,20 @@ const MAX_ATTEMPTS: usize = 5;
49 60 #[derive(Clone)]
50 61 pub struct AppState {
51 62 client: Client,
52 − /// Bearer token required on every `/api` request; `None` disables auth.
53 − token: Option<Arc<str>>,
63 + /// Admin token: any request, including listing, creating and deleting
64 + /// Mutations. `None`: only Mutation tokens are accepted.
65 + admin_token: Option<Arc<str>>,
66 + /// Accept every request unauthenticated. For local development only.
67 + insecure: bool,
54 68 }
55 69
56 70 impl AppState {
57 − pub fn new(client: Client, token: Option<String>) -> Self {
58 − Self { client, token: token.map(Into::into) }
71 + pub fn new(client: Client, admin_token: Option<String>, insecure: bool) -> Self {
72 + Self { client, admin_token: admin_token.map(Into::into), insecure }
73 + }
74 +
75 + fn mutations(&self, ns: &str) -> Api<Mutation> {
76 + Api::namespaced(self.client.clone(), ns)
59 77 }
60 78 }
61 79
@@ -70,39 +88,72 @@ pub fn router(state: AppState) -> Router {
70 88 .route(
71 89 "/namespaces/{ns}/mutations/{name}/variants/{label}",
72 90 get(get_variant).delete(delete_variant),
73 − )
74 − .route_layer(middleware::from_fn_with_state(state.clone(), require_token));
91 + );
75 92 Router::new()
76 93 .route("/healthz", get(|| async { "ok" }))
77 94 .nest("/api/v1", api)
78 95 .with_state(state)
79 96 }
80 97
81 −async fn require_token(State(state): State<AppState>, req: Request, next: Next) -> Response {
82 − let presented = req
83 − .headers()
98 +fn bearer(headers: &HeaderMap) -> Option<&str> {
99 + headers
84 100 .get(header::AUTHORIZATION)
85 101 .and_then(|v| v.to_str().ok())
86 − .and_then(|v| v.strip_prefix("Bearer "));
87 − if authorized(state.token.as_deref(), presented) {
88 − next.run(req).await
89 − } else {
90 − ApiError::Unauthorized.into_response()
91 − }
102 + .and_then(|v| v.strip_prefix("Bearer "))
92 103 }
93 104
94 −fn authorized(expected: Option<&str>, presented: Option<&str>) -> bool {
95 − match (expected, presented) {
96 − (None, _) => true,
97 − (Some(want), Some(got)) => constant_time_eq(want.as_bytes(), got.as_bytes()),
98 − (Some(_), None) => false,
99 − }
105 +/// Whether `presented` is the admin token (or auth is off).
106 +fn admin_ok(admin_token: Option<&str>, insecure: bool, presented: Option<&str>) -> bool {
107 + insecure
108 + || match (admin_token, presented) {
109 + (Some(want), Some(got)) => constant_time_eq(want.as_bytes(), got.as_bytes()),
110 + _ => false,
111 + }
112 +}
113 +
114 +/// Whether `secret` is the Mutation's own -- owned by the Mutation with
115 +/// `uid`, so not a look-alike someone else created -- and holds `presented`
116 +/// as its token.
117 +fn secret_grants(secret: &Secret, uid: &str, presented: &str) -> bool {
118 + let owned = secret.owner_references().iter().any(|o| o.uid == uid);
119 + let token = secret.data.as_ref().and_then(|d| d.get(SECRET_TOKEN_KEY));
120 + owned && token.is_some_and(|t| constant_time_eq(&t.0, presented.as_bytes()))
100 121 }
101 122
102 123 fn constant_time_eq(a: &[u8], b: &[u8]) -> bool {
103 124 a.len() == b.len() && a.iter().zip(b).fold(0u8, |acc, (x, y)| acc | (x ^ y)) == 0
104 125 }
105 126
127 +/// Allows only the admin token.
128 +fn require_admin(st: &AppState, headers: &HeaderMap) -> ApiResult<()> {
129 + if admin_ok(st.admin_token.as_deref(), st.insecure, bearer(headers)) {
130 + Ok(())
131 + } else {
132 + Err(ApiError::Unauthorized)
133 + }
134 +}
135 +
136 +/// Allows the admin token, or the Mutation's own token, for a request about
137 +/// Mutation `name` in `ns`. Returns the Mutation.
138 +///
139 +/// Without the admin token, a Mutation that does not exist answers the same
140 +/// as a wrong token, so one Mutation's token cannot probe for others.
141 +async fn authorize_mutation(st: &AppState, headers: &HeaderMap, ns: &str, name: &str) -> ApiResult<Mutation> {
142 + let presented = bearer(headers);
143 + if admin_ok(st.admin_token.as_deref(), st.insecure, presented) {
144 + return Ok(st.mutations(ns).get(name).await?);
145 + }
146 + let presented = presented.ok_or(ApiError::Unauthorized)?;
147 + let m = st.mutations(ns).get_opt(name).await?.ok_or(ApiError::Unauthorized)?;
148 + let uid = m.uid().ok_or(ApiError::Unauthorized)?;
149 + let secret = Api::<Secret>::namespaced(st.client.clone(), ns).get_opt(&secret_name(name)).await?;
150 + if secret.is_some_and(|s| secret_grants(&s, &uid, presented)) {
151 + Ok(m)
152 + } else {
153 + Err(ApiError::Unauthorized)
154 + }
155 +}
156 +
106 157 #[derive(Debug)]
107 158 enum ApiError {
108 159 Unauthorized,
@@ -126,7 +177,10 @@ impl From<kube::Error> for ApiError {
126 177 impl IntoResponse for ApiError {
127 178 fn into_response(self) -> Response {
128 179 let (status, msg) = match self {
129 − ApiError::Unauthorized => (StatusCode::UNAUTHORIZED, "missing or invalid bearer token".into()),
180 + ApiError::Unauthorized => (
181 + StatusCode::UNAUTHORIZED,
182 + "missing or invalid bearer token: use this Mutation's token, or the admin token".into(),
183 + ),
130 184 ApiError::BadRequest(m) => (StatusCode::BAD_REQUEST, m),
131 185 ApiError::NotFound(m) => (StatusCode::NOT_FOUND, m),
132 186 ApiError::Conflict(m) => (StatusCode::CONFLICT, m),
@@ -210,41 +264,47 @@ impl From<Mutation> for MutationView {
210 264 }
211 265 }
212 266
213 −fn api(st: &AppState, ns: &str) -> Api<Mutation> {
214 − Api::namespaced(st.client.clone(), ns)
215 −}
216 −
217 −async fn list_mutations(State(st): State<AppState>, Path(ns): Path<String>) -> ApiResult<Json<Vec<MutationView>>> {
218 − let items = api(&st, &ns).list(&ListParams::default()).await?;
267 +async fn list_mutations(
268 + State(st): State<AppState>,
269 + headers: HeaderMap,
270 + Path(ns): Path<String>,
271 +) -> ApiResult<Json<Vec<MutationView>>> {
272 + require_admin(&st, &headers)?;
273 + let items = st.mutations(&ns).list(&ListParams::default()).await?;
219 274 Ok(Json(items.into_iter().map(Into::into).collect()))
220 275 }
221 276
222 277 async fn get_mutation(
223 278 State(st): State<AppState>,
279 + headers: HeaderMap,
224 280 Path((ns, name)): Path<(String, String)>,
225 281 ) -> ApiResult<Json<MutationView>> {
226 − Ok(Json(api(&st, &ns).get(&name).await?.into()))
282 + Ok(Json(authorize_mutation(&st, &headers, &ns, &name).await?.into()))
227 283 }
228 284
229 285 async fn create_mutation(
230 286 State(st): State<AppState>,
287 + headers: HeaderMap,
231 288 Path(ns): Path<String>,
232 289 Json(req): Json<CreateMutation>,
233 290 ) -> ApiResult<(StatusCode, Json<MutationView>)> {
291 + require_admin(&st, &headers)?;
234 292 validate_label(&req.name).map_err(ApiError::BadRequest)?;
235 293 validate_base(&req.deployment).map_err(ApiError::BadRequest)?;
236 294 validate_variants(&req.variants).map_err(ApiError::BadRequest)?;
237 295 let mutation = Mutation::new(&req.name, MutationSpec { deployment: req.deployment, variants: req.variants });
238 − let created = api(&st, &ns).create(&PostParams::default(), &mutation).await?;
296 + let created = st.mutations(&ns).create(&PostParams::default(), &mutation).await?;
239 297 info!(%ns, name = %req.name, "mutation created");
240 298 Ok((StatusCode::CREATED, Json(created.into())))
241 299 }
242 300
243 301 async fn delete_mutation(
244 302 State(st): State<AppState>,
303 + headers: HeaderMap,
245 304 Path((ns, name)): Path<(String, String)>,
246 305 ) -> ApiResult<StatusCode> {
247 − let api = api(&st, &ns);
306 + require_admin(&st, &headers)?;
307 + let api = st.mutations(&ns);
248 308 refuse_if_gitops(&api.get(&name).await?)?;
249 309 api.delete(&name, &DeleteParams::default()).await?;
250 310 info!(%ns, %name, "mutation deleted");
@@ -253,16 +313,18 @@ async fn delete_mutation(
253 313
254 314 async fn list_variants(
255 315 State(st): State<AppState>,
316 + headers: HeaderMap,
256 317 Path((ns, name)): Path<(String, String)>,
257 318 ) -> ApiResult<Json<Vec<VariantView>>> {
258 − Ok(Json(MutationView::from(api(&st, &ns).get(&name).await?).variants))
319 + Ok(Json(MutationView::from(authorize_mutation(&st, &headers, &ns, &name).await?).variants))
259 320 }
260 321
261 322 async fn get_variant(
262 323 State(st): State<AppState>,
324 + headers: HeaderMap,
263 325 Path((ns, name, label)): Path<(String, String, String)>,
264 326 ) -> ApiResult<Json<VariantView>> {
265 − MutationView::from(api(&st, &ns).get(&name).await?)
327 + MutationView::from(authorize_mutation(&st, &headers, &ns, &name).await?)
266 328 .variants
267 329 .into_iter()
268 330 .find(|v| v.variant.label == label)
@@ -272,12 +334,14 @@ async fn get_variant(
272 334
273 335 async fn create_variant(
274 336 State(st): State<AppState>,
337 + headers: HeaderMap,
275 338 Path((ns, name)): Path<(String, String)>,
276 339 Json(variant): Json<Variant>,
277 340 ) -> ApiResult<(StatusCode, Json<Variant>)> {
341 + authorize_mutation(&st, &headers, &ns, &name).await?;
278 342 variant.validate().map_err(ApiError::BadRequest)?;
279 343 let created = variant.clone();
280 − update_variants(&api(&st, &ns), &name, |list| {
344 + update_variants(&st.mutations(&ns), &name, |list| {
281 345 insert_variant(list, variant.clone()).map_err(ApiError::Conflict)
282 346 })
283 347 .await?;
@@ -287,9 +351,11 @@ async fn create_variant(
287 351
288 352 async fn delete_variant(
289 353 State(st): State<AppState>,
354 + headers: HeaderMap,
290 355 Path((ns, name, label)): Path<(String, String, String)>,
291 356 ) -> ApiResult<StatusCode> {
292 − update_variants(&api(&st, &ns), &name, |list| {
357 + authorize_mutation(&st, &headers, &ns, &name).await?;
358 + update_variants(&st.mutations(&ns), &name, |list| {
293 359 remove_variant(list, &label)
294 360 .map(drop)
295 361 .ok_or_else(|| ApiError::NotFound(format!("variant {label:?} not found")))
@@ -347,13 +413,37 @@ mod tests {
347 413 use std::collections::HashMap;
348 414
349 415 #[test]
350 − fn auth() {
351 − assert!(authorized(None, None));
352 − assert!(authorized(None, Some("anything")));
353 − assert!(authorized(Some("s3cret"), Some("s3cret")));
354 − assert!(!authorized(Some("s3cret"), Some("s3cre")));
355 − assert!(!authorized(Some("s3cret"), Some("s3creT")));
356 − assert!(!authorized(Some("s3cret"), None));
416 + fn admin_auth() {
417 + assert!(admin_ok(Some("s3cret"), false, Some("s3cret")));
418 + assert!(!admin_ok(Some("s3cret"), false, Some("s3cre")));
419 + assert!(!admin_ok(Some("s3cret"), false, Some("s3creT")));
420 + assert!(!admin_ok(Some("s3cret"), false, None));
421 + // No admin token configured: nothing is admin, unless auth is off.
422 + assert!(!admin_ok(None, false, Some("anything")));
423 + assert!(!admin_ok(None, false, None));
424 + assert!(admin_ok(None, true, None));
425 + }
426 +
427 + #[test]
428 + fn mutation_token_auth() {
429 + use k8s_openapi::apimachinery::pkg::apis::meta::v1::OwnerReference;
430 + let m = {
431 + let mut m = Mutation::new("browser-snap", MutationSpec { deployment: Deployment::default(), variants: vec![] });
432 + m.metadata.uid = Some("uid-1".into());
433 + m.metadata.namespace = Some("crib".into());
434 + m
435 + };
436 + let owner = OwnerReference { uid: "uid-1".into(), ..Default::default() };
437 + let secret = crate::crd::build_secret(&m, owner, "tok".into());
438 +
439 + assert!(secret_grants(&secret, "uid-1", "tok"));
440 + assert!(!secret_grants(&secret, "uid-1", "tok2"));
441 + assert!(!secret_grants(&secret, "uid-1", ""));
442 + // The same name and token, but not owned by this Mutation.
443 + assert!(!secret_grants(&secret, "uid-2", "tok"));
444 + let mut tokenless = secret.clone();
445 + tokenless.data = None;
446 + assert!(!secret_grants(&tokenless, "uid-1", "tok"));
357 447 }
358 448
359 449 #[test]
modified src/bin/main-deployment.rs
+8 −10
@@ -12,13 +12,15 @@ async fn main() -> anyhow::Result<()> {
12 12
13 13 let client = Client::try_default().await?;
14 14
15 − // The API can create Deployments with arbitrary env in any namespace the
16 − // operator can write to, so it needs a token unless explicitly opted out.
17 − let token = std::env::var("MUTANT_API_TOKEN").ok().filter(|t| !t.is_empty());
15 + // Requests about one Mutation are authorized by that Mutation's own
16 + // generated token. The admin token, optional, covers the rest: listing,
17 + // creating and deleting Mutations.
18 + let admin_token = std::env::var("MUTANT_API_TOKEN").ok().filter(|t| !t.is_empty());
18 19 let insecure = std::env::var("MUTANT_API_INSECURE").is_ok_and(|v| v == "true");
19 − let api_enabled = token.is_some() || insecure;
20 − if token.is_none() && insecure {
20 + if insecure {
21 21 warn!("MUTANT_API_INSECURE=true: HTTP API is serving without authentication");
22 + } else if admin_token.is_none() {
23 + info!("no MUTANT_API_TOKEN: only Mutation tokens are accepted");
22 24 }
23 25
24 26 let addr: SocketAddr = std::env::var("MUTANT_API_ADDR")
@@ -26,13 +28,9 @@ async fn main() -> anyhow::Result<()> {
26 28 .parse()?;
27 29
28 30 let server = async {
29 − if !api_enabled {
30 − warn!("HTTP API disabled: set MUTANT_API_TOKEN (or MUTANT_API_INSECURE=true) to enable it");
31 − std::future::pending::<()>().await;
32 − }
33 31 let listener = tokio::net::TcpListener::bind(addr).await?;
34 32 info!(%addr, "HTTP API listening");
35 − axum::serve(listener, api::router(api::AppState::new(client.clone(), token)))
33 + axum::serve(listener, api::router(api::AppState::new(client.clone(), admin_token, insecure)))
36 34 .with_graceful_shutdown(async {
37 35 let _ = tokio::signal::ctrl_c().await;
38 36 })

Parents: 4932eb3