From 46f620119b1718df1606fd903523d20b90cc9550 Mon Sep 17 00:00:00 2001
From: Alex Auvolat <lx@deuxfleurs.fr>
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:
+		/// `<prefix>.<suffix>`
+		/// 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<AdminApiTokenParams>,
+	}
+
+	#[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<String>,
+
+		/// The optional time of expiration of the token
+		pub expiration: crdt::Lww<Option<u64>>,
+
+		/// The scope of the token, i.e. list of authorized admin API calls
+		pub scope: crdt::Lww<AdminApiTokenScope>,
+	}
+
+	#[derive(PartialEq, Eq, Clone, Debug, Serialize, Deserialize)]
+	pub struct AdminApiTokenScope(pub Vec<String>);
+
+	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<EmptyKey, String> 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<BlockManager>,
 
+	/// Table containing admin API keys
+	pub admin_token_table: Arc<Table<AdminApiTokenTable, TableFullReplication>>,
 	/// Table containing buckets
 	pub bucket_table: Arc<Table<BucketTable, TableFullReplication>>,
 	/// 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<Self>, 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 <lx@deuxfleurs.fr>
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<hyper::http::HeaderValue>,
+	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 <lx@deuxfleurs.fr>
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<String>,
+	metrics_require_token: bool,
 	admin_token: Option<String>,
 	pub(crate) background: Arc<BackgroundRunner>,
 	pub(crate) endpoint: Arc<RpcEndpoint<AdminRpc, Self>>,
@@ -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<hyper::http::HeaderValue>,
 	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<String>,
 	/// File to read metrics token from
 	pub metrics_token_file: Option<PathBuf>,
+	/// 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<String>,

From d067a40b3fe7b55fda1b8f5acdb43977a070f034 Mon Sep 17 00:00:00 2001
From: Alex Auvolat <lx@deuxfleurs.fr>
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<Garage>,
+		_admin: &Admin,
+	) -> Result<ListAdminTokensResponse, Error> {
+		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::<Vec<_>>();
+
+		Ok(ListAdminTokensResponse(res))
+	}
+}
+
+impl RequestHandler for GetAdminTokenInfoRequest {
+	type Response = GetAdminTokenInfoResponse;
+
+	async fn handle(
+		self,
+		garage: &Arc<Garage>,
+		_admin: &Admin,
+	) -> Result<GetAdminTokenInfoResponse, Error> {
+		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::<Vec<_>>();
+				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<Garage>,
+		_admin: &Admin,
+	) -> Result<CreateAdminTokenResponse, Error> {
+		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<Garage>,
+		_admin: &Admin,
+	) -> Result<UpdateAdminTokenResponse, Error> {
+		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<Garage>,
+		_admin: &Admin,
+	) -> Result<DeleteAdminTokenResponse, Error> {
+		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<AdminApiToken, Error> {
+	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<String>,
 }
 
+// **********************************************
+//      Admin token operations
+// **********************************************
+
+// ---- ListAdminTokens ----
+
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub struct ListAdminTokensRequest;
+
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub struct ListAdminTokensResponse(pub Vec<GetAdminTokenInfoResponse>);
+
+#[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<String>,
+	pub search: Option<String>,
+}
+
+#[derive(Debug, Clone, Serialize, Deserialize)]
+#[serde(rename_all = "camelCase")]
+pub struct GetAdminTokenInfoResponse {
+	pub id: String,
+	pub name: String,
+	pub expiration: Option<chrono::DateTime<chrono::Utc>>,
+	pub expired: bool,
+	pub scope: Vec<String>,
+}
+
+// ---- 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<String>,
+	pub expiration: Option<chrono::DateTime<chrono::Utc>>,
+	pub scope: Option<Vec<String>>,
+}
+
+#[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::<Vec<_>>();
+				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<Key, Error> {
-		let candidates = self
-			.0
-			.key_table
-			.get_range(
-				&EmptyKey,
-				None,
-				Some(KeyFilter::MatchesAndNotDeleted(pattern.to_string())),
-				10,
-				EnumerationOrder::Forward,
-			)
-			.await?
-			.into_iter()
-			.collect::<Vec<_>>();
-		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 <lx@deuxfleurs.fr>
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<GetAdminTokenInfoResponse>);
 
-#[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<String>,
 }
 
-#[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<String>)]
 	pub expiration: Option<chrono::DateTime<chrono::Utc>>,
+	/// 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<String>,
 }
 
@@ -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<String>,
+	/// Expiration time and date, formatted according to RFC 3339
+	#[schema(value_type = Option<String>)]
 	pub expiration: Option<chrono::DateTime<chrono::Utc>>,
+	/// 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<Vec<String>>,
 }
 
-#[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 <lx@deuxfleurs.fr>
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<ListAdminTokensResponse, Error> {
 		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::<Vec<_>>();
 
+		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<String>,
 	/// 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 <lx@deuxfleurs.fr>
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::<Vec<_>>()
+				}),
+			}))
+			.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::<Vec<_>>()
+					}),
+				},
+			})
+			.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<String>,
 }
 
+// ------------------------
+// ---- 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<String>,
+	/// Set an expiration time for the token (see docs.rs/parse_duration for date
+	/// format)
+	#[structopt(long = "expires-in")]
+	pub expires_in: Option<String>,
+	/// Set a limited scope for the token (by default, `*`)
+	#[structopt(long = "scope")]
+	pub scope: Option<String>,
+	/// 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<String>,
+	/// Set a limited scope for the token
+	#[structopt(long = "scope")]
+	pub scope: Option<String>,
+}
+
+// ---------------------------
+// ---- 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 <lx@deuxfleurs.fr>
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<String>)]
 	pub expiration: Option<chrono::DateTime<chrono::Utc>>,
 	/// 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<String>,
 	/// Expiration time and date, formatted according to RFC 3339
-	#[schema(value_type = Option<String>)]
 	pub expiration: Option<chrono::DateTime<chrono::Utc>>,
 	/// 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 <lx@deuxfleurs.fr>
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::<Vec<_>>();
+					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<Uuid, Error> {
-		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::<Vec<_>>();
-			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 <lx@deuxfleurs.fr>
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<EmptyKey, String> 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 <lx@deuxfleurs.fr>
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<String>,
+	/// Creation date
+	pub created: Option<chrono::DateTime<chrono::Utc>>,
 	/// 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 <lx@deuxfleurs.fr>
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::<Vec<_>>()
+					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::<Vec<_>>()
+							}
+						}
 					}),
 				},
 			})
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<String>,
-	/// 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<String>,
 	/// 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<String>,
-	/// 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<String>,
 }