mirror of
https://git.deuxfleurs.fr/Deuxfleurs/garage.git
synced 2025-04-12 12:24:07 +00:00
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:
commit
fd2472d488
25 changed files with 1454 additions and 141 deletions
2
Cargo.lock
generated
2
Cargo.lock
generated
|
@ -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",
|
||||
|
|
|
@ -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"] }
|
||||
|
|
|
@ -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": {
|
||||
|
|
|
@ -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}
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
225
src/api/admin/admin_token.rs
Normal file
225
src/api/admin/admin_token.rs
Normal 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));
|
||||
}
|
||||
}
|
|
@ -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
|
||||
// **********************************************
|
||||
|
|
|
@ -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(())
|
||||
}
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -11,6 +11,7 @@ mod router_v0;
|
|||
mod router_v1;
|
||||
mod router_v2;
|
||||
|
||||
mod admin_token;
|
||||
mod bucket;
|
||||
mod cluster;
|
||||
mod key;
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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,
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
|
253
src/garage/cli/remote/admin_token.rs
Normal file
253
src/garage/cli/remote/admin_token.rs
Normal 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(", ")),
|
||||
]);
|
||||
}
|
|
@ -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,
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
177
src/model/admin_token_table.rs
Normal file
177
src/model/admin_token_table.rs
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
|
|
|
@ -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.
|
||||
|
|
|
@ -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())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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>,
|
||||
|
|
Loading…
Reference in a new issue