admin api: add functions to manage admin api tokens

This commit is contained in:
Alex Auvolat 2025-03-11 15:17:31 +01:00
parent ff6ec62d54
commit d067a40b3f
11 changed files with 319 additions and 38 deletions

1
Cargo.lock generated
View file

@ -1298,6 +1298,7 @@ dependencies = [
"argon2",
"async-trait",
"bytesize",
"chrono",
"err-derive",
"format_table",
"futures",

View file

@ -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"

View file

@ -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

View file

@ -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));
}
}

View file

@ -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
// **********************************************

View file

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

View file

@ -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(

View file

@ -11,6 +11,7 @@ mod router_v0;
mod router_v1;
mod router_v2;
mod admin_token;
mod bucket;
mod cluster;
mod key;

View file

@ -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 (),

View file

@ -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);

View file

@ -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())
}
}
}