From 46f620119b1718df1606fd903523d20b90cc9550 Mon Sep 17 00:00:00 2001 From: Alex Auvolat Date: Tue, 11 Mar 2025 13:09:19 +0100 Subject: [PATCH 01/12] add model for admin key table --- Cargo.lock | 1 + src/api/admin/router_v2.rs | 4 +- src/model/Cargo.toml | 1 + src/model/admin_token_table.rs | 167 +++++++++++++++++++++++++++++++++ src/model/garage.rs | 13 +++ src/model/lib.rs | 1 + 6 files changed, 184 insertions(+), 3 deletions(-) create mode 100644 src/model/admin_token_table.rs diff --git a/Cargo.lock b/Cargo.lock index 20820f7d..37e22f21 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1467,6 +1467,7 @@ dependencies = [ name = "garage_model" version = "1.1.0" dependencies = [ + "argon2", "async-trait", "base64 0.21.7", "blake2", diff --git a/src/api/admin/router_v2.rs b/src/api/admin/router_v2.rs index 9f6106e5..133f9c29 100644 --- a/src/api/admin/router_v2.rs +++ b/src/api/admin/router_v2.rs @@ -243,9 +243,7 @@ impl AdminApiRequest { /// Get the kind of authorization which is required to perform the operation. pub fn authorization_type(&self) -> Authorization { match self { - Self::Options(_) => Authorization::None, - Self::Health(_) => Authorization::None, - Self::CheckDomain(_) => Authorization::None, + Self::Options(_) | Self::Health(_) | Self::CheckDomain(_) => Authorization::None, Self::Metrics(_) => Authorization::MetricsToken, _ => Authorization::AdminToken, } diff --git a/src/model/Cargo.toml b/src/model/Cargo.toml index 42ec8537..a990a191 100644 --- a/src/model/Cargo.toml +++ b/src/model/Cargo.toml @@ -21,6 +21,7 @@ garage_block.workspace = true garage_util.workspace = true garage_net.workspace = true +argon2.workspace = true async-trait.workspace = true blake2.workspace = true chrono.workspace = true diff --git a/src/model/admin_token_table.rs b/src/model/admin_token_table.rs new file mode 100644 index 00000000..089c72e2 --- /dev/null +++ b/src/model/admin_token_table.rs @@ -0,0 +1,167 @@ +use garage_util::crdt::{self, Crdt}; + +use garage_table::{EmptyKey, Entry, TableSchema}; + +pub use crate::key_table::KeyFilter; + +mod v2 { + use garage_util::crdt; + use serde::{Deserialize, Serialize}; + + #[derive(PartialEq, Eq, Clone, Debug, Serialize, Deserialize)] + pub struct AdminApiToken { + /// An admin API token is a bearer token of the following form: + /// `.` + /// Only the prefix is saved here, it is used as an identifier. + /// The entire API token is hashed and saved in `token_hash` in `state`. + pub prefix: String, + + /// If the token is not deleted, its parameters + pub state: crdt::Deletable, + } + + #[derive(PartialEq, Eq, Clone, Debug, Serialize, Deserialize)] + pub struct AdminApiTokenParams { + /// The entire API token hashed as a password + pub token_hash: String, + + /// User-defined name + pub name: crdt::Lww, + + /// The optional time of expiration of the token + pub expiration: crdt::Lww>, + + /// The scope of the token, i.e. list of authorized admin API calls + pub scope: crdt::Lww, + } + + #[derive(PartialEq, Eq, Clone, Debug, Serialize, Deserialize)] + pub struct AdminApiTokenScope(pub Vec); + + impl garage_util::migrate::InitialFormat for AdminApiToken { + const VERSION_MARKER: &'static [u8] = b"G2admtok"; + } +} + +pub use v2::*; + +impl Crdt for AdminApiTokenParams { + fn merge(&mut self, o: &Self) { + self.name.merge(&o.name); + self.expiration.merge(&o.expiration); + self.scope.merge(&o.scope); + } +} + +impl Crdt for AdminApiToken { + fn merge(&mut self, other: &Self) { + self.state.merge(&other.state); + } +} + +impl Crdt for AdminApiTokenScope { + fn merge(&mut self, other: &Self) { + self.0.retain(|x| other.0.contains(x)); + } +} + +impl AdminApiToken { + /// Create a new admin API token. + /// Returns the AdminApiToken object, which contains the hashed bearer token, + /// as well as the plaintext bearer token. + pub fn new(name: &str) -> (Self, String) { + use argon2::{ + password_hash::{rand_core::OsRng, PasswordHasher, SaltString}, + Argon2, + }; + + let prefix = hex::encode(&rand::random::<[u8; 12]>()[..]); + let secret = hex::encode(&rand::random::<[u8; 32]>()[..]); + let token = format!("{}.{}", prefix, secret); + + let salt = SaltString::generate(&mut OsRng); + let argon2 = Argon2::default(); + let hashed_token = argon2 + .hash_password(token.as_bytes(), &salt) + .expect("could not hash admin API token") + .to_string(); + + let ret = AdminApiToken { + prefix, + state: crdt::Deletable::present(AdminApiTokenParams { + token_hash: hashed_token, + name: crdt::Lww::new(name.to_string()), + expiration: crdt::Lww::new(None), + scope: crdt::Lww::new(AdminApiTokenScope(vec!["*".to_string()])), + }), + }; + + (ret, token) + } + + pub fn delete(prefix: String) -> Self { + Self { + prefix, + state: crdt::Deletable::Deleted, + } + } + + /// Returns true if this represents a deleted bucket + pub fn is_deleted(&self) -> bool { + self.state.is_deleted() + } + + /// Returns an option representing the params (None if in deleted state) + pub fn params(&self) -> Option<&AdminApiTokenParams> { + self.state.as_option() + } + + /// Mutable version of `.state()` + pub fn params_mut(&mut self) -> Option<&mut AdminApiTokenParams> { + self.state.as_option_mut() + } + + /// Scope, if not deleted, or empty slice + pub fn scope(&self) -> &[String] { + self.state + .as_option() + .map(|x| &x.scope.get().0[..]) + .unwrap_or_default() + } +} + +impl Entry for AdminApiToken { + fn partition_key(&self) -> &EmptyKey { + &EmptyKey + } + fn sort_key(&self) -> &String { + &self.prefix + } +} + +pub struct AdminApiTokenTable; + +impl TableSchema for AdminApiTokenTable { + const TABLE_NAME: &'static str = "admin_token"; + + type P = EmptyKey; + type S = String; + type E = AdminApiToken; + type Filter = KeyFilter; + + fn matches_filter(entry: &Self::E, filter: &Self::Filter) -> bool { + match filter { + KeyFilter::Deleted(df) => df.apply(entry.state.is_deleted()), + KeyFilter::MatchesAndNotDeleted(pat) => { + let pat = pat.to_lowercase(); + entry + .params() + .map(|p| { + entry.prefix.to_lowercase().starts_with(&pat) + || p.name.get().to_lowercase() == pat + }) + .unwrap_or(false) + } + } + } +} diff --git a/src/model/garage.rs b/src/model/garage.rs index 11c0d90f..95f7b577 100644 --- a/src/model/garage.rs +++ b/src/model/garage.rs @@ -24,6 +24,7 @@ use crate::s3::mpu_table::*; use crate::s3::object_table::*; use crate::s3::version_table::*; +use crate::admin_token_table::*; use crate::bucket_alias_table::*; use crate::bucket_table::*; use crate::helper; @@ -50,6 +51,8 @@ pub struct Garage { /// The block manager pub block_manager: Arc, + /// Table containing admin API keys + pub admin_token_table: Arc>, /// Table containing buckets pub bucket_table: Arc>, /// Table containing bucket aliases @@ -174,6 +177,14 @@ impl Garage { block_manager.register_bg_vars(&mut bg_vars); // ---- admin tables ---- + info!("Initialize admin_token_table..."); + let admin_token_table = Table::new( + AdminApiTokenTable, + control_rep_param.clone(), + system.clone(), + &db, + ); + info!("Initialize bucket_table..."); let bucket_table = Table::new(BucketTable, control_rep_param.clone(), system.clone(), &db); @@ -263,6 +274,7 @@ impl Garage { db, system, block_manager, + admin_token_table, bucket_table, bucket_alias_table, key_table, @@ -282,6 +294,7 @@ impl Garage { pub fn spawn_workers(self: &Arc, bg: &BackgroundRunner) -> Result<(), Error> { self.block_manager.spawn_workers(bg); + self.admin_token_table.spawn_workers(bg); self.bucket_table.spawn_workers(bg); self.bucket_alias_table.spawn_workers(bg); self.key_table.spawn_workers(bg); diff --git a/src/model/lib.rs b/src/model/lib.rs index 1939a7a9..b4dc1e81 100644 --- a/src/model/lib.rs +++ b/src/model/lib.rs @@ -5,6 +5,7 @@ pub mod permission; pub mod index_counter; +pub mod admin_token_table; pub mod bucket_alias_table; pub mod bucket_table; pub mod key_table; From 004eb94e14dad1544c661cbb049d6e538f6e3520 Mon Sep 17 00:00:00 2001 From: Alex Auvolat Date: Tue, 11 Mar 2025 13:40:23 +0100 Subject: [PATCH 02/12] admin api: verify tokens using the new admin api token table --- src/api/admin/api_server.rs | 88 +++++++++++++++++++++++++++---------- 1 file changed, 66 insertions(+), 22 deletions(-) diff --git a/src/api/admin/api_server.rs b/src/api/admin/api_server.rs index 0e6afce2..98fc2529 100644 --- a/src/api/admin/api_server.rs +++ b/src/api/admin/api_server.rs @@ -1,8 +1,6 @@ use std::borrow::Cow; use std::sync::Arc; -use argon2::password_hash::PasswordHash; - use http::header::{HeaderValue, ACCESS_CONTROL_ALLOW_ORIGIN, AUTHORIZATION}; use hyper::{body::Incoming as IncomingBody, Request, Response}; use serde::{Deserialize, Serialize}; @@ -15,10 +13,12 @@ use opentelemetry_prometheus::PrometheusExporter; use garage_model::garage::Garage; use garage_rpc::{Endpoint as RpcEndpoint, *}; +use garage_table::EmptyKey; use garage_util::background::BackgroundRunner; use garage_util::data::Uuid; use garage_util::error::Error as GarageError; use garage_util::socket_address::UnixOrTCPSocketAddress; +use garage_util::time::now_msec; use garage_api_common::generic_server::*; use garage_api_common::helpers::*; @@ -168,14 +168,13 @@ impl AdminApiServer { }, }; - if let Some(password_hash) = required_auth_hash { - match auth_header { - None => return Err(Error::forbidden("Authorization token must be provided")), - Some(authorization) => { - verify_bearer_token(&authorization, password_hash)?; - } - } - } + verify_authorization( + &self.garage, + required_auth_hash, + auth_header, + request.name(), + ) + .await?; match request { AdminApiRequest::Options(req) => req.handle(&self.garage, &self).await, @@ -249,20 +248,65 @@ fn hash_bearer_token(token: &str) -> String { .to_string() } -fn verify_bearer_token(token: &hyper::http::HeaderValue, password_hash: &str) -> Result<(), Error> { - use argon2::{password_hash::PasswordVerifier, Argon2}; +async fn verify_authorization( + garage: &Garage, + required_token_hash: Option<&str>, + auth_header: Option, + endpoint_name: &str, +) -> Result<(), Error> { + use argon2::{password_hash::PasswordHash, password_hash::PasswordVerifier, Argon2}; - let parsed_hash = PasswordHash::new(&password_hash).unwrap(); + let invalid_msg = "Invalid bearer token"; - token - .to_str()? - .strip_prefix("Bearer ") - .and_then(|token| { - Argon2::default() - .verify_password(token.trim().as_bytes(), &parsed_hash) - .ok() - }) - .ok_or_else(|| Error::forbidden("Invalid authorization 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)); + } + } Ok(()) } From ff6ec62d543d240b67dd90229bdb06a6cc55fd0f Mon Sep 17 00:00:00 2001 From: Alex Auvolat Date: Tue, 11 Mar 2025 14:15:13 +0100 Subject: [PATCH 03/12] 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, From d067a40b3fe7b55fda1b8f5acdb43977a070f034 Mon Sep 17 00:00:00 2001 From: Alex Auvolat Date: Tue, 11 Mar 2025 15:17:31 +0100 Subject: [PATCH 04/12] admin api: add functions to manage admin api tokens --- Cargo.lock | 1 + Cargo.toml | 2 +- src/api/admin/Cargo.toml | 1 + src/api/admin/admin_token.rs | 193 +++++++++++++++++++++++++++++++++ src/api/admin/api.rs | 85 +++++++++++++++ src/api/admin/error.rs | 12 +- src/api/admin/key.rs | 21 +++- src/api/admin/lib.rs | 1 + src/api/admin/router_v2.rs | 6 + src/model/admin_token_table.rs | 4 +- src/model/helper/key.rs | 31 +----- 11 files changed, 319 insertions(+), 38 deletions(-) create mode 100644 src/api/admin/admin_token.rs diff --git a/Cargo.lock b/Cargo.lock index 37e22f21..b9d48116 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1298,6 +1298,7 @@ dependencies = [ "argon2", "async-trait", "bytesize", + "chrono", "err-derive", "format_table", "futures", diff --git a/Cargo.toml b/Cargo.toml index d1cae350..b7830a7d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -48,7 +48,7 @@ blake2 = "0.10" bytes = "1.0" bytesize = "1.1" cfg-if = "1.0" -chrono = "0.4" +chrono = { version = "0.4", features = ["serde"] } crc32fast = "1.4" crc32c = "0.6" crypto-common = "0.1" diff --git a/src/api/admin/Cargo.toml b/src/api/admin/Cargo.toml index b4e2350a..65d9fda9 100644 --- a/src/api/admin/Cargo.toml +++ b/src/api/admin/Cargo.toml @@ -25,6 +25,7 @@ garage_api_common.workspace = true argon2.workspace = true async-trait.workspace = true bytesize.workspace = true +chrono.workspace = true err-derive.workspace = true hex.workspace = true paste.workspace = true diff --git a/src/api/admin/admin_token.rs b/src/api/admin/admin_token.rs new file mode 100644 index 00000000..10a23a68 --- /dev/null +++ b/src/api/admin/admin_token.rs @@ -0,0 +1,193 @@ +use std::sync::Arc; + +use chrono::{DateTime, Utc}; + +use garage_table::*; +use garage_util::time::now_msec; + +use garage_model::admin_token_table::*; +use garage_model::garage::Garage; + +use crate::api::*; +use crate::error::*; +use crate::{Admin, RequestHandler}; + +impl RequestHandler for ListAdminTokensRequest { + type Response = ListAdminTokensResponse; + + async fn handle( + self, + garage: &Arc, + _admin: &Admin, + ) -> Result { + let now = now_msec(); + + let res = garage + .admin_token_table + .get_range( + &EmptyKey, + None, + Some(KeyFilter::Deleted(DeletedFilter::NotDeleted)), + 10000, + EnumerationOrder::Forward, + ) + .await? + .iter() + .map(|t| admin_token_info_results(t, now)) + .collect::>(); + + Ok(ListAdminTokensResponse(res)) + } +} + +impl RequestHandler for GetAdminTokenInfoRequest { + type Response = GetAdminTokenInfoResponse; + + async fn handle( + self, + garage: &Arc, + _admin: &Admin, + ) -> Result { + let token = match (self.id, self.search) { + (Some(id), None) => get_existing_admin_token(garage, &id).await?, + (None, Some(search)) => { + let candidates = garage + .admin_token_table + .get_range( + &EmptyKey, + None, + Some(KeyFilter::MatchesAndNotDeleted(search.to_string())), + 10, + EnumerationOrder::Forward, + ) + .await? + .into_iter() + .collect::>(); + if candidates.len() != 1 { + return Err(Error::bad_request(format!( + "{} matching admin tokens", + candidates.len() + ))); + } + candidates.into_iter().next().unwrap() + } + _ => { + return Err(Error::bad_request( + "Either id or search must be provided (but not both)", + )); + } + }; + + Ok(admin_token_info_results(&token, now_msec())) + } +} + +impl RequestHandler for CreateAdminTokenRequest { + type Response = CreateAdminTokenResponse; + + async fn handle( + self, + garage: &Arc, + _admin: &Admin, + ) -> Result { + let (mut token, secret) = if self.0.name.is_some() { + AdminApiToken::new("") + } else { + AdminApiToken::new(&format!("token_{}", Utc::now().format("%Y%m%d_%H%M"))) + }; + + apply_token_updates(&mut token, self.0); + + garage.admin_token_table.insert(&token).await?; + + Ok(CreateAdminTokenResponse { + secret_token: secret, + info: admin_token_info_results(&token, now_msec()), + }) + } +} + +impl RequestHandler for UpdateAdminTokenRequest { + type Response = UpdateAdminTokenResponse; + + async fn handle( + self, + garage: &Arc, + _admin: &Admin, + ) -> Result { + let mut token = get_existing_admin_token(&garage, &self.id).await?; + + apply_token_updates(&mut token, self.body); + + garage.admin_token_table.insert(&token).await?; + + Ok(UpdateAdminTokenResponse(admin_token_info_results( + &token, + now_msec(), + ))) + } +} + +impl RequestHandler for DeleteAdminTokenRequest { + type Response = DeleteAdminTokenResponse; + + async fn handle( + self, + garage: &Arc, + _admin: &Admin, + ) -> Result { + let token = get_existing_admin_token(&garage, &self.id).await?; + + garage + .admin_token_table + .insert(&AdminApiToken::delete(token.prefix)) + .await?; + + Ok(DeleteAdminTokenResponse) + } +} + +// ---- helpers ---- + +fn admin_token_info_results(token: &AdminApiToken, now: u64) -> GetAdminTokenInfoResponse { + let params = token.params().unwrap(); + + GetAdminTokenInfoResponse { + id: token.prefix.clone(), + name: params.name.get().to_string(), + expiration: params.expiration.get().map(|x| { + DateTime::from_timestamp_millis(x as i64).expect("invalid timestamp stored in db") + }), + expired: params + .expiration + .get() + .map(|exp| now > exp) + .unwrap_or(false), + scope: params.scope.get().0.clone(), + } +} + +async fn get_existing_admin_token(garage: &Garage, id: &String) -> Result { + garage + .admin_token_table + .get(&EmptyKey, id) + .await? + .filter(|k| !k.state.is_deleted()) + .ok_or_else(|| Error::NoSuchAdminToken(id.to_string())) +} + +fn apply_token_updates(token: &mut AdminApiToken, updates: UpdateAdminTokenRequestBody) { + let params = token.params_mut().unwrap(); + + if let Some(name) = updates.name { + params.name.update(name); + } + if let Some(expiration) = updates.expiration { + params + .expiration + .update(Some(expiration.timestamp_millis() as u64)); + } + if let Some(scope) = updates.scope { + params.scope.update(AdminApiTokenScope(scope)); + } +} diff --git a/src/api/admin/api.rs b/src/api/admin/api.rs index 78706ce3..13b2c3b1 100644 --- a/src/api/admin/api.rs +++ b/src/api/admin/api.rs @@ -49,6 +49,13 @@ admin_endpoints![ GetClusterStatistics, ConnectClusterNodes, + // Admin tokens operations + ListAdminTokens, + GetAdminTokenInfo, + CreateAdminToken, + UpdateAdminToken, + DeleteAdminToken, + // Layout operations GetClusterLayout, GetClusterLayoutHistory, @@ -282,6 +289,84 @@ pub struct ConnectNodeResponse { pub error: Option, } +// ********************************************** +// Admin token operations +// ********************************************** + +// ---- ListAdminTokens ---- + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ListAdminTokensRequest; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ListAdminTokensResponse(pub Vec); + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ListAdminTokensResponseItem { + pub id: String, + pub name: String, +} + +// ---- GetAdminTokenInfo ---- + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct GetAdminTokenInfoRequest { + pub id: Option, + pub search: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct GetAdminTokenInfoResponse { + pub id: String, + pub name: String, + pub expiration: Option>, + pub expired: bool, + pub scope: Vec, +} + +// ---- CreateAdminToken ---- + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CreateAdminTokenRequest(pub UpdateAdminTokenRequestBody); + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct CreateAdminTokenResponse { + pub secret_token: String, + #[serde(flatten)] + pub info: GetAdminTokenInfoResponse, +} + +// ---- UpdateAdminToken ---- + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct UpdateAdminTokenRequest { + pub id: String, + pub body: UpdateAdminTokenRequestBody, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct UpdateAdminTokenRequestBody { + pub name: Option, + pub expiration: Option>, + pub scope: Option>, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct UpdateAdminTokenResponse(pub GetAdminTokenInfoResponse); + +// ---- DeleteAdminToken ---- + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct DeleteAdminTokenRequest { + pub id: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct DeleteAdminTokenResponse; + // ********************************************** // Layout operations // ********************************************** diff --git a/src/api/admin/error.rs b/src/api/admin/error.rs index d7ea7dc9..f12a936e 100644 --- a/src/api/admin/error.rs +++ b/src/api/admin/error.rs @@ -21,6 +21,10 @@ pub enum Error { Common(#[error(source)] CommonError), // Category: cannot process + /// The admin API token does not exist + #[error(display = "Admin token not found: {}", _0)] + NoSuchAdminToken(String), + /// The API access key does not exist #[error(display = "Access key not found: {}", _0)] NoSuchAccessKey(String), @@ -60,6 +64,7 @@ impl Error { pub fn code(&self) -> &'static str { match self { Error::Common(c) => c.aws_code(), + Error::NoSuchAdminToken(_) => "NoSuchAdminToken", Error::NoSuchAccessKey(_) => "NoSuchAccessKey", Error::NoSuchWorker(_) => "NoSuchWorker", Error::NoSuchBlock(_) => "NoSuchBlock", @@ -73,9 +78,10 @@ impl ApiError for Error { fn http_status_code(&self) -> StatusCode { match self { Error::Common(c) => c.http_status_code(), - Error::NoSuchAccessKey(_) | Error::NoSuchWorker(_) | Error::NoSuchBlock(_) => { - StatusCode::NOT_FOUND - } + Error::NoSuchAdminToken(_) + | Error::NoSuchAccessKey(_) + | Error::NoSuchWorker(_) + | Error::NoSuchBlock(_) => StatusCode::NOT_FOUND, Error::KeyAlreadyExists(_) => StatusCode::CONFLICT, } } diff --git a/src/api/admin/key.rs b/src/api/admin/key.rs index dc6ae4e9..d1a49ab3 100644 --- a/src/api/admin/key.rs +++ b/src/api/admin/key.rs @@ -46,10 +46,25 @@ impl RequestHandler for GetKeyInfoRequest { let key = match (self.id, self.search) { (Some(id), None) => garage.key_helper().get_existing_key(&id).await?, (None, Some(search)) => { - garage - .key_helper() - .get_existing_matching_key(&search) + let candidates = garage + .key_table + .get_range( + &EmptyKey, + None, + Some(KeyFilter::MatchesAndNotDeleted(search.to_string())), + 10, + EnumerationOrder::Forward, + ) .await? + .into_iter() + .collect::>(); + if candidates.len() != 1 { + return Err(Error::bad_request(format!( + "{} matching keys", + candidates.len() + ))); + } + candidates.into_iter().next().unwrap() } _ => { return Err(Error::bad_request( diff --git a/src/api/admin/lib.rs b/src/api/admin/lib.rs index 0cd1076e..dd164497 100644 --- a/src/api/admin/lib.rs +++ b/src/api/admin/lib.rs @@ -11,6 +11,7 @@ mod router_v0; mod router_v1; mod router_v2; +mod admin_token; mod bucket; mod cluster; mod key; diff --git a/src/api/admin/router_v2.rs b/src/api/admin/router_v2.rs index 133f9c29..73f98308 100644 --- a/src/api/admin/router_v2.rs +++ b/src/api/admin/router_v2.rs @@ -34,6 +34,12 @@ impl AdminApiRequest { GET GetClusterStatus (), GET GetClusterHealth (), POST ConnectClusterNodes (body), + // Admin token endpoints + GET ListAdminTokens (), + GET GetAdminTokenInfo (query_opt::id, query_opt::search), + POST CreateAdminToken (body), + POST UpdateAdminToken (body_field, query::id), + POST DeleteAdminToken (query::id), // Layout endpoints GET GetClusterLayout (), GET GetClusterLayoutHistory (), diff --git a/src/model/admin_token_table.rs b/src/model/admin_token_table.rs index 089c72e2..45532e54 100644 --- a/src/model/admin_token_table.rs +++ b/src/model/admin_token_table.rs @@ -1,3 +1,5 @@ +use base64::prelude::*; + use garage_util::crdt::{self, Crdt}; use garage_table::{EmptyKey, Entry, TableSchema}; @@ -76,7 +78,7 @@ impl AdminApiToken { }; let prefix = hex::encode(&rand::random::<[u8; 12]>()[..]); - let secret = hex::encode(&rand::random::<[u8; 32]>()[..]); + let secret = BASE64_URL_SAFE_NO_PAD.encode(&rand::random::<[u8; 32]>()[..]); let token = format!("{}.{}", prefix, secret); let salt = SaltString::generate(&mut OsRng); diff --git a/src/model/helper/key.rs b/src/model/helper/key.rs index b8a99d55..00d8d5c6 100644 --- a/src/model/helper/key.rs +++ b/src/model/helper/key.rs @@ -3,7 +3,7 @@ use garage_util::error::OkOrMessage; use crate::garage::Garage; use crate::helper::error::*; -use crate::key_table::{Key, KeyFilter}; +use crate::key_table::Key; pub struct KeyHelper<'a>(pub(crate) &'a Garage); @@ -33,33 +33,4 @@ impl<'a> KeyHelper<'a> { .filter(|b| !b.state.is_deleted()) .ok_or_else(|| Error::NoSuchAccessKey(key_id.to_string())) } - - /// Returns a Key if it is present in key table, - /// looking it up by key ID or by a match on its name, - /// only if it is in non-deleted state. - /// Querying a non-existing key ID or a deleted key - /// returns a bad request error. - pub async fn get_existing_matching_key(&self, pattern: &str) -> Result { - let candidates = self - .0 - .key_table - .get_range( - &EmptyKey, - None, - Some(KeyFilter::MatchesAndNotDeleted(pattern.to_string())), - 10, - EnumerationOrder::Forward, - ) - .await? - .into_iter() - .collect::>(); - if candidates.len() != 1 { - Err(Error::BadRequest(format!( - "{} matching keys", - candidates.len() - ))) - } else { - Ok(candidates.into_iter().next().unwrap()) - } - } } From 9511b20153343d52fbc82dac377e040635c4e6c8 Mon Sep 17 00:00:00 2001 From: Alex Auvolat Date: Tue, 11 Mar 2025 15:38:38 +0100 Subject: [PATCH 05/12] admin api: add openapi spec for admin token management functions --- doc/api/garage-admin-v2.json | 257 +++++++++++++++++++++++++++++++++++ src/api/admin/api.rs | 35 +++-- src/api/admin/openapi.rs | 82 +++++++++++ 3 files changed, 363 insertions(+), 11 deletions(-) diff --git a/doc/api/garage-admin-v2.json b/doc/api/garage-admin-v2.json index 97de3a71..f3310256 100644 --- a/doc/api/garage-admin-v2.json +++ b/doc/api/garage-admin-v2.json @@ -225,6 +225,40 @@ } } }, + "/v2/CreateAdminToken": { + "post": { + "tags": [ + "Admin API token" + ], + "description": "Creates a new admin API token", + "operationId": "CreateAdminToken", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpdateAdminTokenRequestBody" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Admin token has been created", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateAdminTokenResponse" + } + } + } + }, + "500": { + "description": "Internal server error" + } + } + } + }, "/v2/CreateBucket": { "post": { "tags": [ @@ -325,6 +359,31 @@ } } }, + "/v2/DeleteAdminToken": { + "post": { + "tags": [ + "Admin API token" + ], + "description": "Delete an admin API token from the cluster, revoking all its permissions.", + "operationId": "DeleteAdminToken", + "parameters": [ + { + "name": "id", + "in": "path", + "description": "Admin API token ID", + "required": true + } + ], + "responses": { + "200": { + "description": "Admin token has been deleted" + }, + "500": { + "description": "Internal server error" + } + } + } + }, "/v2/DeleteBucket": { "post": { "tags": [ @@ -415,6 +474,44 @@ } } }, + "/v2/GetAdminTokenInfo": { + "get": { + "tags": [ + "Admin API token" + ], + "description": "\nReturn information about a specific admin API token.\nYou can search by specifying the exact token identifier (`id`) or by specifying a pattern (`search`).\n ", + "operationId": "GetAdminTokenInfo", + "parameters": [ + { + "name": "id", + "in": "path", + "description": "Admin API token ID", + "required": true + }, + { + "name": "search", + "in": "path", + "description": "Partial token ID or name to search for", + "required": true + } + ], + "responses": { + "200": { + "description": "Information about the admin token", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/GetAdminTokenInfoResponse" + } + } + } + }, + "500": { + "description": "Internal server error" + } + } + } + }, "/v2/GetBlockInfo": { "post": { "tags": [ @@ -886,6 +983,30 @@ } } }, + "/v2/ListAdminTokens": { + "get": { + "tags": [ + "Admin API token" + ], + "description": "Returns all admin API tokens in the cluster.", + "operationId": "ListAdminTokens", + "responses": { + "200": { + "description": "Returns info about all admin API tokens", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ListAdminTokensResponse" + } + } + } + }, + "500": { + "description": "Internal server error" + } + } + } + }, "/v2/ListBlockErrors": { "get": { "tags": [ @@ -1216,6 +1337,48 @@ } } }, + "/v2/UpdateAdminToken": { + "post": { + "tags": [ + "Admin API token" + ], + "description": "\nUpdates information about the specified admin API token.\n ", + "operationId": "UpdateAdminToken", + "parameters": [ + { + "name": "id", + "in": "path", + "description": "Admin API token ID", + "required": true + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpdateAdminTokenRequestBody" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Admin token has been updated", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpdateAdminTokenResponse" + } + } + } + }, + "500": { + "description": "Internal server error" + } + } + } + }, "/v2/UpdateBucket": { "post": { "tags": [ @@ -1775,6 +1938,25 @@ } } }, + "CreateAdminTokenResponse": { + "allOf": [ + { + "$ref": "#/components/schemas/GetAdminTokenInfoResponse" + }, + { + "type": "object", + "required": [ + "secretToken" + ], + "properties": { + "secretToken": { + "type": "string", + "description": "The secret bearer token. **CAUTION:** This token will be shown only\nONCE, so this value MUST be remembered somewhere, or the token\nwill be unusable." + } + } + } + ] + }, "CreateBucketLocalAlias": { "type": "object", "required": [ @@ -1858,6 +2040,43 @@ } } }, + "GetAdminTokenInfoResponse": { + "type": "object", + "required": [ + "id", + "name", + "expired", + "scope" + ], + "properties": { + "expiration": { + "type": [ + "string", + "null" + ], + "description": "Expiration time and date, formatted according to RFC 3339" + }, + "expired": { + "type": "boolean", + "description": "Whether this admin token is expired already" + }, + "id": { + "type": "string", + "description": "Identifier of the admin token (which is also a prefix of the full bearer token)" + }, + "name": { + "type": "string", + "description": "Name of the admin API token" + }, + "scope": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Scope of the admin API token, a list of admin endpoint names (such as\n`GetClusterStatus`, etc), or the special value `*` to allow all\nadmin endpoints" + } + } + }, "GetBucketInfoKey": { "type": "object", "required": [ @@ -2325,6 +2544,12 @@ } } }, + "ListAdminTokensResponse": { + "type": "array", + "items": { + "$ref": "#/components/schemas/GetAdminTokenInfoResponse" + } + }, "ListBucketsResponse": { "type": "array", "items": { @@ -3404,6 +3629,38 @@ "cancel" ] }, + "UpdateAdminTokenRequestBody": { + "type": "object", + "properties": { + "expiration": { + "type": [ + "string", + "null" + ], + "description": "Expiration time and date, formatted according to RFC 3339" + }, + "name": { + "type": [ + "string", + "null" + ], + "description": "Name of the admin API token" + }, + "scope": { + "type": [ + "array", + "null" + ], + "items": { + "type": "string" + }, + "description": "Scope of the admin API token, a list of admin endpoint names (such as\n`GetClusterStatus`, etc), or the special value `*` to allow all\nadmin endpoints. **WARNING:** Granting a scope of `CreateAdminToken` or\n`UpdateAdminToken` trivially allows for privilege escalation, and is thus\nfunctionnally equivalent to granting a scope of `*`." + } + } + }, + "UpdateAdminTokenResponse": { + "$ref": "#/components/schemas/GetAdminTokenInfoResponse" + }, "UpdateBucketRequestBody": { "type": "object", "properties": { diff --git a/src/api/admin/api.rs b/src/api/admin/api.rs index 13b2c3b1..f002efad 100644 --- a/src/api/admin/api.rs +++ b/src/api/admin/api.rs @@ -298,15 +298,9 @@ pub struct ConnectNodeResponse { #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ListAdminTokensRequest; -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] pub struct ListAdminTokensResponse(pub Vec); -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct ListAdminTokensResponseItem { - pub id: String, - pub name: String, -} - // ---- GetAdminTokenInfo ---- #[derive(Debug, Clone, Serialize, Deserialize)] @@ -315,13 +309,21 @@ pub struct GetAdminTokenInfoRequest { pub search: Option, } -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] #[serde(rename_all = "camelCase")] pub struct GetAdminTokenInfoResponse { + /// Identifier of the admin token (which is also a prefix of the full bearer token) pub id: String, + /// Name of the admin API token pub name: String, + /// Expiration time and date, formatted according to RFC 3339 + #[schema(value_type = Option)] pub expiration: Option>, + /// Whether this admin token is expired already pub expired: bool, + /// Scope of the admin API token, a list of admin endpoint names (such as + /// `GetClusterStatus`, etc), or the special value `*` to allow all + /// admin endpoints pub scope: Vec, } @@ -330,9 +332,12 @@ pub struct GetAdminTokenInfoResponse { #[derive(Debug, Clone, Serialize, Deserialize)] pub struct CreateAdminTokenRequest(pub UpdateAdminTokenRequestBody); -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] #[serde(rename_all = "camelCase")] pub struct CreateAdminTokenResponse { + /// The secret bearer token. **CAUTION:** This token will be shown only + /// ONCE, so this value MUST be remembered somewhere, or the token + /// will be unusable. pub secret_token: String, #[serde(flatten)] pub info: GetAdminTokenInfoResponse, @@ -346,15 +351,23 @@ pub struct UpdateAdminTokenRequest { pub body: UpdateAdminTokenRequestBody, } -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] #[serde(rename_all = "camelCase")] pub struct UpdateAdminTokenRequestBody { + /// Name of the admin API token pub name: Option, + /// Expiration time and date, formatted according to RFC 3339 + #[schema(value_type = Option)] pub expiration: Option>, + /// Scope of the admin API token, a list of admin endpoint names (such as + /// `GetClusterStatus`, etc), or the special value `*` to allow all + /// admin endpoints. **WARNING:** Granting a scope of `CreateAdminToken` or + /// `UpdateAdminToken` trivially allows for privilege escalation, and is thus + /// functionnally equivalent to granting a scope of `*`. pub scope: Option>, } -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] pub struct UpdateAdminTokenResponse(pub GetAdminTokenInfoResponse); // ---- DeleteAdminToken ---- diff --git a/src/api/admin/openapi.rs b/src/api/admin/openapi.rs index 01a694e5..24319817 100644 --- a/src/api/admin/openapi.rs +++ b/src/api/admin/openapi.rs @@ -66,6 +66,82 @@ fn GetClusterStatistics() -> () {} )] fn ConnectClusterNodes() -> () {} +// ********************************************** +// Admin API token operations +// ********************************************** + +#[utoipa::path(get, + path = "/v2/ListAdminTokens", + tag = "Admin API token", + description = "Returns all admin API tokens in the cluster.", + responses( + (status = 200, description = "Returns info about all admin API tokens", body = ListAdminTokensResponse), + (status = 500, description = "Internal server error") + ), +)] +fn ListAdminTokens() -> () {} + +#[utoipa::path(get, + path = "/v2/GetAdminTokenInfo", + tag = "Admin API token", + description = " +Return information about a specific admin API token. +You can search by specifying the exact token identifier (`id`) or by specifying a pattern (`search`). + ", + params( + ("id", description = "Admin API token ID"), + ("search", description = "Partial token ID or name to search for"), + ), + responses( + (status = 200, description = "Information about the admin token", body = GetAdminTokenInfoResponse), + (status = 500, description = "Internal server error") + ), +)] +fn GetAdminTokenInfo() -> () {} + +#[utoipa::path(post, + path = "/v2/CreateAdminToken", + tag = "Admin API token", + description = "Creates a new admin API token", + request_body = UpdateAdminTokenRequestBody, + responses( + (status = 200, description = "Admin token has been created", body = CreateAdminTokenResponse), + (status = 500, description = "Internal server error") + ), +)] +fn CreateAdminToken() -> () {} + +#[utoipa::path(post, + path = "/v2/UpdateAdminToken", + tag = "Admin API token", + description = " +Updates information about the specified admin API token. + ", + request_body = UpdateAdminTokenRequestBody, + params( + ("id", description = "Admin API token ID"), + ), + responses( + (status = 200, description = "Admin token has been updated", body = UpdateAdminTokenResponse), + (status = 500, description = "Internal server error") + ), +)] +fn UpdateAdminToken() -> () {} + +#[utoipa::path(post, + path = "/v2/DeleteAdminToken", + tag = "Admin API token", + description = "Delete an admin API token from the cluster, revoking all its permissions.", + params( + ("id", description = "Admin API token ID"), + ), + responses( + (status = 200, description = "Admin token has been deleted"), + (status = 500, description = "Internal server error") + ), +)] +fn DeleteAdminToken() -> () {} + // ********************************************** // Layout operations // ********************************************** @@ -723,6 +799,12 @@ impl Modify for SecurityAddon { GetClusterStatus, GetClusterStatistics, ConnectClusterNodes, + // Admin token operations + ListAdminTokens, + GetAdminTokenInfo, + CreateAdminToken, + UpdateAdminToken, + DeleteAdminToken, // Layout operations GetClusterLayout, GetClusterLayoutHistory, From ec0da3b644ca7f8c5a410f4ffea38dbb6309e042 Mon Sep 17 00:00:00 2001 From: Alex Auvolat Date: Tue, 11 Mar 2025 15:57:29 +0100 Subject: [PATCH 06/12] admin api: mention admin_token and metrics_token in ListAdminTokensResponse --- doc/api/garage-admin-v2.json | 6 ++++-- src/api/admin/admin_token.rs | 30 ++++++++++++++++++++++++++++-- src/api/admin/api.rs | 2 +- 3 files changed, 33 insertions(+), 5 deletions(-) diff --git a/doc/api/garage-admin-v2.json b/doc/api/garage-admin-v2.json index f3310256..6ede967b 100644 --- a/doc/api/garage-admin-v2.json +++ b/doc/api/garage-admin-v2.json @@ -2043,7 +2043,6 @@ "GetAdminTokenInfoResponse": { "type": "object", "required": [ - "id", "name", "expired", "scope" @@ -2061,7 +2060,10 @@ "description": "Whether this admin token is expired already" }, "id": { - "type": "string", + "type": [ + "string", + "null" + ], "description": "Identifier of the admin token (which is also a prefix of the full bearer token)" }, "name": { diff --git a/src/api/admin/admin_token.rs b/src/api/admin/admin_token.rs index 10a23a68..aca7a519 100644 --- a/src/api/admin/admin_token.rs +++ b/src/api/admin/admin_token.rs @@ -22,7 +22,7 @@ impl RequestHandler for ListAdminTokensRequest { ) -> Result { let now = now_msec(); - let res = garage + let mut res = garage .admin_token_table .get_range( &EmptyKey, @@ -36,6 +36,32 @@ impl RequestHandler for ListAdminTokensRequest { .map(|t| admin_token_info_results(t, now)) .collect::>(); + if garage.config.admin.admin_token.is_some() { + res.insert( + 0, + GetAdminTokenInfoResponse { + id: None, + name: "admin_token (from daemon configuration)".into(), + expiration: None, + expired: false, + scope: vec!["*".into()], + }, + ); + } + + if garage.config.admin.metrics_token.is_some() { + res.insert( + 1, + GetAdminTokenInfoResponse { + id: None, + name: "metrics_token (from daemon configuration)".into(), + expiration: None, + expired: false, + scope: vec!["Metrics".into()], + }, + ); + } + Ok(ListAdminTokensResponse(res)) } } @@ -153,7 +179,7 @@ fn admin_token_info_results(token: &AdminApiToken, now: u64) -> GetAdminTokenInf let params = token.params().unwrap(); GetAdminTokenInfoResponse { - id: token.prefix.clone(), + id: Some(token.prefix.clone()), name: params.name.get().to_string(), expiration: params.expiration.get().map(|x| { DateTime::from_timestamp_millis(x as i64).expect("invalid timestamp stored in db") diff --git a/src/api/admin/api.rs b/src/api/admin/api.rs index f002efad..94cb7377 100644 --- a/src/api/admin/api.rs +++ b/src/api/admin/api.rs @@ -313,7 +313,7 @@ pub struct GetAdminTokenInfoRequest { #[serde(rename_all = "camelCase")] pub struct GetAdminTokenInfoResponse { /// Identifier of the admin token (which is also a prefix of the full bearer token) - pub id: String, + pub id: Option, /// Name of the admin API token pub name: String, /// Expiration time and date, formatted according to RFC 3339 From 1bd7689301c843119b6f0c34851729e89b768803 Mon Sep 17 00:00:00 2001 From: Alex Auvolat Date: Tue, 11 Mar 2025 18:09:24 +0100 Subject: [PATCH 07/12] cli: add functions to manage admin api tokens --- src/garage/Cargo.toml | 1 + src/garage/cli/remote/admin_token.rs | 227 +++++++++++++++++++++++++++ src/garage/cli/remote/mod.rs | 2 + src/garage/cli/structs.rs | 126 +++++++++++++++ 4 files changed, 356 insertions(+) create mode 100644 src/garage/cli/remote/admin_token.rs diff --git a/src/garage/Cargo.toml b/src/garage/Cargo.toml index ba747fdf..045a6174 100644 --- a/src/garage/Cargo.toml +++ b/src/garage/Cargo.toml @@ -38,6 +38,7 @@ garage_web.workspace = true backtrace.workspace = true bytes.workspace = true bytesize.workspace = true +chrono.workspace = true timeago.workspace = true parse_duration.workspace = true hex.workspace = true diff --git a/src/garage/cli/remote/admin_token.rs b/src/garage/cli/remote/admin_token.rs new file mode 100644 index 00000000..464480a1 --- /dev/null +++ b/src/garage/cli/remote/admin_token.rs @@ -0,0 +1,227 @@ +use format_table::format_table; + +use chrono::Utc; + +use garage_util::error::*; + +use garage_api_admin::api::*; + +use crate::cli::remote::*; +use crate::cli::structs::*; + +impl Cli { + pub async fn cmd_admin_token(&self, cmd: AdminTokenOperation) -> Result<(), Error> { + match cmd { + AdminTokenOperation::List => self.cmd_list_admin_tokens().await, + AdminTokenOperation::Info { api_token } => self.cmd_admin_token_info(api_token).await, + AdminTokenOperation::Create(opt) => self.cmd_create_admin_token(opt).await, + AdminTokenOperation::Rename { + api_token, + new_name, + } => self.cmd_rename_admin_token(api_token, new_name).await, + AdminTokenOperation::Set(opt) => self.cmd_update_admin_token(opt).await, + AdminTokenOperation::Delete { api_token, yes } => { + self.cmd_delete_admin_token(api_token, yes).await + } + AdminTokenOperation::DeleteExpired { yes } => { + self.cmd_delete_expired_admin_tokens(yes).await + } + } + } + + pub async fn cmd_list_admin_tokens(&self) -> Result<(), Error> { + let list = self.api_request(ListAdminTokensRequest).await?; + + let mut table = vec!["ID\tNAME\tEXPIRATION\tSCOPE".to_string()]; + for tok in list.0.iter() { + let scope = if tok.scope.len() > 1 { + format!("[{}]", tok.scope.len()) + } else { + tok.scope.get(0).cloned().unwrap_or_default() + }; + let exp = if tok.expired { + "expired".to_string() + } else { + tok.expiration + .map(|x| x.to_string()) + .unwrap_or("never".into()) + }; + table.push(format!( + "{}\t{}\t{}\t{}\t", + tok.id.as_deref().unwrap_or("-"), + tok.name, + exp, + scope, + )); + } + format_table(table); + + Ok(()) + } + + pub async fn cmd_admin_token_info(&self, search: String) -> Result<(), Error> { + let info = self + .api_request(GetAdminTokenInfoRequest { + id: None, + search: Some(search), + }) + .await?; + + print_token_info(&info); + + Ok(()) + } + + pub async fn cmd_create_admin_token(&self, opt: AdminTokenCreateOp) -> Result<(), Error> { + // TODO + let res = self + .api_request(CreateAdminTokenRequest(UpdateAdminTokenRequestBody { + name: opt.name, + expiration: opt + .expires_in + .map(|x| parse_duration::parse::parse(&x)) + .transpose() + .ok_or_message("Invalid duration passed for --expires-in parameter")? + .map(|dur| Utc::now() + dur), + scope: opt.scope.map(|s| { + s.split(",") + .map(|x| x.trim().to_string()) + .collect::>() + }), + })) + .await?; + + if opt.quiet { + println!("{}", res.secret_token); + } else { + println!("This is your secret bearer token, it will not be shown again by Garage:"); + println!("\n {}\n", res.secret_token); + print_token_info(&res.info); + } + + Ok(()) + } + + pub async fn cmd_rename_admin_token(&self, old: String, new: String) -> Result<(), Error> { + let token = self + .api_request(GetAdminTokenInfoRequest { + id: None, + search: Some(old), + }) + .await?; + + let info = self + .api_request(UpdateAdminTokenRequest { + id: token.id.unwrap(), + body: UpdateAdminTokenRequestBody { + name: Some(new), + expiration: None, + scope: None, + }, + }) + .await?; + + print_token_info(&info.0); + + Ok(()) + } + + pub async fn cmd_update_admin_token(&self, opt: AdminTokenSetOp) -> Result<(), Error> { + let token = self + .api_request(GetAdminTokenInfoRequest { + id: None, + search: Some(opt.api_token), + }) + .await?; + + let info = self + .api_request(UpdateAdminTokenRequest { + id: token.id.unwrap(), + body: UpdateAdminTokenRequestBody { + name: None, + expiration: opt + .expires_in + .map(|x| parse_duration::parse::parse(&x)) + .transpose() + .ok_or_message("Invalid duration passed for --expires-in parameter")? + .map(|dur| Utc::now() + dur), + scope: opt.scope.map(|s| { + s.split(",") + .map(|x| x.trim().to_string()) + .collect::>() + }), + }, + }) + .await?; + + print_token_info(&info.0); + + Ok(()) + } + + pub async fn cmd_delete_admin_token(&self, token: String, yes: bool) -> Result<(), Error> { + let token = self + .api_request(GetAdminTokenInfoRequest { + id: None, + search: Some(token), + }) + .await?; + + let id = token.id.unwrap(); + + if !yes { + return Err(Error::Message(format!( + "Add the --yes flag to delete API token `{}` ({})", + token.name, id + ))); + } + + self.api_request(DeleteAdminTokenRequest { id }).await?; + + println!("Admin API token has been deleted."); + + Ok(()) + } + + pub async fn cmd_delete_expired_admin_tokens(&self, yes: bool) -> Result<(), Error> { + let mut list = self.api_request(ListAdminTokensRequest).await?.0; + + list.retain(|tok| tok.expired); + + if !yes { + return Err(Error::Message(format!( + "This would delete {} admin API tokens, add the --yes flag to proceed.", + list.len(), + ))); + } + + for token in list.iter() { + let id = token.id.clone().unwrap(); + println!("Deleting token `{}` ({})", token.name, id); + self.api_request(DeleteAdminTokenRequest { id }).await?; + } + + println!("{} admin API tokens have been deleted.", list.len()); + + Ok(()) + } +} + +fn print_token_info(token: &GetAdminTokenInfoResponse) { + format_table(vec![ + format!("ID:\t{}", token.id.as_deref().unwrap_or("-")), + format!("Name:\t{}", token.name), + format!( + "Validity:\t{}", + token.expired.then_some("EXPIRED").unwrap_or("valid") + ), + format!( + "Expiration:\t{}", + token + .expiration + .map(|x| x.to_string()) + .unwrap_or("never".into()) + ), + format!("Scope:\t{}", token.scope.to_vec().join(", ")), + ]); +} diff --git a/src/garage/cli/remote/mod.rs b/src/garage/cli/remote/mod.rs index 40673b91..237b6db9 100644 --- a/src/garage/cli/remote/mod.rs +++ b/src/garage/cli/remote/mod.rs @@ -1,3 +1,4 @@ +pub mod admin_token; pub mod bucket; pub mod cluster; pub mod key; @@ -35,6 +36,7 @@ impl Cli { } Command::Layout(layout_opt) => self.layout_command_dispatch(layout_opt).await, Command::Bucket(bo) => self.cmd_bucket(bo).await, + Command::AdminToken(to) => self.cmd_admin_token(to).await, Command::Key(ko) => self.cmd_key(ko).await, Command::Worker(wo) => self.cmd_worker(wo).await, Command::Block(bo) => self.cmd_block(bo).await, diff --git a/src/garage/cli/structs.rs b/src/garage/cli/structs.rs index 0af92c35..0b0a8b94 100644 --- a/src/garage/cli/structs.rs +++ b/src/garage/cli/structs.rs @@ -30,6 +30,10 @@ pub enum Command { #[structopt(name = "key", version = garage_version())] Key(KeyOperation), + /// Operations on admin API tokens + #[structopt(name = "admin-token", version = garage_version())] + AdminToken(AdminTokenOperation), + /// Start repair of node data on remote node #[structopt(name = "repair", version = garage_version())] Repair(RepairOpt), @@ -64,6 +68,10 @@ pub enum Command { AdminApiSchema, } +// ------------------------- +// ---- garage node ... ---- +// ------------------------- + #[derive(StructOpt, Debug)] pub enum NodeOperation { /// Print the full node ID (public key) of this Garage node, and its publicly reachable IP @@ -91,6 +99,10 @@ pub struct ConnectNodeOpt { pub(crate) node: String, } +// --------------------------- +// ---- garage layout ... ---- +// --------------------------- + #[derive(StructOpt, Debug)] pub enum LayoutOperation { /// Assign role to Garage node @@ -193,6 +205,10 @@ pub struct SkipDeadNodesOpt { pub(crate) allow_missing_data: bool, } +// --------------------------- +// ---- garage bucket ... ---- +// --------------------------- + #[derive(StructOpt, Debug)] pub enum BucketOperation { /// List buckets @@ -350,6 +366,10 @@ pub struct CleanupIncompleteUploadsOpt { pub buckets: Vec, } +// ------------------------ +// ---- garage key ... ---- +// ------------------------ + #[derive(StructOpt, Debug)] pub enum KeyOperation { /// List keys @@ -447,6 +467,92 @@ pub struct KeyImportOpt { pub yes: bool, } +// -------------------------------- +// ---- garage admin-token ... ---- +// -------------------------------- + +#[derive(StructOpt, Debug)] +pub enum AdminTokenOperation { + /// List all admin API tokens + #[structopt(name = "list", version = garage_version())] + List, + + /// Fetch info about a specific admin API token + #[structopt(name = "info", version = garage_version())] + Info { + /// Name or prefix of the ID of the token to look up + api_token: String, + }, + + /// Create new admin API token + #[structopt(name = "create", version = garage_version())] + Create(AdminTokenCreateOp), + + /// Rename an admin API token + #[structopt(name = "rename", version = garage_version())] + Rename { + /// Name or prefix of the ID of the token to rename + api_token: String, + /// New name of the admintoken + new_name: String, + }, + + /// Set parameters for an admin API token + #[structopt(name = "set", version = garage_version())] + Set(AdminTokenSetOp), + + /// Delete an admin API token + #[structopt(name = "delete", version = garage_version())] + Delete { + /// Name or prefix of the ID of the token to delete + api_token: String, + /// Confirm deletion + #[structopt(long = "yes")] + yes: bool, + }, + + /// Delete all expired admin API tokens + #[structopt(name = "delete-expired", version = garage_version())] + DeleteExpired { + /// Confirm deletion + #[structopt(long = "yes")] + yes: bool, + }, +} + +#[derive(StructOpt, Debug, Clone)] +pub struct AdminTokenCreateOp { + /// Set a name for the token + pub name: Option, + /// Set an expiration time for the token (see docs.rs/parse_duration for date + /// format) + #[structopt(long = "expires-in")] + pub expires_in: Option, + /// Set a limited scope for the token (by default, `*`) + #[structopt(long = "scope")] + pub scope: Option, + /// Print only the newly generated API token to stdout + #[structopt(short = "q", long = "quiet")] + pub quiet: bool, +} + +#[derive(StructOpt, Debug, Clone)] +pub struct AdminTokenSetOp { + /// Name or prefix of the ID of the token to modify + pub api_token: String, + /// Set an expiration time for the token (see docs.rs/parse_duration for date + /// format) + #[structopt(long = "expires-in")] + pub expires_in: Option, + /// Set a limited scope for the token + #[structopt(long = "scope")] + pub scope: Option, +} + +// --------------------------- +// ---- garage repair ... ---- +// --------------------------- + #[derive(StructOpt, Debug, Clone)] pub struct RepairOpt { /// Launch repair operation on all nodes @@ -508,6 +614,10 @@ pub enum ScrubCmd { Cancel, } +// ----------------------------------- +// ---- garage offline-repair ... ---- +// ----------------------------------- + #[derive(StructOpt, Debug, Clone)] pub struct OfflineRepairOpt { /// Confirm the launch of the repair operation @@ -529,6 +639,10 @@ pub enum OfflineRepairWhat { ObjectCounters, } +// -------------------------- +// ---- garage stats ... ---- +// -------------------------- + #[derive(StructOpt, Debug, Clone)] pub struct StatsOpt { /// Gather statistics from all nodes @@ -536,6 +650,10 @@ pub struct StatsOpt { pub all_nodes: bool, } +// --------------------------- +// ---- garage worker ... ---- +// --------------------------- + #[derive(StructOpt, Debug, Eq, PartialEq, Clone)] pub enum WorkerOperation { /// List all workers on Garage node @@ -579,6 +697,10 @@ pub struct WorkerListOpt { pub errors: bool, } +// -------------------------- +// ---- garage block ... ---- +// -------------------------- + #[derive(StructOpt, Debug, Eq, PartialEq, Clone)] pub enum BlockOperation { /// List all blocks that currently have a resync error @@ -611,6 +733,10 @@ pub enum BlockOperation { }, } +// ------------------------- +// ---- garage meta ... ---- +// ------------------------- + #[derive(StructOpt, Debug, Eq, PartialEq, Clone, Copy)] pub enum MetaOperation { /// Save a snapshot of the metadata db file From 22c0420607a46750895e533667d9fb9efd4956fc Mon Sep 17 00:00:00 2001 From: Alex Auvolat Date: Tue, 11 Mar 2025 18:21:00 +0100 Subject: [PATCH 08/12] admin api: specify date-time format in openapi spec --- Cargo.toml | 2 +- doc/api/garage-admin-v2.json | 2 ++ src/api/admin/api.rs | 2 -- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index b7830a7d..ab35f757 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -101,7 +101,7 @@ serde = { version = "1.0", default-features = false, features = ["derive", "rc"] serde_bytes = "0.11" serde_json = "1.0" toml = { version = "0.8", default-features = false, features = ["parse"] } -utoipa = "5.3.1" +utoipa = { version = "5.3.1", features = ["chrono"] } # newer version requires rust edition 2021 k8s-openapi = { version = "0.21", features = ["v1_24"] } diff --git a/doc/api/garage-admin-v2.json b/doc/api/garage-admin-v2.json index 6ede967b..8f3517cb 100644 --- a/doc/api/garage-admin-v2.json +++ b/doc/api/garage-admin-v2.json @@ -2053,6 +2053,7 @@ "string", "null" ], + "format": "date-time", "description": "Expiration time and date, formatted according to RFC 3339" }, "expired": { @@ -3639,6 +3640,7 @@ "string", "null" ], + "format": "date-time", "description": "Expiration time and date, formatted according to RFC 3339" }, "name": { diff --git a/src/api/admin/api.rs b/src/api/admin/api.rs index 94cb7377..11ffb772 100644 --- a/src/api/admin/api.rs +++ b/src/api/admin/api.rs @@ -317,7 +317,6 @@ pub struct GetAdminTokenInfoResponse { /// Name of the admin API token pub name: String, /// Expiration time and date, formatted according to RFC 3339 - #[schema(value_type = Option)] pub expiration: Option>, /// Whether this admin token is expired already pub expired: bool, @@ -357,7 +356,6 @@ pub struct UpdateAdminTokenRequestBody { /// Name of the admin API token pub name: Option, /// Expiration time and date, formatted according to RFC 3339 - #[schema(value_type = Option)] pub expiration: Option>, /// Scope of the admin API token, a list of admin endpoint names (such as /// `GetClusterStatus`, etc), or the special value `*` to allow all From eb40475f1ee8972a1210e750f8c4e8d210aecb9e Mon Sep 17 00:00:00 2001 From: Alex Auvolat Date: Wed, 12 Mar 2025 09:21:53 +0100 Subject: [PATCH 09/12] move bucket search logic from helper to admin api --- src/api/admin/bucket.rs | 53 +++++++++++++++++++++++++++++++++----- src/model/helper/bucket.rs | 50 ----------------------------------- 2 files changed, 47 insertions(+), 56 deletions(-) diff --git a/src/api/admin/bucket.rs b/src/api/admin/bucket.rs index 966546bb..7f89d4b2 100644 --- a/src/api/admin/bucket.rs +++ b/src/api/admin/bucket.rs @@ -82,15 +82,56 @@ impl RequestHandler for GetBucketInfoRequest { let bucket_id = match (self.id, self.global_alias, self.search) { (Some(id), None, None) => parse_bucket_id(&id)?, (None, Some(ga), None) => garage - .bucket_helper() - .resolve_global_bucket_name(&ga) + .bucket_alias_table + .get(&EmptyKey, &ga) .await? + .and_then(|x| *x.state.get()) .ok_or_else(|| HelperError::NoSuchBucket(ga.to_string()))?, (None, None, Some(search)) => { - garage - .bucket_helper() - .admin_get_existing_matching_bucket(&search) - .await? + let helper = garage.bucket_helper(); + if let Some(uuid) = helper.resolve_global_bucket_name(&search).await? { + uuid + } else { + let hexdec = if search.len() >= 2 { + search + .get(..search.len() & !1) + .and_then(|x| hex::decode(x).ok()) + } else { + None + }; + let hex = hexdec + .ok_or_else(|| Error::Common(CommonError::NoSuchBucket(search.clone())))?; + + let mut start = [0u8; 32]; + start + .as_mut_slice() + .get_mut(..hex.len()) + .ok_or_bad_request("invalid length")? + .copy_from_slice(&hex); + let mut candidates = garage + .bucket_table + .get_range( + &EmptyKey, + Some(start.into()), + Some(DeletedFilter::NotDeleted), + 10, + EnumerationOrder::Forward, + ) + .await? + .into_iter() + .collect::>(); + candidates.retain(|x| hex::encode(x.id).starts_with(&search)); + if candidates.is_empty() { + return Err(Error::Common(CommonError::NoSuchBucket(search.clone()))); + } else if candidates.len() == 1 { + candidates.into_iter().next().unwrap().id + } else { + return Err(Error::bad_request(format!( + "Several matching buckets: {}", + search + ))); + } + } } _ => { return Err(Error::bad_request( diff --git a/src/model/helper/bucket.rs b/src/model/helper/bucket.rs index fe86c9d9..a712d683 100644 --- a/src/model/helper/bucket.rs +++ b/src/model/helper/bucket.rs @@ -67,56 +67,6 @@ impl<'a> BucketHelper<'a> { } } - /// Find a bucket by its global alias or a prefix of its uuid - pub async fn admin_get_existing_matching_bucket( - &self, - pattern: &String, - ) -> Result { - if let Some(uuid) = self.resolve_global_bucket_name(pattern).await? { - Ok(uuid) - } else { - let hexdec = if pattern.len() >= 2 { - pattern - .get(..pattern.len() & !1) - .and_then(|x| hex::decode(x).ok()) - } else { - None - }; - let hex = hexdec.ok_or_else(|| Error::NoSuchBucket(pattern.clone()))?; - - let mut start = [0u8; 32]; - start - .as_mut_slice() - .get_mut(..hex.len()) - .ok_or_bad_request("invalid length")? - .copy_from_slice(&hex); - let mut candidates = self - .0 - .bucket_table - .get_range( - &EmptyKey, - Some(start.into()), - Some(DeletedFilter::NotDeleted), - 10, - EnumerationOrder::Forward, - ) - .await? - .into_iter() - .collect::>(); - candidates.retain(|x| hex::encode(x.id).starts_with(pattern)); - if candidates.is_empty() { - Err(Error::NoSuchBucket(pattern.clone())) - } else if candidates.len() == 1 { - Ok(candidates.into_iter().next().unwrap().id) - } else { - Err(Error::BadRequest(format!( - "Several matching buckets: {}", - pattern - ))) - } - } - } - /// Returns a Bucket if it is present in bucket table, /// even if it is in deleted state. Querying a non-existing /// bucket ID returns an internal error. From 325f79012cd2f0cbc35c4c4185ecd927561c1928 Mon Sep 17 00:00:00 2001 From: Alex Auvolat Date: Wed, 12 Mar 2025 09:29:54 +0100 Subject: [PATCH 10/12] admin_token_table: implement is_tombstone() --- src/model/admin_token_table.rs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/model/admin_token_table.rs b/src/model/admin_token_table.rs index 45532e54..f3940299 100644 --- a/src/model/admin_token_table.rs +++ b/src/model/admin_token_table.rs @@ -139,6 +139,9 @@ impl Entry for AdminApiToken { fn sort_key(&self) -> &String { &self.prefix } + fn is_tombstone(&self) -> bool { + self.is_deleted() + } } pub struct AdminApiTokenTable; From 88b4623bf14f597cc19fb69d2f82e36e8046ca40 Mon Sep 17 00:00:00 2001 From: Alex Auvolat Date: Wed, 12 Mar 2025 09:52:39 +0100 Subject: [PATCH 11/12] add creation date to admin api tokens --- doc/api/garage-admin-v2.json | 8 ++++++++ src/api/admin/admin_token.rs | 6 ++++++ src/api/admin/api.rs | 2 ++ src/garage/cli/remote/admin_token.rs | 24 ++++++++++++++++-------- src/model/admin_token_table.rs | 5 +++++ 5 files changed, 37 insertions(+), 8 deletions(-) diff --git a/doc/api/garage-admin-v2.json b/doc/api/garage-admin-v2.json index 8f3517cb..91d92e11 100644 --- a/doc/api/garage-admin-v2.json +++ b/doc/api/garage-admin-v2.json @@ -2048,6 +2048,14 @@ "scope" ], "properties": { + "created": { + "type": [ + "string", + "null" + ], + "format": "date-time", + "description": "Creation date" + }, "expiration": { "type": [ "string", diff --git a/src/api/admin/admin_token.rs b/src/api/admin/admin_token.rs index aca7a519..04bfdd96 100644 --- a/src/api/admin/admin_token.rs +++ b/src/api/admin/admin_token.rs @@ -41,6 +41,7 @@ impl RequestHandler for ListAdminTokensRequest { 0, GetAdminTokenInfoResponse { id: None, + created: None, name: "admin_token (from daemon configuration)".into(), expiration: None, expired: false, @@ -54,6 +55,7 @@ impl RequestHandler for ListAdminTokensRequest { 1, GetAdminTokenInfoResponse { id: None, + created: None, name: "metrics_token (from daemon configuration)".into(), expiration: None, expired: false, @@ -180,6 +182,10 @@ fn admin_token_info_results(token: &AdminApiToken, now: u64) -> GetAdminTokenInf GetAdminTokenInfoResponse { id: Some(token.prefix.clone()), + created: Some( + DateTime::from_timestamp_millis(params.created as i64) + .expect("invalid timestamp stored in db"), + ), name: params.name.get().to_string(), expiration: params.expiration.get().map(|x| { DateTime::from_timestamp_millis(x as i64).expect("invalid timestamp stored in db") diff --git a/src/api/admin/api.rs b/src/api/admin/api.rs index 11ffb772..fde304f4 100644 --- a/src/api/admin/api.rs +++ b/src/api/admin/api.rs @@ -314,6 +314,8 @@ pub struct GetAdminTokenInfoRequest { pub struct GetAdminTokenInfoResponse { /// Identifier of the admin token (which is also a prefix of the full bearer token) pub id: Option, + /// Creation date + pub created: Option>, /// Name of the admin API token pub name: String, /// Expiration time and date, formatted according to RFC 3339 diff --git a/src/garage/cli/remote/admin_token.rs b/src/garage/cli/remote/admin_token.rs index 464480a1..4d765b92 100644 --- a/src/garage/cli/remote/admin_token.rs +++ b/src/garage/cli/remote/admin_token.rs @@ -1,6 +1,6 @@ use format_table::format_table; -use chrono::Utc; +use chrono::{Local, Utc}; use garage_util::error::*; @@ -30,11 +30,15 @@ impl Cli { } pub async fn cmd_list_admin_tokens(&self) -> Result<(), Error> { - let list = self.api_request(ListAdminTokensRequest).await?; + let mut list = self.api_request(ListAdminTokensRequest).await?; - let mut table = vec!["ID\tNAME\tEXPIRATION\tSCOPE".to_string()]; + list.0.sort_by_key(|x| x.created); + + let mut table = vec!["ID\tCREATED\tNAME\tEXPIRATION\tSCOPE".to_string()]; for tok in list.0.iter() { - let scope = if tok.scope.len() > 1 { + let scope = if tok.expired { + String::new() + } else if tok.scope.len() > 1 { format!("[{}]", tok.scope.len()) } else { tok.scope.get(0).cloned().unwrap_or_default() @@ -43,12 +47,15 @@ impl Cli { "expired".to_string() } else { tok.expiration - .map(|x| x.to_string()) + .map(|x| x.with_timezone(&Local).to_string()) .unwrap_or("never".into()) }; table.push(format!( - "{}\t{}\t{}\t{}\t", + "{}\t{}\t{}\t{}\t{}", tok.id.as_deref().unwrap_or("-"), + tok.created + .map(|x| x.with_timezone(&Local).date_naive().to_string()) + .unwrap_or("-".into()), tok.name, exp, scope, @@ -209,8 +216,9 @@ impl Cli { fn print_token_info(token: &GetAdminTokenInfoResponse) { format_table(vec![ - format!("ID:\t{}", token.id.as_deref().unwrap_or("-")), + format!("ID:\t{}", token.id.as_ref().unwrap()), format!("Name:\t{}", token.name), + format!("Created:\t{}", token.created.unwrap().with_timezone(&Local)), format!( "Validity:\t{}", token.expired.then_some("EXPIRED").unwrap_or("valid") @@ -219,7 +227,7 @@ fn print_token_info(token: &GetAdminTokenInfoResponse) { "Expiration:\t{}", token .expiration - .map(|x| x.to_string()) + .map(|x| x.with_timezone(&Local).to_string()) .unwrap_or("never".into()) ), format!("Scope:\t{}", token.scope.to_vec().join(", ")), diff --git a/src/model/admin_token_table.rs b/src/model/admin_token_table.rs index f3940299..ef91eb4a 100644 --- a/src/model/admin_token_table.rs +++ b/src/model/admin_token_table.rs @@ -1,6 +1,7 @@ use base64::prelude::*; use garage_util::crdt::{self, Crdt}; +use garage_util::time::now_msec; use garage_table::{EmptyKey, Entry, TableSchema}; @@ -24,6 +25,9 @@ mod v2 { #[derive(PartialEq, Eq, Clone, Debug, Serialize, Deserialize)] pub struct AdminApiTokenParams { + /// Creation date + pub created: u64, + /// The entire API token hashed as a password pub token_hash: String, @@ -91,6 +95,7 @@ impl AdminApiToken { let ret = AdminApiToken { prefix, state: crdt::Deletable::present(AdminApiTokenParams { + created: now_msec(), token_hash: hashed_token, name: crdt::Lww::new(name.to_string()), expiration: crdt::Lww::new(None), From d2a064bb1b9ad01a20e9fba7842b343916da665a Mon Sep 17 00:00:00 2001 From: Alex Auvolat Date: Wed, 12 Mar 2025 10:15:12 +0100 Subject: [PATCH 12/12] cli: add and remove scopes using --scope=+Scope or --scope=-Scope --- src/garage/cli/remote/admin_token.rs | 26 ++++++++++++++++++++++---- src/garage/cli/structs.rs | 16 ++++++++++++++-- 2 files changed, 36 insertions(+), 6 deletions(-) diff --git a/src/garage/cli/remote/admin_token.rs b/src/garage/cli/remote/admin_token.rs index 4d765b92..78286dc4 100644 --- a/src/garage/cli/remote/admin_token.rs +++ b/src/garage/cli/remote/admin_token.rs @@ -152,10 +152,28 @@ impl Cli { .transpose() .ok_or_message("Invalid duration passed for --expires-in parameter")? .map(|dur| Utc::now() + dur), - scope: opt.scope.map(|s| { - s.split(",") - .map(|x| x.trim().to_string()) - .collect::>() + scope: opt.scope.map({ + let mut new_scope = token.scope; + |scope_str| { + if let Some(add) = scope_str.strip_prefix("+") { + for a in add.split(",").map(|x| x.trim().to_string()) { + if !new_scope.contains(&a) { + new_scope.push(a); + } + } + new_scope + } else if let Some(sub) = scope_str.strip_prefix("-") { + for r in sub.split(",").map(|x| x.trim()) { + new_scope.retain(|x| x != r); + } + new_scope + } else { + scope_str + .split(",") + .map(|x| x.trim().to_string()) + .collect::>() + } + } }), }, }) diff --git a/src/garage/cli/structs.rs b/src/garage/cli/structs.rs index 0b0a8b94..d4446a17 100644 --- a/src/garage/cli/structs.rs +++ b/src/garage/cli/structs.rs @@ -528,7 +528,12 @@ pub struct AdminTokenCreateOp { /// format) #[structopt(long = "expires-in")] pub expires_in: Option, - /// Set a limited scope for the token (by default, `*`) + /// Set a limited scope for the token, as a comma-separated list of + /// admin API functions (e.g. GetClusterStatus, etc.). The default scope + /// is `*`, which allows access to all admin API functions. + /// Note that granting a scope that allows `CreateAdminToken` or + /// `UpdateAdminToken` allows for privilege escalation, and is therefore + /// equivalent to `*`. #[structopt(long = "scope")] pub scope: Option, /// Print only the newly generated API token to stdout @@ -544,7 +549,14 @@ pub struct AdminTokenSetOp { /// format) #[structopt(long = "expires-in")] pub expires_in: Option, - /// Set a limited scope for the token + /// Set a limited scope for the token, as a comma-separated list of + /// admin API functions (e.g. GetClusterStatus, etc.), or `*` to allow + /// all admin API functions. + /// Use `--scope=+Scope1,Scope2` to add scopes to the existing list, + /// and `--scope=-Scope1,Scope2` to remove scopes from the existing list. + /// Note that granting a scope that allows `CreateAdminToken` or + /// `UpdateAdminToken` allows for privilege escalation, and is therefore + /// equivalent to `*`. #[structopt(long = "scope")] pub scope: Option, }