From ff6ec62d543d240b67dd90229bdb06a6cc55fd0f Mon Sep 17 00:00:00 2001 From: Alex Auvolat Date: Tue, 11 Mar 2025 14:15:13 +0100 Subject: [PATCH] admin api: add metrics_require_token config option and update doc --- doc/book/reference-manual/configuration.md | 45 ++++++-- src/api/admin/api_server.rs | 128 ++++++++++----------- src/util/config.rs | 3 + 3 files changed, 97 insertions(+), 79 deletions(-) diff --git a/doc/book/reference-manual/configuration.md b/doc/book/reference-manual/configuration.md index e0fc17bc..6e4daea0 100644 --- a/doc/book/reference-manual/configuration.md +++ b/doc/book/reference-manual/configuration.md @@ -80,6 +80,7 @@ add_host_to_metrics = true [admin] api_bind_addr = "0.0.0.0:3903" metrics_token = "BCAdFjoa9G0KJR0WXnHHm7fs1ZAbfpI8iIZ+Z/a2NgI=" +metrics_require_token = true admin_token = "UkLeGWEvHnXBqnueR3ISEMWpOnm40jH2tM2HnnL/0F4=" trace_sink = "http://localhost:4317" ``` @@ -145,6 +146,7 @@ The `[s3_web]` section: The `[admin]` section: [`api_bind_addr`](#admin_api_bind_addr), +[`metrics_require_token`](#admin_metrics_require_token), [`metrics_token`/`metrics_token_file`](#admin_metrics_token), [`admin_token`/`admin_token_file`](#admin_token), [`trace_sink`](#admin_trace_sink), @@ -767,10 +769,34 @@ See [administration API reference](@/documentation/reference-manual/admin-api.md Alternatively, since `v0.8.5`, a path can be used to create a unix socket. Note that for security reasons, the socket will have 0220 mode. Make sure to set user and group permissions accordingly. +#### `admin_token`, `admin_token_file` or `GARAGE_ADMIN_TOKEN`, `GARAGE_ADMIN_TOKEN_FILE` (env) {#admin_token} + +The token for accessing all administration functions on the admin endpoint, +with the exception of the metrics endpoint (see `metrics_token`). + +You can use any random string for this value. We recommend generating a random +token with `openssl rand -base64 32`. + +For Garage version earlier than `v2.0`, if this token is not set, +access to these endpoints is disabled entirely. + +Since Garage `v2.0`, additional admin API tokens can be defined dynamically +in your Garage cluster using administration commands. This new admin token system +is more flexible since it allows admin tokens to have an expiration date, +and to have a scope restricted to certain admin API functions. If `admin_token` +is set, it behaves as an admin token without expiration and with full scope. +Otherwise, only admin API tokens defined dynamically can be used. + +`admin_token` was introduced in Garage `v0.7.2`. +`admin_token_file` and the `GARAGE_ADMIN_TOKEN` environment variable are supported since Garage `v0.8.2`. + +`GARAGE_ADMIN_TOKEN_FILE` is supported since `v0.8.5` / `v0.9.1`. + #### `metrics_token`, `metrics_token_file` or `GARAGE_METRICS_TOKEN`, `GARAGE_METRICS_TOKEN_FILE` (env) {#admin_metrics_token} -The token for accessing the Metrics endpoint. If this token is not set, the -Metrics endpoint can be accessed without access control. +The token for accessing the Prometheus metrics endpoint (`/metrics`). +If this token is not set, and unless `metrics_require_token` is set to `true`, +the metrics endpoint can be accessed without access control. You can use any random string for this value. We recommend generating a random token with `openssl rand -base64 32`. @@ -779,17 +805,12 @@ You can use any random string for this value. We recommend generating a random t `GARAGE_METRICS_TOKEN_FILE` is supported since `v0.8.5` / `v0.9.1`. -#### `admin_token`, `admin_token_file` or `GARAGE_ADMIN_TOKEN`, `GARAGE_ADMIN_TOKEN_FILE` (env) {#admin_token} +#### `metrics_require_token` (since `v2.0.0`) {#admin_metrics_require_token} -The token for accessing all of the other administration endpoints. If this -token is not set, access to these endpoints is disabled entirely. - -You can use any random string for this value. We recommend generating a random token with `openssl rand -base64 32`. - -`admin_token` was introduced in Garage `v0.7.2`. -`admin_token_file` and the `GARAGE_ADMIN_TOKEN` environment variable are supported since Garage `v0.8.2`. - -`GARAGE_ADMIN_TOKEN_FILE` is supported since `v0.8.5` / `v0.9.1`. +If this is set to `true`, accessing the metrics endpoint will always require +an access token. Valid tokens include the `metrics_token` if it is set, +and admin API token defined dynamicaly in Garage which have +the `Metrics` endpoint in their scope. #### `trace_sink` {#admin_trace_sink} diff --git a/src/api/admin/api_server.rs b/src/api/admin/api_server.rs index 98fc2529..a214dfa7 100644 --- a/src/api/admin/api_server.rs +++ b/src/api/admin/api_server.rs @@ -99,6 +99,7 @@ pub struct AdminApiServer { #[cfg(feature = "metrics")] pub(crate) exporter: PrometheusExporter, metrics_token: Option, + metrics_require_token: bool, admin_token: Option, pub(crate) background: Arc, pub(crate) endpoint: Arc>, @@ -118,6 +119,7 @@ impl AdminApiServer { let cfg = &garage.config.admin; let metrics_token = cfg.metrics_token.as_deref().map(hash_bearer_token); let admin_token = cfg.admin_token.as_deref().map(hash_bearer_token); + let metrics_require_token = cfg.metrics_require_token; let endpoint = garage.system.netapp.endpoint(ADMIN_RPC_PATH.into()); let admin = Arc::new(Self { @@ -125,6 +127,7 @@ impl AdminApiServer { #[cfg(feature = "metrics")] exporter, metrics_token, + metrics_require_token, admin_token, background, endpoint, @@ -156,25 +159,19 @@ impl AdminApiServer { HttpEndpoint::New(_) => AdminApiRequest::from_request(req).await?, }; - let required_auth_hash = - match request.authorization_type() { - Authorization::None => None, - Authorization::MetricsToken => self.metrics_token.as_deref(), - Authorization::AdminToken => match self.admin_token.as_deref() { - None => return Err(Error::forbidden( - "Admin token isn't configured, admin API access is disabled for security.", - )), - Some(t) => Some(t), - }, - }; + let (global_token_hash, token_required) = match request.authorization_type() { + Authorization::None => (None, false), + Authorization::MetricsToken => ( + self.metrics_token.as_deref(), + self.metrics_token.is_some() || self.metrics_require_token, + ), + Authorization::AdminToken => (self.admin_token.as_deref(), true), + }; - verify_authorization( - &self.garage, - required_auth_hash, - auth_header, - request.name(), - ) - .await?; + if token_required { + verify_authorization(&self.garage, global_token_hash, auth_header, request.name()) + .await?; + } match request { AdminApiRequest::Options(req) => req.handle(&self.garage, &self).await, @@ -250,7 +247,7 @@ fn hash_bearer_token(token: &str) -> String { async fn verify_authorization( garage: &Garage, - required_token_hash: Option<&str>, + global_token_hash: Option<&str>, auth_header: Option, endpoint_name: &str, ) -> Result<(), Error> { @@ -258,55 +255,52 @@ async fn verify_authorization( let invalid_msg = "Invalid bearer token"; - if let Some(token_hash_str) = required_token_hash { - let token = match &auth_header { - None => { - return Err(Error::forbidden( - "Bearer token must be provided in Authorization header", - )) - } - Some(authorization) => authorization - .to_str()? - .strip_prefix("Bearer ") - .ok_or_else(|| Error::forbidden("Invalid Authorization header"))? - .trim(), - }; - - let token_hash_string = if let Some((prefix, _)) = token.split_once('.') { - garage - .admin_token_table - .get(&EmptyKey, &prefix.to_string()) - .await? - .and_then(|k| k.state.into_option()) - .filter(|p| { - p.expiration - .get() - .map(|exp| now_msec() < exp) - .unwrap_or(true) - }) - .filter(|p| { - p.scope - .get() - .0 - .iter() - .any(|x| x == "*" || x == endpoint_name) - }) - .ok_or_else(|| Error::forbidden(invalid_msg))? - .token_hash - } else { - token_hash_str.to_string() - }; - - let token_hash = PasswordHash::new(&token_hash_string) - .ok_or_internal_error("Could not parse token hash")?; - - if Argon2::default() - .verify_password(token.as_bytes(), &token_hash) - .is_err() - { - return Err(Error::forbidden(invalid_msg)); + let token = match &auth_header { + None => { + return Err(Error::forbidden( + "Bearer token must be provided in Authorization header", + )) } - } + Some(authorization) => authorization + .to_str()? + .strip_prefix("Bearer ") + .ok_or_else(|| Error::forbidden("Invalid Authorization header"))? + .trim(), + }; + + let token_hash_string = if let Some((prefix, _)) = token.split_once('.') { + garage + .admin_token_table + .get(&EmptyKey, &prefix.to_string()) + .await? + .and_then(|k| k.state.into_option()) + .filter(|p| { + p.expiration + .get() + .map(|exp| now_msec() < exp) + .unwrap_or(true) + }) + .filter(|p| { + p.scope + .get() + .0 + .iter() + .any(|x| x == "*" || x == endpoint_name) + }) + .ok_or_else(|| Error::forbidden(invalid_msg))? + .token_hash + } else { + global_token_hash + .ok_or_else(|| Error::forbidden(invalid_msg))? + .to_string() + }; + + let token_hash = + PasswordHash::new(&token_hash_string).ok_or_internal_error("Could not parse token hash")?; + + Argon2::default() + .verify_password(token.as_bytes(), &token_hash) + .map_err(|_| Error::forbidden(invalid_msg))?; Ok(()) } diff --git a/src/util/config.rs b/src/util/config.rs index 73fc4ff4..47247718 100644 --- a/src/util/config.rs +++ b/src/util/config.rs @@ -198,6 +198,9 @@ pub struct AdminConfig { pub metrics_token: Option, /// File to read metrics token from pub metrics_token_file: Option, + /// Whether to require an access token for accessing the metrics endpoint + #[serde(default)] + pub metrics_require_token: bool, /// Bearer token to use to access Admin API endpoints pub admin_token: Option,