Merge pull request 'support for multiple admin API token' (#982) from multi-admin-token into next-v2

Reviewed-on: https://git.deuxfleurs.fr/Deuxfleurs/garage/pulls/982
This commit is contained in:
Alex 2025-03-12 09:30:19 +00:00
commit fd2472d488
25 changed files with 1454 additions and 141 deletions

2
Cargo.lock generated
View file

@ -1298,6 +1298,7 @@ dependencies = [
"argon2",
"async-trait",
"bytesize",
"chrono",
"err-derive",
"format_table",
"futures",
@ -1467,6 +1468,7 @@ dependencies = [
name = "garage_model"
version = "1.1.0"
dependencies = [
"argon2",
"async-trait",
"base64 0.21.7",
"blake2",

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

View file

@ -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,54 @@
}
}
},
"GetAdminTokenInfoResponse": {
"type": "object",
"required": [
"name",
"expired",
"scope"
],
"properties": {
"created": {
"type": [
"string",
"null"
],
"format": "date-time",
"description": "Creation date"
},
"expiration": {
"type": [
"string",
"null"
],
"format": "date-time",
"description": "Expiration time and date, formatted according to RFC 3339"
},
"expired": {
"type": "boolean",
"description": "Whether this admin token is expired already"
},
"id": {
"type": [
"string",
"null"
],
"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 +2555,12 @@
}
}
},
"ListAdminTokensResponse": {
"type": "array",
"items": {
"$ref": "#/components/schemas/GetAdminTokenInfoResponse"
}
},
"ListBucketsResponse": {
"type": "array",
"items": {
@ -3404,6 +3640,39 @@
"cancel"
]
},
"UpdateAdminTokenRequestBody": {
"type": "object",
"properties": {
"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 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": {

View file

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

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,225 @@
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 mut 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<_>>();
if garage.config.admin.admin_token.is_some() {
res.insert(
0,
GetAdminTokenInfoResponse {
id: None,
created: 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,
created: None,
name: "metrics_token (from daemon configuration)".into(),
expiration: None,
expired: false,
scope: vec!["Metrics".into()],
},
);
}
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: 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")
}),
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,97 @@ pub struct ConnectNodeResponse {
pub error: Option<String>,
}
// **********************************************
// Admin token operations
// **********************************************
// ---- ListAdminTokens ----
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ListAdminTokensRequest;
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
pub struct ListAdminTokensResponse(pub Vec<GetAdminTokenInfoResponse>);
// ---- GetAdminTokenInfo ----
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct GetAdminTokenInfoRequest {
pub id: Option<String>,
pub search: Option<String>,
}
#[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: 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
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>,
}
// ---- CreateAdminToken ----
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CreateAdminTokenRequest(pub UpdateAdminTokenRequestBody);
#[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,
}
// ---- UpdateAdminToken ----
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct UpdateAdminTokenRequest {
pub id: String,
pub body: UpdateAdminTokenRequestBody,
}
#[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
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, ToSchema)]
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

@ -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::*;
@ -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,18 @@ 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),
};
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)?;
}
}
if token_required {
verify_authorization(&self.garage, global_token_hash, auth_header, request.name())
.await?;
}
match request {
@ -249,20 +245,62 @@ 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,
global_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"))?;
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(())
}

View file

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

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

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

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 (),
@ -243,9 +249,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,
}

View file

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

View file

@ -0,0 +1,253 @@
use format_table::format_table;
use chrono::{Local, 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 mut list = self.api_request(ListAdminTokensRequest).await?;
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.expired {
String::new()
} else 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.with_timezone(&Local).to_string())
.unwrap_or("never".into())
};
table.push(format!(
"{}\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,
));
}
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({
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<_>>()
}
}
}),
},
})
.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_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")
),
format!(
"Expiration:\t{}",
token
.expiration
.map(|x| x.with_timezone(&Local).to_string())
.unwrap_or("never".into())
),
format!("Scope:\t{}", token.scope.to_vec().join(", ")),
]);
}

View file

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

View file

@ -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,104 @@ 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, 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
#[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, 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>,
}
// ---------------------------
// ---- garage repair ... ----
// ---------------------------
#[derive(StructOpt, Debug, Clone)]
pub struct RepairOpt {
/// Launch repair operation on all nodes
@ -508,6 +626,10 @@ pub enum ScrubCmd {
Cancel,
}
// -----------------------------------
// ---- garage offline-repair ... ----
// -----------------------------------
#[derive(StructOpt, Debug, Clone)]
pub struct OfflineRepairOpt {
/// Confirm the launch of the repair operation
@ -529,6 +651,10 @@ pub enum OfflineRepairWhat {
ObjectCounters,
}
// --------------------------
// ---- garage stats ... ----
// --------------------------
#[derive(StructOpt, Debug, Clone)]
pub struct StatsOpt {
/// Gather statistics from all nodes
@ -536,6 +662,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 +709,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 +745,10 @@ pub enum BlockOperation {
},
}
// -------------------------
// ---- garage meta ... ----
// -------------------------
#[derive(StructOpt, Debug, Eq, PartialEq, Clone, Copy)]
pub enum MetaOperation {
/// Save a snapshot of the metadata db file

View file

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

View file

@ -0,0 +1,177 @@
use base64::prelude::*;
use garage_util::crdt::{self, Crdt};
use garage_util::time::now_msec;
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 {
/// Creation date
pub created: u64,
/// 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 = BASE64_URL_SAFE_NO_PAD.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 {
created: now_msec(),
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
}
fn is_tombstone(&self) -> bool {
self.is_deleted()
}
}
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)
}
}
}
}

View file

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

View file

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

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

View file

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

View file

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