From 2f21181ccb26564ecdbb6425e568f1bd5cfb47df Mon Sep 17 00:00:00 2001 From: Alex Auvolat Date: Thu, 17 Apr 2025 10:29:23 +0200 Subject: [PATCH 1/6] publish bucket creation date in admin api and CLI --- doc/api/garage-admin-v2.json | 11 +++++++++++ src/api/admin/api.rs | 12 ++++++++---- src/api/admin/bucket.rs | 4 ++++ src/garage/cli/remote/bucket.rs | 12 +++++++++--- 4 files changed, 32 insertions(+), 7 deletions(-) diff --git a/doc/api/garage-admin-v2.json b/doc/api/garage-admin-v2.json index 7819a0a6..364d170b 100644 --- a/doc/api/garage-admin-v2.json +++ b/doc/api/garage-admin-v2.json @@ -2280,6 +2280,7 @@ "type": "object", "required": [ "id", + "created", "globalAliases", "websiteAccess", "keys", @@ -2297,6 +2298,11 @@ "format": "int64", "description": "Total number of bytes used by objects in this bucket" }, + "created": { + "type": "string", + "format": "date-time", + "description": "Bucket creation date" + }, "globalAliases": { "type": "array", "items": { @@ -2873,10 +2879,15 @@ "type": "object", "required": [ "id", + "created", "globalAliases", "localAliases" ], "properties": { + "created": { + "type": "string", + "format": "date-time" + }, "globalAliases": { "type": "array", "items": { diff --git a/src/api/admin/api.rs b/src/api/admin/api.rs index ffb9456b..d2daa988 100644 --- a/src/api/admin/api.rs +++ b/src/api/admin/api.rs @@ -3,6 +3,7 @@ use std::convert::TryFrom; use std::net::SocketAddr; use std::sync::Arc; +use chrono::{DateTime, Utc}; use paste::paste; use serde::{Deserialize, Serialize}; use utoipa::{IntoParams, ToSchema}; @@ -321,11 +322,11 @@ 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>, + pub created: Option>, /// Name of the admin API token pub name: String, /// Expiration time and date, formatted according to RFC 3339 - pub expiration: 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 @@ -364,7 +365,7 @@ pub struct UpdateAdminTokenRequestBody { /// Name of the admin API token pub name: Option, /// Expiration time and date, formatted according to RFC 3339 - pub expiration: 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 @@ -759,6 +760,7 @@ pub struct ListBucketsResponse(pub Vec); #[serde(rename_all = "camelCase")] pub struct ListBucketsResponseItem { pub id: String, + pub created: DateTime, pub global_aliases: Vec, pub local_aliases: Vec, } @@ -788,6 +790,8 @@ pub struct GetBucketInfoRequest { pub struct GetBucketInfoResponse { /// Identifier of the bucket pub id: String, + /// Bucket creation date + pub created: DateTime, /// List of global aliases for this bucket pub global_aliases: Vec, /// Whether website acces is enabled for this bucket @@ -932,7 +936,7 @@ pub struct InspectObjectVersion { /// Version ID pub uuid: String, /// Creation timestamp of this object version - pub timestamp: chrono::DateTime, + pub timestamp: DateTime, /// Whether this object version was created with SSE-C encryption pub encrypted: bool, /// Whether this object version is still uploading diff --git a/src/api/admin/bucket.rs b/src/api/admin/bucket.rs index af26200b..b0fd101b 100644 --- a/src/api/admin/bucket.rs +++ b/src/api/admin/bucket.rs @@ -48,6 +48,8 @@ impl RequestHandler for ListBucketsRequest { let state = b.state.as_option().unwrap(); ListBucketsResponseItem { id: hex::encode(b.id), + created: DateTime::from_timestamp_millis(state.creation_date as i64) + .expect("invalid timestamp stored in db"), global_aliases: state .aliases .items() @@ -677,6 +679,8 @@ async fn bucket_info_results( let quotas = state.quotas.get(); let res = GetBucketInfoResponse { id: hex::encode(bucket.id), + created: DateTime::from_timestamp_millis(state.creation_date as i64) + .expect("invalid timestamp stored in db"), global_aliases: state .aliases .items() diff --git a/src/garage/cli/remote/bucket.rs b/src/garage/cli/remote/bucket.rs index bc018b33..1c0774a3 100644 --- a/src/garage/cli/remote/bucket.rs +++ b/src/garage/cli/remote/bucket.rs @@ -1,6 +1,8 @@ //use bytesize::ByteSize; use format_table::format_table; +use chrono::Local; + use garage_util::error::*; use garage_api_admin::api::*; @@ -29,13 +31,16 @@ impl Cli { } pub async fn cmd_list_buckets(&self) -> Result<(), Error> { - let buckets = self.api_request(ListBucketsRequest).await?; + let mut buckets = self.api_request(ListBucketsRequest).await?; - let mut table = vec!["ID\tGlobal aliases\tLocal aliases".to_string()]; + buckets.0.sort_by_key(|x| x.created); + + let mut table = vec!["ID\tCreated\tGlobal aliases\tLocal aliases".to_string()]; for bucket in buckets.0.iter() { table.push(format!( - "{:.16}\t{}\t{}", + "{:.16}\t{}\t{}\t{}", bucket.id, + bucket.created.with_timezone(&Local).date_naive(), table_list_abbr(&bucket.global_aliases), table_list_abbr( bucket @@ -484,6 +489,7 @@ fn print_bucket_info(bucket: &GetBucketInfoResponse) { let mut info = vec![ format!("Bucket:\t{}", bucket.id), + format!("Created:\t{}", bucket.created.with_timezone(&Local)), String::new(), { let size = bytesize::ByteSize::b(bucket.bytes as u64); From c56b7e20c3fbdd5427777e0e7b3c82ddb4af20d2 Mon Sep 17 00:00:00 2001 From: Alex Auvolat Date: Thu, 17 Apr 2025 11:09:21 +0200 Subject: [PATCH 2/6] add creation date and expiration date to access keys --- doc/api/garage-admin-v2.json | 40 +++++++++++++- src/api/admin/admin_token.rs | 6 +- src/api/admin/api.rs | 8 ++- src/api/admin/api_server.rs | 15 +---- src/api/admin/key.rs | 31 ++++++++++- src/api/common/signature/payload.rs | 8 +++ src/garage/cli/remote/admin_token.rs | 5 +- src/garage/cli/remote/key.rs | 48 ++++++++++++++-- src/model/admin_token_table.rs | 15 ++++- src/model/key_table.rs | 82 +++++++++++++++++++++++++++- 10 files changed, 225 insertions(+), 33 deletions(-) diff --git a/doc/api/garage-admin-v2.json b/doc/api/garage-admin-v2.json index 364d170b..4cdcf708 100644 --- a/doc/api/garage-admin-v2.json +++ b/doc/api/garage-admin-v2.json @@ -2569,8 +2569,9 @@ "GetKeyInfoResponse": { "type": "object", "required": [ - "name", "accessKeyId", + "name", + "expired", "permissions", "buckets" ], @@ -2584,6 +2585,23 @@ "$ref": "#/components/schemas/KeyInfoBucketResponse" } }, + "created": { + "type": [ + "string", + "null" + ], + "format": "date-time" + }, + "expiration": { + "type": [ + "string", + "null" + ], + "format": "date-time" + }, + "expired": { + "type": "boolean" + }, "name": { "type": "string" }, @@ -2915,9 +2933,27 @@ "type": "object", "required": [ "id", - "name" + "name", + "expired" ], "properties": { + "created": { + "type": [ + "string", + "null" + ], + "format": "date-time" + }, + "expiration": { + "type": [ + "string", + "null" + ], + "format": "date-time" + }, + "expired": { + "type": "boolean" + }, "id": { "type": "string" }, diff --git a/src/api/admin/admin_token.rs b/src/api/admin/admin_token.rs index 04bfdd96..b010dcf9 100644 --- a/src/api/admin/admin_token.rs +++ b/src/api/admin/admin_token.rs @@ -190,11 +190,7 @@ fn admin_token_info_results(token: &AdminApiToken, now: u64) -> GetAdminTokenInf 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), + expired: params.is_expired(now), scope: params.scope.get().0.clone(), } } diff --git a/src/api/admin/api.rs b/src/api/admin/api.rs index d2daa988..4c0cfa45 100644 --- a/src/api/admin/api.rs +++ b/src/api/admin/api.rs @@ -637,6 +637,9 @@ pub struct ListKeysResponse(pub Vec); pub struct ListKeysResponseItem { pub id: String, pub name: String, + pub created: Option>, + pub expiration: Option>, + pub expired: bool, } // ---- GetKeyInfo ---- @@ -656,8 +659,11 @@ pub struct GetKeyInfoRequest { #[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] #[serde(rename_all = "camelCase")] pub struct GetKeyInfoResponse { - pub name: String, pub access_key_id: String, + pub created: Option>, + pub name: String, + pub expiration: Option>, + pub expired: bool, #[serde(default, skip_serializing_if = "is_default")] pub secret_access_key: Option, pub permissions: KeyPerm, diff --git a/src/api/admin/api_server.rs b/src/api/admin/api_server.rs index 97b1fe0d..14029423 100644 --- a/src/api/admin/api_server.rs +++ b/src/api/admin/api_server.rs @@ -272,19 +272,8 @@ fn verify_authorization( .admin_token_table .get_local(&EmptyKey, &prefix.to_string())? .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) - }) + .filter(|p| !p.is_expired(now_msec())) + .filter(|p| p.has_scope(endpoint_name)) .ok_or_else(|| Error::forbidden(invalid_msg))? .token_hash } else { diff --git a/src/api/admin/key.rs b/src/api/admin/key.rs index d1a49ab3..ee3a4d1c 100644 --- a/src/api/admin/key.rs +++ b/src/api/admin/key.rs @@ -1,7 +1,10 @@ use std::collections::HashMap; use std::sync::Arc; +use chrono::DateTime; + use garage_table::*; +use garage_util::time::now_msec; use garage_model::garage::Garage; use garage_model::key_table::*; @@ -14,6 +17,8 @@ impl RequestHandler for ListKeysRequest { type Response = ListKeysResponse; async fn handle(self, garage: &Arc, _admin: &Admin) -> Result { + let now = now_msec(); + let res = garage .key_table .get_range( @@ -25,9 +30,22 @@ impl RequestHandler for ListKeysRequest { ) .await? .iter() - .map(|k| ListKeysResponseItem { - id: k.key_id.to_string(), - name: k.params().unwrap().name.get().clone(), + .map(|k| { + let p = k.params().unwrap(); + + ListKeysResponseItem { + id: k.key_id.to_string(), + name: p.name.get().clone(), + created: p.created.map(|x| { + DateTime::from_timestamp_millis(x as i64) + .expect("invalid timestamp stored in db") + }), + expiration: p.expiration.get().map(|x| { + DateTime::from_timestamp_millis(x as i64) + .expect("invalid timestamp stored in db") + }), + expired: p.is_expired(now), + } }) .collect::>(); @@ -205,6 +223,13 @@ async fn key_info_results( let res = GetKeyInfoResponse { name: key_state.name.get().clone(), + created: key_state.created.map(|x| { + DateTime::from_timestamp_millis(x as i64).expect("invalid timestamp stored in db") + }), + expiration: key_state.expiration.get().map(|x| { + DateTime::from_timestamp_millis(x as i64).expect("invalid timestamp stored in db") + }), + expired: key_state.is_expired(now_msec()), access_key_id: key.key_id.clone(), secret_access_key: if show_secret { Some(key_state.secret_key.clone()) diff --git a/src/api/common/signature/payload.rs b/src/api/common/signature/payload.rs index 8386607d..88269ba0 100644 --- a/src/api/common/signature/payload.rs +++ b/src/api/common/signature/payload.rs @@ -9,6 +9,7 @@ use sha2::{Digest, Sha256}; use garage_table::*; use garage_util::data::Hash; +use garage_util::time::now_msec; use garage_model::garage::Garage; use garage_model::key_table::*; @@ -396,6 +397,13 @@ pub fn verify_v4( .ok_or_else(|| Error::forbidden(format!("No such key: {}", &auth.key_id)))?; let key_p = key.params().unwrap(); + if key_p.is_expired(now_msec()) { + return Err(Error::forbidden(format!( + "Access key {} has expired", + key.key_id + ))); + } + let mut hmac = signing_hmac( &auth.date, &key_p.secret_key, diff --git a/src/garage/cli/remote/admin_token.rs b/src/garage/cli/remote/admin_token.rs index 09699ad7..cd7ff9b0 100644 --- a/src/garage/cli/remote/admin_token.rs +++ b/src/garage/cli/remote/admin_token.rs @@ -231,9 +231,10 @@ impl Cli { } fn print_token_info(token: &GetAdminTokenInfoResponse) { + println!("==== ADMINISTRATION TOKEN INFORMATION ===="); let mut table = vec![ - format!("ID:\t{}", token.id.as_ref().unwrap()), - format!("Name:\t{}", token.name), + format!("Token ID:\t{}", token.id.as_ref().unwrap()), + format!("Token name:\t{}", token.name), format!("Created:\t{}", token.created.unwrap().with_timezone(&Local)), format!( "Validity:\t{}", diff --git a/src/garage/cli/remote/key.rs b/src/garage/cli/remote/key.rs index 2c6981b6..25937efa 100644 --- a/src/garage/cli/remote/key.rs +++ b/src/garage/cli/remote/key.rs @@ -1,5 +1,7 @@ use format_table::format_table; +use chrono::Local; + use garage_util::error::*; use garage_api_admin::api::*; @@ -22,11 +24,28 @@ impl Cli { } pub async fn cmd_list_keys(&self) -> Result<(), Error> { - let keys = self.api_request(ListKeysRequest).await?; + let mut keys = self.api_request(ListKeysRequest).await?; - let mut table = vec!["ID\tName".to_string()]; + keys.0.sort_by_key(|x| x.created); + + let mut table = vec!["ID\tCreated\tName\tExpiration".to_string()]; for key in keys.0.iter() { - table.push(format!("{}\t{}", key.id, key.name)); + let exp = if key.expired { + "expired".to_string() + } else { + key.expiration + .map(|x| x.with_timezone(&Local).to_string()) + .unwrap_or("never".into()) + }; + table.push(format!( + "{}\t{}\t{}\t{}", + key.id, + key.created + .map(|x| x.with_timezone(&Local).date_naive().to_string()) + .unwrap_or_default(), + key.name, + exp + )); } format_table(table); @@ -186,15 +205,34 @@ impl Cli { fn print_key_info(key: &GetKeyInfoResponse) { println!("==== ACCESS KEY INFORMATION ===="); - format_table(vec![ - format!("Key name:\t{}", key.name), + let mut table = vec![ format!("Key ID:\t{}", key.access_key_id), + format!("Key name:\t{}", key.name), format!( "Secret key:\t{}", key.secret_access_key.as_deref().unwrap_or("(redacted)") ), + ]; + + if let Some(c) = key.created { + table.push(format!("Created:\t{}", c.with_timezone(&Local))); + } + + table.extend([ + format!( + "Validity:\t{}", + key.expired.then_some("EXPIRED").unwrap_or("valid") + ), + format!( + "Expiration:\t{}", + key.expiration + .map(|x| x.with_timezone(&Local).to_string()) + .unwrap_or("never".into()) + ), + String::new(), format!("Can create buckets:\t{}", key.permissions.create_bucket), ]); + format_table(table); println!(""); println!("==== BUCKETS FOR THIS KEY ===="); diff --git a/src/model/admin_token_table.rs b/src/model/admin_token_table.rs index ef91eb4a..0af8ec78 100644 --- a/src/model/admin_token_table.rs +++ b/src/model/admin_token_table.rs @@ -113,7 +113,7 @@ impl AdminApiToken { } } - /// Returns true if this represents a deleted bucket + /// Returns true if this represents a deleted admin token pub fn is_deleted(&self) -> bool { self.state.is_deleted() } @@ -137,6 +137,19 @@ impl AdminApiToken { } } +impl AdminApiTokenParams { + pub fn is_expired(&self, ts_now: u64) -> bool { + match *self.expiration.get() { + None => false, + Some(exp) => ts_now >= exp, + } + } + + pub fn has_scope(&self, endpoint: &str) -> bool { + self.scope.get().0.iter().any(|x| x == "*" || x == endpoint) + } +} + impl Entry for AdminApiToken { fn partition_key(&self) -> &EmptyKey { &EmptyKey diff --git a/src/model/key_table.rs b/src/model/key_table.rs index efb95f08..6cf0800b 100644 --- a/src/model/key_table.rs +++ b/src/model/key_table.rs @@ -2,6 +2,7 @@ use serde::{Deserialize, Serialize}; use garage_util::crdt::{self, Crdt}; use garage_util::data::*; +use garage_util::time::now_msec; use garage_table::{DeletedFilter, EmptyKey, Entry, TableSchema}; @@ -48,13 +49,82 @@ mod v08 { impl garage_util::migrate::InitialFormat for Key {} } -pub use v08::*; +mod v2 { + use crate::permission::BucketKeyPerm; + use garage_util::crdt; + use garage_util::data::Uuid; + use serde::{Deserialize, Serialize}; + + use super::v08; + + /// An api key + #[derive(PartialEq, Eq, Clone, Debug, Serialize, Deserialize)] + pub struct Key { + /// The id of the key (immutable), used as partition key + pub key_id: String, + + /// Internal state of the key + pub state: crdt::Deletable, + } + + /// Configuration for a key + #[derive(PartialEq, Eq, Clone, Debug, Serialize, Deserialize)] + pub struct KeyParams { + /// Key's creation date, if known (older versions of Garage didn't keep track + /// of this information) + pub created: Option, + /// The secret_key associated (immutable) + pub secret_key: String, + + /// Name for the key + pub name: crdt::Lww, + /// The optional time of expiration of the key + pub expiration: crdt::Lww>, + + /// Flag to allow users having this key to create buckets + pub allow_create_bucket: crdt::Lww, + + /// If the key is present: it gives some permissions, + /// a map of bucket IDs (uuids) to permissions. + /// Otherwise no permissions are granted to key + pub authorized_buckets: crdt::Map, + + /// A key can have a local view of buckets names it is + /// the only one to see, this is the namespace for these aliases + pub local_aliases: crdt::LwwMap>, + } + + impl garage_util::migrate::Migrate for Key { + const VERSION_MARKER: &'static [u8] = b"G2key"; + + type Previous = v08::Key; + + fn migrate(old: v08::Key) -> Key { + Key { + key_id: old.key_id, + state: old.state.map(|x| KeyParams { + created: None, + secret_key: x.secret_key, + name: x.name, + expiration: crdt::Lww::raw(0, None), + allow_create_bucket: x.allow_create_bucket, + authorized_buckets: x.authorized_buckets, + local_aliases: x.local_aliases, + }), + } + } + } +} + +pub use v2::*; impl KeyParams { fn new(secret_key: &str, name: &str) -> Self { KeyParams { + created: Some(now_msec()), secret_key: secret_key.to_string(), name: crdt::Lww::new(name.to_string()), + expiration: crdt::Lww::new(None), allow_create_bucket: crdt::Lww::new(false), authorized_buckets: crdt::Map::new(), local_aliases: crdt::LwwMap::new(), @@ -65,6 +135,7 @@ impl KeyParams { impl Crdt for KeyParams { fn merge(&mut self, o: &Self) { self.name.merge(&o.name); + self.expiration.merge(&o.expiration); self.allow_create_bucket.merge(&o.allow_create_bucket); self.authorized_buckets.merge(&o.authorized_buckets); self.local_aliases.merge(&o.local_aliases); @@ -145,6 +216,15 @@ impl Key { } } +impl KeyParams { + pub fn is_expired(&self, ts_now: u64) -> bool { + match *self.expiration.get() { + None => false, + Some(exp) => ts_now >= exp, + } + } +} + impl Entry for Key { fn partition_key(&self) -> &EmptyKey { &EmptyKey From 590c9bb4db16c77bf3b558e58fbe03c58f87f938 Mon Sep 17 00:00:00 2001 From: Alex Auvolat Date: Thu, 17 Apr 2025 11:30:58 +0200 Subject: [PATCH 3/6] possibility to update access key expiration date --- doc/api/garage-admin-v2.json | 27 +++++++++++--------- src/api/admin/api.rs | 9 ++++--- src/api/admin/key.rs | 44 ++++++++++++++++++++------------ src/garage/cli/remote/key.rs | 49 +++++++++++++++++++++++++++++++++--- src/garage/cli/structs.rs | 19 ++++++++++++++ 5 files changed, 114 insertions(+), 34 deletions(-) diff --git a/doc/api/garage-admin-v2.json b/doc/api/garage-admin-v2.json index 4cdcf708..4cc907d1 100644 --- a/doc/api/garage-admin-v2.json +++ b/doc/api/garage-admin-v2.json @@ -2162,15 +2162,7 @@ "$ref": "#/components/schemas/GetBucketInfoResponse" }, "CreateKeyRequest": { - "type": "object", - "properties": { - "name": { - "type": [ - "string", - "null" - ] - } - } + "$ref": "#/components/schemas/UpdateKeyRequestBody" }, "CreateKeyResponse": { "$ref": "#/components/schemas/GetKeyInfoResponse" @@ -4115,7 +4107,8 @@ "type": "null" }, { - "$ref": "#/components/schemas/KeyPerm" + "$ref": "#/components/schemas/KeyPerm", + "description": "Permissions to allow for the key" } ] }, @@ -4125,15 +4118,25 @@ "type": "null" }, { - "$ref": "#/components/schemas/KeyPerm" + "$ref": "#/components/schemas/KeyPerm", + "description": "Permissions to deny for the key" } ] }, + "expiration": { + "type": [ + "string", + "null" + ], + "format": "date-time", + "description": "Expiration time and date, formatted according to RFC 3339" + }, "name": { "type": [ "string", "null" - ] + ], + "description": "Name of the API key" } } }, diff --git a/src/api/admin/api.rs b/src/api/admin/api.rs index 4c0cfa45..fa6c6b2d 100644 --- a/src/api/admin/api.rs +++ b/src/api/admin/api.rs @@ -701,9 +701,7 @@ pub struct ApiBucketKeyPerm { #[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] #[serde(rename_all = "camelCase")] -pub struct CreateKeyRequest { - pub name: Option, -} +pub struct CreateKeyRequest(pub UpdateKeyRequestBody); #[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] pub struct CreateKeyResponse(pub GetKeyInfoResponse); @@ -735,8 +733,13 @@ pub struct UpdateKeyResponse(pub GetKeyInfoResponse); #[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] #[serde(rename_all = "camelCase")] pub struct UpdateKeyRequestBody { + /// Name of the API key pub name: Option, + /// Expiration time and date, formatted according to RFC 3339 + pub expiration: Option>, + /// Permissions to allow for the key pub allow: Option, + /// Permissions to deny for the key pub deny: Option, } diff --git a/src/api/admin/key.rs b/src/api/admin/key.rs index ee3a4d1c..07373e76 100644 --- a/src/api/admin/key.rs +++ b/src/api/admin/key.rs @@ -103,7 +103,10 @@ impl RequestHandler for CreateKeyRequest { garage: &Arc, _admin: &Admin, ) -> Result { - let key = Key::new(self.name.as_deref().unwrap_or("Unnamed key")); + let mut key = Key::new("Unnamed key"); + + apply_key_updates(&mut key, self.0); + garage.key_table.insert(&key).await?; Ok(CreateKeyResponse( @@ -149,21 +152,7 @@ impl RequestHandler for UpdateKeyRequest { ) -> Result { let mut key = garage.key_helper().get_existing_key(&self.id).await?; - let key_state = key.state.as_option_mut().unwrap(); - - if let Some(new_name) = self.body.name { - key_state.name.update(new_name); - } - if let Some(allow) = self.body.allow { - if allow.create_bucket { - key_state.allow_create_bucket.update(true); - } - } - if let Some(deny) = self.body.deny { - if deny.create_bucket { - key_state.allow_create_bucket.update(false); - } - } + apply_key_updates(&mut key, self.body); garage.key_table.insert(&key).await?; @@ -275,3 +264,26 @@ async fn key_info_results( Ok(res) } + +fn apply_key_updates(key: &mut Key, updates: UpdateKeyRequestBody) { + let key_state = key.state.as_option_mut().unwrap(); + + if let Some(new_name) = updates.name { + key_state.name.update(new_name); + } + if let Some(expiration) = updates.expiration { + key_state + .expiration + .update(Some(expiration.timestamp_millis() as u64)); + } + if let Some(allow) = updates.allow { + if allow.create_bucket { + key_state.allow_create_bucket.update(true); + } + } + if let Some(deny) = updates.deny { + if deny.create_bucket { + key_state.allow_create_bucket.update(false); + } + } +} diff --git a/src/garage/cli/remote/key.rs b/src/garage/cli/remote/key.rs index 25937efa..d254f4e0 100644 --- a/src/garage/cli/remote/key.rs +++ b/src/garage/cli/remote/key.rs @@ -1,6 +1,6 @@ use format_table::format_table; -use chrono::Local; +use chrono::{Local, Utc}; use garage_util::error::*; @@ -16,6 +16,7 @@ impl Cli { KeyOperation::Info(query) => self.cmd_key_info(query).await, KeyOperation::Create(query) => self.cmd_create_key(query).await, KeyOperation::Rename(query) => self.cmd_rename_key(query).await, + KeyOperation::Set(opt) => self.cmd_update_key(opt).await, KeyOperation::Delete(query) => self.cmd_delete_key(query).await, KeyOperation::Allow(query) => self.cmd_allow_key(query).await, KeyOperation::Deny(query) => self.cmd_deny_key(query).await, @@ -68,9 +69,17 @@ impl Cli { pub async fn cmd_create_key(&self, opt: KeyNewOpt) -> Result<(), Error> { let key = self - .api_request(CreateKeyRequest { + .api_request(CreateKeyRequest(UpdateKeyRequestBody { name: Some(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), + allow: None, + deny: None, + })) .await?; print_key_info(&key.0); @@ -92,6 +101,38 @@ impl Cli { id: key.access_key_id, body: UpdateKeyRequestBody { name: Some(opt.new_name), + expiration: None, + allow: None, + deny: None, + }, + }) + .await?; + + print_key_info(&new_key.0); + + Ok(()) + } + + pub async fn cmd_update_key(&self, opt: KeySetOpt) -> Result<(), Error> { + let key = self + .api_request(GetKeyInfoRequest { + id: None, + search: Some(opt.key_pattern), + show_secret_key: false, + }) + .await?; + + let new_key = self + .api_request(UpdateKeyRequest { + id: key.access_key_id, + body: UpdateKeyRequestBody { + 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), allow: None, deny: None, }, @@ -143,6 +184,7 @@ impl Cli { id: key.access_key_id, body: UpdateKeyRequestBody { name: None, + expiration: None, allow: Some(KeyPerm { create_bucket: opt.create_bucket, }), @@ -170,6 +212,7 @@ impl Cli { id: key.access_key_id, body: UpdateKeyRequestBody { name: None, + expiration: None, allow: None, deny: Some(KeyPerm { create_bucket: opt.create_bucket, diff --git a/src/garage/cli/structs.rs b/src/garage/cli/structs.rs index 20079709..01a5d77f 100644 --- a/src/garage/cli/structs.rs +++ b/src/garage/cli/structs.rs @@ -426,6 +426,10 @@ pub enum KeyOperation { /// Import key #[structopt(name = "import", version = garage_version())] Import(KeyImportOpt), + + /// Set parameters for an access key + #[structopt(name = "set", version = garage_version())] + Set(KeySetOpt), } #[derive(StructOpt, Debug)] @@ -442,6 +446,21 @@ pub struct KeyNewOpt { /// Name of the key #[structopt(default_value = "Unnamed key")] pub name: String, + /// Set an expiration time for the access key + /// (see docs.rs/parse_duration for date format) + #[structopt(long = "expires-in")] + pub expires_in: Option, +} + +#[derive(StructOpt, Debug)] +pub struct KeySetOpt { + /// ID or name of the key + pub key_pattern: String, + + /// Set an expiration time for the access key + /// (see docs.rs/parse_duration for date format) + #[structopt(long = "expires-in")] + pub expires_in: Option, } #[derive(StructOpt, Debug)] From 5d338f0b8f857145229c5a5b570aa46d5e27d9c2 Mon Sep 17 00:00:00 2001 From: Alex Auvolat Date: Thu, 17 Apr 2025 11:44:09 +0200 Subject: [PATCH 4/6] add never_expires to remove expiration dates of admin tokens and access keys --- doc/api/garage-admin-v2.json | 8 ++++++++ src/api/admin/admin_token.rs | 20 +++++++++++++++++--- src/api/admin/api.rs | 6 ++++++ src/api/admin/key.rs | 17 ++++++++++++++--- src/garage/cli/remote/admin_token.rs | 3 +++ src/garage/cli/remote/key.rs | 5 +++++ src/garage/cli/structs.rs | 8 ++++++++ 7 files changed, 61 insertions(+), 6 deletions(-) diff --git a/doc/api/garage-admin-v2.json b/doc/api/garage-admin-v2.json index 4cc907d1..4e07ed68 100644 --- a/doc/api/garage-admin-v2.json +++ b/doc/api/garage-admin-v2.json @@ -4006,6 +4006,10 @@ ], "description": "Name of the admin API token" }, + "neverExpires": { + "type": "boolean", + "description": "Set the admin token to never expire" + }, "scope": { "type": [ "array", @@ -4137,6 +4141,10 @@ "null" ], "description": "Name of the API key" + }, + "neverExpires": { + "type": "boolean", + "description": "Set the access key to never expire" } } }, diff --git a/src/api/admin/admin_token.rs b/src/api/admin/admin_token.rs index b010dcf9..082d942a 100644 --- a/src/api/admin/admin_token.rs +++ b/src/api/admin/admin_token.rs @@ -124,7 +124,7 @@ impl RequestHandler for CreateAdminTokenRequest { AdminApiToken::new(&format!("token_{}", Utc::now().format("%Y%m%d_%H%M"))) }; - apply_token_updates(&mut token, self.0); + apply_token_updates(&mut token, self.0)?; garage.admin_token_table.insert(&token).await?; @@ -145,7 +145,7 @@ impl RequestHandler for UpdateAdminTokenRequest { ) -> Result { let mut token = get_existing_admin_token(&garage, &self.id).await?; - apply_token_updates(&mut token, self.body); + apply_token_updates(&mut token, self.body)?; garage.admin_token_table.insert(&token).await?; @@ -204,7 +204,16 @@ async fn get_existing_admin_token(garage: &Garage, id: &String) -> Result Result<(), Error> { + if updates.never_expires && updates.expiration.is_some() { + return Err(Error::bad_request( + "cannot specify `expiration` and `never_expires`", + )); + } + let params = token.params_mut().unwrap(); if let Some(name) = updates.name { @@ -215,7 +224,12 @@ fn apply_token_updates(token: &mut AdminApiToken, updates: UpdateAdminTokenReque .expiration .update(Some(expiration.timestamp_millis() as u64)); } + if updates.never_expires { + params.expiration.update(None); + } if let Some(scope) = updates.scope { params.scope.update(AdminApiTokenScope(scope)); } + + Ok(()) } diff --git a/src/api/admin/api.rs b/src/api/admin/api.rs index fa6c6b2d..1766ae28 100644 --- a/src/api/admin/api.rs +++ b/src/api/admin/api.rs @@ -366,6 +366,9 @@ pub struct UpdateAdminTokenRequestBody { pub name: Option, /// Expiration time and date, formatted according to RFC 3339 pub expiration: Option>, + /// Set the admin token to never expire + #[serde(default)] + pub never_expires: 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. **WARNING:** Granting a scope of `CreateAdminToken` or @@ -737,6 +740,9 @@ pub struct UpdateKeyRequestBody { pub name: Option, /// Expiration time and date, formatted according to RFC 3339 pub expiration: Option>, + /// Set the access key to never expire + #[serde(default)] + pub never_expires: bool, /// Permissions to allow for the key pub allow: Option, /// Permissions to deny for the key diff --git a/src/api/admin/key.rs b/src/api/admin/key.rs index 07373e76..7f0d819f 100644 --- a/src/api/admin/key.rs +++ b/src/api/admin/key.rs @@ -105,7 +105,7 @@ impl RequestHandler for CreateKeyRequest { ) -> Result { let mut key = Key::new("Unnamed key"); - apply_key_updates(&mut key, self.0); + apply_key_updates(&mut key, self.0)?; garage.key_table.insert(&key).await?; @@ -152,7 +152,7 @@ impl RequestHandler for UpdateKeyRequest { ) -> Result { let mut key = garage.key_helper().get_existing_key(&self.id).await?; - apply_key_updates(&mut key, self.body); + apply_key_updates(&mut key, self.body)?; garage.key_table.insert(&key).await?; @@ -265,7 +265,13 @@ async fn key_info_results( Ok(res) } -fn apply_key_updates(key: &mut Key, updates: UpdateKeyRequestBody) { +fn apply_key_updates(key: &mut Key, updates: UpdateKeyRequestBody) -> Result<(), Error> { + if updates.never_expires && updates.expiration.is_some() { + return Err(Error::bad_request( + "cannot specify `expiration` and `never_expires`", + )); + } + let key_state = key.state.as_option_mut().unwrap(); if let Some(new_name) = updates.name { @@ -276,6 +282,9 @@ fn apply_key_updates(key: &mut Key, updates: UpdateKeyRequestBody) { .expiration .update(Some(expiration.timestamp_millis() as u64)); } + if updates.never_expires { + key_state.expiration.update(None); + } if let Some(allow) = updates.allow { if allow.create_bucket { key_state.allow_create_bucket.update(true); @@ -286,4 +295,6 @@ fn apply_key_updates(key: &mut Key, updates: UpdateKeyRequestBody) { key_state.allow_create_bucket.update(false); } } + + Ok(()) } diff --git a/src/garage/cli/remote/admin_token.rs b/src/garage/cli/remote/admin_token.rs index cd7ff9b0..83050c92 100644 --- a/src/garage/cli/remote/admin_token.rs +++ b/src/garage/cli/remote/admin_token.rs @@ -88,6 +88,7 @@ impl Cli { .transpose() .ok_or_message("Invalid duration passed for --expires-in parameter")? .map(|dur| Utc::now() + dur), + never_expires: false, scope: opt.scope.map(|s| { s.split(",") .map(|x| x.trim().to_string()) @@ -121,6 +122,7 @@ impl Cli { body: UpdateAdminTokenRequestBody { name: Some(new), expiration: None, + never_expires: false, scope: None, }, }) @@ -150,6 +152,7 @@ impl Cli { .transpose() .ok_or_message("Invalid duration passed for --expires-in parameter")? .map(|dur| Utc::now() + dur), + never_expires: opt.never_expires, scope: opt.scope.map({ let mut new_scope = token.scope; |scope_str| { diff --git a/src/garage/cli/remote/key.rs b/src/garage/cli/remote/key.rs index d254f4e0..6faede01 100644 --- a/src/garage/cli/remote/key.rs +++ b/src/garage/cli/remote/key.rs @@ -77,6 +77,7 @@ impl Cli { .transpose() .ok_or_message("Invalid duration passed for --expires-in parameter")? .map(|dur| Utc::now() + dur), + never_expires: false, allow: None, deny: None, })) @@ -102,6 +103,7 @@ impl Cli { body: UpdateKeyRequestBody { name: Some(opt.new_name), expiration: None, + never_expires: false, allow: None, deny: None, }, @@ -133,6 +135,7 @@ impl Cli { .transpose() .ok_or_message("Invalid duration passed for --expires-in parameter")? .map(|dur| Utc::now() + dur), + never_expires: opt.never_expires, allow: None, deny: None, }, @@ -185,6 +188,7 @@ impl Cli { body: UpdateKeyRequestBody { name: None, expiration: None, + never_expires: false, allow: Some(KeyPerm { create_bucket: opt.create_bucket, }), @@ -213,6 +217,7 @@ impl Cli { body: UpdateKeyRequestBody { name: None, expiration: None, + never_expires: false, allow: None, deny: Some(KeyPerm { create_bucket: opt.create_bucket, diff --git a/src/garage/cli/structs.rs b/src/garage/cli/structs.rs index 01a5d77f..7c00aefc 100644 --- a/src/garage/cli/structs.rs +++ b/src/garage/cli/structs.rs @@ -461,6 +461,9 @@ pub struct KeySetOpt { /// (see docs.rs/parse_duration for date format) #[structopt(long = "expires-in")] pub expires_in: Option, + /// Set the access key to never expire + #[structopt(long = "never-expires")] + pub never_expires: bool, } #[derive(StructOpt, Debug)] @@ -587,10 +590,15 @@ pub struct AdminTokenCreateOp { 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 the token to never expire + #[structopt(long = "never-expires")] + pub never_expires: bool, + /// 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. From abcef7a3fd2440512fc84c0094099c20cfc1a4c9 Mon Sep 17 00:00:00 2001 From: Alex Auvolat Date: Thu, 17 Apr 2025 11:58:19 +0200 Subject: [PATCH 5/6] cli: implement garage key delete-expired --- src/garage/cli/remote/key.rs | 24 ++++++++++++++++++++++++ src/garage/cli/structs.rs | 8 ++++++++ 2 files changed, 32 insertions(+) diff --git a/src/garage/cli/remote/key.rs b/src/garage/cli/remote/key.rs index 6faede01..67df9c48 100644 --- a/src/garage/cli/remote/key.rs +++ b/src/garage/cli/remote/key.rs @@ -21,6 +21,7 @@ impl Cli { KeyOperation::Allow(query) => self.cmd_allow_key(query).await, KeyOperation::Deny(query) => self.cmd_deny_key(query).await, KeyOperation::Import(query) => self.cmd_import_key(query).await, + KeyOperation::DeleteExpired { yes } => self.cmd_delete_expired_keys(yes).await, } } @@ -248,6 +249,29 @@ impl Cli { Ok(()) } + + pub async fn cmd_delete_expired_keys(&self, yes: bool) -> Result<(), Error> { + let mut list = self.api_request(ListKeysRequest).await?.0; + + list.retain(|key| key.expired); + + if !yes { + return Err(Error::Message(format!( + "This would delete {} access keys, add the --yes flag to proceed.", + list.len(), + ))); + } + + for key in list.iter() { + let id = key.id.clone(); + println!("Deleting access key `{}` ({})", key.name, id); + self.api_request(DeleteKeyRequest { id }).await?; + } + + println!("{} access keys have been deleted.", list.len()); + + Ok(()) + } } fn print_key_info(key: &GetKeyInfoResponse) { diff --git a/src/garage/cli/structs.rs b/src/garage/cli/structs.rs index 7c00aefc..fadfcc66 100644 --- a/src/garage/cli/structs.rs +++ b/src/garage/cli/structs.rs @@ -430,6 +430,14 @@ pub enum KeyOperation { /// Set parameters for an access key #[structopt(name = "set", version = garage_version())] Set(KeySetOpt), + + /// Delete all expired access keys + #[structopt(name = "delete-expired", version = garage_version())] + DeleteExpired { + /// Confirm deletion + #[structopt(long = "yes")] + yes: bool, + }, } #[derive(StructOpt, Debug)] From 52437e4298210b867dc9b0427fffb55f48fe3fe0 Mon Sep 17 00:00:00 2001 From: Alex Auvolat Date: Thu, 17 Apr 2025 12:14:51 +0200 Subject: [PATCH 6/6] refactor parsing of --expires-in --- src/garage/cli/remote/admin_token.rs | 17 +++-------------- src/garage/cli/remote/key.rs | 16 +++------------- src/garage/cli/remote/mod.rs | 10 ++++++++++ 3 files changed, 16 insertions(+), 27 deletions(-) diff --git a/src/garage/cli/remote/admin_token.rs b/src/garage/cli/remote/admin_token.rs index 83050c92..6b2bd67e 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::{Local, Utc}; +use chrono::Local; use garage_util::error::*; @@ -78,16 +78,10 @@ impl Cli { } 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), + expiration: parse_expires_in(&opt.expires_in)?, never_expires: false, scope: opt.scope.map(|s| { s.split(",") @@ -146,12 +140,7 @@ impl Cli { 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), + expiration: parse_expires_in(&opt.expires_in)?, never_expires: opt.never_expires, scope: opt.scope.map({ let mut new_scope = token.scope; diff --git a/src/garage/cli/remote/key.rs b/src/garage/cli/remote/key.rs index 67df9c48..f448bb17 100644 --- a/src/garage/cli/remote/key.rs +++ b/src/garage/cli/remote/key.rs @@ -1,6 +1,6 @@ use format_table::format_table; -use chrono::{Local, Utc}; +use chrono::Local; use garage_util::error::*; @@ -72,12 +72,7 @@ impl Cli { let key = self .api_request(CreateKeyRequest(UpdateKeyRequestBody { name: Some(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), + expiration: parse_expires_in(&opt.expires_in)?, never_expires: false, allow: None, deny: None, @@ -130,12 +125,7 @@ impl Cli { id: key.access_key_id, body: UpdateKeyRequestBody { 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), + expiration: parse_expires_in(&opt.expires_in)?, never_expires: opt.never_expires, allow: None, deny: None, diff --git a/src/garage/cli/remote/mod.rs b/src/garage/cli/remote/mod.rs index af79157c..31cbdc6e 100644 --- a/src/garage/cli/remote/mod.rs +++ b/src/garage/cli/remote/mod.rs @@ -12,6 +12,8 @@ use std::convert::TryFrom; use std::sync::Arc; use std::time::Duration; +use chrono::{DateTime, Utc}; + use garage_util::error::*; use garage_rpc::*; @@ -162,3 +164,11 @@ pub fn table_list_abbr, S: AsRef>(values: T) -> S None => String::new(), } } + +pub fn parse_expires_in(expires_in: &Option) -> Result>, Error> { + expires_in + .as_ref() + .map(|x| parse_duration::parse::parse(&x).map(|dur| Utc::now() + dur)) + .transpose() + .ok_or_message("Invalid duration passed for --expires-in parameter") +}