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 : }
|