caps and delete

This commit is contained in:
2025-11-05 12:32:10 +01:00
parent 4ec19dbd70
commit 480bc20ae7
30 changed files with 2702 additions and 262 deletions
+52 -12
View File
@@ -19,6 +19,8 @@ use uuid::Uuid;
use crate::{
auth::{
api_tokens::{find_active_token_by_secret, touch_api_token},
capability_sets::load_capability_set,
jwt::{AccessTokenContext, PrincipalKind},
passkeys::{
AuthenticationChallengeResponse, PasskeyLoginFinishPayload, PasskeyLoginStartPayload,
PasskeyRegistrationFinishPayload, PasskeySummary, RegistrationChallengeResponse,
@@ -27,8 +29,8 @@ use crate::{
},
error::{AppError, AppResult},
models::{
ApiTokenCapability, MagicToken, MagicTokenKind, NewUser, NewUserSession, TenantStatus,
User, UserSession,
ApiCapability, MagicToken, MagicTokenKind, NewUser, NewUserSession, TenantStatus, User,
UserMembership, UserSession,
},
schema::{
magic_tokens::dsl as magic_dsl, tenants::dsl as tenant_dsl,
@@ -160,7 +162,7 @@ pub enum LoginResponseVariants {
crate::auth::passkeys::PasskeyRegistrationFinishPayload,
crate::auth::passkeys::PasskeyLoginStartPayload,
crate::auth::passkeys::PasskeyLoginFinishPayload,
crate::models::ApiTokenCapability,
crate::models::ApiCapability,
))
)]
pub struct AuthApiDoc;
@@ -230,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, ApiTokenCapability::Api)?
let token = find_active_token_by_secret(&mut conn, None, secret, ApiCapability::ProfileRead)?
.ok_or_else(AppError::unauthorized)?;
let user: User = dsl::users.find(token.user_id).first(&mut conn)?;
@@ -239,21 +241,36 @@ pub async fn api_token_exchange(
let membership = memberships_dsl::user_memberships
.filter(memberships_dsl::user_id.eq(user.id))
.filter(memberships_dsl::tenant_id.eq(token.tenant_id))
.select(memberships_dsl::tenant_id)
.first::<Uuid>(&mut conn)
.first::<UserMembership>(&mut conn)
.optional()?;
clear_user_guc(&mut conn)?;
if membership.is_none() {
return Err(AppError::unauthorized());
}
let membership = membership.ok_or_else(AppError::unauthorized)?;
let membership_capability_set = membership.capability_set_id.ok_or_else(|| {
AppError::new(
StatusCode::FORBIDDEN,
"membership has no capability set assigned",
)
})?;
let token_capability_set = load_capability_set(&mut conn, token.capability_set_id)?;
let _membership_set = load_capability_set(&mut conn, membership_capability_set)?;
apply_tenant_guc(&mut conn, token.tenant_id)?;
touch_api_token(&mut conn, token.id)?;
let access_token = state
.jwt
.generate_token(user.id, token.tenant_id, &user.username)
.generate_token(AccessTokenContext {
user_id: user.id,
tenant_id: token.tenant_id,
username: user.username.clone(),
principal_kind: PrincipalKind::ApiToken,
principal_id: token.id,
capability_set_id: token_capability_set.id,
cap_version: token_capability_set.cap_version,
})
.map_err(AppError::from)?;
let tenant_name: String = tenant_dsl::tenants
@@ -856,10 +873,33 @@ fn issue_session(
clear_user_guc(conn)?;
clear_user_session_hash(conn)?;
let membership: UserMembership = memberships_dsl::user_memberships
.filter(memberships_dsl::user_id.eq(user.id))
.filter(memberships_dsl::tenant_id.eq(tenant_id))
.first(conn)?;
let capability_set_id = membership.capability_set_id.ok_or_else(|| {
AppError::new(
StatusCode::FORBIDDEN,
"membership has no capability set assigned",
)
})?;
let capability_set = load_capability_set(conn, capability_set_id)?;
let now = Utc::now();
let session_id = Uuid::new_v4();
let access_token = state
.jwt
.generate_token(user.id, tenant_id, &user.username)
.generate_token(AccessTokenContext {
user_id: user.id,
tenant_id,
username: user.username.clone(),
principal_kind: PrincipalKind::UserSession,
principal_id: session_id,
capability_set_id,
cap_version: capability_set.cap_version,
})
.map_err(AppError::from)?;
let tenant_name: String = tenant_dsl::tenants
@@ -873,7 +913,7 @@ fn issue_session(
let refresh_expires_at = now + ChronoDuration::days(state.config.refresh_token_expiry_days);
let new_session = NewUserSession {
id: Uuid::new_v4(),
id: session_id,
user_id: user.id,
token_hash: session_hash,
issued_at: now.naive_utc(),
+349
View File
@@ -0,0 +1,349 @@
use axum::{extract::Path, http::StatusCode, Json};
use chrono::Utc;
use diesel::prelude::*;
use serde::{Deserialize, Serialize};
use uuid::Uuid;
use crate::{
auth::capability_sets::{
compute_slug, create_capability_set as create_capability_set_record, is_system_slug,
load_capabilities_for_set, normalize_capabilities, refresh_capability_set,
},
auth::TenantScopedConn,
error::{AppError, AppResult},
models::{ApiCapability, CapabilitySet},
schema::{
api_tokens,
capability_sets::{self, dsl as cs_dsl},
user_memberships,
},
};
#[derive(Serialize, utoipa::ToSchema)]
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>,
}
#[derive(Deserialize, utoipa::ToSchema)]
pub struct CreateCapabilitySetRequest {
#[serde(default)]
#[serde(rename = "slug")]
pub slug: Option<String>,
pub capabilities: Vec<ApiCapability>,
}
#[derive(Deserialize, utoipa::ToSchema)]
pub struct UpdateCapabilitySetRequest {
#[serde(default)]
#[serde(rename = "slug")]
pub slug: Option<String>,
#[serde(default)]
pub capabilities: Option<Vec<ApiCapability>>,
}
fn normalize_slug(value: &str) -> AppResult<String> {
let trimmed = value.trim();
if trimmed.is_empty() {
return Err(AppError::bad_request("slug must not be empty"));
}
if trimmed.len() > 64 {
return Err(AppError::bad_request("slug must not exceed 64 characters"));
}
let mut normalized = String::with_capacity(trimmed.len());
for ch in trimmed.chars() {
if ch.is_ascii_alphanumeric() || ch == '-' || ch == '_' {
normalized.push(ch.to_ascii_lowercase());
} else if ch.is_whitespace() {
normalized.push('-');
} else {
return Err(AppError::bad_request(
"slug may only contain alphanumeric characters, hyphen, or underscore",
));
}
}
if normalized.is_empty() {
return Err(AppError::bad_request("slug must not be empty"));
}
Ok(normalized)
}
fn to_response(set: CapabilitySet, capabilities: Vec<ApiCapability>) -> CapabilitySetResponse {
CapabilitySetResponse {
id: set.id,
slug: set.slug,
label: None,
is_system: set.is_system,
cap_version: set.cap_version,
capabilities,
}
}
#[utoipa::path(
get,
path = "/api/capability-sets",
responses((status = 200, body = [CapabilitySetResponse])),
tag = "Capability Sets"
)]
pub async fn list_capability_sets(
TenantScopedConn {
mut conn,
tenant_id,
..
}: TenantScopedConn,
) -> AppResult<Json<Vec<CapabilitySetResponse>>> {
let sets = cs_dsl::capability_sets
.filter(cs_dsl::tenant_id.eq(tenant_id))
.order(cs_dsl::slug.asc())
.load::<CapabilitySet>(&mut conn)?;
let mut responses = Vec::with_capacity(sets.len());
for set in sets {
let capabilities = load_capabilities_for_set(&mut conn, set.id)?;
responses.push(to_response(set, capabilities));
}
Ok(Json(responses))
}
#[utoipa::path(
get,
path = "/api/capability-sets/{id}",
params(("id" = Uuid, Path, description = "Capability set ID")),
responses((status = 200, body = CapabilitySetResponse), (status = 404, description = "Not found")),
tag = "Capability Sets"
)]
pub async fn get_capability_set(
TenantScopedConn {
mut conn,
tenant_id,
..
}: TenantScopedConn,
Path(id): Path<Uuid>,
) -> AppResult<Json<CapabilitySetResponse>> {
let set = cs_dsl::capability_sets
.filter(cs_dsl::tenant_id.eq(tenant_id))
.find(id)
.first::<CapabilitySet>(&mut conn)
.map_err(|err| match err {
diesel::result::Error::NotFound => AppError::not_found(),
other => AppError::from(other),
})?;
let capabilities = load_capabilities_for_set(&mut conn, set.id)?;
Ok(Json(to_response(set, capabilities)))
}
#[utoipa::path(
post,
path = "/api/capability-sets",
request_body = CreateCapabilitySetRequest,
responses((status = 201, body = CapabilitySetResponse)),
tag = "Capability Sets"
)]
pub async fn create_capability_set(
TenantScopedConn {
mut conn,
tenant_id,
..
}: TenantScopedConn,
Json(payload): Json<CreateCapabilitySetRequest>,
) -> AppResult<(StatusCode, Json<CapabilitySetResponse>)> {
let original_caps = payload.capabilities;
let normalized_caps = normalize_capabilities(original_caps.clone())?;
if normalized_caps.is_empty() {
return Err(AppError::bad_request("at least one capability is required"));
}
let slug = if let Some(raw) = payload.slug {
let normalized = normalize_slug(&raw)?;
if is_system_slug(&normalized) {
return Err(AppError::conflict("slug is reserved"));
}
normalized
} else {
let generated = compute_slug(&normalized_caps);
if is_system_slug(&generated) {
return Err(AppError::conflict(
"capabilities match a reserved system capability set",
));
}
generated
};
let set = create_capability_set_record(&mut conn, tenant_id, &slug, original_caps)?;
let response = to_response(set, normalized_caps);
Ok((StatusCode::CREATED, Json(response)))
}
#[utoipa::path(
patch,
path = "/api/capability-sets/{id}",
params(("id" = Uuid, Path, description = "Capability set ID")),
request_body = UpdateCapabilitySetRequest,
responses((status = 200, body = CapabilitySetResponse)),
tag = "Capability Sets"
)]
pub async fn update_capability_set(
TenantScopedConn {
mut conn,
tenant_id,
..
}: TenantScopedConn,
Path(id): Path<Uuid>,
Json(payload): Json<UpdateCapabilitySetRequest>,
) -> AppResult<Json<CapabilitySetResponse>> {
let set = cs_dsl::capability_sets
.filter(cs_dsl::tenant_id.eq(tenant_id))
.find(id)
.first::<CapabilitySet>(&mut conn)
.map_err(|err| match err {
diesel::result::Error::NotFound => AppError::not_found(),
other => AppError::from(other),
})?;
if set.is_system {
if payload.slug.is_some() || payload.capabilities.is_some() {
return Err(AppError::conflict(
"system capability sets cannot be modified",
));
}
let capabilities = load_capabilities_for_set(&mut conn, set.id)?;
return Ok(Json(to_response(set, capabilities)));
}
let set = conn.transaction::<CapabilitySet, AppError, _>(|conn| {
let mut working = set.clone();
if let Some(slug) = &payload.slug {
let normalized = normalize_slug(slug)?;
if is_system_slug(&normalized) {
return Err(AppError::conflict("slug is reserved"));
}
if cs_dsl::capability_sets
.filter(cs_dsl::tenant_id.eq(tenant_id))
.filter(cs_dsl::slug.eq(&normalized))
.filter(cs_dsl::id.ne(working.id))
.first::<CapabilitySet>(conn)
.optional()
.map_err(AppError::from)?
.is_some()
{
return Err(AppError::conflict("slug already exists"));
}
diesel::update(cs_dsl::capability_sets.find(working.id))
.set((
cs_dsl::slug.eq(&normalized),
cs_dsl::updated_at.eq(Utc::now().naive_utc()),
))
.execute(conn)
.map_err(AppError::from)?;
working.slug = normalized;
}
if let Some(capabilities) = &payload.capabilities {
let normalized = normalize_capabilities(capabilities.clone())?;
if normalized.is_empty() {
return Err(AppError::bad_request("at least one capability is required"));
}
let updated = refresh_capability_set(conn, &working, &normalized)?;
working = updated;
}
capability_sets::table
.find(working.id)
.first::<CapabilitySet>(conn)
.map_err(AppError::from)
})?;
let capabilities = load_capabilities_for_set(&mut conn, set.id)?;
Ok(Json(to_response(set, capabilities)))
}
#[utoipa::path(
delete,
path = "/api/capability-sets/{id}",
params(("id" = Uuid, Path, description = "Capability set ID")),
responses((status = 204), (status = 409, description = "Set in use")),
tag = "Capability Sets"
)]
pub async fn delete_capability_set(
TenantScopedConn {
mut conn,
tenant_id,
..
}: TenantScopedConn,
Path(id): Path<Uuid>,
) -> AppResult<StatusCode> {
let set = cs_dsl::capability_sets
.filter(cs_dsl::tenant_id.eq(tenant_id))
.find(id)
.first::<CapabilitySet>(&mut conn)
.map_err(|err| match err {
diesel::result::Error::NotFound => AppError::not_found(),
other => AppError::from(other),
})?;
if set.is_system {
return Err(AppError::conflict(
"system capability sets cannot be deleted",
));
}
let in_use_memberships: i64 = user_memberships::table
.filter(user_memberships::capability_set_id.eq(Some(set.id)))
.count()
.get_result(&mut conn)?;
if in_use_memberships > 0 {
return Err(AppError::conflict(
"capability set is assigned to user memberships",
));
}
let in_use_tokens: i64 = api_tokens::table
.filter(api_tokens::capability_set_id.eq(set.id))
.count()
.get_result(&mut conn)?;
if in_use_tokens > 0 {
return Err(AppError::conflict(
"capability set is assigned to API tokens",
));
}
diesel::delete(cs_dsl::capability_sets.find(set.id)).execute(&mut conn)?;
Ok(StatusCode::NO_CONTENT)
}
#[derive(utoipa::OpenApi)]
#[openapi(
paths(
crate::routes::capability_sets::list_capability_sets,
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::routes::capability_sets::CapabilitySetResponse,
crate::routes::capability_sets::CreateCapabilitySetRequest,
crate::routes::capability_sets::UpdateCapabilitySetRequest,
))
)]
pub struct CapabilitySetsApiDoc;
+51 -4
View File
@@ -35,7 +35,9 @@ use crate::documents::{
tags::{assign_tags as assign_tags_to_document, load_tags_for_documents},
};
use crate::error::{AppError, AppResult};
use crate::jobs::{enqueue_job, JOB_ANALYZE_DOCUMENT, JOB_INDEX_DOCUMENT_TEXT};
use crate::jobs::{
enqueue_job, JobQueueError, JOB_ANALYZE_DOCUMENT, JOB_INDEX_DOCUMENT_TEXT, JOB_PURGE_DOCUMENT,
};
use crate::models::{
Document, DocumentAsset, DocumentAssetObject, DocumentVersion, NewDocument, NewDocumentTag,
NewDocumentVersion, Tag,
@@ -1288,13 +1290,13 @@ pub async fn download_with_token(
}
#[utoipa::path(
delete,
path = "/api/documents/{id}",
post,
path = "/api/documents/{id}/trash",
params(("id" = Uuid, Path, description = "Document ID")),
responses((status = 204, description = "Document deleted")),
tag = "Documents"
)]
pub async fn delete_document(
pub async fn trash_document(
Path(document_id): Path<Uuid>,
TenantScopedConn {
mut conn,
@@ -1316,6 +1318,50 @@ pub async fn delete_document(
Ok(StatusCode::NO_CONTENT)
}
#[utoipa::path(
delete,
path = "/api/documents/{id}",
params(("id" = Uuid, Path, description = "Document ID")),
responses((status = 202, description = "Document purge scheduled")),
tag = "Documents"
)]
pub async fn delete_document(
Path(document_id): Path<Uuid>,
TenantScopedConn {
mut conn,
tenant_id,
..
}: TenantScopedConn,
) -> AppResult<impl IntoResponse> {
conn.transaction::<(), AppError, _>(|conn| {
let document = documents::table
.find(document_id)
.filter(documents::tenant_id.eq(tenant_id))
.for_update()
.first::<Document>(conn)
.optional()?
.ok_or_else(AppError::not_found)?;
if document.deleted_at.is_none() {
return Err(AppError::conflict(
"document must be trashed before permanent deletion",
));
}
let payload = json!({ "document_id": document_id });
match enqueue_job(conn, tenant_id, JOB_PURGE_DOCUMENT, payload, None) {
Ok(_) => Ok(()),
Err(JobQueueError::Database(diesel::result::Error::DatabaseError(
DatabaseErrorKind::UniqueViolation,
_,
))) => Ok(()),
Err(JobQueueError::Database(err)) => Err(AppError::from(err)),
}
})?;
Ok(StatusCode::ACCEPTED)
}
#[utoipa::path(
patch,
path = "/api/documents/{id}",
@@ -2433,6 +2479,7 @@ pub(crate) fn hydrate_documents(
crate::routes::documents::upload_document,
crate::routes::documents::get_document,
crate::routes::documents::update_document,
crate::routes::documents::trash_document,
crate::routes::documents::delete_document,
crate::routes::documents::restore_document,
crate::routes::documents::download_with_token,
+240 -36
View File
@@ -13,9 +13,15 @@ use tower_http::{
};
use utoipa::OpenApi;
use crate::{auth::AuthenticatedUser, openapi::ApiDoc, state::AppState};
use crate::{
auth::{capability_guard::RequireCapabilitiesLayer, AuthenticatedUser},
models::ApiCapability,
openapi::ApiDoc,
state::AppState,
};
pub mod auth;
pub mod capability_sets;
pub mod correspondents;
pub mod documents;
pub mod folders;
@@ -75,93 +81,290 @@ pub fn create_router(state: AppState) -> Router<()> {
.route("/me", get(auth::me));
let documents_routes = Router::new()
.route("/check", get(documents::check_document))
.route(
"/check",
get(documents::check_document).layer(RequireCapabilitiesLayer::all([
ApiCapability::DocumentsRead,
])),
)
.route(
"/",
get(documents::list_documents).post(documents::upload_document),
get(documents::list_documents).layer(RequireCapabilitiesLayer::all([
ApiCapability::DocumentsRead,
])),
)
.route(
"/",
post(documents::upload_document).layer(RequireCapabilitiesLayer::all([
ApiCapability::DocumentsWrite,
ApiCapability::DocumentsUpload,
])),
)
.route(
"/bulk/move",
post(documents::bulk_move_documents).layer(RequireCapabilitiesLayer::all([
ApiCapability::DocumentsEdit,
])),
)
.route(
"/bulk/tags",
post(documents::bulk_update_tags).layer(RequireCapabilitiesLayer::all([
ApiCapability::DocumentsEdit,
])),
)
.route("/bulk/move", post(documents::bulk_move_documents))
.route("/bulk/tags", post(documents::bulk_update_tags))
.route(
"/bulk/correspondents",
post(documents::bulk_assign_correspondents),
post(documents::bulk_assign_correspondents).layer(RequireCapabilitiesLayer::all([
ApiCapability::DocumentsEdit,
])),
)
.route(
"/bulk/reanalyze",
post(documents::reanalyze_selected_documents),
post(documents::reanalyze_selected_documents).layer(RequireCapabilitiesLayer::all([
ApiCapability::DocumentsWrite,
])),
)
.route(
"/:id",
get(documents::get_document)
.delete(documents::delete_document)
.patch(documents::update_document),
get(documents::get_document).layer(RequireCapabilitiesLayer::all([
ApiCapability::DocumentsRead,
])),
)
.route(
"/:id/trash",
post(documents::trash_document).layer(RequireCapabilitiesLayer::all([
ApiCapability::DocumentsWrite,
])),
)
.route(
"/:id",
delete(documents::delete_document).layer(RequireCapabilitiesLayer::all([
ApiCapability::DocumentsWrite,
])),
)
.route(
"/:id",
patch(documents::update_document).layer(RequireCapabilitiesLayer::all([
ApiCapability::DocumentsEdit,
])),
)
.route(
"/:id/assets",
get(documents::list_document_assets).post(documents::request_document_assets),
get(documents::list_document_assets).layer(RequireCapabilitiesLayer::all([
ApiCapability::DocumentsRead,
])),
)
.route(
"/:id/assets",
post(documents::request_document_assets).layer(RequireCapabilitiesLayer::all([
ApiCapability::DocumentsWrite,
])),
)
.route(
"/:id/folder",
patch(documents::move_document).layer(RequireCapabilitiesLayer::all([
ApiCapability::DocumentsEdit,
])),
)
.route(
"/:id/versions",
get(documents::list_document_versions).layer(RequireCapabilitiesLayer::all([
ApiCapability::DocumentsRead,
])),
)
.route("/:id/folder", patch(documents::move_document))
.route("/:id/versions", get(documents::list_document_versions))
.route(
"/:id/versions/:version_id",
get(documents::get_document_version),
get(documents::get_document_version).layer(RequireCapabilitiesLayer::all([
ApiCapability::DocumentsRead,
])),
)
.route(
"/:id/restore",
post(documents::restore_document).layer(RequireCapabilitiesLayer::all([
ApiCapability::DocumentsEdit,
])),
)
.route(
"/:id/tags",
post(documents::assign_tags).layer(RequireCapabilitiesLayer::all([
ApiCapability::DocumentsEdit,
])),
)
.route(
"/:id/tags/:tag_id",
delete(documents::remove_tag).layer(RequireCapabilitiesLayer::all([
ApiCapability::DocumentsEdit,
])),
)
.route("/:id/restore", post(documents::restore_document))
.route("/:id/tags", post(documents::assign_tags))
.route("/:id/tags/:tag_id", delete(documents::remove_tag))
.route(
"/:id/correspondents",
post(documents::assign_correspondents),
post(documents::assign_correspondents).layer(RequireCapabilitiesLayer::all([
ApiCapability::DocumentsEdit,
])),
)
.route(
"/:id/correspondents/:correspondent_id",
delete(documents::remove_correspondent),
delete(documents::remove_correspondent).layer(RequireCapabilitiesLayer::all([
ApiCapability::DocumentsEdit,
])),
);
let download_routes =
Router::new().route("/download/:token", get(documents::download_with_token));
let folders_routes = Router::new()
.route("/", post(folders::create_folder))
.route("/path", post(folders::ensure_folder_path))
.route("/:id", get(folders::get_folder))
.route("/:id", delete(folders::delete_folder))
.route("/:id", patch(folders::update_folder))
.route("/:id/contents", get(folders::list_folder_contents));
.route(
"/",
post(folders::create_folder)
.layer(RequireCapabilitiesLayer::all([ApiCapability::FoldersWrite])),
)
.route(
"/path",
post(folders::ensure_folder_path)
.layer(RequireCapabilitiesLayer::all([ApiCapability::FoldersWrite])),
)
.route(
"/:id",
get(folders::get_folder)
.layer(RequireCapabilitiesLayer::all([ApiCapability::FoldersRead])),
)
.route(
"/:id",
delete(folders::delete_folder)
.layer(RequireCapabilitiesLayer::all([ApiCapability::FoldersWrite])),
)
.route(
"/:id",
patch(folders::update_folder)
.layer(RequireCapabilitiesLayer::all([ApiCapability::FoldersEdit])),
)
.route(
"/:id/contents",
get(folders::list_folder_contents)
.layer(RequireCapabilitiesLayer::all([ApiCapability::FoldersRead])),
);
let tags_routes = Router::new()
.route("/", get(tags::list_tags).post(tags::create_tag))
.route("/:id", patch(tags::update_tag).delete(tags::delete_tag));
.route(
"/",
get(tags::list_tags).layer(RequireCapabilitiesLayer::all([ApiCapability::TagsRead])),
)
.route(
"/",
post(tags::create_tag).layer(RequireCapabilitiesLayer::all([ApiCapability::TagsWrite])),
)
.route(
"/:id",
patch(tags::update_tag).layer(RequireCapabilitiesLayer::all([ApiCapability::TagsEdit])),
)
.route(
"/:id",
delete(tags::delete_tag)
.layer(RequireCapabilitiesLayer::all([ApiCapability::TagsWrite])),
);
let correspondents_routes = Router::new()
.route(
"/",
get(correspondents::list_correspondents).post(correspondents::create_correspondent),
get(correspondents::list_correspondents).layer(RequireCapabilitiesLayer::all([
ApiCapability::CorrespondentsRead,
])),
)
.route(
"/",
post(correspondents::create_correspondent).layer(RequireCapabilitiesLayer::all([
ApiCapability::CorrespondentsWrite,
])),
)
.route(
"/:id",
patch(correspondents::update_correspondent)
.delete(correspondents::delete_correspondent),
patch(correspondents::update_correspondent).layer(RequireCapabilitiesLayer::all([
ApiCapability::CorrespondentsEdit,
])),
)
.route(
"/:id",
delete(correspondents::delete_correspondent).layer(RequireCapabilitiesLayer::all([
ApiCapability::CorrespondentsWrite,
])),
);
let profile_routes = Router::new()
.route(
"/api-tokens",
get(profile::list_api_tokens).post(profile::create_api_token),
get(profile::list_api_tokens)
.layer(RequireCapabilitiesLayer::all([ApiCapability::ProfileRead])),
)
.route(
"/api-tokens",
post(profile::create_api_token)
.layer(RequireCapabilitiesLayer::all([ApiCapability::ProfileWrite])),
)
.route(
"/api-tokens/:id/regenerate",
post(profile::regenerate_api_token),
post(profile::regenerate_api_token)
.layer(RequireCapabilitiesLayer::all([ApiCapability::ProfileWrite])),
)
.route(
"/api-tokens/:id",
patch(profile::update_api_token).delete(profile::delete_api_token),
patch(profile::update_api_token)
.layer(RequireCapabilitiesLayer::all([ApiCapability::ProfileWrite])),
)
.route("/passkeys", get(profile::list_passkeys))
.route("/passkeys/:id", delete(profile::delete_passkey));
.route(
"/api-tokens/:id",
delete(profile::delete_api_token)
.layer(RequireCapabilitiesLayer::all([ApiCapability::ProfileWrite])),
)
.route(
"/passkeys",
get(profile::list_passkeys)
.layer(RequireCapabilitiesLayer::all([ApiCapability::ProfileRead])),
)
.route(
"/passkeys/:id",
delete(profile::delete_passkey)
.layer(RequireCapabilitiesLayer::all([ApiCapability::ProfileWrite])),
);
let capability_sets_routes = Router::new()
.route(
"/",
get(capability_sets::list_capability_sets).layer(RequireCapabilitiesLayer::all([
ApiCapability::CapabilitySetsRead,
])),
)
.route(
"/",
post(capability_sets::create_capability_set).layer(RequireCapabilitiesLayer::all([
ApiCapability::CapabilitySetsWrite,
])),
)
.route(
"/:id",
get(capability_sets::get_capability_set).layer(RequireCapabilitiesLayer::all([
ApiCapability::CapabilitySetsRead,
])),
)
.route(
"/:id",
patch(capability_sets::update_capability_set).layer(RequireCapabilitiesLayer::all([
ApiCapability::CapabilitySetsWrite,
])),
)
.route(
"/:id",
delete(capability_sets::delete_capability_set).layer(RequireCapabilitiesLayer::all([
ApiCapability::CapabilitySetsWrite,
])),
);
let protected_state = state.clone();
let assets_routes = Router::new().route("/:asset_id", get(documents::get_document_asset));
let assets_routes = Router::new().route(
"/:asset_id",
get(documents::get_document_asset).layer(RequireCapabilitiesLayer::all([
ApiCapability::DocumentsRead,
])),
);
let protected_routes = Router::new()
.nest("/api/documents", documents_routes)
@@ -169,6 +372,7 @@ pub fn create_router(state: AppState) -> Router<()> {
.nest("/api/tags", tags_routes)
.nest("/api/correspondents", correspondents_routes)
.nest("/api/profile", profile_routes)
.nest("/api/capability-sets", capability_sets_routes)
.nest("/api/assets", assets_routes)
.layer(middleware::from_extractor_with_state::<AuthenticatedUser, _>(protected_state));
+37 -15
View File
@@ -14,12 +14,13 @@ use crate::auth::{
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,
passkeys::PasskeySummary,
TenantScopedConn,
};
use crate::error::{AppError, AppResult};
use crate::models::{ApiToken, ApiTokenCapability};
use crate::state::AppState;
use crate::models::{ApiCapability, ApiToken};
use crate::state::{AppState, PgPooledConnection};
use crate::utils::{db::no_content, time::to_iso};
#[derive(Debug, Serialize, ToSchema)]
@@ -28,7 +29,7 @@ pub struct ApiTokenResponse {
pub tenant_id: Uuid,
#[schema(nullable)]
pub label: Option<String>,
pub capabilities: Vec<ApiTokenCapability>,
pub capabilities: Vec<ApiCapability>,
pub created_at: String,
#[schema(nullable)]
pub last_used_at: Option<String>,
@@ -52,12 +53,12 @@ pub struct CreateApiTokenRequest {
pub expires_at: Option<String>,
#[serde(default)]
#[schema(nullable)]
pub capabilities: Option<Vec<ApiTokenCapability>>,
pub capabilities: Option<Vec<ApiCapability>>,
}
#[derive(Debug, Deserialize, ToSchema)]
pub struct UpdateApiTokenCapabilitiesRequest {
pub capabilities: Vec<ApiTokenCapability>,
pub capabilities: Vec<ApiCapability>,
}
#[derive(Debug, Deserialize, ToSchema)]
@@ -103,7 +104,10 @@ pub async fn list_api_tokens(
}: TenantScopedConn,
) -> AppResult<Json<Vec<ApiTokenResponse>>> {
let tokens = load_tokens(&mut conn, user_id, Some(tenant_id))?;
let responses = tokens.into_iter().map(api_token_to_response).collect();
let mut responses = Vec::with_capacity(tokens.len());
for token in tokens {
responses.push(api_token_to_response(&mut conn, token)?);
}
Ok(Json(responses))
}
@@ -130,7 +134,11 @@ pub async fn create_api_token(
let capabilities = payload
.capabilities
.unwrap_or_else(|| vec![ApiTokenCapability::Webdav]);
.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 issued = issue_token(
&mut conn,
@@ -141,9 +149,11 @@ pub async fn create_api_token(
capabilities,
)?;
let token_info = api_token_to_response(&mut conn, issued.record)?;
let response = ApiTokenCreatedResponse {
token: issued.token,
token_info: api_token_to_response(issued.record),
token_info,
};
Ok((StatusCode::CREATED, Json(response)))
@@ -166,9 +176,10 @@ 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 response = ApiTokenCreatedResponse {
token: issued.token,
token_info: api_token_to_response(issued.record),
token_info,
};
Ok(Json(response))
@@ -192,6 +203,10 @@ pub async fn update_api_token(
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,
@@ -200,7 +215,9 @@ pub async fn update_api_token(
payload.capabilities,
)?;
Ok(Json(api_token_to_response(updated)))
let response = api_token_to_response(&mut conn, updated)?;
Ok(Json(response))
}
#[utoipa::path(
@@ -254,7 +271,10 @@ pub async fn delete_passkey(
no_content()
}
fn api_token_to_response(token: ApiToken) -> ApiTokenResponse {
fn api_token_to_response(
conn: &mut PgPooledConnection,
token: ApiToken,
) -> AppResult<ApiTokenResponse> {
let ApiToken {
id,
tenant_id,
@@ -263,11 +283,13 @@ fn api_token_to_response(token: ApiToken) -> ApiTokenResponse {
last_used_at,
expires_at,
revoked_at,
capabilities,
capability_set_id,
..
} = token;
ApiTokenResponse {
let capabilities = load_capabilities_for_set(conn, capability_set_id)?;
Ok(ApiTokenResponse {
id,
tenant_id,
label,
@@ -276,7 +298,7 @@ fn api_token_to_response(token: ApiToken) -> ApiTokenResponse {
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> {
@@ -297,7 +319,7 @@ fn parse_timestamp(value: &str) -> AppResult<NaiveDateTime> {
crate::routes::profile::delete_passkey
),
components(schemas(
crate::models::ApiTokenCapability,
crate::models::ApiCapability,
crate::routes::profile::ApiTokenResponse,
crate::routes::profile::ApiTokenCreatedResponse,
crate::routes::profile::CreateApiTokenRequest,
+2 -2
View File
@@ -18,7 +18,7 @@ use uuid::Uuid;
use crate::auth::api_tokens::{find_active_token_by_secret, touch_api_token};
use crate::error::{AppError, AppResult};
use crate::models::{ApiTokenCapability, Document, DocumentVersion, Folder, User};
use crate::models::{ApiCapability, Document, DocumentVersion, Folder, User};
use crate::schema::{
document_versions::dsl as document_versions_dsl, documents::dsl as documents_dsl,
folders::dsl as folders_dsl, user_memberships::dsl as memberships_dsl, users::dsl as users_dsl,
@@ -438,7 +438,7 @@ fn authenticate(state: &AppState, headers: &HeaderMap) -> Result<Option<WebDavCo
&mut conn,
None,
secret,
ApiTokenCapability::Webdav,
ApiCapability::WebdavRead,
)? {
Some(token) => token,
None => {