LCOV - code coverage report
Current view: top level - antares-api/src - admin_auth.rs (source / functions) Coverage Total Hit
Test: merged.info Lines: 100.0 % 186 186
Test Date: 2026-11-02 13:21:27 Functions: 46.3 % 95 44

            Line data    Source code
       1              : // SPDX-License-Identifier: EUPL-1.2
       2              : //! The `/q` admin credential: one bearer secret a deployment
       3              : //! configures. Configured, every `/q` route except `/q/health` and
       4              : //! `/q/ready` (and `/q/metrics` when the deployment leaves it open) answers
       5              : //! `401` without it; unconfigured, `/q` serves as it always did and the
       6              : //! broker says so once at start. A network position is then no longer the
       7              : //! only thing between a caller and a tenant purge.
       8              : 
       9              : use crate::state::AppState;
      10              : use axum::{
      11              :     extract::{Request, State},
      12              :     http::{header, StatusCode},
      13              :     middleware::Next,
      14              :     response::{IntoResponse, Response},
      15              : };
      16              : 
      17              : /// The shortest secret accepted, in bytes: a random token of 256 bits.
      18              : pub const MIN_LEN: usize = 32;
      19              : 
      20              : /// The ProblemDetails type a refused admin call answers with: the broker's
      21              : /// own namespace, as for `AccessDenied`, since no ETSI error covers `/q`.
      22              : pub const UNAUTHORIZED_TYPE: &str = "urn:antares:error:Unauthorized";
      23              : 
      24              : /// The admin secret and whether `/q/metrics` is served without it.
      25              : pub struct AdminAuth {
      26              :     token: crate::secret::Secret,
      27              :     /// `/q/metrics` answers without the credential (a scraper that cannot
      28              :     /// send one); `false` puts it behind the credential like the rest.
      29              :     pub metrics_open: bool,
      30              : }
      31              : 
      32              : /// Never prints the secret, so a state dump or a panic message cannot leak it.
      33              : impl std::fmt::Debug for AdminAuth {
      34            4 :     fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
      35            4 :         f.debug_struct("AdminAuth")
      36            4 :             .field("metrics_open", &self.metrics_open)
      37            4 :             .finish_non_exhaustive()
      38            4 :     }
      39              : }
      40              : 
      41              : impl AdminAuth {
      42              :     /// The secret as a file or an environment value holds it: one trailing
      43              :     /// line break is dropped, anything shorter than [`MIN_LEN`] is refused.
      44              :     /// The error names the length only, never the content.
      45           66 :     pub fn new(secret: &str, metrics_open: bool) -> Result<Self, String> {
      46           66 :         let secret = secret
      47           66 :             .strip_suffix('\n')
      48           66 :             .map_or(secret, |s| s.strip_suffix('\r').unwrap_or(s));
      49           66 :         if secret.len() < MIN_LEN {
      50           16 :             return Err(format!(
      51           16 :                 "the admin token has {} bytes; at least {MIN_LEN} are required",
      52           16 :                 secret.len()
      53           16 :             ));
      54           50 :         }
      55           50 :         Ok(Self {
      56           50 :             token: crate::secret::Secret::new(secret),
      57           50 :             metrics_open,
      58           50 :         })
      59           66 :     }
      60              : 
      61              :     /// The credential a deployment configured: `file` is
      62              :     /// `ANTARES_ADMIN_TOKEN_FILE` (the secret, mounted from a Secret), and
      63              :     /// `metrics` is `ANTARES_ADMIN_METRICS`, `token` (default) or `open`.
      64              :     /// No file is no credential (`Ok(None)`); a file that cannot be read
      65              :     /// or holds too short a secret, or an unknown `metrics` value, is an
      66              :     /// error, so a configured credential never silently becomes none.
      67           44 :     pub fn from_config(file: Option<&str>, metrics: Option<&str>) -> Result<Option<Self>, String> {
      68           44 :         let metrics_open = match metrics.map(str::trim).filter(|v| !v.is_empty()) {
      69           32 :             None | Some("token") => false,
      70           12 :             Some("open") => true,
      71            4 :             Some(other) => {
      72            4 :                 return Err(format!(
      73            4 :                     "ANTARES_ADMIN_METRICS: {other:?} is neither token nor open"
      74            4 :                 ))
      75              :             }
      76              :         };
      77           40 :         let Some(path) = file.map(str::trim).filter(|p| !p.is_empty()) else {
      78           20 :             return Ok(None);
      79              :         };
      80           20 :         let secret = std::fs::read_to_string(path)
      81           20 :             .map_err(|e| format!("ANTARES_ADMIN_TOKEN_FILE {path}: unreadable ({e})"))?;
      82           16 :         Self::new(&secret, metrics_open)
      83           16 :             .map(Some)
      84           16 :             .map_err(|e| format!("ANTARES_ADMIN_TOKEN_FILE {path}: {e}"))
      85           44 :     }
      86              : 
      87              :     /// `Authorization: Bearer <secret>` (scheme case-insensitive, RFC 9110
      88              :     /// 11.1), compared without an early exit on the first differing byte.
      89              :     /// A surface's internal route asks it too.
      90              :     #[must_use]
      91          152 :     pub fn admits(&self, authorization: Option<&[u8]>) -> bool {
      92          152 :         let Some(value) = authorization else {
      93           34 :             return false;
      94              :         };
      95         1282 :         let Some(space) = value.iter().position(|b| *b == b' ') else {
      96           26 :             return false;
      97              :         };
      98           92 :         let (scheme, rest) = value.split_at(space);
      99           92 :         let presented = rest.get(1..).unwrap_or_default();
     100           92 :         scheme.eq_ignore_ascii_case(b"bearer")
     101           88 :             && constant_time_eq(self.token.expose().as_bytes(), presented)
     102          152 :     }
     103              : }
     104              : 
     105              : /// Equal length and equal bytes, every byte visited: the time taken tells
     106              : /// a caller the length at most, which the configured minimum makes public
     107              : /// anyway.
     108          112 : fn constant_time_eq(a: &[u8], b: &[u8]) -> bool {
     109         2532 :     let diff = a.iter().zip(b).fold(0u8, |acc, (x, y)| acc | (x ^ y));
     110          112 :     a.len() == b.len() && std::hint::black_box(diff) == 0
     111          112 : }
     112              : 
     113              : /// The route layer on every guarded `/q` route: passes through when no
     114              : /// credential is configured, refuses `401` without the right one.
     115          366 : pub async fn require(State(st): State<AppState>, req: Request, next: Next) -> Response {
     116          366 :     let Some(auth) = st.admin_auth.as_deref() else {
     117          300 :         return next.run(req).await;
     118              :     };
     119           66 :     let presented = req
     120           66 :         .headers()
     121           66 :         .get(header::AUTHORIZATION)
     122           66 :         .map(axum::http::HeaderValue::as_bytes);
     123           66 :     if auth.admits(presented) {
     124           18 :         return next.run(req).await;
     125           48 :     }
     126           48 :     unauthorized("this admin route requires the broker's admin bearer token")
     127          366 : }
     128              : 
     129              : /// The NGSI-LD API layer. On a broker that requires a subject
     130              : /// (`subject_required` on the state), a request carrying none of the subject
     131              : /// headers the deployment named is its own control plane or nobody: it is
     132              : /// served only with the admin bearer token and answers `401` otherwise,
     133              : /// with no credential configured as well. So a pod that merely reaches the
     134              : /// API port neither registers a Context Source nor creates a Tenant. A
     135              : /// request with a subject from a caller the broker believes
     136              : /// (`vouched_subject`) goes on to the policy engine unchanged; one from
     137              : /// anybody else is answered as if it carried none.
     138        55174 : pub async fn subject_or_admin(State(st): State<AppState>, req: Request, next: Next) -> Response {
     139        55174 :     if admitted(&st, req.headers()) {
     140        55158 :         return next.run(req).await;
     141           16 :     }
     142           16 :     unauthorized("a request without a subject needs the broker's admin bearer token")
     143        55174 : }
     144              : 
     145              : /// Whether [`subject_or_admin`] lets a request with `headers` through.
     146        55174 : fn admitted(st: &AppState, headers: &axum::http::HeaderMap) -> bool {
     147        55174 :     let presented = headers
     148        55174 :         .get(header::AUTHORIZATION)
     149        55174 :         .map(axum::http::HeaderValue::as_bytes);
     150        55174 :     !st.subject_required
     151           26 :         || vouched_subject(&crate::policy::SUBJECT_HEADERS, st, headers)
     152           26 :         || st
     153           26 :             .admin_auth
     154           26 :             .as_deref()
     155           26 :             .is_some_and(|a| a.admits(presented))
     156        55174 : }
     157              : 
     158              : /// A subject header from a caller the broker believes: an in-process
     159              : /// call, which only this process makes. [`from_named_gateway`] is the seam
     160              : /// for a gateway's mesh identity and believes nobody today, so a request
     161              : /// from outside the process is never vouched for by its subject header. A
     162              : /// pod that only reaches the API port cannot pass by adding the header. An
     163              : /// empty value counts: an in-process caller may send an anonymous subject
     164              : /// as a roles header with none.
     165           26 : fn vouched_subject(names: &[String], st: &AppState, headers: &axum::http::HeaderMap) -> bool {
     166           26 :     names.iter().any(|name| headers.contains_key(name.as_str()))
     167           12 :         && (crate::state::in_process_depth() > 0 || from_named_gateway(st, headers))
     168           26 : }
     169              : 
     170              : /// This broker reads no mesh identity (`provenance` reads no header), so
     171              : /// no request proves it comes from the gateway `provenance_from` names, and
     172              : /// a broker that names none believes nobody: a subject header from outside
     173              : /// the process vouches for nothing.
     174           12 : fn from_named_gateway(_st: &AppState, _headers: &axum::http::HeaderMap) -> bool {
     175           12 :     false
     176           12 : }
     177              : 
     178              : /// `401` with a bearer challenge and `detail`.
     179           64 : fn unauthorized(detail: &str) -> Response {
     180           64 :     (
     181           64 :         StatusCode::UNAUTHORIZED,
     182           64 :         [
     183           64 :             (header::WWW_AUTHENTICATE, "Bearer realm=\"antares-admin\""),
     184           64 :             (header::CONTENT_TYPE, "application/json"),
     185           64 :         ],
     186           64 :         axum::Json(serde_json::json!({
     187           64 :             "type": UNAUTHORIZED_TYPE,
     188           64 :             "title": "Unauthorized",
     189           64 :             "status": StatusCode::UNAUTHORIZED.as_u16(),
     190           64 :             "detail": detail,
     191           64 :         })),
     192           64 :     )
     193           64 :         .into_response()
     194           64 : }
     195              : 
     196              : #[cfg(test)]
     197              : mod tests {
     198              :     use super::*;
     199              : 
     200              :     const SECRET: &str = "0123456789abcdef0123456789abcdef";
     201              : 
     202           12 :     fn auth() -> AdminAuth {
     203           12 :         AdminAuth::new(SECRET, false).expect("a 32-byte secret is accepted")
     204           12 :     }
     205              : 
     206              :     #[test]
     207            4 :     fn a_short_secret_is_refused_and_the_error_holds_no_secret() {
     208            4 :         let short = "0123456789abcdef0123456789abcde";
     209            4 :         let err = AdminAuth::new(short, false).expect_err("31 bytes");
     210            4 :         assert!(err.contains("31 bytes"), "{err}");
     211            4 :         assert!(!err.contains(short), "{err}");
     212            4 :         assert!(AdminAuth::new("", true).is_err());
     213              :         // a trailing line break is not part of the secret
     214            4 :         assert!(AdminAuth::new(&format!("{short}\n"), false).is_err());
     215            4 :     }
     216              : 
     217              :     #[test]
     218            4 :     fn one_trailing_line_break_is_dropped() {
     219            8 :         for raw in [format!("{SECRET}\n"), format!("{SECRET}\r\n")] {
     220            8 :             let a = AdminAuth::new(&raw, false).expect("secret");
     221            8 :             assert!(a.admits(Some(format!("Bearer {SECRET}").as_bytes())));
     222              :         }
     223            4 :     }
     224              : 
     225              :     #[test]
     226            4 :     fn only_the_exact_bearer_secret_is_admitted() {
     227            4 :         let a = auth();
     228            4 :         assert!(a.admits(Some(format!("Bearer {SECRET}").as_bytes())));
     229            4 :         assert!(a.admits(Some(format!("bearer {SECRET}").as_bytes())));
     230           36 :         for refused in [
     231            4 :             String::new(),
     232            4 :             "Bearer".into(),
     233            4 :             "Bearer ".into(),
     234            4 :             format!("Basic {SECRET}"),
     235            4 :             format!("Bearer {SECRET}x"),
     236            4 :             format!("Bearer {}", &SECRET[1..]),
     237            4 :             format!("Bearer  {SECRET}"),
     238            4 :             format!("Bearer {}0", &SECRET[..31]),
     239            4 :             SECRET.into(),
     240            4 :         ] {
     241           36 :             assert!(!a.admits(Some(refused.as_bytes())), "{refused:?}");
     242              :         }
     243            4 :         assert!(!a.admits(None));
     244            4 :     }
     245              : 
     246              :     #[test]
     247            4 :     fn constant_time_eq_needs_equal_length_and_bytes() {
     248            4 :         assert!(constant_time_eq(b"", b""));
     249            4 :         assert!(constant_time_eq(b"abc", b"abc"));
     250            4 :         assert!(!constant_time_eq(b"abc", b"abd"));
     251            4 :         assert!(!constant_time_eq(b"abc", b"ab"));
     252            4 :         assert!(!constant_time_eq(b"ab", b"abc"));
     253              :         // two wrong bytes whose differences cancel under XOR: an accumulator
     254              :         // that XORs instead of ORs would admit this guess
     255            4 :         assert!(!constant_time_eq(b"abc", b"`cc"));
     256            4 :         assert!(!auth().admits(Some(b"Bearer 1023456789abcdef0123456789abcdef")));
     257            4 :     }
     258              : 
     259              :     #[test]
     260            4 :     fn the_config_reads_the_file_and_refuses_what_it_cannot_use() {
     261            4 :         let dir = std::env::temp_dir().join(format!("antares-admin-{}", uuid::Uuid::new_v4()));
     262            4 :         std::fs::create_dir(&dir).expect("temp dir");
     263            4 :         let good = dir.join("token");
     264            4 :         std::fs::write(&good, format!("{SECRET}\n")).expect("write");
     265            4 :         let short = dir.join("short");
     266            4 :         std::fs::write(&short, "0123").expect("write");
     267            4 :         let good_s = good.to_str().expect("utf-8 path");
     268              : 
     269            4 :         assert!(AdminAuth::from_config(None, None).expect("unset").is_none());
     270            4 :         assert!(AdminAuth::from_config(Some(" "), Some("open"))
     271            4 :             .expect("blank")
     272            4 :             .is_none());
     273            4 :         let a = AdminAuth::from_config(Some(good_s), None)
     274            4 :             .expect("ok")
     275            4 :             .expect("some");
     276            4 :         assert!(!a.metrics_open);
     277            4 :         assert!(a.admits(Some(format!("Bearer {SECRET}").as_bytes())));
     278            4 :         let open = AdminAuth::from_config(Some(good_s), Some("open"))
     279            4 :             .expect("ok")
     280            4 :             .expect("some");
     281            4 :         assert!(open.metrics_open);
     282            4 :         assert!(
     283            4 :             !AdminAuth::from_config(Some(good_s), Some("token"))
     284            4 :                 .expect("ok")
     285            4 :                 .expect("some")
     286            4 :                 .metrics_open
     287              :         );
     288              : 
     289            4 :         let err = AdminAuth::from_config(Some(good_s), Some("public")).expect_err("unknown");
     290            4 :         assert!(err.contains("ANTARES_ADMIN_METRICS"), "{err}");
     291            4 :         let err =
     292            4 :             AdminAuth::from_config(Some(short.to_str().expect("path")), None).expect_err("short");
     293            4 :         assert!(err.contains("4 bytes") && !err.contains("0123\""), "{err}");
     294            4 :         let missing = dir.join("absent");
     295            4 :         let err = AdminAuth::from_config(missing.to_str(), None).expect_err("absent");
     296            4 :         assert!(err.contains("unreadable"), "{err}");
     297            4 :         std::fs::remove_dir_all(&dir).expect("cleanup");
     298            4 :     }
     299              : 
     300              :     #[test]
     301            4 :     fn debug_never_prints_the_secret() {
     302            4 :         let shown = format!("{:?}", auth());
     303            4 :         assert!(!shown.contains(SECRET), "{shown}");
     304            4 :         assert!(shown.contains("metrics_open"), "{shown}");
     305            4 :     }
     306              : }
        

Generated by: LCOV version 2.0-1