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] 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>,
 }