refactor
This commit is contained in:
@@ -26,7 +26,7 @@ use papercrate::{
|
||||
},
|
||||
storage::{ObjectStorage, S3Storage, TenantStorage},
|
||||
tenants::TenantService,
|
||||
utils::tracing::init_tracing,
|
||||
utils::{text::normalize_identifier, tracing::init_tracing},
|
||||
};
|
||||
|
||||
#[derive(Parser)]
|
||||
@@ -162,20 +162,26 @@ async fn main() -> Result<()> {
|
||||
}
|
||||
|
||||
fn create_user(pool: &PgPool, username: &str) -> Result<()> {
|
||||
if username.trim().is_empty() {
|
||||
bail!("username must not be empty");
|
||||
}
|
||||
let username = normalize_identifier(
|
||||
username,
|
||||
100,
|
||||
"username must not be empty",
|
||||
"username must not exceed 100 characters",
|
||||
Some("username may only contain printable characters"),
|
||||
|ch| !ch.is_control(),
|
||||
)
|
||||
.map_err(|err| anyhow!("{:?}", err))?;
|
||||
|
||||
let mut conn = pool.get().context("failed to get database connection")?;
|
||||
let exists: bool =
|
||||
select(exists(users::table.filter(users::username.eq(username)))).get_result(&mut conn)?;
|
||||
select(exists(users::table.filter(users::username.eq(&username)))).get_result(&mut conn)?;
|
||||
if exists {
|
||||
bail!("user '{}' already exists", username);
|
||||
}
|
||||
|
||||
let new_user = NewUser {
|
||||
id: Uuid::new_v4(),
|
||||
username: username.to_string(),
|
||||
username: username.clone(),
|
||||
};
|
||||
|
||||
diesel::insert_into(users::table)
|
||||
|
||||
@@ -3,6 +3,7 @@ pub mod correspondents;
|
||||
pub mod folders;
|
||||
pub mod metadata;
|
||||
pub mod ordering;
|
||||
pub mod relations;
|
||||
pub mod search;
|
||||
pub mod tags;
|
||||
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
use std::collections::HashMap;
|
||||
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::documents::correspondents::{
|
||||
load_correspondents_for_documents, DocumentCorrespondentResponse,
|
||||
};
|
||||
use crate::documents::tags::load_tags_for_documents;
|
||||
use crate::error::AppResult;
|
||||
use crate::models::Tag;
|
||||
use crate::state::PgPooledConnection;
|
||||
|
||||
/// Loads tags and correspondents for the provided documents in a single pass.
|
||||
pub fn load_tags_and_correspondents(
|
||||
conn: &mut PgPooledConnection,
|
||||
document_ids: &[Uuid],
|
||||
) -> AppResult<HashMap<Uuid, (Vec<Tag>, Vec<DocumentCorrespondentResponse>)>> {
|
||||
if document_ids.is_empty() {
|
||||
return Ok(HashMap::new());
|
||||
}
|
||||
|
||||
let tags_map = load_tags_for_documents(conn, document_ids)?;
|
||||
let mut correspondents_map = load_correspondents_for_documents(conn, document_ids)?;
|
||||
|
||||
let mut result = HashMap::with_capacity(document_ids.len());
|
||||
for id in document_ids {
|
||||
let tags = tags_map.get(id).cloned().unwrap_or_default();
|
||||
let correspondents = correspondents_map.remove(id).unwrap_or_default();
|
||||
result.insert(*id, (tags, correspondents));
|
||||
}
|
||||
|
||||
Ok(result)
|
||||
}
|
||||
@@ -10,6 +10,7 @@ pub mod openapi;
|
||||
pub mod routes;
|
||||
pub mod s3;
|
||||
pub mod schema;
|
||||
pub mod services;
|
||||
pub mod state;
|
||||
pub mod storage;
|
||||
pub mod tenants;
|
||||
|
||||
+27
-19
@@ -74,35 +74,43 @@ pub mod schemas {
|
||||
};
|
||||
pub use crate::documents::correspondents::DocumentCorrespondentResponse;
|
||||
pub use crate::models::ApiCapability;
|
||||
pub use crate::routes::auth::{
|
||||
ApiTokenExchangeRequest, LoginRequest, LoginResponse, LoginResponseVariants,
|
||||
SignupFinishRequest, SignupStartRequest, SignupStartResponse, TenantListResponse,
|
||||
TenantSelectionRequest, TenantSelectionResponse, TenantSnippet,
|
||||
};
|
||||
pub use crate::routes::capability_sets::{
|
||||
CapabilitySetResponse, CreateCapabilitySetRequest, UpdateCapabilitySetRequest,
|
||||
};
|
||||
pub use crate::routes::correspondents::{
|
||||
CorrespondentSummary, CorrespondentUsage, CreateCorrespondentRequest,
|
||||
UpdateCorrespondentRequest,
|
||||
};
|
||||
pub use crate::routes::documents::{
|
||||
AssetObjectsQuery, AssetRequestQuery, AssignCorrespondentsRequest, AssignTagsRequest,
|
||||
BulkCorrespondentAction, BulkCorrespondentResponse, BulkCorrespondentsRequest,
|
||||
AssetObjectsQuery, AssetRequestQuery, DocumentCheckQuery, MoveDocumentRequest,
|
||||
RestoreDocumentRequest, UploadDocumentForm,
|
||||
};
|
||||
pub use crate::routes::folders::FolderContentsResponse;
|
||||
pub use crate::routes::tags::{CreateTagRequest, TagCatalogEntry, UpdateTagRequest};
|
||||
pub use crate::services::auth::{
|
||||
ApiTokenExchangeRequest, LoginRequest, LoginResponse, LoginResponseVariants,
|
||||
SignupFinishRequest, SignupStartRequest, SignupStartResponse, TenantListResponse,
|
||||
TenantSelectionRequest, TenantSelectionResponse, TenantSnippet,
|
||||
};
|
||||
pub use crate::services::capability_sets::{
|
||||
CapabilitySetResponse, CreateCapabilitySetRequest, UpdateCapabilitySetRequest,
|
||||
};
|
||||
pub use crate::services::correspondents::{
|
||||
AssignCorrespondentsRequest, BulkCorrespondentAction, BulkCorrespondentResponse,
|
||||
BulkCorrespondentsRequest, CorrespondentAssignmentInput,
|
||||
};
|
||||
pub use crate::services::documents::{
|
||||
BulkMoveRequest, BulkMoveResponse, BulkReanalyzeResponse, BulkReanalyzeSelectionRequest,
|
||||
BulkTagAction, BulkTagRequest, BulkTagResponse, CorrespondentAssignmentInput,
|
||||
DocumentCheckQuery, DocumentCheckResponse, DocumentDetailResponse, DocumentListQuery,
|
||||
DocumentMetadataUpdate, DocumentResponse, DocumentStatusFilter, MoveDocumentRequest,
|
||||
RestoreDocumentRequest, TagResponse, UpdateDocumentRequest, UploadDocumentForm,
|
||||
DocumentCheckResponse, DocumentDetailResponse, DocumentListQuery, DocumentMetadataUpdate,
|
||||
DocumentResponse, DocumentStatusFilter, TagResponse, UpdateDocumentRequest,
|
||||
};
|
||||
pub use crate::routes::folders::{
|
||||
CreateFolderRequest, EnsureFolderPathRequest, FolderContentsQuery, FolderContentsResponse,
|
||||
FolderInfo, FolderResponse, UpdateFolderRequest,
|
||||
pub use crate::services::folders::{
|
||||
CreateFolderRequest, EnsureFolderPathRequest, FolderContentsQuery, FolderInfo,
|
||||
UpdateFolderRequest,
|
||||
};
|
||||
pub use crate::routes::profile::{
|
||||
pub use crate::services::profile::{
|
||||
ApiTokenCreatedResponse, ApiTokenResponse, CreateApiTokenRequest, RevokePasskeyQuery,
|
||||
};
|
||||
pub use crate::routes::tags::{CreateTagRequest, TagCatalogEntry, UpdateTagRequest};
|
||||
pub use crate::services::tags::{
|
||||
AssignTagsRequest, BulkTagAction, BulkTagRequest, BulkTagResponse,
|
||||
};
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
+34
-764
@@ -1,26 +1,17 @@
|
||||
use axum::{
|
||||
extract::State,
|
||||
http::{header::SET_COOKIE, HeaderMap, HeaderValue, StatusCode},
|
||||
response::{IntoResponse, Response},
|
||||
http::{HeaderMap, StatusCode},
|
||||
response::Response,
|
||||
Json,
|
||||
};
|
||||
use axum_extra::{
|
||||
headers::{authorization::Bearer, Authorization, Cookie},
|
||||
typed_header::TypedHeader,
|
||||
};
|
||||
use chrono::{Duration as ChronoDuration, Utc};
|
||||
use diesel::{pg::PgConnection, prelude::*};
|
||||
use rand::{rngs::OsRng, RngCore};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sha2::{Digest, Sha256};
|
||||
use utoipa::{OpenApi, ToSchema};
|
||||
use uuid::Uuid;
|
||||
use utoipa::OpenApi;
|
||||
|
||||
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,
|
||||
@@ -28,105 +19,15 @@ use crate::{
|
||||
AuthenticatedUser,
|
||||
},
|
||||
error::{AppError, AppResult},
|
||||
http::responders::{ok_json, JsonResponse},
|
||||
models::{
|
||||
MagicToken, MagicTokenKind, NewUser, NewUserSession, TenantStatus, User, UserMembership,
|
||||
UserSession,
|
||||
},
|
||||
schema::{
|
||||
magic_tokens::dsl as magic_dsl, tenants::dsl as tenant_dsl,
|
||||
user_memberships::dsl as memberships_dsl, user_passkeys::dsl as passkey_dsl, user_sessions,
|
||||
users::dsl,
|
||||
http::responders::JsonResponse,
|
||||
services::auth::{
|
||||
ApiTokenExchangeRequest, AuthService, LoginRequest, LoginResponse, LoginResponseVariants,
|
||||
SignupFinishRequest, SignupStartRequest, SignupStartResponse, TenantListResponse,
|
||||
TenantSelectionRequest, TenantSelectionResponse, TenantSnippet, SESSION_COOKIE_NAME,
|
||||
},
|
||||
state::AppState,
|
||||
tenants::{
|
||||
apply_tenant_guc, apply_user_guc, apply_user_session_hash, clear_user_guc,
|
||||
clear_user_session_hash,
|
||||
},
|
||||
};
|
||||
|
||||
use crate::schema::user_sessions::dsl as session_dsl;
|
||||
use webauthn_rs::prelude::RegisterPublicKeyCredential;
|
||||
|
||||
const SESSION_COOKIE_NAME: &str = "refresh_token";
|
||||
|
||||
#[derive(Deserialize, ToSchema)]
|
||||
pub struct LoginRequest {
|
||||
#[serde(default)]
|
||||
pub username: String,
|
||||
#[serde(default)]
|
||||
#[schema(nullable)]
|
||||
pub password: Option<String>,
|
||||
#[serde(default)]
|
||||
#[schema(nullable)]
|
||||
pub magic_token: Option<String>,
|
||||
#[serde(default)]
|
||||
#[schema(nullable)]
|
||||
pub preferred_tenant_id: Option<Uuid>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, ToSchema)]
|
||||
pub struct ApiTokenExchangeRequest {
|
||||
pub api_token: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Serialize, ToSchema)]
|
||||
pub struct LoginResponse {
|
||||
pub access_token: String,
|
||||
pub token_type: String,
|
||||
pub expires_in: i64,
|
||||
pub tenant: TenantSnippet,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, ToSchema)]
|
||||
pub struct TenantSnippet {
|
||||
pub id: Uuid,
|
||||
pub name: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, ToSchema)]
|
||||
pub struct TenantSelectionResponse {
|
||||
pub access_token: String,
|
||||
pub tenants: Vec<TenantSnippet>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, ToSchema)]
|
||||
pub struct TenantListResponse {
|
||||
pub tenants: Vec<TenantSnippet>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, ToSchema)]
|
||||
pub struct TenantSelectionRequest {
|
||||
pub tenant_id: Uuid,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, ToSchema)]
|
||||
pub struct SignupStartRequest {
|
||||
pub username: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, ToSchema)]
|
||||
pub struct SignupStartResponse {
|
||||
pub signup_token: String,
|
||||
pub challenge: RegistrationChallengeResponse,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, ToSchema)]
|
||||
pub struct SignupFinishRequest {
|
||||
pub signup_token: String,
|
||||
#[schema(value_type = Object)]
|
||||
pub credential: RegisterPublicKeyCredential,
|
||||
#[schema(nullable)]
|
||||
pub nickname: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, ToSchema)]
|
||||
#[serde(untagged)]
|
||||
pub enum LoginResponseVariants {
|
||||
Token(LoginResponse),
|
||||
Selection(TenantSelectionResponse),
|
||||
}
|
||||
|
||||
#[derive(OpenApi)]
|
||||
#[openapi(
|
||||
paths(
|
||||
@@ -182,37 +83,7 @@ pub async fn login(
|
||||
State(state): State<AppState>,
|
||||
Json(payload): Json<LoginRequest>,
|
||||
) -> AppResult<Response> {
|
||||
let magic_token = payload
|
||||
.magic_token
|
||||
.as_ref()
|
||||
.map(|value| value.trim())
|
||||
.filter(|value| !value.is_empty());
|
||||
|
||||
if magic_token.is_none() {
|
||||
if payload.password.is_some() {
|
||||
return Err(AppError::bad_request(
|
||||
"password authentication is no longer supported",
|
||||
));
|
||||
}
|
||||
|
||||
return Err(AppError::bad_request(
|
||||
"magic_token is required for passwordless login",
|
||||
));
|
||||
}
|
||||
|
||||
let token_value = magic_token.unwrap();
|
||||
|
||||
let mut conn = state.db_unscoped()?;
|
||||
let username_hint = payload.username.trim();
|
||||
let preferred_tenant_id = payload.preferred_tenant_id;
|
||||
|
||||
magic_token_login(
|
||||
&state,
|
||||
&mut conn,
|
||||
token_value,
|
||||
(!username_hint.is_empty()).then_some(username_hint),
|
||||
preferred_tenant_id,
|
||||
)
|
||||
AuthService::new(&state).login(payload)
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
@@ -226,71 +97,7 @@ pub async fn api_token_exchange(
|
||||
State(state): State<AppState>,
|
||||
Json(payload): Json<ApiTokenExchangeRequest>,
|
||||
) -> AppResult<JsonResponse<LoginResponse>> {
|
||||
let secret = payload.api_token.trim();
|
||||
if secret.is_empty() {
|
||||
return Err(AppError::bad_request("api_token must not be empty"));
|
||||
}
|
||||
|
||||
let mut conn = state.db_unscoped()?;
|
||||
|
||||
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)?;
|
||||
|
||||
apply_user_guc(&mut conn, user.id)?;
|
||||
let membership = memberships_dsl::user_memberships
|
||||
.filter(memberships_dsl::user_id.eq(user.id))
|
||||
.filter(memberships_dsl::tenant_id.eq(token.tenant_id))
|
||||
.first::<UserMembership>(&mut conn)
|
||||
.optional()?;
|
||||
clear_user_guc(&mut conn)?;
|
||||
|
||||
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(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
|
||||
.find(token.tenant_id)
|
||||
.select(tenant_dsl::name)
|
||||
.first(&mut conn)
|
||||
.map_err(AppError::from)?;
|
||||
|
||||
let response = LoginResponse {
|
||||
access_token,
|
||||
token_type: "Bearer".to_string(),
|
||||
expires_in: state.config.jwt_expiry_minutes * 60,
|
||||
tenant: TenantSnippet {
|
||||
id: token.tenant_id,
|
||||
name: tenant_name,
|
||||
},
|
||||
};
|
||||
|
||||
ok_json(response)
|
||||
AuthService::new(&state).exchange_api_token(payload)
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
@@ -308,37 +115,7 @@ pub async fn signup_start(
|
||||
State(state): State<AppState>,
|
||||
Json(payload): Json<SignupStartRequest>,
|
||||
) -> AppResult<JsonResponse<SignupStartResponse>> {
|
||||
let username = payload.username.trim();
|
||||
if username.is_empty() {
|
||||
return Err(AppError::bad_request("username must not be empty"));
|
||||
}
|
||||
|
||||
let mut conn = state.db_unscoped()?;
|
||||
let exists: bool = dsl::users
|
||||
.filter(dsl::username.eq(username))
|
||||
.first::<User>(&mut conn)
|
||||
.optional()?
|
||||
.is_some();
|
||||
if exists {
|
||||
return Err(AppError::conflict("username already exists"));
|
||||
}
|
||||
|
||||
let user_id = Uuid::new_v4();
|
||||
let challenge = state
|
||||
.passkeys
|
||||
.as_ref()
|
||||
.ok_or_else(|| AppError::bad_request("passkey support is disabled"))?
|
||||
.start_signup_registration(&mut conn, user_id, username)?;
|
||||
|
||||
let signup_token = state
|
||||
.jwt
|
||||
.generate_signup_token(user_id, challenge.challenge_id, username.to_owned())
|
||||
.map_err(AppError::from)?;
|
||||
|
||||
ok_json(SignupStartResponse {
|
||||
signup_token,
|
||||
challenge,
|
||||
})
|
||||
AuthService::new(&state).signup_start(payload)
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
@@ -356,57 +133,7 @@ pub async fn signup_finish(
|
||||
State(state): State<AppState>,
|
||||
Json(payload): Json<SignupFinishRequest>,
|
||||
) -> AppResult<Response> {
|
||||
let claims = state
|
||||
.jwt
|
||||
.verify_signup_token(&payload.signup_token)
|
||||
.map_err(|_| AppError::unauthorized())?;
|
||||
|
||||
let mut conn = state.db_unscoped()?;
|
||||
|
||||
let exists: bool = dsl::users
|
||||
.filter(dsl::username.eq(&claims.username))
|
||||
.first::<User>(&mut conn)
|
||||
.optional()?
|
||||
.is_some();
|
||||
if exists {
|
||||
return Err(AppError::conflict("username already exists"));
|
||||
}
|
||||
|
||||
let service = state
|
||||
.passkeys
|
||||
.as_ref()
|
||||
.ok_or_else(|| AppError::bad_request("passkey support is disabled"))?;
|
||||
|
||||
let prepared_passkey =
|
||||
service.consume_signup_challenge(&mut conn, claims.challenge_id, &payload.credential)?;
|
||||
|
||||
let state_clone = state.clone();
|
||||
let response = conn.transaction::<Response, AppError, _>(|conn| {
|
||||
insert_user(conn, claims.sub, &claims.username)?;
|
||||
|
||||
let tenant = state_clone.tenants.create_tenant_with_conn(
|
||||
conn,
|
||||
&claims.username,
|
||||
None,
|
||||
None,
|
||||
TenantStatus::Creating,
|
||||
&[claims.sub],
|
||||
Some(claims.sub),
|
||||
)?;
|
||||
|
||||
let passkey_insert =
|
||||
prepared_passkey.into_new_user_passkey(claims.sub, payload.nickname.clone());
|
||||
|
||||
diesel::insert_into(passkey_dsl::user_passkeys)
|
||||
.values(&passkey_insert)
|
||||
.execute(conn)
|
||||
.map_err(AppError::from)?;
|
||||
|
||||
let user: User = dsl::users.find(claims.sub).first(conn)?;
|
||||
issue_session(&state_clone, conn, &user, tenant.id)
|
||||
})?;
|
||||
|
||||
Ok(response)
|
||||
AuthService::new(&state).signup_finish(payload)
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
@@ -427,53 +154,7 @@ pub async fn refresh(
|
||||
.get(SESSION_COOKIE_NAME)
|
||||
.ok_or_else(AppError::unauthorized)?;
|
||||
|
||||
let hashed = hash_session_token(refresh_value);
|
||||
let mut conn = state.db_unscoped()?;
|
||||
let now = Utc::now();
|
||||
let now_naive = now.naive_utc();
|
||||
|
||||
apply_user_session_hash(&mut conn, &hashed)?;
|
||||
let token = match session_dsl::user_sessions
|
||||
.filter(session_dsl::token_hash.eq(&hashed))
|
||||
.filter(session_dsl::revoked_at.is_null())
|
||||
.filter(session_dsl::expires_at.gt(now_naive))
|
||||
.first::<UserSession>(&mut conn)
|
||||
{
|
||||
Ok(token) => token,
|
||||
Err(diesel::result::Error::NotFound) => return Err(AppError::unauthorized()),
|
||||
Err(err) => return Err(AppError::from(err)),
|
||||
};
|
||||
|
||||
clear_user_session_hash(&mut conn)?;
|
||||
apply_tenant_guc(&mut conn, token.tenant_id)?;
|
||||
clear_user_guc(&mut conn)?;
|
||||
|
||||
diesel::update(session_dsl::user_sessions.filter(session_dsl::id.eq(token.id)))
|
||||
.set((
|
||||
session_dsl::revoked_at.eq(now_naive),
|
||||
session_dsl::updated_at.eq(now_naive),
|
||||
))
|
||||
.execute(&mut conn)?;
|
||||
|
||||
let user: User = dsl::users
|
||||
.find(token.user_id)
|
||||
.first(&mut conn)
|
||||
.map_err(AppError::from)?;
|
||||
|
||||
issue_session(&state, &mut conn, &user, token.tenant_id)
|
||||
}
|
||||
|
||||
fn insert_user(conn: &mut PgConnection, id: Uuid, username: &str) -> AppResult<()> {
|
||||
let new_user = NewUser {
|
||||
id,
|
||||
username: username.to_string(),
|
||||
};
|
||||
|
||||
diesel::insert_into(dsl::users)
|
||||
.values(&new_user)
|
||||
.execute(conn)
|
||||
.map(|_| ())
|
||||
.map_err(AppError::from)
|
||||
AuthService::new(&state).refresh(refresh_value)
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
@@ -488,37 +169,7 @@ pub async fn select_tenant(
|
||||
TypedHeader(Authorization(bearer)): TypedHeader<Authorization<Bearer>>,
|
||||
Json(payload): Json<TenantSelectionRequest>,
|
||||
) -> AppResult<Response> {
|
||||
let user_id = match state.jwt.verify_tenant_selector_token(bearer.token()) {
|
||||
Ok(claims) => claims.sub,
|
||||
Err(_) => state
|
||||
.jwt
|
||||
.verify_token(bearer.token())
|
||||
.map(|claims| claims.sub)
|
||||
.map_err(|_| AppError::unauthorized())?,
|
||||
};
|
||||
|
||||
let mut conn = state.db_unscoped()?;
|
||||
apply_user_guc(&mut conn, user_id)?;
|
||||
|
||||
let membership_exists = memberships_dsl::user_memberships
|
||||
.filter(memberships_dsl::user_id.eq(user_id))
|
||||
.filter(memberships_dsl::tenant_id.eq(payload.tenant_id))
|
||||
.select(memberships_dsl::tenant_id)
|
||||
.first::<Uuid>(&mut conn)
|
||||
.optional()?;
|
||||
|
||||
clear_user_guc(&mut conn)?;
|
||||
|
||||
if membership_exists.is_none() {
|
||||
return Err(AppError::unauthorized());
|
||||
}
|
||||
|
||||
let user: User = dsl::users
|
||||
.find(user_id)
|
||||
.first(&mut conn)
|
||||
.map_err(AppError::from)?;
|
||||
|
||||
issue_session(&state, &mut conn, &user, payload.tenant_id)
|
||||
AuthService::new(&state).select_tenant(bearer.token(), payload.tenant_id)
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
@@ -532,50 +183,18 @@ pub async fn logout(
|
||||
user: AuthenticatedUser,
|
||||
jar: Option<TypedHeader<Cookie>>,
|
||||
) -> AppResult<(HeaderMap, StatusCode)> {
|
||||
let mut conn = state.db_for_tenant(user.tenant_id)?;
|
||||
let now = Utc::now().naive_utc();
|
||||
let mut rows_affected = 0;
|
||||
|
||||
if let Some(cookies) = jar {
|
||||
if let Some(value) = cookies.get(SESSION_COOKIE_NAME) {
|
||||
let hashed = hash_session_token(value);
|
||||
rows_affected = diesel::update(
|
||||
session_dsl::user_sessions
|
||||
.filter(session_dsl::token_hash.eq(hashed))
|
||||
.filter(session_dsl::user_id.eq(user.user_id))
|
||||
.filter(session_dsl::revoked_at.is_null()),
|
||||
)
|
||||
.set((
|
||||
session_dsl::revoked_at.eq(now),
|
||||
session_dsl::updated_at.eq(now),
|
||||
))
|
||||
.execute(&mut conn)
|
||||
.unwrap_or(0);
|
||||
}
|
||||
}
|
||||
|
||||
if rows_affected == 0 {
|
||||
let _ = diesel::update(
|
||||
session_dsl::user_sessions
|
||||
.filter(session_dsl::user_id.eq(user.user_id))
|
||||
.filter(session_dsl::revoked_at.is_null()),
|
||||
)
|
||||
.set((
|
||||
session_dsl::revoked_at.eq(now),
|
||||
session_dsl::updated_at.eq(now),
|
||||
))
|
||||
.execute(&mut conn);
|
||||
}
|
||||
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.insert(SET_COOKIE, build_clear_session_cookie(&state));
|
||||
Ok((headers, StatusCode::NO_CONTENT))
|
||||
let refresh_cookie = jar.as_ref().and_then(|cookies| {
|
||||
cookies
|
||||
.get(SESSION_COOKIE_NAME)
|
||||
.map(|value| value.to_owned())
|
||||
});
|
||||
AuthService::new(&state).logout(user, refresh_cookie.as_deref())
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/api/auth/me",
|
||||
responses((status = 200, description = "Authenticated principal", body = AuthenticatedUser)),
|
||||
responses((status = 200, description = "Current session", body = crate::auth::AuthenticatedUser)),
|
||||
tag = "Auth"
|
||||
)]
|
||||
pub async fn me(user: AuthenticatedUser) -> Json<AuthenticatedUser> {
|
||||
@@ -585,81 +204,34 @@ pub async fn me(user: AuthenticatedUser) -> Json<AuthenticatedUser> {
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/api/auth/tenants",
|
||||
responses((status = 200, description = "Available tenants", body = TenantListResponse)),
|
||||
responses((status = 200, description = "List of tenants", body = TenantListResponse)),
|
||||
tag = "Auth"
|
||||
)]
|
||||
pub async fn list_tenants(
|
||||
State(state): State<AppState>,
|
||||
auth: Option<TypedHeader<Authorization<Bearer>>>,
|
||||
user: AuthenticatedUser,
|
||||
) -> AppResult<JsonResponse<TenantListResponse>> {
|
||||
let bearer = auth.ok_or_else(AppError::unauthorized)?;
|
||||
let token = bearer.token();
|
||||
|
||||
let user_id = match state.jwt.verify_token(token) {
|
||||
Ok(claims) => claims.sub,
|
||||
Err(_) => {
|
||||
let claims = state
|
||||
.jwt
|
||||
.verify_tenant_selector_token(token)
|
||||
.map_err(|_| AppError::unauthorized())?;
|
||||
claims.sub
|
||||
}
|
||||
};
|
||||
|
||||
let mut conn = state.db_unscoped()?;
|
||||
apply_user_guc(&mut conn, user_id)?;
|
||||
|
||||
let tenant_ids: Vec<Uuid> = memberships_dsl::user_memberships
|
||||
.filter(memberships_dsl::user_id.eq(user_id))
|
||||
.select(memberships_dsl::tenant_id)
|
||||
.load(&mut conn)?;
|
||||
|
||||
clear_user_guc(&mut conn)?;
|
||||
drop(conn);
|
||||
|
||||
let mut tenants = Vec::with_capacity(tenant_ids.len());
|
||||
for tenant_id in tenant_ids {
|
||||
let mut tenant_conn = state.db_for_tenant(tenant_id)?;
|
||||
let name: String = tenant_dsl::tenants
|
||||
.find(tenant_id)
|
||||
.select(tenant_dsl::name)
|
||||
.first(&mut tenant_conn)
|
||||
.map_err(AppError::from)?;
|
||||
tenants.push(TenantSnippet {
|
||||
id: tenant_id,
|
||||
name,
|
||||
});
|
||||
}
|
||||
|
||||
ok_json(TenantListResponse { tenants })
|
||||
AuthService::new(&state).list_tenants(user)
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
post,
|
||||
path = "/api/auth/passkeys/register/start",
|
||||
responses((status = 200, description = "Passkey registration challenge", body = RegistrationChallengeResponse)),
|
||||
responses((status = 200, body = crate::auth::passkeys::RegistrationChallengeResponse)),
|
||||
tag = "Auth"
|
||||
)]
|
||||
pub async fn passkey_register_start(
|
||||
State(state): State<AppState>,
|
||||
user: AuthenticatedUser,
|
||||
) -> AppResult<JsonResponse<RegistrationChallengeResponse>> {
|
||||
let service = state
|
||||
.passkeys
|
||||
.as_ref()
|
||||
.ok_or_else(|| AppError::bad_request("passkey support is disabled"))?;
|
||||
|
||||
let mut conn = state.db_unscoped()?;
|
||||
let current_user: User = dsl::users.find(user.user_id).first(&mut conn)?;
|
||||
let challenge = service.start_registration(&mut conn, ¤t_user)?;
|
||||
ok_json(challenge)
|
||||
AuthService::new(&state).passkey_register_start(user)
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
post,
|
||||
path = "/api/auth/passkeys/register/finish",
|
||||
request_body = PasskeyRegistrationFinishPayload,
|
||||
responses((status = 200, description = "Passkey registered", body = PasskeySummary)),
|
||||
request_body = crate::auth::passkeys::PasskeyRegistrationFinishPayload,
|
||||
responses((status = 201, body = crate::auth::passkeys::PasskeySummary)),
|
||||
tag = "Auth"
|
||||
)]
|
||||
pub async fn passkey_register_finish(
|
||||
@@ -667,65 +239,27 @@ pub async fn passkey_register_finish(
|
||||
user: AuthenticatedUser,
|
||||
Json(payload): Json<PasskeyRegistrationFinishPayload>,
|
||||
) -> AppResult<JsonResponse<PasskeySummary>> {
|
||||
let service = state
|
||||
.passkeys
|
||||
.as_ref()
|
||||
.ok_or_else(|| AppError::bad_request("passkey support is disabled"))?;
|
||||
|
||||
let mut conn = state.db_unscoped()?;
|
||||
let current_user: User = dsl::users.find(user.user_id).first(&mut conn)?;
|
||||
|
||||
let PasskeyRegistrationFinishPayload {
|
||||
challenge_id,
|
||||
credential,
|
||||
nickname,
|
||||
} = payload;
|
||||
|
||||
let passkey = service.finish_registration(
|
||||
&mut conn,
|
||||
¤t_user,
|
||||
challenge_id,
|
||||
credential,
|
||||
nickname,
|
||||
)?;
|
||||
|
||||
ok_json(PasskeySummary::from(passkey))
|
||||
AuthService::new(&state).passkey_register_finish(user, payload)
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
post,
|
||||
path = "/api/auth/passkeys/login/start",
|
||||
request_body = PasskeyLoginStartPayload,
|
||||
responses((status = 200, description = "Passkey authentication challenge", body = AuthenticationChallengeResponse)),
|
||||
request_body = crate::auth::passkeys::PasskeyLoginStartPayload,
|
||||
responses((status = 200, body = crate::auth::passkeys::AuthenticationChallengeResponse)),
|
||||
tag = "Auth"
|
||||
)]
|
||||
pub async fn passkey_login_start(
|
||||
State(state): State<AppState>,
|
||||
Json(payload): Json<PasskeyLoginStartPayload>,
|
||||
) -> AppResult<JsonResponse<AuthenticationChallengeResponse>> {
|
||||
let service = state
|
||||
.passkeys
|
||||
.as_ref()
|
||||
.ok_or_else(|| AppError::bad_request("passkey support is disabled"))?;
|
||||
|
||||
let username = payload.username.trim();
|
||||
if username.is_empty() {
|
||||
return Err(AppError::bad_request("username must not be empty"));
|
||||
}
|
||||
|
||||
let mut conn = state.db_unscoped()?;
|
||||
let user: User = dsl::users
|
||||
.filter(dsl::username.eq(username))
|
||||
.first(&mut conn)?;
|
||||
|
||||
let challenge = service.start_authentication(&mut conn, &user)?;
|
||||
ok_json(challenge)
|
||||
AuthService::new(&state).passkey_login_start(&payload.username)
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
post,
|
||||
path = "/api/auth/passkeys/login/finish",
|
||||
request_body = PasskeyLoginFinishPayload,
|
||||
request_body = crate::auth::passkeys::PasskeyLoginFinishPayload,
|
||||
responses(
|
||||
(status = 200, description = "Passkey login successful", body = LoginResponseVariants),
|
||||
(status = 401, description = "Authentication failed")
|
||||
@@ -736,269 +270,5 @@ pub async fn passkey_login_finish(
|
||||
State(state): State<AppState>,
|
||||
Json(payload): Json<PasskeyLoginFinishPayload>,
|
||||
) -> AppResult<Response> {
|
||||
let service = state
|
||||
.passkeys
|
||||
.as_ref()
|
||||
.ok_or_else(|| AppError::bad_request("passkey support is disabled"))?;
|
||||
|
||||
let mut conn = state.db_unscoped()?;
|
||||
let (user, _passkey, auth_result) =
|
||||
service.finish_authentication(&mut conn, payload.challenge_id, payload.credential)?;
|
||||
|
||||
if !auth_result.user_verified() {
|
||||
return Err(AppError::unauthorized());
|
||||
}
|
||||
|
||||
complete_login(&state, &mut conn, &user, None)
|
||||
}
|
||||
|
||||
fn complete_login(
|
||||
state: &AppState,
|
||||
conn: &mut PgConnection,
|
||||
user: &User,
|
||||
preferred_tenant_id: Option<Uuid>,
|
||||
) -> AppResult<Response> {
|
||||
apply_user_guc(conn, user.id)?;
|
||||
let tenant_ids: Vec<Uuid> = memberships_dsl::user_memberships
|
||||
.filter(memberships_dsl::user_id.eq(user.id))
|
||||
.select(memberships_dsl::tenant_id)
|
||||
.load(conn)?;
|
||||
clear_user_guc(conn)?;
|
||||
|
||||
tracing::debug!(user_id = %user.id, tenants = tenant_ids.len(), "passkey login memberships");
|
||||
|
||||
if tenant_ids.is_empty() {
|
||||
return Err(AppError::unauthorized());
|
||||
}
|
||||
|
||||
if let Some(preferred_id) = preferred_tenant_id {
|
||||
if tenant_ids.iter().any(|id| *id == preferred_id) {
|
||||
return issue_session(state, conn, user, preferred_id);
|
||||
}
|
||||
}
|
||||
|
||||
if tenant_ids.len() == 1 {
|
||||
return issue_session(state, conn, user, tenant_ids[0]);
|
||||
}
|
||||
|
||||
let selection_token = state
|
||||
.jwt
|
||||
.generate_tenant_selector_token(user.id)
|
||||
.map_err(AppError::from)?;
|
||||
|
||||
let mut tenants = Vec::with_capacity(tenant_ids.len());
|
||||
for tenant_id in tenant_ids {
|
||||
let mut tenant_conn = state.db_for_tenant(tenant_id)?;
|
||||
let name: String = tenant_dsl::tenants
|
||||
.find(tenant_id)
|
||||
.select(tenant_dsl::name)
|
||||
.first(&mut tenant_conn)
|
||||
.map_err(AppError::from)?;
|
||||
tenants.push(TenantSnippet {
|
||||
id: tenant_id,
|
||||
name,
|
||||
});
|
||||
}
|
||||
|
||||
let response = ok_json(TenantSelectionResponse {
|
||||
access_token: selection_token,
|
||||
tenants,
|
||||
})?;
|
||||
|
||||
Ok(response.into_response())
|
||||
}
|
||||
|
||||
fn magic_token_login(
|
||||
state: &AppState,
|
||||
conn: &mut PgConnection,
|
||||
token_value: &str,
|
||||
username_hint: Option<&str>,
|
||||
preferred_tenant_id: Option<Uuid>,
|
||||
) -> AppResult<Response> {
|
||||
if token_value.is_empty() {
|
||||
return Err(AppError::bad_request("magic_token must not be empty"));
|
||||
}
|
||||
|
||||
let token_hash = hash_magic_token(token_value);
|
||||
let now = Utc::now();
|
||||
let now_naive = now.naive_utc();
|
||||
|
||||
conn.transaction::<Response, AppError, _>(|conn| {
|
||||
let magic = magic_dsl::magic_tokens
|
||||
.filter(magic_dsl::token_hash.eq(&token_hash))
|
||||
.filter(magic_dsl::expires_at.gt(now_naive))
|
||||
.first::<MagicToken>(conn)
|
||||
.map_err(|err| match err {
|
||||
diesel::result::Error::NotFound => AppError::unauthorized(),
|
||||
_ => AppError::from(err),
|
||||
})?;
|
||||
|
||||
if let Some(limit) = magic.max_uses {
|
||||
if magic.used_count >= limit {
|
||||
return Err(AppError::unauthorized());
|
||||
}
|
||||
}
|
||||
|
||||
match magic.kind {
|
||||
MagicTokenKind::EmailLogin | MagicTokenKind::DemoLogin => {}
|
||||
}
|
||||
|
||||
let user: User = dsl::users
|
||||
.find(magic.user_id)
|
||||
.first(conn)
|
||||
.map_err(AppError::from)?;
|
||||
|
||||
if let Some(expected) = username_hint {
|
||||
if expected != user.username {
|
||||
return Err(AppError::unauthorized());
|
||||
}
|
||||
}
|
||||
|
||||
diesel::update(magic_dsl::magic_tokens.filter(magic_dsl::id.eq(magic.id)))
|
||||
.set((
|
||||
magic_dsl::used_count.eq(magic.used_count + 1),
|
||||
magic_dsl::last_used_at.eq(Some(now_naive)),
|
||||
))
|
||||
.execute(conn)?;
|
||||
|
||||
complete_login(state, conn, &user, preferred_tenant_id)
|
||||
})
|
||||
}
|
||||
|
||||
fn issue_session(
|
||||
state: &AppState,
|
||||
conn: &mut PgConnection,
|
||||
user: &User,
|
||||
tenant_id: Uuid,
|
||||
) -> AppResult<Response> {
|
||||
apply_tenant_guc(conn, tenant_id)?;
|
||||
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(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
|
||||
.find(tenant_id)
|
||||
.select(tenant_dsl::name)
|
||||
.first(conn)
|
||||
.map_err(AppError::from)?;
|
||||
|
||||
let session_value = generate_session_token();
|
||||
let session_hash = hash_session_token(&session_value);
|
||||
let refresh_expires_at = now + ChronoDuration::days(state.config.refresh_token_expiry_days);
|
||||
|
||||
let new_session = NewUserSession {
|
||||
id: session_id,
|
||||
user_id: user.id,
|
||||
token_hash: session_hash,
|
||||
issued_at: now.naive_utc(),
|
||||
expires_at: refresh_expires_at.naive_utc(),
|
||||
tenant_id,
|
||||
};
|
||||
|
||||
diesel::insert_into(user_sessions::table)
|
||||
.values(&new_session)
|
||||
.execute(conn)?;
|
||||
|
||||
let json = ok_json(LoginResponse {
|
||||
access_token,
|
||||
token_type: "Bearer".to_string(),
|
||||
expires_in: state.config.jwt_expiry_minutes * 60,
|
||||
tenant: TenantSnippet {
|
||||
id: tenant_id,
|
||||
name: tenant_name,
|
||||
},
|
||||
})?;
|
||||
|
||||
let mut response = json.into_response();
|
||||
|
||||
response.headers_mut().insert(
|
||||
SET_COOKIE,
|
||||
build_session_cookie(state, &session_value, refresh_expires_at),
|
||||
);
|
||||
|
||||
Ok(response)
|
||||
}
|
||||
|
||||
fn hash_session_token(token: &str) -> String {
|
||||
let mut hasher = Sha256::new();
|
||||
hasher.update(token.as_bytes());
|
||||
hex::encode(hasher.finalize())
|
||||
}
|
||||
|
||||
fn hash_magic_token(token: &str) -> String {
|
||||
hash_session_token(token)
|
||||
}
|
||||
|
||||
fn generate_session_token() -> String {
|
||||
let mut bytes = [0u8; 32];
|
||||
OsRng.fill_bytes(&mut bytes);
|
||||
hex::encode(bytes)
|
||||
}
|
||||
|
||||
fn build_session_cookie(
|
||||
state: &AppState,
|
||||
token: &str,
|
||||
expires_at: chrono::DateTime<Utc>,
|
||||
) -> HeaderValue {
|
||||
let max_age = ChronoDuration::days(state.config.refresh_token_expiry_days).num_seconds();
|
||||
|
||||
let mut parts = vec![format!("{}={}", SESSION_COOKIE_NAME, token)];
|
||||
parts.push("Path=/".into());
|
||||
parts.push("HttpOnly".into());
|
||||
parts.push("SameSite=Strict".into());
|
||||
parts.push(format!("Max-Age={}", max_age));
|
||||
parts.push(format!("Expires={}", expires_at.to_rfc2822()));
|
||||
if state.config.refresh_cookie_secure {
|
||||
parts.push("Secure".into());
|
||||
}
|
||||
if let Some(domain) = &state.config.refresh_cookie_domain {
|
||||
parts.push(format!("Domain={}", domain));
|
||||
}
|
||||
|
||||
HeaderValue::from_str(&parts.join("; ")).expect("valid session cookie")
|
||||
}
|
||||
|
||||
fn build_clear_session_cookie(state: &AppState) -> HeaderValue {
|
||||
let mut parts = vec![format!("{}=", SESSION_COOKIE_NAME)];
|
||||
parts.push("Path=/".into());
|
||||
parts.push("HttpOnly".into());
|
||||
parts.push("SameSite=Strict".into());
|
||||
parts.push("Max-Age=0".into());
|
||||
parts.push("Expires=Thu, 01 Jan 1970 00:00:00 GMT".into());
|
||||
if state.config.refresh_cookie_secure {
|
||||
parts.push("Secure".into());
|
||||
}
|
||||
if let Some(domain) = &state.config.refresh_cookie_domain {
|
||||
parts.push(format!("Domain={}", domain));
|
||||
}
|
||||
|
||||
HeaderValue::from_str(&parts.join("; ")).expect("valid session cookie")
|
||||
AuthService::new(&state).passkey_login_finish(payload)
|
||||
}
|
||||
|
||||
@@ -1,93 +1,17 @@
|
||||
use axum::{extract::Path, http::StatusCode, Json};
|
||||
use chrono::Utc;
|
||||
use diesel::prelude::*;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use utoipa::OpenApi;
|
||||
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},
|
||||
http::responders::{
|
||||
created_json, no_content, ok_json, IntoAppResult, JsonResponse, RowsAffectedExt,
|
||||
},
|
||||
models::{ApiCapability, CapabilitySet},
|
||||
schema::{
|
||||
api_tokens,
|
||||
capability_sets::{self, dsl as cs_dsl},
|
||||
user_memberships,
|
||||
error::AppResult,
|
||||
http::responders::JsonResponse,
|
||||
services::capability_sets::{
|
||||
CapabilitySetResponse, CapabilitySetService, CreateCapabilitySetRequest,
|
||||
UpdateCapabilitySetRequest,
|
||||
},
|
||||
};
|
||||
|
||||
#[derive(Serialize, utoipa::ToSchema)]
|
||||
pub struct CapabilitySetResponse {
|
||||
pub id: Uuid,
|
||||
pub slug: 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,
|
||||
is_system: set.is_system,
|
||||
cap_version: set.cap_version,
|
||||
capabilities,
|
||||
}
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/api/capability-sets",
|
||||
@@ -101,34 +25,19 @@ pub async fn list_capability_sets(
|
||||
..
|
||||
}: TenantScopedConn,
|
||||
) -> AppResult<JsonResponse<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)
|
||||
CapabilitySetService::new().list(&mut conn, tenant_id)
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/api/capabilities",
|
||||
responses((status = 200, body = [ApiCapability])),
|
||||
responses((status = 200, body = [crate::models::ApiCapability])),
|
||||
tag = "Capability Sets"
|
||||
)]
|
||||
pub async fn list_capabilities(
|
||||
TenantScopedConn { .. }: TenantScopedConn,
|
||||
) -> AppResult<JsonResponse<Vec<ApiCapability>>> {
|
||||
let capabilities = ApiCapability::variants()
|
||||
.iter()
|
||||
.map(|value| value.parse::<ApiCapability>().expect("valid capability"))
|
||||
.collect();
|
||||
ok_json(capabilities)
|
||||
) -> AppResult<JsonResponse<Vec<crate::models::ApiCapability>>> {
|
||||
CapabilitySetService::new().list_capabilities()
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
@@ -146,14 +55,7 @@ pub async fn get_capability_set(
|
||||
}: TenantScopedConn,
|
||||
Path(id): Path<Uuid>,
|
||||
) -> AppResult<JsonResponse<CapabilitySetResponse>> {
|
||||
let set = cs_dsl::capability_sets
|
||||
.filter(cs_dsl::tenant_id.eq(tenant_id))
|
||||
.find(id)
|
||||
.first::<CapabilitySet>(&mut conn)
|
||||
.into_app_result()?;
|
||||
|
||||
let capabilities = load_capabilities_for_set(&mut conn, set.id)?;
|
||||
ok_json(to_response(set, capabilities))
|
||||
CapabilitySetService::new().get(&mut conn, tenant_id, id)
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
@@ -171,32 +73,7 @@ pub async fn create_capability_set(
|
||||
}: TenantScopedConn,
|
||||
Json(payload): Json<CreateCapabilitySetRequest>,
|
||||
) -> AppResult<JsonResponse<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);
|
||||
|
||||
created_json(response)
|
||||
CapabilitySetService::new().create(&mut conn, tenant_id, payload)
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
@@ -216,72 +93,7 @@ pub async fn update_capability_set(
|
||||
Path(id): Path<Uuid>,
|
||||
Json(payload): Json<UpdateCapabilitySetRequest>,
|
||||
) -> AppResult<JsonResponse<CapabilitySetResponse>> {
|
||||
let set = cs_dsl::capability_sets
|
||||
.filter(cs_dsl::tenant_id.eq(tenant_id))
|
||||
.find(id)
|
||||
.first::<CapabilitySet>(&mut conn)
|
||||
.into_app_result()?;
|
||||
|
||||
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()
|
||||
.into_app_result()?
|
||||
.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)
|
||||
.into_app_result()?;
|
||||
|
||||
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)
|
||||
.into_app_result()
|
||||
})?;
|
||||
|
||||
let capabilities = load_capabilities_for_set(&mut conn, set.id)?;
|
||||
ok_json(to_response(set, capabilities))
|
||||
CapabilitySetService::new().update(&mut conn, tenant_id, id, payload)
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
@@ -299,49 +111,10 @@ pub async fn delete_capability_set(
|
||||
}: 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)
|
||||
.into_app_result()?;
|
||||
|
||||
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)
|
||||
.into_app_result()?
|
||||
.or_not_found()?;
|
||||
|
||||
no_content()
|
||||
CapabilitySetService::new().delete(&mut conn, tenant_id, id)
|
||||
}
|
||||
|
||||
#[derive(utoipa::OpenApi)]
|
||||
#[derive(OpenApi)]
|
||||
#[openapi(
|
||||
paths(
|
||||
crate::routes::capability_sets::list_capability_sets,
|
||||
@@ -353,9 +126,9 @@ pub async fn delete_capability_set(
|
||||
),
|
||||
components(schemas(
|
||||
crate::models::ApiCapability,
|
||||
crate::routes::capability_sets::CapabilitySetResponse,
|
||||
crate::routes::capability_sets::CreateCapabilitySetRequest,
|
||||
crate::routes::capability_sets::UpdateCapabilitySetRequest,
|
||||
crate::services::capability_sets::CapabilitySetResponse,
|
||||
crate::services::capability_sets::CreateCapabilitySetRequest,
|
||||
crate::services::capability_sets::UpdateCapabilitySetRequest,
|
||||
))
|
||||
)]
|
||||
pub struct CapabilitySetsApiDoc;
|
||||
|
||||
+108
-1735
File diff suppressed because it is too large
Load Diff
+50
-550
@@ -2,52 +2,28 @@ use axum::{
|
||||
extract::{Json, Path, Query, State},
|
||||
http::StatusCode,
|
||||
};
|
||||
use diesel::{
|
||||
dsl::{exists, sql},
|
||||
prelude::*,
|
||||
sql_types::Text,
|
||||
PgConnection,
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use utoipa::{IntoParams, ToSchema};
|
||||
use utoipa::OpenApi;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::models::{Document, Folder, NewFolder};
|
||||
use crate::schema::{documents, folders};
|
||||
use crate::state::AppState;
|
||||
use crate::{
|
||||
auth::TenantScopedConn,
|
||||
error::{AppError, AppResult},
|
||||
http::responders::{
|
||||
created_json, no_content, ok_json, IntoAppResult, JsonResponse, RowsAffectedExt,
|
||||
http::responders::{created_json, no_content, ok_json, JsonResponse},
|
||||
services::folders::{
|
||||
CreateFolderRequest, EnsureFolderPathRequest, FolderContentsData, FolderContentsQuery,
|
||||
FolderInfo, FolderService, FolderTreeNode, UpdateFolderRequest,
|
||||
},
|
||||
state::AppState,
|
||||
};
|
||||
|
||||
use super::documents::{hydrate_documents, DocumentResponse};
|
||||
use crate::documents::ordering::{ordering_clauses, DocumentSortField, SortDirection};
|
||||
use crate::utils::{json::deserialize_patch_field, time::to_iso};
|
||||
use std::collections::HashMap;
|
||||
use crate::services::documents::DocumentResponse;
|
||||
|
||||
#[derive(Deserialize, ToSchema)]
|
||||
pub struct CreateFolderRequest {
|
||||
pub name: String,
|
||||
#[schema(nullable)]
|
||||
pub parent_id: Option<Uuid>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, ToSchema)]
|
||||
pub struct EnsureFolderPathRequest {
|
||||
#[schema(nullable)]
|
||||
pub parent_id: Option<Uuid>,
|
||||
pub segments: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, ToSchema)]
|
||||
#[derive(utoipa::ToSchema, serde::Serialize)]
|
||||
pub struct FolderResponse {
|
||||
pub folder: FolderInfo,
|
||||
}
|
||||
|
||||
#[derive(Serialize, ToSchema)]
|
||||
#[derive(utoipa::ToSchema, serde::Serialize)]
|
||||
pub struct FolderContentsResponse {
|
||||
#[schema(nullable)]
|
||||
pub folder: Option<FolderInfo>,
|
||||
@@ -55,81 +31,6 @@ pub struct FolderContentsResponse {
|
||||
pub documents: Vec<DocumentResponse>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Serialize, ToSchema)]
|
||||
pub struct FolderTreeNode {
|
||||
pub id: Uuid,
|
||||
pub name: String,
|
||||
#[schema(nullable)]
|
||||
pub parent_id: Option<Uuid>,
|
||||
pub created_at: String,
|
||||
pub updated_at: String,
|
||||
#[serde(default)]
|
||||
pub children: Vec<FolderTreeNode>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, IntoParams, ToSchema)]
|
||||
#[into_params(parameter_in = Query)]
|
||||
pub struct FolderContentsQuery {
|
||||
#[serde(default = "default_include_documents")]
|
||||
#[schema(default = true)]
|
||||
pub include_documents: bool,
|
||||
#[serde(default)]
|
||||
#[schema(default = "title")]
|
||||
pub sort: DocumentSortField,
|
||||
#[serde(default)]
|
||||
#[schema(default = "asc")]
|
||||
pub dir: SortDirection,
|
||||
}
|
||||
|
||||
const fn default_include_documents() -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
#[derive(Serialize, ToSchema)]
|
||||
pub struct FolderInfo {
|
||||
pub id: Uuid,
|
||||
pub name: String,
|
||||
#[schema(nullable)]
|
||||
pub parent_id: Option<Uuid>,
|
||||
pub created_at: String,
|
||||
pub updated_at: String,
|
||||
}
|
||||
|
||||
#[derive(Default, Deserialize, ToSchema)]
|
||||
pub struct UpdateFolderRequest {
|
||||
#[serde(default, deserialize_with = "deserialize_patch_field")]
|
||||
#[schema(nullable, value_type = Option<Uuid>)]
|
||||
pub parent_id: Option<Option<Uuid>>,
|
||||
#[serde(default, deserialize_with = "deserialize_patch_field")]
|
||||
#[schema(nullable)]
|
||||
pub name: Option<Option<String>>,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
fn update_folder_request_deserializes_null_parent() {
|
||||
let req: UpdateFolderRequest =
|
||||
serde_json::from_value(json!({ "parent_id": null })).unwrap();
|
||||
assert!(matches!(req.parent_id, Some(None)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn update_folder_request_deserializes_absent_parent() {
|
||||
let req: UpdateFolderRequest = serde_json::from_value(json!({})).unwrap();
|
||||
assert!(req.parent_id.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn update_folder_request_deserializes_null_name() {
|
||||
let req: UpdateFolderRequest = serde_json::from_value(json!({ "name": null })).unwrap();
|
||||
assert!(matches!(req.name, Some(None)));
|
||||
}
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/api/folders/{id}",
|
||||
@@ -138,6 +39,7 @@ mod tests {
|
||||
tag = "Folders"
|
||||
)]
|
||||
pub async fn get_folder(
|
||||
State(state): State<AppState>,
|
||||
Path(folder_id): Path<Uuid>,
|
||||
TenantScopedConn {
|
||||
mut conn,
|
||||
@@ -145,14 +47,9 @@ pub async fn get_folder(
|
||||
..
|
||||
}: TenantScopedConn,
|
||||
) -> AppResult<JsonResponse<FolderResponse>> {
|
||||
let folder: Folder = folders::table
|
||||
.find(folder_id)
|
||||
.filter(folders::tenant_id.eq(tenant_id))
|
||||
.first(&mut conn)?;
|
||||
|
||||
ok_json(FolderResponse {
|
||||
folder: folder_to_info(folder),
|
||||
})
|
||||
let service = FolderService::new(&state);
|
||||
let folder = service.get_folder(&mut conn, tenant_id, folder_id)?;
|
||||
ok_json(FolderResponse { folder })
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
@@ -163,6 +60,7 @@ pub async fn get_folder(
|
||||
tag = "Folders"
|
||||
)]
|
||||
pub async fn ensure_folder_path(
|
||||
State(state): State<AppState>,
|
||||
TenantScopedConn {
|
||||
mut conn,
|
||||
tenant_id,
|
||||
@@ -170,82 +68,9 @@ pub async fn ensure_folder_path(
|
||||
}: TenantScopedConn,
|
||||
Json(payload): Json<EnsureFolderPathRequest>,
|
||||
) -> AppResult<JsonResponse<FolderResponse>> {
|
||||
if payload.segments.is_empty() {
|
||||
return Err(AppError::bad_request("segments must not be empty"));
|
||||
}
|
||||
|
||||
let target_folder = conn.transaction::<Folder, AppError, _>(|conn| {
|
||||
let mut current_parent = payload.parent_id;
|
||||
let mut last_folder: Option<Folder> = None;
|
||||
|
||||
for raw_name in &payload.segments {
|
||||
let name = raw_name.trim();
|
||||
if name.is_empty() {
|
||||
return Err(AppError::bad_request("folder names must not be empty"));
|
||||
}
|
||||
|
||||
let existing: Option<Folder> = if let Some(parent_id) = current_parent {
|
||||
folders::table
|
||||
.filter(folders::tenant_id.eq(tenant_id))
|
||||
.filter(folders::parent_id.eq(Some(parent_id)))
|
||||
.filter(folders::name.eq(name))
|
||||
.first(conn)
|
||||
.optional()?
|
||||
} else {
|
||||
folders::table
|
||||
.filter(folders::tenant_id.eq(tenant_id))
|
||||
.filter(folders::parent_id.is_null())
|
||||
.filter(folders::name.eq(name))
|
||||
.first(conn)
|
||||
.optional()?
|
||||
};
|
||||
|
||||
let folder = if let Some(folder) = existing {
|
||||
folder
|
||||
} else {
|
||||
let new_folder = NewFolder {
|
||||
id: Uuid::new_v4(),
|
||||
name: name.to_string(),
|
||||
parent_id: current_parent,
|
||||
tenant_id,
|
||||
};
|
||||
|
||||
let inserted_id: Option<Uuid> = diesel::insert_into(folders::table)
|
||||
.values(&new_folder)
|
||||
.on_conflict_do_nothing()
|
||||
.returning(folders::id)
|
||||
.get_result(conn)
|
||||
.optional()?;
|
||||
|
||||
if let Some(id) = inserted_id {
|
||||
folders::table
|
||||
.find(id)
|
||||
.filter(folders::tenant_id.eq(tenant_id))
|
||||
.first(conn)?
|
||||
} else if let Some(parent_id) = current_parent {
|
||||
folders::table
|
||||
.filter(folders::tenant_id.eq(tenant_id))
|
||||
.filter(folders::parent_id.eq(Some(parent_id)))
|
||||
.filter(folders::name.eq(name))
|
||||
.first(conn)?
|
||||
} else {
|
||||
folders::table
|
||||
.filter(folders::tenant_id.eq(tenant_id))
|
||||
.filter(folders::parent_id.is_null())
|
||||
.filter(folders::name.eq(name))
|
||||
.first(conn)?
|
||||
}
|
||||
};
|
||||
current_parent = Some(folder.id);
|
||||
last_folder = Some(folder);
|
||||
}
|
||||
|
||||
last_folder.ok_or_else(|| AppError::internal("failed to resolve folder path"))
|
||||
})?;
|
||||
|
||||
ok_json(FolderResponse {
|
||||
folder: folder_to_info(target_folder),
|
||||
})
|
||||
let service = FolderService::new(&state);
|
||||
let folder = service.ensure_folder_path(&mut conn, tenant_id, payload)?;
|
||||
ok_json(FolderResponse { folder })
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
@@ -259,6 +84,7 @@ pub async fn ensure_folder_path(
|
||||
tag = "Folders"
|
||||
)]
|
||||
pub async fn create_folder(
|
||||
State(state): State<AppState>,
|
||||
TenantScopedConn {
|
||||
mut conn,
|
||||
tenant_id,
|
||||
@@ -266,78 +92,9 @@ pub async fn create_folder(
|
||||
}: TenantScopedConn,
|
||||
Json(payload): Json<CreateFolderRequest>,
|
||||
) -> AppResult<JsonResponse<FolderResponse>> {
|
||||
if payload.name.trim().is_empty() {
|
||||
return Err(AppError::bad_request("name must not be empty"));
|
||||
}
|
||||
|
||||
let name = payload.name.trim();
|
||||
|
||||
let existing: Option<Folder> = if let Some(parent_id) = payload.parent_id {
|
||||
folders::table
|
||||
.filter(folders::tenant_id.eq(tenant_id))
|
||||
.filter(folders::parent_id.eq(Some(parent_id)))
|
||||
.filter(folders::name.eq(name))
|
||||
.first(&mut conn)
|
||||
.optional()?
|
||||
} else {
|
||||
folders::table
|
||||
.filter(folders::tenant_id.eq(tenant_id))
|
||||
.filter(folders::parent_id.is_null())
|
||||
.filter(folders::name.eq(name))
|
||||
.first(&mut conn)
|
||||
.optional()?
|
||||
};
|
||||
|
||||
let (folder, created): (Folder, bool) = if let Some(folder) = existing {
|
||||
(folder, false)
|
||||
} else {
|
||||
let new_folder = NewFolder {
|
||||
id: Uuid::new_v4(),
|
||||
name: name.to_string(),
|
||||
parent_id: payload.parent_id,
|
||||
tenant_id,
|
||||
};
|
||||
|
||||
let inserted_id: Option<Uuid> = diesel::insert_into(folders::table)
|
||||
.values(&new_folder)
|
||||
.on_conflict_do_nothing()
|
||||
.returning(folders::id)
|
||||
.get_result(&mut conn)
|
||||
.optional()?;
|
||||
|
||||
if let Some(id) = inserted_id {
|
||||
(
|
||||
folders::table
|
||||
.find(id)
|
||||
.filter(folders::tenant_id.eq(tenant_id))
|
||||
.first(&mut conn)?,
|
||||
true,
|
||||
)
|
||||
} else if let Some(parent_id) = payload.parent_id {
|
||||
(
|
||||
folders::table
|
||||
.filter(folders::tenant_id.eq(tenant_id))
|
||||
.filter(folders::parent_id.eq(Some(parent_id)))
|
||||
.filter(folders::name.eq(name))
|
||||
.first(&mut conn)?,
|
||||
false,
|
||||
)
|
||||
} else {
|
||||
(
|
||||
folders::table
|
||||
.filter(folders::tenant_id.eq(tenant_id))
|
||||
.filter(folders::parent_id.is_null())
|
||||
.filter(folders::name.eq(name))
|
||||
.first(&mut conn)?,
|
||||
false,
|
||||
)
|
||||
}
|
||||
};
|
||||
|
||||
let response = FolderResponse {
|
||||
folder: folder_to_info(folder),
|
||||
};
|
||||
|
||||
let service = FolderService::new(&state);
|
||||
let (folder, created) = service.create_folder(&mut conn, tenant_id, payload)?;
|
||||
let response = FolderResponse { folder };
|
||||
if created {
|
||||
created_json(response)
|
||||
} else {
|
||||
@@ -348,7 +105,7 @@ pub async fn create_folder(
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/api/folders/{id}/contents",
|
||||
params(("id" = Uuid, Path, description = "Folder ID"), FolderContentsQuery),
|
||||
params(("id" = String, Path, description = "Folder ID or 'root'"), FolderContentsQuery),
|
||||
responses((status = 200, description = "Folder contents", body = FolderContentsResponse)),
|
||||
tag = "Folders"
|
||||
)]
|
||||
@@ -378,54 +135,22 @@ pub async fn list_folder_contents(
|
||||
)
|
||||
};
|
||||
|
||||
let folder = match folder_id {
|
||||
Some(id) => Some(folder_to_info(
|
||||
folders::table
|
||||
.find(id)
|
||||
.filter(folders::tenant_id.eq(tenant_id))
|
||||
.first::<Folder>(&mut conn)?,
|
||||
)),
|
||||
None => None,
|
||||
};
|
||||
|
||||
let child_folders: Vec<Folder> = if let Some(parent_id) = folder_id {
|
||||
folders::table
|
||||
.filter(folders::parent_id.eq(parent_id))
|
||||
.filter(folders::tenant_id.eq(tenant_id))
|
||||
.order(sql::<Text>("name COLLATE \"unicode_ci\" ASC"))
|
||||
.load(&mut conn)?
|
||||
} else {
|
||||
folders::table
|
||||
.filter(folders::parent_id.is_null())
|
||||
.filter(folders::tenant_id.eq(tenant_id))
|
||||
.order(sql::<Text>("name COLLATE \"unicode_ci\" ASC"))
|
||||
.load(&mut conn)?
|
||||
};
|
||||
let subfolders = child_folders.into_iter().map(folder_to_info).collect();
|
||||
let service = FolderService::new(&state);
|
||||
let FolderContentsData {
|
||||
folder,
|
||||
subfolders,
|
||||
documents,
|
||||
} = service.list_folder_contents(
|
||||
&mut conn,
|
||||
tenant_id,
|
||||
folder_id,
|
||||
sort,
|
||||
dir,
|
||||
include_documents,
|
||||
)?;
|
||||
|
||||
let documents = if include_documents {
|
||||
let mut docs_query = documents::table
|
||||
.filter(documents::deleted_at.is_null())
|
||||
.filter(documents::tenant_id.eq(tenant_id))
|
||||
.into_boxed();
|
||||
|
||||
let (primary_sql, secondary_sql) = ordering_clauses(sort, dir);
|
||||
docs_query = docs_query.order(sql::<Text>(primary_sql));
|
||||
if let Some(second) = secondary_sql {
|
||||
docs_query = docs_query.then_order_by(sql::<Text>(second));
|
||||
}
|
||||
|
||||
let docs: Vec<Document> = if let Some(current_folder) = folder_id {
|
||||
docs_query
|
||||
.filter(documents::folder_id.eq(current_folder))
|
||||
.load(&mut conn)?
|
||||
} else {
|
||||
docs_query
|
||||
.filter(documents::folder_id.is_null())
|
||||
.load(&mut conn)?
|
||||
};
|
||||
|
||||
hydrate_documents(&state, &mut conn, tenant_id, user_id, docs)?
|
||||
service.hydrate_documents(&mut conn, tenant_id, user_id, documents)?
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
@@ -444,64 +169,15 @@ pub async fn list_folder_contents(
|
||||
tag = "Folders"
|
||||
)]
|
||||
pub async fn list_folder_tree(
|
||||
State(state): State<AppState>,
|
||||
TenantScopedConn {
|
||||
mut conn,
|
||||
tenant_id,
|
||||
..
|
||||
}: TenantScopedConn,
|
||||
) -> AppResult<JsonResponse<Vec<FolderTreeNode>>> {
|
||||
let folders: Vec<Folder> = folders::table
|
||||
.filter(folders::tenant_id.eq(tenant_id))
|
||||
.order(sql::<Text>("name COLLATE \"unicode_ci\" ASC"))
|
||||
.load(&mut conn)?;
|
||||
|
||||
let mut node_map: HashMap<Uuid, FolderTreeNode> = HashMap::with_capacity(folders.len());
|
||||
let mut children_map: HashMap<Uuid, Vec<Uuid>> = HashMap::new();
|
||||
let mut roots: Vec<Uuid> = Vec::new();
|
||||
|
||||
for folder in folders {
|
||||
let id = folder.id;
|
||||
let parent_id = folder.parent_id;
|
||||
let node = FolderTreeNode {
|
||||
id,
|
||||
name: folder.name,
|
||||
parent_id,
|
||||
created_at: to_iso(folder.created_at),
|
||||
updated_at: to_iso(folder.updated_at),
|
||||
children: Vec::new(),
|
||||
};
|
||||
|
||||
if let Some(parent) = parent_id {
|
||||
children_map.entry(parent).or_default().push(id);
|
||||
} else {
|
||||
roots.push(id);
|
||||
}
|
||||
|
||||
node_map.insert(id, node);
|
||||
}
|
||||
|
||||
fn build_node(
|
||||
id: Uuid,
|
||||
nodes: &HashMap<Uuid, FolderTreeNode>,
|
||||
child_map: &HashMap<Uuid, Vec<Uuid>>,
|
||||
) -> FolderTreeNode {
|
||||
let mut node = nodes.get(&id).cloned().expect("folder node must exist");
|
||||
|
||||
if let Some(children) = child_map.get(&id) {
|
||||
node.children = children
|
||||
.iter()
|
||||
.map(|child_id| build_node(*child_id, nodes, child_map))
|
||||
.collect();
|
||||
}
|
||||
|
||||
node
|
||||
}
|
||||
|
||||
let tree = roots
|
||||
.iter()
|
||||
.map(|root_id| build_node(*root_id, &node_map, &children_map))
|
||||
.collect();
|
||||
|
||||
let service = FolderService::new(&state);
|
||||
let tree = service.list_folder_tree(&mut conn, tenant_id)?;
|
||||
ok_json(tree)
|
||||
}
|
||||
|
||||
@@ -513,6 +189,7 @@ pub async fn list_folder_tree(
|
||||
tag = "Folders"
|
||||
)]
|
||||
pub async fn delete_folder(
|
||||
State(state): State<AppState>,
|
||||
Path(folder_id): Path<Uuid>,
|
||||
TenantScopedConn {
|
||||
mut conn,
|
||||
@@ -520,51 +197,7 @@ pub async fn delete_folder(
|
||||
..
|
||||
}: TenantScopedConn,
|
||||
) -> AppResult<StatusCode> {
|
||||
conn.transaction::<_, AppError, _>(|conn| {
|
||||
folders::table
|
||||
.find(folder_id)
|
||||
.filter(folders::tenant_id.eq(tenant_id))
|
||||
.first::<Folder>(conn)?;
|
||||
|
||||
let has_child_folders: bool = diesel::select(exists(
|
||||
folders::table
|
||||
.filter(folders::parent_id.eq(Some(folder_id)))
|
||||
.filter(folders::tenant_id.eq(tenant_id)),
|
||||
))
|
||||
.get_result(conn)?;
|
||||
|
||||
if has_child_folders {
|
||||
return Err(AppError::bad_request(
|
||||
"folder must be empty before deletion",
|
||||
));
|
||||
}
|
||||
|
||||
let has_documents: bool = diesel::select(exists(
|
||||
documents::table
|
||||
.filter(documents::folder_id.eq(Some(folder_id)))
|
||||
.filter(documents::tenant_id.eq(tenant_id))
|
||||
.filter(documents::deleted_at.is_null()),
|
||||
))
|
||||
.get_result(conn)?;
|
||||
|
||||
if has_documents {
|
||||
return Err(AppError::bad_request(
|
||||
"folder must be empty before deletion",
|
||||
));
|
||||
}
|
||||
|
||||
diesel::delete(
|
||||
folders::table
|
||||
.filter(folders::id.eq(folder_id))
|
||||
.filter(folders::tenant_id.eq(tenant_id)),
|
||||
)
|
||||
.execute(conn)
|
||||
.into_app_result()?
|
||||
.or_not_found()?;
|
||||
|
||||
Ok(())
|
||||
})?;
|
||||
|
||||
FolderService::new(&state).delete_folder(&mut conn, tenant_id, folder_id)?;
|
||||
no_content()
|
||||
}
|
||||
|
||||
@@ -577,6 +210,7 @@ pub async fn delete_folder(
|
||||
tag = "Folders"
|
||||
)]
|
||||
pub async fn update_folder(
|
||||
State(state): State<AppState>,
|
||||
Path(folder_id): Path<Uuid>,
|
||||
TenantScopedConn {
|
||||
mut conn,
|
||||
@@ -585,145 +219,11 @@ pub async fn update_folder(
|
||||
}: TenantScopedConn,
|
||||
Json(payload): Json<UpdateFolderRequest>,
|
||||
) -> AppResult<StatusCode> {
|
||||
conn.transaction::<(), AppError, _>(|conn| {
|
||||
let folder: Folder = folders::table
|
||||
.find(folder_id)
|
||||
.filter(folders::tenant_id.eq(tenant_id))
|
||||
.first(conn)?;
|
||||
|
||||
let mut next_parent = folder.parent_id;
|
||||
let mut parent_changed = false;
|
||||
match payload.parent_id {
|
||||
None => {}
|
||||
Some(None) => {
|
||||
if folder.parent_id.is_some() {
|
||||
parent_changed = true;
|
||||
}
|
||||
next_parent = None;
|
||||
}
|
||||
Some(Some(parent_id)) => {
|
||||
if parent_id == folder_id {
|
||||
return Err(AppError::bad_request("folder cannot be its own parent"));
|
||||
}
|
||||
|
||||
let _parent: Folder = folders::table
|
||||
.find(parent_id)
|
||||
.filter(folders::tenant_id.eq(tenant_id))
|
||||
.first(conn)?;
|
||||
|
||||
if folder.parent_id != Some(parent_id) {
|
||||
let descendant_ids = gather_descendant_folder_ids(conn, tenant_id, folder_id)?;
|
||||
if descendant_ids.contains(&parent_id) {
|
||||
return Err(AppError::bad_request(
|
||||
"cannot move folder into itself or a descendant",
|
||||
));
|
||||
}
|
||||
parent_changed = true;
|
||||
}
|
||||
|
||||
next_parent = Some(parent_id);
|
||||
}
|
||||
}
|
||||
|
||||
let mut new_name = folder.name.clone();
|
||||
let mut name_changed = false;
|
||||
match payload.name {
|
||||
None => {}
|
||||
Some(None) => {
|
||||
return Err(AppError::bad_request("name cannot be null"));
|
||||
}
|
||||
Some(Some(value)) => {
|
||||
let trimmed = value.trim();
|
||||
if trimmed.is_empty() {
|
||||
return Err(AppError::bad_request("name must not be empty"));
|
||||
}
|
||||
|
||||
if trimmed != folder.name {
|
||||
new_name = trimmed.to_string();
|
||||
name_changed = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !parent_changed && !name_changed {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let conflict = if let Some(parent_id) = next_parent {
|
||||
folders::table
|
||||
.filter(folders::parent_id.eq(Some(parent_id)))
|
||||
.filter(folders::name.eq(&new_name))
|
||||
.filter(folders::id.ne(folder_id))
|
||||
.filter(folders::tenant_id.eq(tenant_id))
|
||||
.first::<Folder>(conn)
|
||||
.optional()?
|
||||
} else {
|
||||
folders::table
|
||||
.filter(folders::parent_id.is_null())
|
||||
.filter(folders::name.eq(&new_name))
|
||||
.filter(folders::id.ne(folder_id))
|
||||
.filter(folders::tenant_id.eq(tenant_id))
|
||||
.first::<Folder>(conn)
|
||||
.optional()?
|
||||
};
|
||||
|
||||
if conflict.is_some() {
|
||||
return Err(AppError::bad_request(
|
||||
"a folder with the same name already exists in the target",
|
||||
));
|
||||
}
|
||||
|
||||
diesel::update(
|
||||
folders::table
|
||||
.find(folder_id)
|
||||
.filter(folders::tenant_id.eq(tenant_id)),
|
||||
)
|
||||
.set((
|
||||
folders::parent_id.eq(next_parent),
|
||||
folders::name.eq(&new_name),
|
||||
))
|
||||
.execute(conn)
|
||||
.into_app_result()?
|
||||
.or_not_found()?;
|
||||
|
||||
Ok(())
|
||||
})?;
|
||||
|
||||
FolderService::new(&state).update_folder(&mut conn, tenant_id, folder_id, payload)?;
|
||||
no_content()
|
||||
}
|
||||
|
||||
fn folder_to_info(folder: Folder) -> FolderInfo {
|
||||
FolderInfo {
|
||||
id: folder.id,
|
||||
name: folder.name,
|
||||
parent_id: folder.parent_id,
|
||||
created_at: to_iso(folder.created_at),
|
||||
updated_at: to_iso(folder.updated_at),
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn gather_descendant_folder_ids(
|
||||
conn: &mut PgConnection,
|
||||
tenant_id: Uuid,
|
||||
folder_id: Uuid,
|
||||
) -> AppResult<Vec<Uuid>> {
|
||||
let mut ids = vec![folder_id];
|
||||
let mut queue = vec![folder_id];
|
||||
|
||||
while let Some(current) = queue.pop() {
|
||||
let child_ids: Vec<Uuid> = folders::table
|
||||
.filter(folders::parent_id.eq(Some(current)))
|
||||
.filter(folders::tenant_id.eq(tenant_id))
|
||||
.select(folders::id)
|
||||
.load(conn)?;
|
||||
queue.extend(child_ids.iter().copied());
|
||||
ids.extend(child_ids);
|
||||
}
|
||||
|
||||
Ok(ids)
|
||||
}
|
||||
|
||||
#[derive(utoipa::OpenApi)]
|
||||
#[derive(OpenApi)]
|
||||
#[openapi(
|
||||
paths(
|
||||
crate::routes::folders::create_folder,
|
||||
@@ -735,14 +235,14 @@ pub(super) fn gather_descendant_folder_ids(
|
||||
crate::routes::folders::update_folder
|
||||
),
|
||||
components(schemas(
|
||||
crate::routes::folders::CreateFolderRequest,
|
||||
crate::routes::folders::EnsureFolderPathRequest,
|
||||
crate::services::folders::CreateFolderRequest,
|
||||
crate::services::folders::EnsureFolderPathRequest,
|
||||
crate::routes::folders::FolderResponse,
|
||||
crate::routes::folders::FolderInfo,
|
||||
crate::routes::folders::FolderContentsQuery,
|
||||
crate::services::folders::FolderInfo,
|
||||
crate::services::folders::FolderContentsQuery,
|
||||
crate::routes::folders::FolderContentsResponse,
|
||||
crate::routes::folders::FolderTreeNode,
|
||||
crate::routes::folders::UpdateFolderRequest
|
||||
crate::services::folders::FolderTreeNode,
|
||||
crate::services::folders::UpdateFolderRequest
|
||||
))
|
||||
)]
|
||||
pub struct FoldersApiDoc;
|
||||
|
||||
+24
-162
@@ -3,63 +3,19 @@ use axum::{
|
||||
http::StatusCode,
|
||||
Json,
|
||||
};
|
||||
use chrono::{DateTime, NaiveDateTime};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use utoipa::ToSchema;
|
||||
use utoipa::OpenApi;
|
||||
use uuid::Uuid;
|
||||
|
||||
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,
|
||||
use crate::{
|
||||
auth::{passkeys::PasskeySummary, TenantScopedConn},
|
||||
error::AppResult,
|
||||
http::responders::JsonResponse,
|
||||
services::profile::{
|
||||
ApiTokenCreatedResponse, ApiTokenResponse, CreateApiTokenRequest, ProfileService,
|
||||
RevokePasskeyQuery,
|
||||
},
|
||||
capability_sets::load_capability_set,
|
||||
passkeys::PasskeySummary,
|
||||
TenantScopedConn,
|
||||
state::AppState,
|
||||
};
|
||||
use crate::error::{AppError, AppResult};
|
||||
use crate::http::responders::{created_json, no_content, ok_json, JsonResponse};
|
||||
use crate::models::ApiToken;
|
||||
use crate::state::{AppState, PgPooledConnection};
|
||||
use crate::utils::time::to_iso;
|
||||
|
||||
#[derive(Debug, Serialize, ToSchema)]
|
||||
pub struct ApiTokenResponse {
|
||||
pub id: Uuid,
|
||||
pub tenant_id: Uuid,
|
||||
#[schema(nullable)]
|
||||
pub label: Option<String>,
|
||||
pub capability_set_id: Uuid,
|
||||
pub created_at: String,
|
||||
#[schema(nullable)]
|
||||
pub last_used_at: Option<String>,
|
||||
#[schema(nullable)]
|
||||
pub expires_at: Option<String>,
|
||||
#[schema(nullable)]
|
||||
pub revoked_at: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, ToSchema)]
|
||||
pub struct ApiTokenCreatedResponse {
|
||||
pub token: String,
|
||||
pub token_info: ApiTokenResponse,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, ToSchema)]
|
||||
pub struct CreateApiTokenRequest {
|
||||
#[schema(nullable)]
|
||||
pub label: Option<String>,
|
||||
#[schema(nullable)]
|
||||
pub expires_at: Option<String>,
|
||||
pub capability_set_id: Uuid,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, ToSchema)]
|
||||
pub struct RevokePasskeyQuery {
|
||||
#[serde(default)]
|
||||
#[schema(nullable)]
|
||||
pub reason: Option<String>,
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
get,
|
||||
@@ -73,13 +29,7 @@ pub async fn list_passkeys(
|
||||
mut conn, user_id, ..
|
||||
}: TenantScopedConn,
|
||||
) -> AppResult<JsonResponse<Vec<PasskeySummary>>> {
|
||||
let service = state
|
||||
.passkeys
|
||||
.as_ref()
|
||||
.ok_or_else(|| AppError::bad_request("passkey support is disabled"))?;
|
||||
|
||||
let passkeys = service.list_for_user(&mut conn, user_id)?;
|
||||
ok_json(passkeys)
|
||||
ProfileService::new(&state).list_passkeys(&mut conn, user_id)
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
@@ -89,6 +39,7 @@ pub async fn list_passkeys(
|
||||
tag = "Profile"
|
||||
)]
|
||||
pub async fn list_api_tokens(
|
||||
State(state): State<AppState>,
|
||||
TenantScopedConn {
|
||||
mut conn,
|
||||
tenant_id,
|
||||
@@ -96,9 +47,7 @@ pub async fn list_api_tokens(
|
||||
..
|
||||
}: TenantScopedConn,
|
||||
) -> AppResult<JsonResponse<Vec<ApiTokenResponse>>> {
|
||||
let tokens = load_tokens(&mut conn, user_id, Some(tenant_id))?;
|
||||
let responses = tokens.into_iter().map(api_token_to_response).collect();
|
||||
ok_json(responses)
|
||||
ProfileService::new(&state).list_api_tokens(&mut conn, tenant_id, user_id)
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
@@ -109,6 +58,7 @@ pub async fn list_api_tokens(
|
||||
tag = "Profile"
|
||||
)]
|
||||
pub async fn create_api_token(
|
||||
State(state): State<AppState>,
|
||||
TenantScopedConn {
|
||||
mut conn,
|
||||
tenant_id,
|
||||
@@ -117,31 +67,7 @@ pub async fn create_api_token(
|
||||
}: TenantScopedConn,
|
||||
Json(payload): Json<CreateApiTokenRequest>,
|
||||
) -> AppResult<JsonResponse<ApiTokenCreatedResponse>> {
|
||||
let expires_at = match payload.expires_at {
|
||||
Some(ref value) => Some(parse_timestamp(value)?),
|
||||
None => None,
|
||||
};
|
||||
|
||||
let capability_set_id =
|
||||
validate_capability_set(&mut conn, tenant_id, payload.capability_set_id)?;
|
||||
|
||||
let issued = issue_token(
|
||||
&mut conn,
|
||||
user_id,
|
||||
tenant_id,
|
||||
payload.label.clone(),
|
||||
expires_at,
|
||||
capability_set_id,
|
||||
)?;
|
||||
|
||||
let token_info = api_token_to_response(issued.record);
|
||||
|
||||
let response = ApiTokenCreatedResponse {
|
||||
token: issued.token,
|
||||
token_info,
|
||||
};
|
||||
|
||||
created_json(response)
|
||||
ProfileService::new(&state).create_api_token(&mut conn, tenant_id, user_id, payload)
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
@@ -152,6 +78,7 @@ pub async fn create_api_token(
|
||||
tag = "Profile"
|
||||
)]
|
||||
pub async fn regenerate_api_token(
|
||||
State(state): State<AppState>,
|
||||
TenantScopedConn {
|
||||
mut conn,
|
||||
tenant_id,
|
||||
@@ -160,14 +87,7 @@ pub async fn regenerate_api_token(
|
||||
}: TenantScopedConn,
|
||||
Path(token_id): Path<Uuid>,
|
||||
) -> AppResult<JsonResponse<ApiTokenCreatedResponse>> {
|
||||
let issued = rotate_token(&mut conn, token_id, user_id, Some(tenant_id))?;
|
||||
let token_info = api_token_to_response(issued.record);
|
||||
let response = ApiTokenCreatedResponse {
|
||||
token: issued.token,
|
||||
token_info,
|
||||
};
|
||||
|
||||
ok_json(response)
|
||||
ProfileService::new(&state).regenerate_api_token(&mut conn, tenant_id, user_id, token_id)
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
@@ -178,13 +98,13 @@ pub async fn regenerate_api_token(
|
||||
tag = "Profile"
|
||||
)]
|
||||
pub async fn delete_api_token(
|
||||
State(state): State<AppState>,
|
||||
TenantScopedConn {
|
||||
mut conn, user_id, ..
|
||||
}: TenantScopedConn,
|
||||
Path(token_id): Path<Uuid>,
|
||||
) -> AppResult<StatusCode> {
|
||||
revoke_token(&mut conn, token_id, user_id)?;
|
||||
no_content()
|
||||
ProfileService::new(&state).delete_api_token(&mut conn, user_id, token_id)
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
@@ -205,68 +125,10 @@ pub async fn delete_passkey(
|
||||
Path(passkey_id): Path<Uuid>,
|
||||
Query(query): Query<RevokePasskeyQuery>,
|
||||
) -> AppResult<StatusCode> {
|
||||
let service = state
|
||||
.passkeys
|
||||
.as_ref()
|
||||
.ok_or_else(|| AppError::bad_request("passkey support is disabled"))?;
|
||||
|
||||
let active_count = service.active_passkey_count(&mut conn, user_id)?;
|
||||
if active_count <= 1 {
|
||||
return Err(AppError::bad_request(
|
||||
"cannot revoke the last remaining passkey",
|
||||
));
|
||||
}
|
||||
|
||||
service.revoke_passkey(&mut conn, user_id, passkey_id, query.reason)?;
|
||||
no_content()
|
||||
ProfileService::new(&state).delete_passkey(&mut conn, user_id, passkey_id, query.reason)
|
||||
}
|
||||
|
||||
fn api_token_to_response(token: ApiToken) -> ApiTokenResponse {
|
||||
let ApiToken {
|
||||
id,
|
||||
tenant_id,
|
||||
label,
|
||||
created_at,
|
||||
last_used_at,
|
||||
expires_at,
|
||||
revoked_at,
|
||||
capability_set_id,
|
||||
..
|
||||
} = token;
|
||||
|
||||
ApiTokenResponse {
|
||||
id,
|
||||
tenant_id,
|
||||
label,
|
||||
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> {
|
||||
let dt = DateTime::parse_from_rfc3339(value)
|
||||
.map_err(|_| AppError::bad_request("invalid expires_at timestamp"))?;
|
||||
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)]
|
||||
#[derive(OpenApi)]
|
||||
#[openapi(
|
||||
paths(
|
||||
crate::routes::profile::list_api_tokens,
|
||||
@@ -278,10 +140,10 @@ fn validate_capability_set(
|
||||
),
|
||||
components(schemas(
|
||||
crate::models::ApiCapability,
|
||||
crate::routes::profile::ApiTokenResponse,
|
||||
crate::routes::profile::ApiTokenCreatedResponse,
|
||||
crate::routes::profile::CreateApiTokenRequest,
|
||||
crate::routes::profile::RevokePasskeyQuery,
|
||||
crate::services::profile::ApiTokenResponse,
|
||||
crate::services::profile::ApiTokenCreatedResponse,
|
||||
crate::services::profile::CreateApiTokenRequest,
|
||||
crate::services::profile::RevokePasskeyQuery,
|
||||
crate::auth::passkeys::PasskeySummary
|
||||
))
|
||||
)]
|
||||
|
||||
@@ -0,0 +1,840 @@
|
||||
use axum::http::{header::SET_COOKIE, HeaderMap, HeaderValue, StatusCode};
|
||||
use axum::response::{IntoResponse, Response};
|
||||
use chrono::{Duration as ChronoDuration, TimeZone, Utc};
|
||||
use diesel::{pg::PgConnection, prelude::*, OptionalExtension};
|
||||
use rand::{rngs::OsRng, RngCore};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sha2::{Digest, Sha256};
|
||||
use utoipa::ToSchema;
|
||||
use uuid::Uuid;
|
||||
use webauthn_rs::prelude::RegisterPublicKeyCredential;
|
||||
|
||||
use crate::auth::{
|
||||
api_tokens::{find_active_token_by_secret, touch_api_token},
|
||||
capability_sets::load_capability_set,
|
||||
jwt::{AccessTokenContext, PrincipalKind},
|
||||
passkeys::{
|
||||
AuthenticationChallengeResponse, PasskeyLoginFinishPayload,
|
||||
PasskeyRegistrationFinishPayload, PasskeySummary, RegistrationChallengeResponse,
|
||||
},
|
||||
AuthenticatedUser,
|
||||
};
|
||||
use crate::error::{AppError, AppResult};
|
||||
use crate::http::responders::{ok_json, JsonResponse};
|
||||
use crate::models::{
|
||||
MagicToken, MagicTokenKind, NewUser, NewUserSession, TenantStatus, User, UserMembership,
|
||||
UserSession,
|
||||
};
|
||||
use crate::schema::{
|
||||
magic_tokens::dsl as magic_dsl,
|
||||
tenants::dsl as tenant_dsl,
|
||||
user_memberships::dsl as memberships_dsl,
|
||||
user_passkeys::dsl as passkey_dsl,
|
||||
user_sessions::{self, dsl as session_dsl},
|
||||
users::dsl,
|
||||
};
|
||||
use crate::services::ServiceContext;
|
||||
use crate::state::AppState;
|
||||
use crate::tenants::{
|
||||
apply_tenant_guc, apply_user_guc, apply_user_session_hash, clear_user_guc,
|
||||
clear_user_session_hash,
|
||||
};
|
||||
use crate::utils::text::normalize_identifier;
|
||||
|
||||
pub const SESSION_COOKIE_NAME: &str = "refresh_token";
|
||||
|
||||
#[derive(Deserialize, ToSchema)]
|
||||
pub struct LoginRequest {
|
||||
#[serde(default)]
|
||||
pub username: String,
|
||||
#[serde(default)]
|
||||
#[schema(nullable)]
|
||||
pub password: Option<String>,
|
||||
#[serde(default)]
|
||||
#[schema(nullable)]
|
||||
pub magic_token: Option<String>,
|
||||
#[serde(default)]
|
||||
#[schema(nullable)]
|
||||
pub preferred_tenant_id: Option<Uuid>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, ToSchema)]
|
||||
pub struct ApiTokenExchangeRequest {
|
||||
pub api_token: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, ToSchema, Clone)]
|
||||
pub struct LoginResponse {
|
||||
pub access_token: String,
|
||||
pub token_type: String,
|
||||
pub expires_in: i64,
|
||||
pub tenant: TenantSnippet,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, ToSchema, Clone)]
|
||||
pub struct TenantSnippet {
|
||||
pub id: Uuid,
|
||||
pub name: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, ToSchema, Clone)]
|
||||
pub struct TenantSelectionResponse {
|
||||
pub access_token: String,
|
||||
pub tenants: Vec<TenantSnippet>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, ToSchema, Clone)]
|
||||
pub struct TenantListResponse {
|
||||
pub tenants: Vec<TenantSnippet>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, ToSchema)]
|
||||
pub struct TenantSelectionRequest {
|
||||
pub tenant_id: Uuid,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, ToSchema)]
|
||||
pub struct SignupStartRequest {
|
||||
pub username: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, ToSchema)]
|
||||
pub struct SignupStartResponse {
|
||||
pub signup_token: String,
|
||||
pub challenge: RegistrationChallengeResponse,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, ToSchema)]
|
||||
pub struct SignupFinishRequest {
|
||||
pub signup_token: String,
|
||||
#[schema(value_type = Object)]
|
||||
pub credential: RegisterPublicKeyCredential,
|
||||
#[schema(nullable)]
|
||||
pub nickname: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, ToSchema)]
|
||||
#[serde(untagged)]
|
||||
pub enum LoginResponseVariants {
|
||||
Token(LoginResponse),
|
||||
Selection(TenantSelectionResponse),
|
||||
}
|
||||
|
||||
pub struct AuthService<'a> {
|
||||
state: &'a AppState,
|
||||
ctx: ServiceContext<'a>,
|
||||
}
|
||||
|
||||
impl<'a> AuthService<'a> {
|
||||
pub fn new(state: &'a AppState) -> Self {
|
||||
Self {
|
||||
state,
|
||||
ctx: ServiceContext::new(state),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn login(&self, payload: LoginRequest) -> AppResult<Response> {
|
||||
let magic_token = payload
|
||||
.magic_token
|
||||
.as_ref()
|
||||
.map(|value| value.trim())
|
||||
.filter(|value| !value.is_empty());
|
||||
|
||||
if magic_token.is_none() {
|
||||
if payload.password.is_some() {
|
||||
return Err(AppError::bad_request(
|
||||
"password authentication is no longer supported",
|
||||
));
|
||||
}
|
||||
|
||||
return Err(AppError::bad_request(
|
||||
"magic_token is required for passwordless login",
|
||||
));
|
||||
}
|
||||
|
||||
let token_value = magic_token.unwrap();
|
||||
|
||||
let mut conn = self.ctx.db_conn()?;
|
||||
let username_hint = payload.username.trim();
|
||||
let preferred_tenant_id = payload.preferred_tenant_id;
|
||||
|
||||
self.magic_token_login(
|
||||
&mut conn,
|
||||
token_value,
|
||||
(!username_hint.is_empty()).then_some(username_hint),
|
||||
preferred_tenant_id,
|
||||
)
|
||||
}
|
||||
|
||||
pub fn exchange_api_token(
|
||||
&self,
|
||||
payload: ApiTokenExchangeRequest,
|
||||
) -> AppResult<JsonResponse<LoginResponse>> {
|
||||
let secret = payload.api_token.trim();
|
||||
if secret.is_empty() {
|
||||
return Err(AppError::bad_request("api_token must not be empty"));
|
||||
}
|
||||
|
||||
let mut conn = self.ctx.db_conn()?;
|
||||
|
||||
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)?;
|
||||
|
||||
apply_user_guc(&mut conn, user.id)?;
|
||||
let membership = memberships_dsl::user_memberships
|
||||
.filter(memberships_dsl::user_id.eq(user.id))
|
||||
.filter(memberships_dsl::tenant_id.eq(token.tenant_id))
|
||||
.first::<UserMembership>(&mut conn)
|
||||
.optional()?;
|
||||
clear_user_guc(&mut conn)?;
|
||||
|
||||
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 = self
|
||||
.state
|
||||
.jwt
|
||||
.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
|
||||
.find(token.tenant_id)
|
||||
.select(tenant_dsl::name)
|
||||
.first(&mut conn)
|
||||
.map_err(AppError::from)?;
|
||||
|
||||
ok_json(LoginResponse {
|
||||
access_token,
|
||||
token_type: "Bearer".to_string(),
|
||||
expires_in: self.state.config.jwt_expiry_minutes * 60,
|
||||
tenant: TenantSnippet {
|
||||
id: token.tenant_id,
|
||||
name: tenant_name,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
pub fn signup_start(
|
||||
&self,
|
||||
payload: SignupStartRequest,
|
||||
) -> AppResult<JsonResponse<SignupStartResponse>> {
|
||||
let username = normalize_username(&payload.username)?;
|
||||
|
||||
let mut conn = self.ctx.db_conn()?;
|
||||
let exists: bool = dsl::users
|
||||
.filter(dsl::username.eq(&username))
|
||||
.first::<User>(&mut conn)
|
||||
.optional()?
|
||||
.is_some();
|
||||
if exists {
|
||||
return Err(AppError::conflict("username already exists"));
|
||||
}
|
||||
|
||||
let user_id = Uuid::new_v4();
|
||||
let challenge = self
|
||||
.state
|
||||
.passkeys
|
||||
.as_ref()
|
||||
.ok_or_else(|| AppError::bad_request("passkey support is disabled"))?
|
||||
.start_signup_registration(&mut conn, user_id, username.as_str())?;
|
||||
|
||||
let signup_token = self
|
||||
.state
|
||||
.jwt
|
||||
.generate_signup_token(user_id, challenge.challenge_id, username.clone())
|
||||
.map_err(AppError::from)?;
|
||||
|
||||
ok_json(SignupStartResponse {
|
||||
signup_token,
|
||||
challenge,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn signup_finish(&self, payload: SignupFinishRequest) -> AppResult<Response> {
|
||||
let claims = self
|
||||
.state
|
||||
.jwt
|
||||
.verify_signup_token(&payload.signup_token)
|
||||
.map_err(|_| AppError::unauthorized())?;
|
||||
|
||||
let mut conn = self.ctx.db_conn()?;
|
||||
|
||||
let exists: bool = dsl::users
|
||||
.filter(dsl::username.eq(&claims.username))
|
||||
.first::<User>(&mut conn)
|
||||
.optional()?
|
||||
.is_some();
|
||||
if exists {
|
||||
return Err(AppError::conflict("username already exists"));
|
||||
}
|
||||
|
||||
let service = self
|
||||
.state
|
||||
.passkeys
|
||||
.as_ref()
|
||||
.ok_or_else(|| AppError::bad_request("passkey support is disabled"))?;
|
||||
|
||||
let prepared_passkey = service.consume_signup_challenge(
|
||||
&mut conn,
|
||||
claims.challenge_id,
|
||||
&payload.credential,
|
||||
)?;
|
||||
|
||||
let state_clone = self.state.clone();
|
||||
let response = self.ctx.tx(&mut conn, |conn| {
|
||||
insert_user(conn, claims.sub, &claims.username)?;
|
||||
|
||||
let tenant = state_clone.tenants.create_tenant_with_conn(
|
||||
conn,
|
||||
&claims.username,
|
||||
None,
|
||||
None,
|
||||
TenantStatus::Creating,
|
||||
&[claims.sub],
|
||||
Some(claims.sub),
|
||||
)?;
|
||||
|
||||
let passkey_insert =
|
||||
prepared_passkey.into_new_user_passkey(claims.sub, payload.nickname.clone());
|
||||
|
||||
diesel::insert_into(passkey_dsl::user_passkeys)
|
||||
.values(&passkey_insert)
|
||||
.execute(conn)
|
||||
.map_err(AppError::from)?;
|
||||
|
||||
let user: User = dsl::users.find(claims.sub).first(conn)?;
|
||||
self.issue_session(conn, &user, tenant.id)
|
||||
})?;
|
||||
|
||||
Ok(response)
|
||||
}
|
||||
|
||||
pub fn refresh(&self, refresh_value: &str) -> AppResult<Response> {
|
||||
let hashed = hash_session_token(refresh_value);
|
||||
let mut conn = self.ctx.db_conn()?;
|
||||
let now = Utc::now();
|
||||
let now_naive = now.naive_utc();
|
||||
|
||||
apply_user_session_hash(&mut conn, &hashed)?;
|
||||
let token = match session_dsl::user_sessions
|
||||
.filter(session_dsl::token_hash.eq(&hashed))
|
||||
.filter(session_dsl::revoked_at.is_null())
|
||||
.filter(session_dsl::expires_at.gt(now_naive))
|
||||
.first::<UserSession>(&mut conn)
|
||||
{
|
||||
Ok(token) => token,
|
||||
Err(diesel::result::Error::NotFound) => return Err(AppError::unauthorized()),
|
||||
Err(err) => return Err(AppError::from(err)),
|
||||
};
|
||||
|
||||
clear_user_session_hash(&mut conn)?;
|
||||
apply_tenant_guc(&mut conn, token.tenant_id)?;
|
||||
clear_user_guc(&mut conn)?;
|
||||
|
||||
diesel::update(session_dsl::user_sessions.filter(session_dsl::id.eq(token.id)))
|
||||
.set((
|
||||
session_dsl::revoked_at.eq(now_naive),
|
||||
session_dsl::updated_at.eq(now_naive),
|
||||
))
|
||||
.execute(&mut conn)?;
|
||||
|
||||
let user: User = dsl::users
|
||||
.find(token.user_id)
|
||||
.first(&mut conn)
|
||||
.map_err(AppError::from)?;
|
||||
|
||||
self.issue_session(&mut conn, &user, token.tenant_id)
|
||||
}
|
||||
|
||||
pub fn select_tenant(&self, token: &str, tenant_id: Uuid) -> AppResult<Response> {
|
||||
let user_id = match self.state.jwt.verify_tenant_selector_token(token) {
|
||||
Ok(claims) => claims.sub,
|
||||
Err(_) => self
|
||||
.state
|
||||
.jwt
|
||||
.verify_token(token)
|
||||
.map(|claims| claims.sub)
|
||||
.map_err(|_| AppError::unauthorized())?,
|
||||
};
|
||||
|
||||
let mut conn = self.ctx.db_conn()?;
|
||||
apply_user_guc(&mut conn, user_id)?;
|
||||
|
||||
let membership_exists = memberships_dsl::user_memberships
|
||||
.filter(memberships_dsl::user_id.eq(user_id))
|
||||
.filter(memberships_dsl::tenant_id.eq(tenant_id))
|
||||
.select(memberships_dsl::tenant_id)
|
||||
.first::<Uuid>(&mut conn)
|
||||
.optional()?;
|
||||
|
||||
clear_user_guc(&mut conn)?;
|
||||
|
||||
if membership_exists.is_none() {
|
||||
return Err(AppError::unauthorized());
|
||||
}
|
||||
|
||||
let user: User = dsl::users
|
||||
.find(user_id)
|
||||
.first(&mut conn)
|
||||
.map_err(AppError::from)?;
|
||||
|
||||
self.issue_session(&mut conn, &user, tenant_id)
|
||||
}
|
||||
|
||||
pub fn logout(
|
||||
&self,
|
||||
user: AuthenticatedUser,
|
||||
refresh_cookie: Option<&str>,
|
||||
) -> AppResult<(HeaderMap, StatusCode)> {
|
||||
let mut conn = self.state.db_for_tenant(user.tenant_id)?;
|
||||
let now = Utc::now().naive_utc();
|
||||
|
||||
let revoked = if let Some(value) = refresh_cookie {
|
||||
let hashed = hash_session_token(value);
|
||||
diesel::update(
|
||||
session_dsl::user_sessions
|
||||
.filter(session_dsl::token_hash.eq(hashed))
|
||||
.filter(session_dsl::user_id.eq(user.user_id))
|
||||
.filter(session_dsl::revoked_at.is_null()),
|
||||
)
|
||||
.set((
|
||||
session_dsl::revoked_at.eq(now),
|
||||
session_dsl::updated_at.eq(now),
|
||||
))
|
||||
.execute(&mut conn)?
|
||||
} else {
|
||||
0
|
||||
};
|
||||
|
||||
if revoked == 0 {
|
||||
diesel::update(
|
||||
session_dsl::user_sessions
|
||||
.filter(session_dsl::user_id.eq(user.user_id))
|
||||
.filter(session_dsl::tenant_id.eq(user.tenant_id))
|
||||
.filter(session_dsl::revoked_at.is_null()),
|
||||
)
|
||||
.set((
|
||||
session_dsl::revoked_at.eq(now),
|
||||
session_dsl::updated_at.eq(now),
|
||||
))
|
||||
.execute(&mut conn)?;
|
||||
}
|
||||
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.insert(SET_COOKIE, build_clear_session_cookie(self.state));
|
||||
Ok((headers, StatusCode::NO_CONTENT))
|
||||
}
|
||||
|
||||
pub fn list_tenants(
|
||||
&self,
|
||||
user: AuthenticatedUser,
|
||||
) -> AppResult<JsonResponse<TenantListResponse>> {
|
||||
let mut conn = self.ctx.db_conn()?;
|
||||
apply_user_guc(&mut conn, user.user_id)?;
|
||||
|
||||
let tenant_ids: Vec<Uuid> = memberships_dsl::user_memberships
|
||||
.filter(memberships_dsl::user_id.eq(user.user_id))
|
||||
.select(memberships_dsl::tenant_id)
|
||||
.load(&mut conn)?;
|
||||
|
||||
clear_user_guc(&mut conn)?;
|
||||
|
||||
let mut tenants = Vec::with_capacity(tenant_ids.len());
|
||||
for tenant_id in tenant_ids {
|
||||
let mut tenant_conn = self.state.db_for_tenant(tenant_id)?;
|
||||
let name: String = tenant_dsl::tenants
|
||||
.find(tenant_id)
|
||||
.select(tenant_dsl::name)
|
||||
.first(&mut tenant_conn)
|
||||
.map_err(AppError::from)?;
|
||||
tenants.push(TenantSnippet {
|
||||
id: tenant_id,
|
||||
name,
|
||||
});
|
||||
}
|
||||
|
||||
ok_json(TenantListResponse { tenants })
|
||||
}
|
||||
|
||||
pub fn passkey_register_start(
|
||||
&self,
|
||||
user: AuthenticatedUser,
|
||||
) -> AppResult<JsonResponse<RegistrationChallengeResponse>> {
|
||||
let service = self
|
||||
.state
|
||||
.passkeys
|
||||
.as_ref()
|
||||
.ok_or_else(|| AppError::bad_request("passkey support is disabled"))?;
|
||||
|
||||
let mut conn = self.ctx.db_conn()?;
|
||||
let current_user: User = dsl::users.find(user.user_id).first(&mut conn)?;
|
||||
let challenge = service.start_registration(&mut conn, ¤t_user)?;
|
||||
ok_json(challenge)
|
||||
}
|
||||
|
||||
pub fn passkey_register_finish(
|
||||
&self,
|
||||
user: AuthenticatedUser,
|
||||
payload: PasskeyRegistrationFinishPayload,
|
||||
) -> AppResult<JsonResponse<PasskeySummary>> {
|
||||
let service = self
|
||||
.state
|
||||
.passkeys
|
||||
.as_ref()
|
||||
.ok_or_else(|| AppError::bad_request("passkey support is disabled"))?;
|
||||
|
||||
let mut conn = self.ctx.db_conn()?;
|
||||
let current_user: User = dsl::users.find(user.user_id).first(&mut conn)?;
|
||||
|
||||
let PasskeyRegistrationFinishPayload {
|
||||
challenge_id,
|
||||
credential,
|
||||
nickname,
|
||||
} = payload;
|
||||
|
||||
let passkey = service.finish_registration(
|
||||
&mut conn,
|
||||
¤t_user,
|
||||
challenge_id,
|
||||
credential,
|
||||
nickname,
|
||||
)?;
|
||||
|
||||
ok_json(PasskeySummary::from(passkey))
|
||||
}
|
||||
|
||||
pub fn passkey_login_start(
|
||||
&self,
|
||||
username: &str,
|
||||
) -> AppResult<JsonResponse<AuthenticationChallengeResponse>> {
|
||||
let username = normalize_username(username)?;
|
||||
|
||||
let service = self
|
||||
.state
|
||||
.passkeys
|
||||
.as_ref()
|
||||
.ok_or_else(|| AppError::bad_request("passkey support is disabled"))?;
|
||||
|
||||
let mut conn = self.ctx.db_conn()?;
|
||||
let user: User = dsl::users
|
||||
.filter(dsl::username.eq(&username))
|
||||
.first(&mut conn)?;
|
||||
|
||||
let challenge = service.start_authentication(&mut conn, &user)?;
|
||||
ok_json(challenge)
|
||||
}
|
||||
|
||||
pub fn passkey_login_finish(&self, payload: PasskeyLoginFinishPayload) -> AppResult<Response> {
|
||||
let service = self
|
||||
.state
|
||||
.passkeys
|
||||
.as_ref()
|
||||
.ok_or_else(|| AppError::bad_request("passkey support is disabled"))?;
|
||||
|
||||
let mut conn = self.ctx.db_conn()?;
|
||||
let (user, _passkey, auth_result) =
|
||||
service.finish_authentication(&mut conn, payload.challenge_id, payload.credential)?;
|
||||
|
||||
if !auth_result.user_verified() {
|
||||
return Err(AppError::unauthorized());
|
||||
}
|
||||
|
||||
self.complete_login(&mut conn, &user, None)
|
||||
}
|
||||
|
||||
fn complete_login(
|
||||
&self,
|
||||
conn: &mut PgConnection,
|
||||
user: &User,
|
||||
preferred_tenant_id: Option<Uuid>,
|
||||
) -> AppResult<Response> {
|
||||
apply_user_guc(conn, user.id)?;
|
||||
let tenant_ids: Vec<Uuid> = memberships_dsl::user_memberships
|
||||
.filter(memberships_dsl::user_id.eq(user.id))
|
||||
.select(memberships_dsl::tenant_id)
|
||||
.load(conn)?;
|
||||
clear_user_guc(conn)?;
|
||||
|
||||
if tenant_ids.is_empty() {
|
||||
return Err(AppError::unauthorized());
|
||||
}
|
||||
|
||||
if let Some(preferred_id) = preferred_tenant_id {
|
||||
if tenant_ids.iter().any(|id| *id == preferred_id) {
|
||||
return self.issue_session(conn, user, preferred_id);
|
||||
}
|
||||
}
|
||||
|
||||
if tenant_ids.len() == 1 {
|
||||
return self.issue_session(conn, user, tenant_ids[0]);
|
||||
}
|
||||
|
||||
let selection_token = self
|
||||
.state
|
||||
.jwt
|
||||
.generate_tenant_selector_token(user.id)
|
||||
.map_err(AppError::from)?;
|
||||
|
||||
let mut tenants = Vec::with_capacity(tenant_ids.len());
|
||||
for tenant_id in tenant_ids {
|
||||
let mut tenant_conn = self.state.db_for_tenant(tenant_id)?;
|
||||
let name: String = tenant_dsl::tenants
|
||||
.find(tenant_id)
|
||||
.select(tenant_dsl::name)
|
||||
.first(&mut tenant_conn)
|
||||
.map_err(AppError::from)?;
|
||||
tenants.push(TenantSnippet {
|
||||
id: tenant_id,
|
||||
name,
|
||||
});
|
||||
}
|
||||
|
||||
let response = ok_json(LoginResponseVariants::Selection(TenantSelectionResponse {
|
||||
access_token: selection_token,
|
||||
tenants,
|
||||
}))?;
|
||||
|
||||
Ok(response.into_response())
|
||||
}
|
||||
|
||||
fn magic_token_login(
|
||||
&self,
|
||||
conn: &mut PgConnection,
|
||||
token_value: &str,
|
||||
username_hint: Option<&str>,
|
||||
preferred_tenant_id: Option<Uuid>,
|
||||
) -> AppResult<Response> {
|
||||
if token_value.is_empty() {
|
||||
return Err(AppError::bad_request("magic_token must not be empty"));
|
||||
}
|
||||
|
||||
let token_hash = hash_magic_token(token_value);
|
||||
let now = Utc::now();
|
||||
let now_naive = now.naive_utc();
|
||||
|
||||
self.ctx.tx(conn, |conn| {
|
||||
let magic = magic_dsl::magic_tokens
|
||||
.filter(magic_dsl::token_hash.eq(&token_hash))
|
||||
.filter(magic_dsl::expires_at.gt(now_naive))
|
||||
.first::<MagicToken>(conn)
|
||||
.map_err(|err| match err {
|
||||
diesel::result::Error::NotFound => AppError::unauthorized(),
|
||||
_ => AppError::from(err),
|
||||
})?;
|
||||
|
||||
if let Some(limit) = magic.max_uses {
|
||||
if magic.used_count >= limit {
|
||||
return Err(AppError::unauthorized());
|
||||
}
|
||||
}
|
||||
|
||||
match magic.kind {
|
||||
MagicTokenKind::EmailLogin | MagicTokenKind::DemoLogin => {}
|
||||
}
|
||||
|
||||
let user: User = dsl::users
|
||||
.find(magic.user_id)
|
||||
.first(conn)
|
||||
.map_err(AppError::from)?;
|
||||
|
||||
if let Some(expected) = username_hint {
|
||||
if expected != user.username {
|
||||
return Err(AppError::unauthorized());
|
||||
}
|
||||
}
|
||||
|
||||
diesel::update(magic_dsl::magic_tokens.filter(magic_dsl::id.eq(magic.id)))
|
||||
.set((
|
||||
magic_dsl::used_count.eq(magic.used_count + 1),
|
||||
magic_dsl::last_used_at.eq(Some(now_naive)),
|
||||
))
|
||||
.execute(conn)?;
|
||||
|
||||
self.complete_login(conn, &user, preferred_tenant_id)
|
||||
})
|
||||
}
|
||||
|
||||
fn issue_session(
|
||||
&self,
|
||||
conn: &mut PgConnection,
|
||||
user: &User,
|
||||
tenant_id: Uuid,
|
||||
) -> AppResult<Response> {
|
||||
apply_tenant_guc(conn, tenant_id)?;
|
||||
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 = self
|
||||
.state
|
||||
.jwt
|
||||
.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
|
||||
.find(tenant_id)
|
||||
.select(tenant_dsl::name)
|
||||
.first(conn)
|
||||
.map_err(AppError::from)?;
|
||||
|
||||
let session_value = generate_session_token();
|
||||
let session_hash = hash_session_token(&session_value);
|
||||
let refresh_expires_at =
|
||||
now + ChronoDuration::days(self.state.config.refresh_token_expiry_days);
|
||||
|
||||
let new_session = NewUserSession {
|
||||
id: session_id,
|
||||
user_id: user.id,
|
||||
token_hash: session_hash,
|
||||
issued_at: now.naive_utc(),
|
||||
expires_at: refresh_expires_at.naive_utc(),
|
||||
tenant_id,
|
||||
};
|
||||
|
||||
diesel::insert_into(user_sessions::table)
|
||||
.values(&new_session)
|
||||
.execute(conn)?;
|
||||
|
||||
let json = ok_json(LoginResponseVariants::Token(LoginResponse {
|
||||
access_token,
|
||||
token_type: "Bearer".to_string(),
|
||||
expires_in: self.state.config.jwt_expiry_minutes * 60,
|
||||
tenant: TenantSnippet {
|
||||
id: tenant_id,
|
||||
name: tenant_name,
|
||||
},
|
||||
}))?;
|
||||
|
||||
let mut response = json.into_response();
|
||||
|
||||
response.headers_mut().insert(
|
||||
SET_COOKIE,
|
||||
build_session_cookie(self.state, &session_value, refresh_expires_at),
|
||||
);
|
||||
|
||||
Ok(response)
|
||||
}
|
||||
}
|
||||
|
||||
fn insert_user(conn: &mut PgConnection, id: Uuid, username: &str) -> AppResult<()> {
|
||||
let new_user = NewUser {
|
||||
id,
|
||||
username: username.to_string(),
|
||||
};
|
||||
|
||||
diesel::insert_into(dsl::users)
|
||||
.values(&new_user)
|
||||
.execute(conn)
|
||||
.map(|_| ())
|
||||
.map_err(AppError::from)
|
||||
}
|
||||
|
||||
fn hash_session_token(token: &str) -> String {
|
||||
let mut hasher = Sha256::new();
|
||||
hasher.update(token.as_bytes());
|
||||
hex::encode(hasher.finalize())
|
||||
}
|
||||
|
||||
fn hash_magic_token(token: &str) -> String {
|
||||
hash_session_token(token)
|
||||
}
|
||||
|
||||
fn generate_session_token() -> String {
|
||||
let mut bytes = [0u8; 32];
|
||||
OsRng.fill_bytes(&mut bytes);
|
||||
hex::encode(bytes)
|
||||
}
|
||||
|
||||
fn build_cookie(
|
||||
state: &AppState,
|
||||
token: Option<&str>,
|
||||
expires_at: Option<chrono::DateTime<Utc>>,
|
||||
max_age: i64,
|
||||
) -> HeaderValue {
|
||||
let mut parts = vec![format!("{}={}", SESSION_COOKIE_NAME, token.unwrap_or(""))];
|
||||
parts.push("Path=/".into());
|
||||
parts.push("HttpOnly".into());
|
||||
parts.push("SameSite=Strict".into());
|
||||
parts.push(format!("Max-Age={}", max_age));
|
||||
if let Some(expires) = expires_at {
|
||||
parts.push(format!("Expires={}", expires.to_rfc2822()));
|
||||
}
|
||||
if state.config.refresh_cookie_secure {
|
||||
parts.push("Secure".into());
|
||||
}
|
||||
if let Some(domain) = &state.config.refresh_cookie_domain {
|
||||
parts.push(format!("Domain={}", domain));
|
||||
}
|
||||
|
||||
HeaderValue::from_str(&parts.join("; ")).expect("valid session cookie")
|
||||
}
|
||||
|
||||
fn build_session_cookie(
|
||||
state: &AppState,
|
||||
token: &str,
|
||||
expires_at: chrono::DateTime<Utc>,
|
||||
) -> HeaderValue {
|
||||
let max_age = ChronoDuration::days(state.config.refresh_token_expiry_days).num_seconds();
|
||||
build_cookie(state, Some(token), Some(expires_at), max_age)
|
||||
}
|
||||
|
||||
fn build_clear_session_cookie(state: &AppState) -> HeaderValue {
|
||||
let epoch = Utc.timestamp_opt(0, 0).single().unwrap();
|
||||
build_cookie(state, None, Some(epoch), 0)
|
||||
}
|
||||
|
||||
fn normalize_username(value: &str) -> AppResult<String> {
|
||||
normalize_identifier(
|
||||
value,
|
||||
100,
|
||||
"username must not be empty",
|
||||
"username must not exceed 100 characters",
|
||||
Some("username may only contain printable characters"),
|
||||
|ch| !ch.is_control(),
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,294 @@
|
||||
use axum::http::StatusCode;
|
||||
use chrono::Utc;
|
||||
use diesel::{pg::PgConnection, prelude::*, OptionalExtension};
|
||||
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,
|
||||
},
|
||||
error::{AppError, AppResult},
|
||||
http::responders::{
|
||||
created_json, no_content, ok_json, IntoAppResult, JsonResponse, RowsAffectedExt,
|
||||
},
|
||||
models::{ApiCapability, CapabilitySet},
|
||||
schema::{
|
||||
api_tokens,
|
||||
capability_sets::{self, dsl as cs_dsl},
|
||||
user_memberships,
|
||||
},
|
||||
services::TransactionExt,
|
||||
utils::text::normalize_identifier,
|
||||
};
|
||||
|
||||
#[derive(Serialize, utoipa::ToSchema)]
|
||||
pub struct CapabilitySetResponse {
|
||||
pub id: Uuid,
|
||||
pub slug: 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>>,
|
||||
}
|
||||
|
||||
pub struct CapabilitySetService;
|
||||
|
||||
impl CapabilitySetService {
|
||||
pub fn new() -> Self {
|
||||
Self
|
||||
}
|
||||
|
||||
pub fn list(
|
||||
&self,
|
||||
conn: &mut PgConnection,
|
||||
tenant_id: Uuid,
|
||||
) -> AppResult<JsonResponse<Vec<CapabilitySetResponse>>> {
|
||||
let sets = cs_dsl::capability_sets
|
||||
.filter(cs_dsl::tenant_id.eq(tenant_id))
|
||||
.order(cs_dsl::slug.asc())
|
||||
.load::<CapabilitySet>(conn)?;
|
||||
|
||||
let mut responses = Vec::with_capacity(sets.len());
|
||||
for set in sets {
|
||||
let capabilities = load_capabilities_for_set(conn, set.id)?;
|
||||
responses.push(to_response(set, capabilities));
|
||||
}
|
||||
|
||||
ok_json(responses)
|
||||
}
|
||||
|
||||
pub fn list_capabilities(&self) -> AppResult<JsonResponse<Vec<ApiCapability>>> {
|
||||
let capabilities = ApiCapability::variants()
|
||||
.iter()
|
||||
.map(|value| value.parse::<ApiCapability>().expect("valid capability"))
|
||||
.collect();
|
||||
ok_json(capabilities)
|
||||
}
|
||||
|
||||
pub fn get(
|
||||
&self,
|
||||
conn: &mut PgConnection,
|
||||
tenant_id: Uuid,
|
||||
id: Uuid,
|
||||
) -> AppResult<JsonResponse<CapabilitySetResponse>> {
|
||||
let set = cs_dsl::capability_sets
|
||||
.filter(cs_dsl::tenant_id.eq(tenant_id))
|
||||
.find(id)
|
||||
.first::<CapabilitySet>(conn)
|
||||
.into_app_result()?;
|
||||
|
||||
let capabilities = load_capabilities_for_set(conn, set.id)?;
|
||||
ok_json(to_response(set, capabilities))
|
||||
}
|
||||
|
||||
pub fn create(
|
||||
&self,
|
||||
conn: &mut PgConnection,
|
||||
tenant_id: Uuid,
|
||||
payload: CreateCapabilitySetRequest,
|
||||
) -> AppResult<JsonResponse<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(conn, tenant_id, &slug, original_caps)?;
|
||||
let response = to_response(set, normalized_caps);
|
||||
|
||||
created_json(response)
|
||||
}
|
||||
|
||||
pub fn update(
|
||||
&self,
|
||||
conn: &mut PgConnection,
|
||||
tenant_id: Uuid,
|
||||
id: Uuid,
|
||||
payload: UpdateCapabilitySetRequest,
|
||||
) -> AppResult<JsonResponse<CapabilitySetResponse>> {
|
||||
let set = cs_dsl::capability_sets
|
||||
.filter(cs_dsl::tenant_id.eq(tenant_id))
|
||||
.find(id)
|
||||
.first::<CapabilitySet>(conn)
|
||||
.into_app_result()?;
|
||||
|
||||
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(conn, set.id)?;
|
||||
return ok_json(to_response(set, capabilities));
|
||||
}
|
||||
|
||||
let set = conn.app_transaction(|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()
|
||||
.into_app_result()?
|
||||
.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)
|
||||
.into_app_result()?;
|
||||
|
||||
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)
|
||||
.into_app_result()
|
||||
})?;
|
||||
|
||||
let capabilities = load_capabilities_for_set(conn, set.id)?;
|
||||
ok_json(to_response(set, capabilities))
|
||||
}
|
||||
|
||||
pub fn delete(
|
||||
&self,
|
||||
conn: &mut PgConnection,
|
||||
tenant_id: Uuid,
|
||||
id: Uuid,
|
||||
) -> AppResult<StatusCode> {
|
||||
let set = cs_dsl::capability_sets
|
||||
.filter(cs_dsl::tenant_id.eq(tenant_id))
|
||||
.find(id)
|
||||
.first::<CapabilitySet>(conn)
|
||||
.into_app_result()?;
|
||||
|
||||
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(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(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(conn)
|
||||
.into_app_result()?
|
||||
.or_not_found()?;
|
||||
|
||||
no_content()
|
||||
}
|
||||
}
|
||||
|
||||
fn normalize_slug(value: &str) -> AppResult<String> {
|
||||
let base = normalize_identifier(
|
||||
value,
|
||||
64,
|
||||
"slug must not be empty",
|
||||
"slug must not exceed 64 characters",
|
||||
Some("slug may only contain alphanumeric characters, hyphen, underscore, or whitespace"),
|
||||
|ch| ch.is_ascii_alphanumeric() || ch == '-' || ch == '_' || ch.is_whitespace(),
|
||||
)?;
|
||||
|
||||
let mut normalized = String::with_capacity(base.len());
|
||||
for ch in base.chars() {
|
||||
if ch.is_whitespace() {
|
||||
normalized.push('-');
|
||||
} else {
|
||||
normalized.push(ch.to_ascii_lowercase());
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
is_system: set.is_system,
|
||||
cap_version: set.cap_version,
|
||||
capabilities,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,262 @@
|
||||
use chrono::Utc;
|
||||
use diesel::{dsl::not, prelude::*};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use utoipa::ToSchema;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::documents::correspondents::{
|
||||
insert_document_correspondents, normalize_correspondent_ids,
|
||||
};
|
||||
use crate::error::{AppError, AppResult};
|
||||
use crate::schema::{document_correspondents, documents};
|
||||
use crate::services::helpers::load_active_document;
|
||||
use crate::services::TransactionExt;
|
||||
use crate::state::{AppState, PgPooledConnection};
|
||||
use crate::utils::db::validate_bulk_ids;
|
||||
|
||||
#[derive(Serialize, ToSchema)]
|
||||
pub struct BulkCorrespondentResponse {
|
||||
pub assigned: usize,
|
||||
pub removed: usize,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, ToSchema)]
|
||||
pub struct CorrespondentAssignmentInput {
|
||||
pub correspondent_id: Uuid,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, ToSchema)]
|
||||
pub struct AssignCorrespondentsRequest {
|
||||
pub assignments: Vec<CorrespondentAssignmentInput>,
|
||||
#[serde(default)]
|
||||
#[schema(default = false)]
|
||||
pub replace: bool,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Copy, Clone, PartialEq, Eq, ToSchema)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum BulkCorrespondentAction {
|
||||
Add,
|
||||
Remove,
|
||||
}
|
||||
|
||||
fn default_bulk_correspondent_action() -> BulkCorrespondentAction {
|
||||
BulkCorrespondentAction::Add
|
||||
}
|
||||
|
||||
#[derive(Deserialize, ToSchema)]
|
||||
pub struct BulkCorrespondentsRequest {
|
||||
pub document_ids: Vec<Uuid>,
|
||||
pub assignments: Vec<CorrespondentAssignmentInput>,
|
||||
#[serde(default = "default_bulk_correspondent_action")]
|
||||
pub action: BulkCorrespondentAction,
|
||||
}
|
||||
|
||||
pub struct CorrespondentsService<'a> {
|
||||
_state: &'a AppState,
|
||||
}
|
||||
|
||||
impl<'a> CorrespondentsService<'a> {
|
||||
pub fn new(state: &'a AppState) -> Self {
|
||||
Self { _state: state }
|
||||
}
|
||||
|
||||
pub fn assign_to_document(
|
||||
&self,
|
||||
conn: &mut PgPooledConnection,
|
||||
tenant_id: Uuid,
|
||||
user_id: Uuid,
|
||||
document_id: Uuid,
|
||||
request: &AssignCorrespondentsRequest,
|
||||
) -> AppResult<()> {
|
||||
if request.assignments.is_empty() {
|
||||
return Err(AppError::bad_request("assignments must not be empty"));
|
||||
}
|
||||
|
||||
let raw_ids: Vec<Uuid> = request
|
||||
.assignments
|
||||
.iter()
|
||||
.map(|assignment| assignment.correspondent_id)
|
||||
.collect();
|
||||
let correspondent_ids = normalize_correspondent_ids(&raw_ids)?;
|
||||
|
||||
conn.app_transaction(|conn| {
|
||||
let document = load_active_document(conn, tenant_id, document_id)?;
|
||||
|
||||
let mut updated = false;
|
||||
if request.replace {
|
||||
let base = document_correspondents::table
|
||||
.filter(document_correspondents::document_id.eq(document_id))
|
||||
.filter(document_correspondents::tenant_id.eq(tenant_id));
|
||||
|
||||
let removed = if correspondent_ids.is_empty() {
|
||||
diesel::delete(base).execute(conn)?
|
||||
} else {
|
||||
diesel::delete(base.filter(not(
|
||||
document_correspondents::correspondent_id.eq_any(&correspondent_ids),
|
||||
)))
|
||||
.execute(conn)?
|
||||
};
|
||||
|
||||
if removed > 0 {
|
||||
updated = true;
|
||||
}
|
||||
}
|
||||
|
||||
let inserted = insert_document_correspondents(
|
||||
conn,
|
||||
tenant_id,
|
||||
document.id,
|
||||
user_id,
|
||||
&correspondent_ids,
|
||||
)?;
|
||||
|
||||
if inserted > 0 {
|
||||
updated = true;
|
||||
}
|
||||
|
||||
if updated && inserted == 0 {
|
||||
diesel::update(
|
||||
documents::table
|
||||
.find(document_id)
|
||||
.filter(documents::tenant_id.eq(tenant_id)),
|
||||
)
|
||||
.set(documents::updated_at.eq(Utc::now().naive_utc()))
|
||||
.execute(conn)?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
|
||||
pub fn bulk_update(
|
||||
&self,
|
||||
conn: &mut PgPooledConnection,
|
||||
tenant_id: Uuid,
|
||||
user_id: Uuid,
|
||||
mut payload: BulkCorrespondentsRequest,
|
||||
) -> AppResult<BulkCorrespondentResponse> {
|
||||
if payload.assignments.is_empty() {
|
||||
return Err(AppError::bad_request("assignments must not be empty"));
|
||||
}
|
||||
|
||||
validate_bulk_ids(&mut payload.document_ids, "document_ids")?;
|
||||
|
||||
let raw_ids: Vec<Uuid> = payload
|
||||
.assignments
|
||||
.iter()
|
||||
.map(|assignment| assignment.correspondent_id)
|
||||
.collect();
|
||||
let correspondent_ids = normalize_correspondent_ids(&raw_ids)?;
|
||||
|
||||
let action = payload.action;
|
||||
|
||||
conn.app_transaction(|conn| {
|
||||
let docs: Vec<(Uuid, Option<chrono::NaiveDateTime>)> = documents::table
|
||||
.filter(documents::id.eq_any(&payload.document_ids))
|
||||
.filter(documents::tenant_id.eq(tenant_id))
|
||||
.select((documents::id, documents::deleted_at))
|
||||
.load(conn)?;
|
||||
|
||||
if docs.len() != payload.document_ids.len() {
|
||||
return Err(AppError::bad_request(
|
||||
"one or more documents do not exist or are inaccessible",
|
||||
));
|
||||
}
|
||||
|
||||
if docs.iter().any(|(_, deleted)| deleted.is_some()) {
|
||||
return Err(AppError::bad_request(
|
||||
"cannot assign correspondents to deleted documents",
|
||||
));
|
||||
}
|
||||
|
||||
match action {
|
||||
BulkCorrespondentAction::Add => {
|
||||
let mut assigned_total = 0;
|
||||
for (doc_id, _) in &docs {
|
||||
assigned_total += insert_document_correspondents(
|
||||
conn,
|
||||
tenant_id,
|
||||
*doc_id,
|
||||
user_id,
|
||||
&correspondent_ids,
|
||||
)?;
|
||||
}
|
||||
|
||||
Ok(BulkCorrespondentResponse {
|
||||
assigned: assigned_total,
|
||||
removed: 0,
|
||||
})
|
||||
}
|
||||
BulkCorrespondentAction::Remove => {
|
||||
if correspondent_ids.is_empty() {
|
||||
return Ok(BulkCorrespondentResponse {
|
||||
assigned: 0,
|
||||
removed: 0,
|
||||
});
|
||||
}
|
||||
|
||||
let removed = diesel::delete(
|
||||
document_correspondents::table
|
||||
.filter(
|
||||
document_correspondents::document_id.eq_any(&payload.document_ids),
|
||||
)
|
||||
.filter(document_correspondents::tenant_id.eq(tenant_id))
|
||||
.filter(
|
||||
document_correspondents::correspondent_id
|
||||
.eq_any(&correspondent_ids),
|
||||
),
|
||||
)
|
||||
.execute(conn)?;
|
||||
|
||||
if removed > 0 {
|
||||
diesel::update(
|
||||
documents::table
|
||||
.filter(documents::id.eq_any(&payload.document_ids))
|
||||
.filter(documents::tenant_id.eq(tenant_id)),
|
||||
)
|
||||
.set(documents::updated_at.eq(Utc::now().naive_utc()))
|
||||
.execute(conn)?;
|
||||
}
|
||||
|
||||
Ok(BulkCorrespondentResponse {
|
||||
assigned: 0,
|
||||
removed,
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
pub fn remove_from_document(
|
||||
&self,
|
||||
conn: &mut PgPooledConnection,
|
||||
tenant_id: Uuid,
|
||||
document_id: Uuid,
|
||||
correspondent_id: Uuid,
|
||||
) -> AppResult<()> {
|
||||
load_active_document(conn, tenant_id, document_id)?;
|
||||
|
||||
let deleted = diesel::delete(
|
||||
document_correspondents::table
|
||||
.filter(document_correspondents::document_id.eq(document_id))
|
||||
.filter(document_correspondents::tenant_id.eq(tenant_id))
|
||||
.filter(document_correspondents::correspondent_id.eq(correspondent_id)),
|
||||
)
|
||||
.execute(conn)?;
|
||||
|
||||
if deleted == 0 {
|
||||
return Err(AppError::not_found());
|
||||
}
|
||||
|
||||
diesel::update(
|
||||
documents::table
|
||||
.find(document_id)
|
||||
.filter(documents::tenant_id.eq(tenant_id)),
|
||||
)
|
||||
.set(documents::updated_at.eq(Utc::now().naive_utc()))
|
||||
.execute(conn)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,637 @@
|
||||
use std::collections::HashMap;
|
||||
|
||||
use diesel::pg::PgConnection;
|
||||
use diesel::{
|
||||
dsl::{exists, sql},
|
||||
prelude::*,
|
||||
sql_types::Text,
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use utoipa::{IntoParams, ToSchema};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::documents::ordering::{ordering_clauses, DocumentSortField, SortDirection};
|
||||
use crate::error::{AppError, AppResult};
|
||||
use crate::http::responders::{IntoAppResult, RowsAffectedExt};
|
||||
use crate::models::{Document, Folder, NewFolder};
|
||||
use crate::schema::{documents, folders};
|
||||
use crate::services::{
|
||||
documents::{DocumentResponse, DocumentsService},
|
||||
TransactionExt,
|
||||
};
|
||||
use crate::state::{AppState, PgPooledConnection};
|
||||
use crate::utils::{json::deserialize_patch_field, text::normalize_identifier, time::to_iso};
|
||||
|
||||
const MAX_FOLDER_NAME_LEN: usize = 255;
|
||||
|
||||
#[derive(Deserialize, ToSchema)]
|
||||
pub struct CreateFolderRequest {
|
||||
pub name: String,
|
||||
#[schema(nullable)]
|
||||
pub parent_id: Option<Uuid>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, ToSchema)]
|
||||
pub struct EnsureFolderPathRequest {
|
||||
#[schema(nullable)]
|
||||
pub parent_id: Option<Uuid>,
|
||||
pub segments: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, ToSchema, Clone, Debug)]
|
||||
pub struct FolderInfo {
|
||||
pub id: Uuid,
|
||||
pub name: String,
|
||||
#[schema(nullable)]
|
||||
pub parent_id: Option<Uuid>,
|
||||
pub created_at: String,
|
||||
pub updated_at: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Serialize, ToSchema)]
|
||||
pub struct FolderTreeNode {
|
||||
pub id: Uuid,
|
||||
pub name: String,
|
||||
#[schema(nullable)]
|
||||
pub parent_id: Option<Uuid>,
|
||||
pub created_at: String,
|
||||
pub updated_at: String,
|
||||
#[serde(default)]
|
||||
pub children: Vec<FolderTreeNode>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, IntoParams, ToSchema)]
|
||||
#[into_params(parameter_in = Query)]
|
||||
pub struct FolderContentsQuery {
|
||||
#[serde(default = "default_include_documents")]
|
||||
#[schema(default = true)]
|
||||
pub include_documents: bool,
|
||||
#[serde(default)]
|
||||
#[schema(default = "title")]
|
||||
pub sort: DocumentSortField,
|
||||
#[serde(default)]
|
||||
#[schema(default = "asc")]
|
||||
pub dir: SortDirection,
|
||||
}
|
||||
|
||||
#[derive(Default, Deserialize, ToSchema)]
|
||||
pub struct UpdateFolderRequest {
|
||||
#[serde(default, deserialize_with = "deserialize_patch_field")]
|
||||
#[schema(nullable, value_type = Option<Uuid>)]
|
||||
pub parent_id: Option<Option<Uuid>>,
|
||||
#[serde(default, deserialize_with = "deserialize_patch_field")]
|
||||
#[schema(nullable)]
|
||||
pub name: Option<Option<String>>,
|
||||
}
|
||||
|
||||
pub struct FolderContentsData {
|
||||
pub folder: Option<FolderInfo>,
|
||||
pub subfolders: Vec<FolderInfo>,
|
||||
pub documents: Vec<Document>,
|
||||
}
|
||||
|
||||
pub struct FolderService<'a> {
|
||||
state: &'a AppState,
|
||||
}
|
||||
|
||||
impl<'a> FolderService<'a> {
|
||||
pub fn new(state: &'a AppState) -> Self {
|
||||
Self { state }
|
||||
}
|
||||
|
||||
pub fn get_folder(
|
||||
&self,
|
||||
conn: &mut PgPooledConnection,
|
||||
tenant_id: Uuid,
|
||||
folder_id: Uuid,
|
||||
) -> AppResult<FolderInfo> {
|
||||
let folder: Folder = folders::table
|
||||
.find(folder_id)
|
||||
.filter(folders::tenant_id.eq(tenant_id))
|
||||
.first(conn)?;
|
||||
Ok(folder_to_info(folder))
|
||||
}
|
||||
|
||||
pub fn ensure_folder_path(
|
||||
&self,
|
||||
conn: &mut PgPooledConnection,
|
||||
tenant_id: Uuid,
|
||||
payload: EnsureFolderPathRequest,
|
||||
) -> AppResult<FolderInfo> {
|
||||
if payload.segments.is_empty() {
|
||||
return Err(AppError::bad_request("segments must not be empty"));
|
||||
}
|
||||
|
||||
let folder = conn.app_transaction(|conn| {
|
||||
let mut current_parent = payload.parent_id;
|
||||
let mut last_folder: Option<Folder> = None;
|
||||
|
||||
for raw_name in &payload.segments {
|
||||
let name = normalize_folder_name(raw_name, "folder names must not be empty")?;
|
||||
|
||||
let existing: Option<Folder> = if let Some(parent_id) = current_parent {
|
||||
folders::table
|
||||
.filter(folders::tenant_id.eq(tenant_id))
|
||||
.filter(folders::parent_id.eq(Some(parent_id)))
|
||||
.filter(folders::name.eq(&name))
|
||||
.first(conn)
|
||||
.optional()?
|
||||
} else {
|
||||
folders::table
|
||||
.filter(folders::tenant_id.eq(tenant_id))
|
||||
.filter(folders::parent_id.is_null())
|
||||
.filter(folders::name.eq(&name))
|
||||
.first(conn)
|
||||
.optional()?
|
||||
};
|
||||
|
||||
let folder = if let Some(folder) = existing {
|
||||
folder
|
||||
} else {
|
||||
let new_folder = NewFolder {
|
||||
id: Uuid::new_v4(),
|
||||
name: name.clone(),
|
||||
parent_id: current_parent,
|
||||
tenant_id,
|
||||
};
|
||||
|
||||
let inserted_id: Option<Uuid> = diesel::insert_into(folders::table)
|
||||
.values(&new_folder)
|
||||
.on_conflict_do_nothing()
|
||||
.returning(folders::id)
|
||||
.get_result(conn)
|
||||
.optional()?;
|
||||
|
||||
if let Some(id) = inserted_id {
|
||||
folders::table
|
||||
.find(id)
|
||||
.filter(folders::tenant_id.eq(tenant_id))
|
||||
.first(conn)?
|
||||
} else if let Some(parent_id) = current_parent {
|
||||
folders::table
|
||||
.filter(folders::tenant_id.eq(tenant_id))
|
||||
.filter(folders::parent_id.eq(Some(parent_id)))
|
||||
.filter(folders::name.eq(&name))
|
||||
.first(conn)?
|
||||
} else {
|
||||
folders::table
|
||||
.filter(folders::tenant_id.eq(tenant_id))
|
||||
.filter(folders::parent_id.is_null())
|
||||
.filter(folders::name.eq(&name))
|
||||
.first(conn)?
|
||||
}
|
||||
};
|
||||
|
||||
current_parent = Some(folder.id);
|
||||
last_folder = Some(folder);
|
||||
}
|
||||
|
||||
last_folder.ok_or_else(|| AppError::internal("failed to resolve folder path"))
|
||||
})?;
|
||||
|
||||
Ok(folder_to_info(folder))
|
||||
}
|
||||
|
||||
pub fn create_folder(
|
||||
&self,
|
||||
conn: &mut PgPooledConnection,
|
||||
tenant_id: Uuid,
|
||||
payload: CreateFolderRequest,
|
||||
) -> AppResult<(FolderInfo, bool)> {
|
||||
let name = normalize_folder_name(&payload.name, "name must not be empty")?;
|
||||
|
||||
let existing: Option<Folder> = if let Some(parent_id) = payload.parent_id {
|
||||
folders::table
|
||||
.filter(folders::tenant_id.eq(tenant_id))
|
||||
.filter(folders::parent_id.eq(Some(parent_id)))
|
||||
.filter(folders::name.eq(&name))
|
||||
.first(conn)
|
||||
.optional()?
|
||||
} else {
|
||||
folders::table
|
||||
.filter(folders::tenant_id.eq(tenant_id))
|
||||
.filter(folders::parent_id.is_null())
|
||||
.filter(folders::name.eq(&name))
|
||||
.first(conn)
|
||||
.optional()?
|
||||
};
|
||||
|
||||
let (folder, created) = if let Some(folder) = existing {
|
||||
(folder, false)
|
||||
} else {
|
||||
let new_folder = NewFolder {
|
||||
id: Uuid::new_v4(),
|
||||
name: name.clone(),
|
||||
parent_id: payload.parent_id,
|
||||
tenant_id,
|
||||
};
|
||||
|
||||
let inserted_id: Option<Uuid> = diesel::insert_into(folders::table)
|
||||
.values(&new_folder)
|
||||
.on_conflict_do_nothing()
|
||||
.returning(folders::id)
|
||||
.get_result(conn)
|
||||
.optional()?;
|
||||
|
||||
if let Some(id) = inserted_id {
|
||||
(
|
||||
folders::table
|
||||
.find(id)
|
||||
.filter(folders::tenant_id.eq(tenant_id))
|
||||
.first(conn)?,
|
||||
true,
|
||||
)
|
||||
} else if let Some(parent_id) = payload.parent_id {
|
||||
(
|
||||
folders::table
|
||||
.filter(folders::tenant_id.eq(tenant_id))
|
||||
.filter(folders::parent_id.eq(Some(parent_id)))
|
||||
.filter(folders::name.eq(&name))
|
||||
.first(conn)?,
|
||||
false,
|
||||
)
|
||||
} else {
|
||||
(
|
||||
folders::table
|
||||
.filter(folders::tenant_id.eq(tenant_id))
|
||||
.filter(folders::parent_id.is_null())
|
||||
.filter(folders::name.eq(&name))
|
||||
.first(conn)?,
|
||||
false,
|
||||
)
|
||||
}
|
||||
};
|
||||
|
||||
Ok((folder_to_info(folder), created))
|
||||
}
|
||||
|
||||
pub fn list_folder_contents(
|
||||
&self,
|
||||
conn: &mut PgPooledConnection,
|
||||
tenant_id: Uuid,
|
||||
folder_id: Option<Uuid>,
|
||||
sort: DocumentSortField,
|
||||
dir: SortDirection,
|
||||
include_documents: bool,
|
||||
) -> AppResult<FolderContentsData> {
|
||||
let folder = match folder_id {
|
||||
Some(id) => Some(folder_to_info(
|
||||
folders::table
|
||||
.find(id)
|
||||
.filter(folders::tenant_id.eq(tenant_id))
|
||||
.first::<Folder>(conn)?,
|
||||
)),
|
||||
None => None,
|
||||
};
|
||||
|
||||
let child_folders: Vec<Folder> = if let Some(parent_id) = folder_id {
|
||||
folders::table
|
||||
.filter(folders::parent_id.eq(parent_id))
|
||||
.filter(folders::tenant_id.eq(tenant_id))
|
||||
.order(sql::<Text>("name COLLATE \"unicode_ci\" ASC"))
|
||||
.load(conn)?
|
||||
} else {
|
||||
folders::table
|
||||
.filter(folders::parent_id.is_null())
|
||||
.filter(folders::tenant_id.eq(tenant_id))
|
||||
.order(sql::<Text>("name COLLATE \"unicode_ci\" ASC"))
|
||||
.load(conn)?
|
||||
};
|
||||
|
||||
let subfolders = child_folders.into_iter().map(folder_to_info).collect();
|
||||
|
||||
let documents = if include_documents {
|
||||
let mut docs_query = documents::table
|
||||
.filter(documents::deleted_at.is_null())
|
||||
.filter(documents::tenant_id.eq(tenant_id))
|
||||
.into_boxed();
|
||||
|
||||
let (primary_sql, secondary_sql) = ordering_clauses(sort, dir);
|
||||
docs_query = docs_query.order(sql::<Text>(primary_sql));
|
||||
if let Some(second) = secondary_sql {
|
||||
docs_query = docs_query.then_order_by(sql::<Text>(second));
|
||||
}
|
||||
|
||||
if let Some(current_folder) = folder_id {
|
||||
docs_query
|
||||
.filter(documents::folder_id.eq(current_folder))
|
||||
.load::<Document>(conn)?
|
||||
} else {
|
||||
docs_query
|
||||
.filter(documents::folder_id.is_null())
|
||||
.load::<Document>(conn)?
|
||||
}
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
|
||||
Ok(FolderContentsData {
|
||||
folder,
|
||||
subfolders,
|
||||
documents,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn list_folder_tree(
|
||||
&self,
|
||||
conn: &mut PgPooledConnection,
|
||||
tenant_id: Uuid,
|
||||
) -> AppResult<Vec<FolderTreeNode>> {
|
||||
let folders: Vec<Folder> = folders::table
|
||||
.filter(folders::tenant_id.eq(tenant_id))
|
||||
.order(sql::<Text>("name COLLATE \"unicode_ci\" ASC"))
|
||||
.load(conn)?;
|
||||
|
||||
let mut node_map: HashMap<Uuid, FolderTreeNode> = HashMap::with_capacity(folders.len());
|
||||
let mut children_map: HashMap<Uuid, Vec<Uuid>> = HashMap::new();
|
||||
let mut roots: Vec<Uuid> = Vec::new();
|
||||
|
||||
for folder in folders {
|
||||
let id = folder.id;
|
||||
let parent_id = folder.parent_id;
|
||||
let node = FolderTreeNode {
|
||||
id,
|
||||
name: folder.name,
|
||||
parent_id,
|
||||
created_at: to_iso(folder.created_at),
|
||||
updated_at: to_iso(folder.updated_at),
|
||||
children: Vec::new(),
|
||||
};
|
||||
|
||||
if let Some(parent) = parent_id {
|
||||
children_map.entry(parent).or_default().push(id);
|
||||
} else {
|
||||
roots.push(id);
|
||||
}
|
||||
|
||||
node_map.insert(id, node);
|
||||
}
|
||||
|
||||
fn build_node(
|
||||
id: Uuid,
|
||||
nodes: &HashMap<Uuid, FolderTreeNode>,
|
||||
child_map: &HashMap<Uuid, Vec<Uuid>>,
|
||||
) -> FolderTreeNode {
|
||||
let mut node = nodes.get(&id).cloned().expect("folder node must exist");
|
||||
|
||||
if let Some(children) = child_map.get(&id) {
|
||||
node.children = children
|
||||
.iter()
|
||||
.map(|child_id| build_node(*child_id, nodes, child_map))
|
||||
.collect();
|
||||
}
|
||||
|
||||
node
|
||||
}
|
||||
|
||||
let tree = roots
|
||||
.iter()
|
||||
.map(|root_id| build_node(*root_id, &node_map, &children_map))
|
||||
.collect();
|
||||
|
||||
Ok(tree)
|
||||
}
|
||||
|
||||
pub fn delete_folder(
|
||||
&self,
|
||||
conn: &mut PgPooledConnection,
|
||||
tenant_id: Uuid,
|
||||
folder_id: Uuid,
|
||||
) -> AppResult<()> {
|
||||
conn.app_transaction(|conn| {
|
||||
folders::table
|
||||
.find(folder_id)
|
||||
.filter(folders::tenant_id.eq(tenant_id))
|
||||
.first::<Folder>(conn)?;
|
||||
|
||||
let has_child_folders: bool = diesel::select(exists(
|
||||
folders::table
|
||||
.filter(folders::parent_id.eq(Some(folder_id)))
|
||||
.filter(folders::tenant_id.eq(tenant_id)),
|
||||
))
|
||||
.get_result(conn)?;
|
||||
|
||||
if has_child_folders {
|
||||
return Err(AppError::bad_request(
|
||||
"folder must be empty before deletion",
|
||||
));
|
||||
}
|
||||
|
||||
let has_documents: bool = diesel::select(exists(
|
||||
documents::table
|
||||
.filter(documents::folder_id.eq(Some(folder_id)))
|
||||
.filter(documents::tenant_id.eq(tenant_id))
|
||||
.filter(documents::deleted_at.is_null()),
|
||||
))
|
||||
.get_result(conn)?;
|
||||
|
||||
if has_documents {
|
||||
return Err(AppError::bad_request(
|
||||
"folder must be empty before deletion",
|
||||
));
|
||||
}
|
||||
|
||||
diesel::delete(
|
||||
folders::table
|
||||
.filter(folders::id.eq(folder_id))
|
||||
.filter(folders::tenant_id.eq(tenant_id)),
|
||||
)
|
||||
.execute(conn)
|
||||
.into_app_result()?
|
||||
.or_not_found()?;
|
||||
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
|
||||
pub fn update_folder(
|
||||
&self,
|
||||
conn: &mut PgPooledConnection,
|
||||
tenant_id: Uuid,
|
||||
folder_id: Uuid,
|
||||
payload: UpdateFolderRequest,
|
||||
) -> AppResult<()> {
|
||||
conn.app_transaction(|conn| {
|
||||
let folder: Folder = folders::table
|
||||
.find(folder_id)
|
||||
.filter(folders::tenant_id.eq(tenant_id))
|
||||
.first(conn)?;
|
||||
|
||||
let mut next_parent = folder.parent_id;
|
||||
let mut parent_changed = false;
|
||||
match payload.parent_id {
|
||||
None => {}
|
||||
Some(None) => {
|
||||
if folder.parent_id.is_some() {
|
||||
parent_changed = true;
|
||||
}
|
||||
next_parent = None;
|
||||
}
|
||||
Some(Some(parent_id)) => {
|
||||
if parent_id == folder_id {
|
||||
return Err(AppError::bad_request("folder cannot be its own parent"));
|
||||
}
|
||||
|
||||
folders::table
|
||||
.find(parent_id)
|
||||
.filter(folders::tenant_id.eq(tenant_id))
|
||||
.first::<Folder>(conn)?;
|
||||
|
||||
if folder.parent_id != Some(parent_id) {
|
||||
let descendant_ids =
|
||||
gather_descendant_folder_ids(conn, tenant_id, folder_id)?;
|
||||
if descendant_ids.contains(&parent_id) {
|
||||
return Err(AppError::bad_request(
|
||||
"cannot move folder into itself or a descendant",
|
||||
));
|
||||
}
|
||||
parent_changed = true;
|
||||
}
|
||||
|
||||
next_parent = Some(parent_id);
|
||||
}
|
||||
}
|
||||
|
||||
let mut new_name = folder.name.clone();
|
||||
let mut name_changed = false;
|
||||
match payload.name {
|
||||
None => {}
|
||||
Some(None) => {
|
||||
return Err(AppError::bad_request("name cannot be null"));
|
||||
}
|
||||
Some(Some(value)) => {
|
||||
let normalized = normalize_folder_name(&value, "name must not be empty")?;
|
||||
|
||||
if normalized != folder.name {
|
||||
new_name = normalized;
|
||||
name_changed = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !parent_changed && !name_changed {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let conflict = if let Some(parent_id) = next_parent {
|
||||
folders::table
|
||||
.filter(folders::parent_id.eq(Some(parent_id)))
|
||||
.filter(folders::name.eq(&new_name))
|
||||
.filter(folders::id.ne(folder_id))
|
||||
.filter(folders::tenant_id.eq(tenant_id))
|
||||
.first::<Folder>(conn)
|
||||
.optional()?
|
||||
} else {
|
||||
folders::table
|
||||
.filter(folders::parent_id.is_null())
|
||||
.filter(folders::name.eq(&new_name))
|
||||
.filter(folders::id.ne(folder_id))
|
||||
.filter(folders::tenant_id.eq(tenant_id))
|
||||
.first::<Folder>(conn)
|
||||
.optional()?
|
||||
};
|
||||
|
||||
if conflict.is_some() {
|
||||
return Err(AppError::bad_request(
|
||||
"a folder with the same name already exists in the target",
|
||||
));
|
||||
}
|
||||
|
||||
diesel::update(
|
||||
folders::table
|
||||
.find(folder_id)
|
||||
.filter(folders::tenant_id.eq(tenant_id)),
|
||||
)
|
||||
.set((
|
||||
folders::parent_id.eq(next_parent),
|
||||
folders::name.eq(&new_name),
|
||||
))
|
||||
.execute(conn)
|
||||
.into_app_result()?
|
||||
.or_not_found()?;
|
||||
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
|
||||
pub fn hydrate_documents(
|
||||
&self,
|
||||
conn: &mut PgPooledConnection,
|
||||
tenant_id: Uuid,
|
||||
user_id: Uuid,
|
||||
docs: Vec<Document>,
|
||||
) -> AppResult<Vec<DocumentResponse>> {
|
||||
DocumentsService::new(self.state).hydrate_documents(conn, tenant_id, user_id, docs)
|
||||
}
|
||||
}
|
||||
|
||||
pub fn gather_descendant_folder_ids(
|
||||
conn: &mut PgConnection,
|
||||
tenant_id: Uuid,
|
||||
folder_id: Uuid,
|
||||
) -> AppResult<Vec<Uuid>> {
|
||||
let mut ids = vec![folder_id];
|
||||
let mut queue = vec![folder_id];
|
||||
|
||||
while let Some(current) = queue.pop() {
|
||||
let child_ids: Vec<Uuid> = folders::table
|
||||
.filter(folders::parent_id.eq(Some(current)))
|
||||
.filter(folders::tenant_id.eq(tenant_id))
|
||||
.select(folders::id)
|
||||
.load(conn)?;
|
||||
queue.extend(child_ids.iter().copied());
|
||||
ids.extend(child_ids);
|
||||
}
|
||||
|
||||
Ok(ids)
|
||||
}
|
||||
|
||||
fn folder_to_info(folder: Folder) -> FolderInfo {
|
||||
FolderInfo {
|
||||
id: folder.id,
|
||||
name: folder.name,
|
||||
parent_id: folder.parent_id,
|
||||
created_at: to_iso(folder.created_at),
|
||||
updated_at: to_iso(folder.updated_at),
|
||||
}
|
||||
}
|
||||
|
||||
fn normalize_folder_name(value: &str, empty_message: &str) -> AppResult<String> {
|
||||
normalize_identifier(
|
||||
value,
|
||||
MAX_FOLDER_NAME_LEN,
|
||||
empty_message,
|
||||
"folder name must not exceed 255 characters",
|
||||
Some("folder name may only contain printable characters"),
|
||||
|ch| !ch.is_control(),
|
||||
)
|
||||
}
|
||||
|
||||
const fn default_include_documents() -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::UpdateFolderRequest;
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
fn update_folder_request_deserializes_null_parent() {
|
||||
let req: UpdateFolderRequest =
|
||||
serde_json::from_value(json!({ "parent_id": null })).unwrap();
|
||||
assert!(matches!(req.parent_id, Some(None)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn update_folder_request_deserializes_absent_parent() {
|
||||
let req: UpdateFolderRequest = serde_json::from_value(json!({})).unwrap();
|
||||
assert!(req.parent_id.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn update_folder_request_deserializes_null_name() {
|
||||
let req: UpdateFolderRequest = serde_json::from_value(json!({ "name": null })).unwrap();
|
||||
assert!(matches!(req.name, Some(None)));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
use diesel::prelude::*;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::error::{AppError, AppResult};
|
||||
use crate::models::Document;
|
||||
use crate::schema::documents;
|
||||
use crate::state::PgPooledConnection;
|
||||
|
||||
/// Load a document that belongs to the tenant and is not soft-deleted.
|
||||
pub fn load_active_document(
|
||||
conn: &mut PgPooledConnection,
|
||||
tenant_id: Uuid,
|
||||
document_id: Uuid,
|
||||
) -> AppResult<Document> {
|
||||
let doc: Document = documents::table
|
||||
.find(document_id)
|
||||
.filter(documents::tenant_id.eq(tenant_id))
|
||||
.first(conn)?;
|
||||
|
||||
if doc.deleted_at.is_some() {
|
||||
return Err(AppError::not_found());
|
||||
}
|
||||
|
||||
Ok(doc)
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
use diesel::{Connection, PgConnection};
|
||||
|
||||
use crate::error::{AppError, AppResult};
|
||||
use crate::state::{AppState, PgPooledConnection};
|
||||
|
||||
pub trait TransactionExt {
|
||||
fn app_transaction<T, F>(&mut self, f: F) -> AppResult<T>
|
||||
where
|
||||
F: FnOnce(&mut Self) -> AppResult<T>;
|
||||
}
|
||||
|
||||
impl TransactionExt for PgConnection {
|
||||
fn app_transaction<T, F>(&mut self, f: F) -> AppResult<T>
|
||||
where
|
||||
F: FnOnce(&mut Self) -> AppResult<T>,
|
||||
{
|
||||
self.transaction::<T, AppError, _>(|conn| f(conn))
|
||||
}
|
||||
}
|
||||
|
||||
impl TransactionExt for PgPooledConnection {
|
||||
fn app_transaction<T, F>(&mut self, f: F) -> AppResult<T>
|
||||
where
|
||||
F: FnOnce(&mut Self) -> AppResult<T>,
|
||||
{
|
||||
self.transaction::<T, AppError, _>(|conn| f(conn))
|
||||
}
|
||||
}
|
||||
|
||||
pub struct ServiceContext<'a> {
|
||||
state: &'a AppState,
|
||||
}
|
||||
|
||||
impl<'a> ServiceContext<'a> {
|
||||
pub fn new(state: &'a AppState) -> Self {
|
||||
Self { state }
|
||||
}
|
||||
|
||||
pub fn state(&self) -> &'a AppState {
|
||||
self.state
|
||||
}
|
||||
|
||||
pub fn db_conn(&self) -> AppResult<PgPooledConnection> {
|
||||
self.state.db_unscoped()
|
||||
}
|
||||
|
||||
pub fn db_conn_for_tenant(&self, tenant_id: uuid::Uuid) -> AppResult<PgPooledConnection> {
|
||||
self.state.db_for_tenant(tenant_id)
|
||||
}
|
||||
|
||||
pub fn tx<C, T, F>(&self, conn: &mut C, f: F) -> AppResult<T>
|
||||
where
|
||||
C: TransactionExt,
|
||||
F: FnOnce(&mut C) -> AppResult<T>,
|
||||
{
|
||||
conn.app_transaction(f)
|
||||
}
|
||||
}
|
||||
|
||||
pub mod auth;
|
||||
pub mod capability_sets;
|
||||
pub mod correspondents;
|
||||
pub mod documents;
|
||||
pub mod folders;
|
||||
pub mod helpers;
|
||||
pub mod profile;
|
||||
pub mod tags;
|
||||
@@ -0,0 +1,222 @@
|
||||
use axum::http::StatusCode;
|
||||
use chrono::{DateTime, NaiveDateTime};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use utoipa::ToSchema;
|
||||
use uuid::Uuid;
|
||||
|
||||
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,
|
||||
},
|
||||
capability_sets::load_capability_set,
|
||||
passkeys::PasskeySummary,
|
||||
};
|
||||
use crate::error::{AppError, AppResult};
|
||||
use crate::http::responders::{created_json, no_content, ok_json, JsonResponse};
|
||||
use crate::models::ApiToken;
|
||||
use crate::state::{AppState, PgPooledConnection};
|
||||
use crate::utils::time::to_iso;
|
||||
|
||||
#[derive(Debug, Serialize, ToSchema)]
|
||||
pub struct ApiTokenResponse {
|
||||
pub id: Uuid,
|
||||
pub tenant_id: Uuid,
|
||||
#[schema(nullable)]
|
||||
pub label: Option<String>,
|
||||
pub capability_set_id: Uuid,
|
||||
pub created_at: String,
|
||||
#[schema(nullable)]
|
||||
pub last_used_at: Option<String>,
|
||||
#[schema(nullable)]
|
||||
pub expires_at: Option<String>,
|
||||
#[schema(nullable)]
|
||||
pub revoked_at: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, ToSchema)]
|
||||
pub struct ApiTokenCreatedResponse {
|
||||
pub token: String,
|
||||
pub token_info: ApiTokenResponse,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, ToSchema)]
|
||||
pub struct CreateApiTokenRequest {
|
||||
#[schema(nullable)]
|
||||
pub label: Option<String>,
|
||||
#[schema(nullable)]
|
||||
pub expires_at: Option<String>,
|
||||
pub capability_set_id: Uuid,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, ToSchema)]
|
||||
pub struct RevokePasskeyQuery {
|
||||
#[serde(default)]
|
||||
#[schema(nullable)]
|
||||
pub reason: Option<String>,
|
||||
}
|
||||
|
||||
pub struct ProfileService<'a> {
|
||||
state: &'a AppState,
|
||||
}
|
||||
|
||||
impl<'a> ProfileService<'a> {
|
||||
pub fn new(state: &'a AppState) -> Self {
|
||||
Self { state }
|
||||
}
|
||||
|
||||
pub fn list_passkeys(
|
||||
&self,
|
||||
conn: &mut PgPooledConnection,
|
||||
user_id: Uuid,
|
||||
) -> AppResult<JsonResponse<Vec<PasskeySummary>>> {
|
||||
let service = self
|
||||
.state
|
||||
.passkeys
|
||||
.as_ref()
|
||||
.ok_or_else(|| AppError::bad_request("passkey support is disabled"))?;
|
||||
|
||||
let passkeys = service.list_for_user(conn, user_id)?;
|
||||
ok_json(passkeys)
|
||||
}
|
||||
|
||||
pub fn list_api_tokens(
|
||||
&self,
|
||||
conn: &mut PgPooledConnection,
|
||||
tenant_id: Uuid,
|
||||
user_id: Uuid,
|
||||
) -> AppResult<JsonResponse<Vec<ApiTokenResponse>>> {
|
||||
let tokens = load_tokens(conn, user_id, Some(tenant_id))?;
|
||||
let responses = tokens.into_iter().map(api_token_to_response).collect();
|
||||
ok_json(responses)
|
||||
}
|
||||
|
||||
pub fn create_api_token(
|
||||
&self,
|
||||
conn: &mut PgPooledConnection,
|
||||
tenant_id: Uuid,
|
||||
user_id: Uuid,
|
||||
payload: CreateApiTokenRequest,
|
||||
) -> AppResult<JsonResponse<ApiTokenCreatedResponse>> {
|
||||
let expires_at = payload
|
||||
.expires_at
|
||||
.as_ref()
|
||||
.map(|value| parse_timestamp(value))
|
||||
.transpose()?;
|
||||
|
||||
let capability_set_id =
|
||||
validate_capability_set(conn, tenant_id, payload.capability_set_id)?;
|
||||
|
||||
let issued = issue_token(
|
||||
conn,
|
||||
user_id,
|
||||
tenant_id,
|
||||
payload.label.clone(),
|
||||
expires_at,
|
||||
capability_set_id,
|
||||
)?;
|
||||
|
||||
let token_info = api_token_to_response(issued.record);
|
||||
|
||||
let response = ApiTokenCreatedResponse {
|
||||
token: issued.token,
|
||||
token_info,
|
||||
};
|
||||
|
||||
created_json(response)
|
||||
}
|
||||
|
||||
pub fn regenerate_api_token(
|
||||
&self,
|
||||
conn: &mut PgPooledConnection,
|
||||
tenant_id: Uuid,
|
||||
user_id: Uuid,
|
||||
token_id: Uuid,
|
||||
) -> AppResult<JsonResponse<ApiTokenCreatedResponse>> {
|
||||
let issued = rotate_token(conn, token_id, user_id, Some(tenant_id))?;
|
||||
let token_info = api_token_to_response(issued.record);
|
||||
ok_json(ApiTokenCreatedResponse {
|
||||
token: issued.token,
|
||||
token_info,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn delete_api_token(
|
||||
&self,
|
||||
conn: &mut PgPooledConnection,
|
||||
user_id: Uuid,
|
||||
token_id: Uuid,
|
||||
) -> AppResult<StatusCode> {
|
||||
revoke_token(conn, token_id, user_id)?;
|
||||
no_content()
|
||||
}
|
||||
|
||||
pub fn delete_passkey(
|
||||
&self,
|
||||
conn: &mut PgPooledConnection,
|
||||
user_id: Uuid,
|
||||
passkey_id: Uuid,
|
||||
reason: Option<String>,
|
||||
) -> AppResult<StatusCode> {
|
||||
let service = self
|
||||
.state
|
||||
.passkeys
|
||||
.as_ref()
|
||||
.ok_or_else(|| AppError::bad_request("passkey support is disabled"))?;
|
||||
|
||||
let active_count = service.active_passkey_count(conn, user_id)?;
|
||||
if active_count <= 1 {
|
||||
return Err(AppError::bad_request(
|
||||
"cannot revoke the last remaining passkey",
|
||||
));
|
||||
}
|
||||
|
||||
service.revoke_passkey(conn, user_id, passkey_id, reason)?;
|
||||
no_content()
|
||||
}
|
||||
}
|
||||
|
||||
fn api_token_to_response(token: ApiToken) -> ApiTokenResponse {
|
||||
let ApiToken {
|
||||
id,
|
||||
tenant_id,
|
||||
label,
|
||||
created_at,
|
||||
last_used_at,
|
||||
expires_at,
|
||||
revoked_at,
|
||||
capability_set_id,
|
||||
..
|
||||
} = token;
|
||||
|
||||
ApiTokenResponse {
|
||||
id,
|
||||
tenant_id,
|
||||
label,
|
||||
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> {
|
||||
let dt = DateTime::parse_from_rfc3339(value)
|
||||
.map_err(|_| AppError::bad_request("invalid expires_at timestamp"))?;
|
||||
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)
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
use diesel::prelude::*;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use utoipa::ToSchema;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::documents::tags::assign_tags as assign_tags_to_document;
|
||||
use crate::error::{AppError, AppResult};
|
||||
use crate::models::{Document, NewDocumentTag, Tag};
|
||||
use crate::schema::{document_tags, documents, tags};
|
||||
use crate::state::{AppState, PgPooledConnection};
|
||||
use crate::utils::db::validate_bulk_ids;
|
||||
|
||||
#[derive(Deserialize, ToSchema)]
|
||||
pub struct AssignTagsRequest {
|
||||
pub tag_ids: Vec<Uuid>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Copy, Clone, PartialEq, Eq, ToSchema)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum BulkTagAction {
|
||||
Add,
|
||||
Remove,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, ToSchema)]
|
||||
pub struct BulkTagRequest {
|
||||
pub document_ids: Vec<Uuid>,
|
||||
pub tag_ids: Vec<Uuid>,
|
||||
pub action: BulkTagAction,
|
||||
}
|
||||
|
||||
#[derive(Serialize, ToSchema)]
|
||||
pub struct BulkTagResponse {
|
||||
pub added: usize,
|
||||
pub removed: usize,
|
||||
}
|
||||
|
||||
pub struct TagsService<'a> {
|
||||
_state: &'a AppState,
|
||||
}
|
||||
|
||||
impl<'a> TagsService<'a> {
|
||||
pub fn new(state: &'a AppState) -> Self {
|
||||
Self { _state: state }
|
||||
}
|
||||
|
||||
pub fn assign_to_document(
|
||||
&self,
|
||||
conn: &mut PgPooledConnection,
|
||||
tenant_id: Uuid,
|
||||
user_id: Uuid,
|
||||
document_id: Uuid,
|
||||
tag_ids: &[Uuid],
|
||||
) -> AppResult<()> {
|
||||
if tag_ids.is_empty() {
|
||||
return Err(AppError::bad_request("tag_ids must not be empty"));
|
||||
}
|
||||
|
||||
let document: Document = documents::table
|
||||
.find(document_id)
|
||||
.filter(documents::tenant_id.eq(tenant_id))
|
||||
.first(conn)?;
|
||||
|
||||
assign_tags_to_document(conn, tenant_id, &document, tag_ids, Some(user_id))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn bulk_update(
|
||||
&self,
|
||||
conn: &mut PgPooledConnection,
|
||||
tenant_id: Uuid,
|
||||
user_id: Uuid,
|
||||
mut payload: BulkTagRequest,
|
||||
) -> AppResult<BulkTagResponse> {
|
||||
validate_bulk_ids(&mut payload.document_ids, "document_ids")?;
|
||||
validate_bulk_ids(&mut payload.tag_ids, "tag_ids")?;
|
||||
|
||||
let docs: Vec<(Uuid, Option<chrono::NaiveDateTime>)> = documents::table
|
||||
.filter(documents::id.eq_any(&payload.document_ids))
|
||||
.filter(documents::tenant_id.eq(tenant_id))
|
||||
.select((documents::id, documents::deleted_at))
|
||||
.load(conn)?;
|
||||
|
||||
if docs.len() != payload.document_ids.len() {
|
||||
return Err(AppError::bad_request(
|
||||
"one or more documents do not exist or are inaccessible",
|
||||
));
|
||||
}
|
||||
|
||||
if docs.iter().any(|(_, deleted)| deleted.is_some()) {
|
||||
return Err(AppError::bad_request(
|
||||
"cannot assign or remove tags from deleted documents",
|
||||
));
|
||||
}
|
||||
|
||||
let existing_tags: Vec<Tag> = tags::table
|
||||
.filter(tags::id.eq_any(&payload.tag_ids))
|
||||
.filter(tags::tenant_id.eq(tenant_id))
|
||||
.load(conn)?;
|
||||
|
||||
if existing_tags.len() != payload.tag_ids.len() {
|
||||
return Err(AppError::bad_request("one or more tags do not exist"));
|
||||
}
|
||||
|
||||
match payload.action {
|
||||
BulkTagAction::Add => {
|
||||
let mut inserts =
|
||||
Vec::with_capacity(payload.document_ids.len() * payload.tag_ids.len());
|
||||
for doc_id in &payload.document_ids {
|
||||
for tag_id in &payload.tag_ids {
|
||||
inserts.push(NewDocumentTag {
|
||||
document_id: *doc_id,
|
||||
tag_id: *tag_id,
|
||||
assigned_by: Some(user_id),
|
||||
tenant_id,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
let added = if inserts.is_empty() {
|
||||
0
|
||||
} else {
|
||||
diesel::insert_into(document_tags::table)
|
||||
.values(&inserts)
|
||||
.on_conflict_do_nothing()
|
||||
.execute(conn)?
|
||||
};
|
||||
|
||||
Ok(BulkTagResponse { added, removed: 0 })
|
||||
}
|
||||
BulkTagAction::Remove => {
|
||||
let removed = diesel::delete(
|
||||
document_tags::table
|
||||
.filter(document_tags::document_id.eq_any(&payload.document_ids))
|
||||
.filter(document_tags::tenant_id.eq(tenant_id))
|
||||
.filter(document_tags::tag_id.eq_any(&payload.tag_ids)),
|
||||
)
|
||||
.execute(conn)?;
|
||||
|
||||
Ok(BulkTagResponse { added: 0, removed })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn remove_from_document(
|
||||
&self,
|
||||
conn: &mut PgPooledConnection,
|
||||
tenant_id: Uuid,
|
||||
document_id: Uuid,
|
||||
tag_id: Uuid,
|
||||
) -> AppResult<()> {
|
||||
let deleted = diesel::delete(
|
||||
document_tags::table
|
||||
.filter(document_tags::document_id.eq(document_id))
|
||||
.filter(document_tags::tenant_id.eq(tenant_id))
|
||||
.filter(document_tags::tag_id.eq(tag_id)),
|
||||
)
|
||||
.execute(conn)?;
|
||||
|
||||
if deleted == 0 {
|
||||
return Err(AppError::not_found());
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
+15
-1
@@ -8,6 +8,7 @@ use crate::{
|
||||
jobs::{enqueue_job, JOB_PROVISION_TENANT},
|
||||
models::{Tenant, TenantStatus},
|
||||
schema::tenants::dsl,
|
||||
utils::text::normalize_identifier,
|
||||
};
|
||||
|
||||
pub struct TenantRepository;
|
||||
@@ -96,6 +97,8 @@ impl TenantService {
|
||||
return Err(AppError::bad_request("tenant name must not be empty"));
|
||||
}
|
||||
|
||||
let name = normalize_tenant_name(name)?;
|
||||
|
||||
let id = Uuid::new_v4();
|
||||
let storage_root = normalize_storage_root(storage_root, id);
|
||||
let quickwit_index = normalize_quickwit_index(quickwit_index, id);
|
||||
@@ -103,7 +106,7 @@ impl TenantService {
|
||||
diesel::insert_into(dsl::tenants)
|
||||
.values((
|
||||
dsl::id.eq(id),
|
||||
dsl::name.eq(name),
|
||||
dsl::name.eq(&name),
|
||||
dsl::storage_root.eq(Some(storage_root.clone())),
|
||||
dsl::quickwit_index.eq(Some(quickwit_index.clone())),
|
||||
dsl::config.eq(json!({})),
|
||||
@@ -186,6 +189,17 @@ pub fn apply_api_token_prefix(conn: &mut PgConnection, prefix: &str) -> AppResul
|
||||
.map_err(AppError::from)
|
||||
}
|
||||
|
||||
fn normalize_tenant_name(value: &str) -> AppResult<String> {
|
||||
normalize_identifier(
|
||||
value,
|
||||
255,
|
||||
"tenant name must not be empty",
|
||||
"tenant name must not exceed 255 characters",
|
||||
Some("tenant name may only contain printable characters"),
|
||||
|ch| !ch.is_control(),
|
||||
)
|
||||
}
|
||||
|
||||
pub fn clear_api_token_prefix(conn: &mut PgConnection) -> AppResult<()> {
|
||||
diesel::sql_query("SELECT set_config('papercrate.api_token_prefix', '', false)")
|
||||
.execute(conn)
|
||||
|
||||
@@ -4,7 +4,9 @@ pub mod error;
|
||||
pub mod http;
|
||||
pub mod json;
|
||||
pub mod named_entity;
|
||||
pub mod setops;
|
||||
pub mod storage_paths;
|
||||
pub mod text;
|
||||
pub mod time;
|
||||
pub mod tracing;
|
||||
pub mod validation;
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
use std::collections::HashSet;
|
||||
use std::hash::Hash;
|
||||
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::error::AppResult;
|
||||
use crate::state::PgPooledConnection;
|
||||
|
||||
/// Intersect an optional base set with a new set, returning the resulting option.
|
||||
pub fn intersect_option_sets<T>(base: Option<HashSet<T>>, next: HashSet<T>) -> Option<HashSet<T>>
|
||||
where
|
||||
T: Eq + Hash + Copy,
|
||||
{
|
||||
Some(match base {
|
||||
Some(existing) => existing.intersection(&next).copied().collect(),
|
||||
None => next,
|
||||
})
|
||||
}
|
||||
|
||||
/// Iteratively intersect documents linked via a join table loader.
|
||||
pub fn load_linked_doc_ids<F>(
|
||||
conn: &mut PgPooledConnection,
|
||||
ids: &[Uuid],
|
||||
mut loader: F,
|
||||
) -> AppResult<HashSet<Uuid>>
|
||||
where
|
||||
F: FnMut(&mut PgPooledConnection, Uuid) -> AppResult<HashSet<Uuid>>,
|
||||
{
|
||||
let mut current: Option<HashSet<Uuid>> = None;
|
||||
|
||||
for id in ids {
|
||||
let docs_set = loader(conn, *id)?;
|
||||
current = intersect_option_sets(current, docs_set);
|
||||
|
||||
if current.as_ref().is_some_and(|set| set.is_empty()) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(current.unwrap_or_default())
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
use crate::error::{AppError, AppResult};
|
||||
|
||||
/// Normalizes an identifier-like user input by trimming, enforcing length, and validating characters.
|
||||
pub fn normalize_identifier<F>(
|
||||
value: &str,
|
||||
max_len: usize,
|
||||
empty_message: &str,
|
||||
length_message: &str,
|
||||
invalid_message: Option<&str>,
|
||||
mut validator: F,
|
||||
) -> AppResult<String>
|
||||
where
|
||||
F: FnMut(char) -> bool,
|
||||
{
|
||||
let trimmed = value.trim();
|
||||
if trimmed.is_empty() {
|
||||
return Err(AppError::bad_request(empty_message));
|
||||
}
|
||||
|
||||
if trimmed.len() > max_len {
|
||||
return Err(AppError::bad_request(length_message));
|
||||
}
|
||||
|
||||
if let Some(msg) = invalid_message {
|
||||
if !trimmed.chars().all(|ch| validator(ch)) {
|
||||
return Err(AppError::bad_request(msg));
|
||||
}
|
||||
}
|
||||
|
||||
Ok(trimmed.to_string())
|
||||
}
|
||||
Reference in New Issue
Block a user