neiam
/mutant-operator
An Experimental Operator
public · Issues · Pulls · Labels · Forks · Compare · Actions success · Badges · Packages
⭐
Log in to mark this repository.
API: a Mutation's own token authorizes requests about it
241f400 · gmorell · 2026-11-09 15:26
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