This commit is contained in:
2025-11-06 22:21:27 +01:00
parent a6f79dbc75
commit ef2ed2dc58
23 changed files with 4371 additions and 3482 deletions
+34 -764
View File
@@ -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, &current_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,
&current_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)
}
+18 -245
View File
@@ -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;
File diff suppressed because it is too large Load Diff
+50 -550
View File
@@ -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
View File
@@ -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
))
)]