From c56b7e20c3fbdd5427777e0e7b3c82ddb4af20d2 Mon Sep 17 00:00:00 2001 From: Alex Auvolat Date: Thu, 17 Apr 2025 11:09:21 +0200 Subject: [PATCH] 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