This commit is contained in:
2025-11-05 22:15:27 +01:00
parent a75281aa13
commit c193b30c8e
10 changed files with 213 additions and 205 deletions
+25 -38
View File
@@ -9,11 +9,9 @@ use rand::RngCore;
use uuid::Uuid;
use crate::{
auth::capability_sets::{
ensure_capability_set, load_capabilities_for_set, normalize_capabilities,
},
auth::capability_sets::{load_capabilities_for_set, load_capability_set},
error::AppError,
models::{ApiCapability, ApiToken, NewApiToken},
models::{ApiCapability, ApiToken, CapabilitySet, NewApiToken},
schema::api_tokens,
state::PgPooledConnection,
tenants::{apply_api_token_prefix, clear_api_token_prefix},
@@ -37,10 +35,9 @@ pub fn create_api_token(
tenant_id: Uuid,
label: Option<String>,
expires_at: Option<NaiveDateTime>,
capabilities: Vec<ApiCapability>,
capability_set_id: Uuid,
) -> Result<IssuedApiToken, AppError> {
let capabilities = normalize_capabilities(capabilities)?;
let capability_set = ensure_capability_set(conn, tenant_id, &capabilities)?;
let capability_set = validate_capability_set_belongs_to_tenant(conn, capability_set_id, tenant_id)?;
let raw_secret = generate_secret()?;
let token_prefix = raw_secret[..TOKEN_PREFIX_LENGTH].to_string();
@@ -120,40 +117,13 @@ pub fn regenerate_api_token(
})
}
/// Updates the set of capabilities associated with an API token.
pub fn update_api_token_capabilities(
conn: &mut PgPooledConnection,
token_id: Uuid,
user_id: Uuid,
tenant_id: Option<Uuid>,
capabilities: Vec<ApiCapability>,
) -> Result<ApiToken, AppError> {
let capabilities = normalize_capabilities(capabilities)?;
let token = find_user_token(conn, token_id, user_id, tenant_id)?;
if token.revoked_at.is_some() {
return Err(AppError::bad_request(
"cannot modify capabilities of a revoked API token",
));
}
let capability_set = ensure_capability_set(conn, token.tenant_id, &capabilities)?;
let updated = diesel::update(api_tokens::table.find(token.id))
.set(api_tokens::capability_set_id.eq(capability_set.id))
.get_result::<ApiToken>(conn)?;
Ok(updated)
}
/// Attempts to resolve an API token by its secret value while ensuring it provides the
/// requested capability.
pub fn find_active_token_by_secret(
conn: &mut PgPooledConnection,
tenant_id: Option<Uuid>,
secret: &str,
required_capability: ApiCapability,
required_capability: Option<ApiCapability>,
) -> Result<Option<ApiToken>, AppError> {
if secret.len() < TOKEN_PREFIX_LENGTH {
return Ok(None);
@@ -181,9 +151,11 @@ pub fn find_active_token_by_secret(
})?;
for token in candidates {
let capabilities = load_capabilities_for_set(conn, token.capability_set_id)?;
if !capabilities.contains(&required_capability) {
continue;
if let Some(required) = required_capability {
let capabilities = load_capabilities_for_set(conn, token.capability_set_id)?;
if !capabilities.contains(&required) {
continue;
}
}
if verify_token_secret(secret, &token.token_hash)? {
@@ -247,6 +219,21 @@ fn find_user_token(
.ok_or_else(AppError::not_found)
}
fn validate_capability_set_belongs_to_tenant(
conn: &mut PgPooledConnection,
capability_set_id: Uuid,
tenant_id: Uuid,
) -> Result<CapabilitySet, AppError> {
let capability_set = load_capability_set(conn, capability_set_id)?;
if capability_set.tenant_id != tenant_id {
return Err(AppError::bad_request(
"capability set does not belong to the tenant",
));
}
Ok(capability_set)
}
fn with_api_token_prefix<T, F>(
conn: &mut PgPooledConnection,
prefix: &str,
-1
View File
@@ -101,7 +101,6 @@ pub mod schemas {
};
pub use crate::routes::profile::{
ApiTokenCreatedResponse, ApiTokenResponse, CreateApiTokenRequest, RevokePasskeyQuery,
UpdateApiTokenCapabilitiesRequest,
};
pub use crate::routes::tags::{CreateTagRequest, TagCatalogEntry, UpdateTagRequest};
}
+3 -3
View File
@@ -29,8 +29,8 @@ use crate::{
},
error::{AppError, AppResult},
models::{
ApiCapability, MagicToken, MagicTokenKind, NewUser, NewUserSession, TenantStatus, User,
UserMembership, UserSession,
MagicToken, MagicTokenKind, NewUser, NewUserSession, TenantStatus, User, UserMembership,
UserSession,
},
schema::{
magic_tokens::dsl as magic_dsl, tenants::dsl as tenant_dsl,
@@ -232,7 +232,7 @@ pub async fn api_token_exchange(
let mut conn = state.db_unscoped()?;
let token = find_active_token_by_secret(&mut conn, None, secret, ApiCapability::ProfileRead)?
let token = find_active_token_by_secret(&mut conn, None, secret, None)?
.ok_or_else(AppError::unauthorized)?;
let user: User = dsl::users.find(token.user_id).first(&mut conn)?;
+18 -3
View File
@@ -23,8 +23,6 @@ use crate::{
pub struct CapabilitySetResponse {
pub id: Uuid,
pub slug: String,
#[serde(default)]
pub label: Option<String>,
pub is_system: bool,
pub cap_version: i32,
pub capabilities: Vec<ApiCapability>,
@@ -81,7 +79,6 @@ fn to_response(set: CapabilitySet, capabilities: Vec<ApiCapability>) -> Capabili
CapabilitySetResponse {
id: set.id,
slug: set.slug,
label: None,
is_system: set.is_system,
cap_version: set.cap_version,
capabilities,
@@ -115,6 +112,22 @@ pub async fn list_capability_sets(
Ok(Json(responses))
}
#[utoipa::path(
get,
path = "/api/capabilities",
responses((status = 200, body = [ApiCapability])),
tag = "Capability Sets"
)]
pub async fn list_capabilities(
TenantScopedConn { .. }: TenantScopedConn,
) -> AppResult<Json<Vec<ApiCapability>>> {
let capabilities = ApiCapability::variants()
.iter()
.map(|value| value.parse::<ApiCapability>().expect("valid capability"))
.collect();
Ok(Json(capabilities))
}
#[utoipa::path(
get,
path = "/api/capability-sets/{id}",
@@ -335,12 +348,14 @@ pub async fn delete_capability_set(
#[openapi(
paths(
crate::routes::capability_sets::list_capability_sets,
crate::routes::capability_sets::list_capabilities,
crate::routes::capability_sets::get_capability_set,
crate::routes::capability_sets::create_capability_set,
crate::routes::capability_sets::update_capability_set,
crate::routes::capability_sets::delete_capability_set,
),
components(schemas(
crate::models::ApiCapability,
crate::routes::capability_sets::CapabilitySetResponse,
crate::routes::capability_sets::CreateCapabilitySetRequest,
crate::routes::capability_sets::UpdateCapabilitySetRequest,
+8 -5
View File
@@ -305,11 +305,6 @@ pub fn create_router(state: AppState) -> Router<()> {
post(profile::regenerate_api_token)
.layer(RequireCapabilitiesLayer::all([ApiCapability::ProfileWrite])),
)
.route(
"/api-tokens/:id",
patch(profile::update_api_token)
.layer(RequireCapabilitiesLayer::all([ApiCapability::ProfileWrite])),
)
.route(
"/api-tokens/:id",
delete(profile::delete_api_token)
@@ -358,6 +353,13 @@ pub fn create_router(state: AppState) -> Router<()> {
])),
);
let capabilities_routes = Router::new().route(
"/",
get(capability_sets::list_capabilities).layer(RequireCapabilitiesLayer::all([
ApiCapability::CapabilitySetsRead,
])),
);
let protected_state = state.clone();
let assets_routes = Router::new().route(
"/:asset_id",
@@ -373,6 +375,7 @@ pub fn create_router(state: AppState) -> Router<()> {
.nest("/api/correspondents", correspondents_routes)
.nest("/api/profile", profile_routes)
.nest("/api/capability-sets", capability_sets_routes)
.nest("/api/capabilities", capabilities_routes)
.nest("/api/assets", assets_routes)
.layer(middleware::from_extractor_with_state::<AuthenticatedUser, _>(protected_state));
+27 -72
View File
@@ -12,14 +12,13 @@ use crate::auth::{
api_tokens::{
create_api_token as issue_token, list_api_tokens as load_tokens,
regenerate_api_token as rotate_token, revoke_api_token as revoke_token,
update_api_token_capabilities as update_capabilities,
},
capability_sets::load_capabilities_for_set,
capability_sets::load_capability_set,
passkeys::PasskeySummary,
TenantScopedConn,
};
use crate::error::{AppError, AppResult};
use crate::models::{ApiCapability, ApiToken};
use crate::models::ApiToken;
use crate::state::{AppState, PgPooledConnection};
use crate::utils::{db::no_content, time::to_iso};
@@ -29,7 +28,7 @@ pub struct ApiTokenResponse {
pub tenant_id: Uuid,
#[schema(nullable)]
pub label: Option<String>,
pub capabilities: Vec<ApiCapability>,
pub capability_set_id: Uuid,
pub created_at: String,
#[schema(nullable)]
pub last_used_at: Option<String>,
@@ -51,14 +50,7 @@ pub struct CreateApiTokenRequest {
pub label: Option<String>,
#[schema(nullable)]
pub expires_at: Option<String>,
#[serde(default)]
#[schema(nullable)]
pub capabilities: Option<Vec<ApiCapability>>,
}
#[derive(Debug, Deserialize, ToSchema)]
pub struct UpdateApiTokenCapabilitiesRequest {
pub capabilities: Vec<ApiCapability>,
pub capability_set_id: Uuid,
}
#[derive(Debug, Deserialize, ToSchema)]
@@ -104,10 +96,7 @@ pub async fn list_api_tokens(
}: TenantScopedConn,
) -> AppResult<Json<Vec<ApiTokenResponse>>> {
let tokens = load_tokens(&mut conn, user_id, Some(tenant_id))?;
let mut responses = Vec::with_capacity(tokens.len());
for token in tokens {
responses.push(api_token_to_response(&mut conn, token)?);
}
let responses = tokens.into_iter().map(api_token_to_response).collect();
Ok(Json(responses))
}
@@ -132,13 +121,7 @@ pub async fn create_api_token(
None => None,
};
let capabilities = payload
.capabilities
.ok_or_else(|| AppError::bad_request("at least one capability is required"))?;
if capabilities.is_empty() {
return Err(AppError::bad_request("at least one capability is required"));
}
let capability_set_id = validate_capability_set(&mut conn, tenant_id, payload.capability_set_id)?;
let issued = issue_token(
&mut conn,
@@ -146,10 +129,10 @@ pub async fn create_api_token(
tenant_id,
payload.label.clone(),
expires_at,
capabilities,
capability_set_id,
)?;
let token_info = api_token_to_response(&mut conn, issued.record)?;
let token_info = api_token_to_response(issued.record);
let response = ApiTokenCreatedResponse {
token: issued.token,
@@ -176,7 +159,7 @@ pub async fn regenerate_api_token(
Path(token_id): Path<Uuid>,
) -> AppResult<Json<ApiTokenCreatedResponse>> {
let issued = rotate_token(&mut conn, token_id, user_id, Some(tenant_id))?;
let token_info = api_token_to_response(&mut conn, issued.record)?;
let token_info = api_token_to_response(issued.record);
let response = ApiTokenCreatedResponse {
token: issued.token,
token_info,
@@ -185,41 +168,6 @@ pub async fn regenerate_api_token(
Ok(Json(response))
}
#[utoipa::path(
patch,
path = "/api/profile/api-tokens/{id}",
params(("id" = Uuid, Path, description = "API token ID")),
request_body = UpdateApiTokenCapabilitiesRequest,
responses((status = 200, description = "API token updated", body = ApiTokenResponse)),
tag = "Profile"
)]
pub async fn update_api_token(
TenantScopedConn {
mut conn,
tenant_id,
user_id,
..
}: TenantScopedConn,
Path(token_id): Path<Uuid>,
Json(payload): Json<UpdateApiTokenCapabilitiesRequest>,
) -> AppResult<Json<ApiTokenResponse>> {
if payload.capabilities.is_empty() {
return Err(AppError::bad_request("at least one capability is required"));
}
let updated = update_capabilities(
&mut conn,
token_id,
user_id,
Some(tenant_id),
payload.capabilities,
)?;
let response = api_token_to_response(&mut conn, updated)?;
Ok(Json(response))
}
#[utoipa::path(
delete,
path = "/api/profile/api-tokens/{id}",
@@ -271,10 +219,7 @@ pub async fn delete_passkey(
no_content()
}
fn api_token_to_response(
conn: &mut PgPooledConnection,
token: ApiToken,
) -> AppResult<ApiTokenResponse> {
fn api_token_to_response(token: ApiToken) -> ApiTokenResponse {
let ApiToken {
id,
tenant_id,
@@ -287,18 +232,16 @@ fn api_token_to_response(
..
} = token;
let capabilities = load_capabilities_for_set(conn, capability_set_id)?;
Ok(ApiTokenResponse {
ApiTokenResponse {
id,
tenant_id,
label,
capabilities,
capability_set_id,
created_at: to_iso(created_at),
last_used_at: last_used_at.map(to_iso),
expires_at: expires_at.map(to_iso),
revoked_at: revoked_at.map(to_iso),
})
}
}
fn parse_timestamp(value: &str) -> AppResult<NaiveDateTime> {
@@ -307,13 +250,26 @@ fn parse_timestamp(value: &str) -> AppResult<NaiveDateTime> {
Ok(dt.naive_utc())
}
fn validate_capability_set(
conn: &mut PgPooledConnection,
tenant_id: Uuid,
capability_set_id: Uuid,
) -> AppResult<Uuid> {
let set = load_capability_set(conn, capability_set_id)?;
if set.tenant_id != tenant_id {
return Err(AppError::bad_request(
"capability set does not belong to the tenant",
));
}
Ok(set.id)
}
#[derive(utoipa::OpenApi)]
#[openapi(
paths(
crate::routes::profile::list_api_tokens,
crate::routes::profile::create_api_token,
crate::routes::profile::regenerate_api_token,
crate::routes::profile::update_api_token,
crate::routes::profile::delete_api_token,
crate::routes::profile::list_passkeys,
crate::routes::profile::delete_passkey
@@ -323,7 +279,6 @@ fn parse_timestamp(value: &str) -> AppResult<NaiveDateTime> {
crate::routes::profile::ApiTokenResponse,
crate::routes::profile::ApiTokenCreatedResponse,
crate::routes::profile::CreateApiTokenRequest,
crate::routes::profile::UpdateApiTokenCapabilitiesRequest,
crate::routes::profile::RevokePasskeyQuery,
crate::auth::passkeys::PasskeySummary
))
+1 -1
View File
@@ -438,7 +438,7 @@ fn authenticate(state: &AppState, headers: &HeaderMap) -> Result<Option<WebDavCo
&mut conn,
None,
secret,
ApiCapability::WebdavRead,
Some(ApiCapability::WebdavRead),
)? {
Some(token) => token,
None => {