Optimize storage, GC, and upload hot paths

This commit is contained in:
DioCrafts
2026-07-22 02:06:04 +02:00
parent 67fe944c2a
commit d66956824c
68 changed files with 16500 additions and 108 deletions
+3
View File
@@ -113,6 +113,9 @@ pub struct AdminResetPasswordDto {
pub struct ListUsersQueryDto {
pub limit: Option<i64>,
pub offset: Option<i64>,
/// Return only the fields rendered by the paginated management table.
/// Defaults to `false` so existing API clients keep the full user shape.
pub summary: Option<bool>,
}
/// Dashboard statistics
+39
View File
@@ -1,4 +1,5 @@
use crate::domain::entities::user::User;
use crate::domain::repositories::user_repository::UserListEntry;
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use smol_str::SmolStr;
@@ -73,6 +74,44 @@ pub struct UserDto {
pub ui_preferences: serde_json::Value,
}
/// Compact row returned by the paginated admin user table.
///
/// Account-detail fields deliberately do not appear here. In particular,
/// omitting `image` and `ui_preferences` prevents a 100-row page from turning
/// into tens of MiB when users have uploaded avatars. `GET /api/admin/users/:id`
/// remains the full-detail endpoint.
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
pub struct AdminUserSummaryDto {
pub id: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub username: Option<String>,
pub email: String,
pub role: String,
pub storage_quota_bytes: i64,
pub storage_used_bytes: i64,
pub last_login_at: Option<DateTime<Utc>>,
pub active: bool,
pub auth_provider: String,
pub is_external: bool,
}
impl From<UserListEntry> for AdminUserSummaryDto {
fn from(entry: UserListEntry) -> Self {
Self {
id: entry.id.to_string(),
username: entry.username,
email: entry.email,
role: entry.role.to_string(),
storage_quota_bytes: entry.storage_quota_bytes,
storage_used_bytes: entry.storage_used_bytes,
last_login_at: entry.last_login_at,
active: entry.active,
auth_provider: entry.oidc_provider.unwrap_or_else(|| "local".to_string()),
is_external: entry.is_external,
}
}
}
impl From<User> for UserDto {
fn from(user: User) -> Self {
// `user` is owned and dropped here, so every owned field is MOVED out
+10
View File
@@ -3,6 +3,7 @@ use crate::domain::entities::app_password::AppPassword;
use crate::domain::entities::device_code::DeviceCode;
use crate::domain::entities::session::Session;
use crate::domain::entities::user::User;
use crate::domain::repositories::user_repository::UserListEntry;
use std::sync::Arc;
use uuid::Uuid;
@@ -129,6 +130,15 @@ pub trait UserStoragePort: Send + Sync + 'static {
include_external: bool,
) -> Result<Vec<User>, DomainError>;
/// Narrow user-list projection for management tables. Keeps heavyweight
/// account-detail fields off the database and JSON hot path.
async fn list_user_summaries(
&self,
limit: i64,
offset: i64,
include_external: bool,
) -> Result<Vec<UserListEntry>, DomainError>;
/// Searches users by username or email (SQL ILIKE) with a limit.
/// See [`list_users`] for the meaning of `include_external`.
async fn search_users(
@@ -11,6 +11,7 @@
use uuid::Uuid;
use crate::common::errors::DomainError;
use crate::domain::entities::user::UserRole;
use crate::domain::services::authorization::{
Grant, GrantCursor, IncomingGrantSummary, OutgoingResourceSummary, Permission, Resource,
ResourceKind, Role, Subject,
@@ -29,6 +30,21 @@ pub enum AuthzDenialVisibility {
Hidden,
}
fn system_admin_denial_reason(
subject: Subject,
role: UserRole,
is_external: bool,
active: bool,
) -> Option<&'static str> {
match subject {
Subject::User(_) if !active => Some("inactive"),
Subject::User(_) if is_external => Some("external_account"),
Subject::User(_) if role != UserRole::Admin => Some("not_admin"),
Subject::User(_) => None,
_ => Some("unsupported_subject"),
}
}
impl AuthzDenialVisibility {
pub fn as_str(self) -> &'static str {
match self {
@@ -39,6 +55,44 @@ impl AuthzDenialVisibility {
}
pub trait AuthorizationEngine: Send + Sync + 'static {
/// Require the authenticated principal to hold the deployment-wide admin
/// role. System administration has no resource UUID, so it cannot be
/// represented by [`Resource`]; it still belongs in this policy port rather
/// than in an HTTP handler or an application-service role shortcut.
///
/// The application authentication service supplies its already cached,
/// image-free live flags. This avoids a second database query/cache for the
/// same caller while keeping the authorization decision and denial audit in
/// the engine's single policy surface.
fn require_system_admin(
&self,
subject: Subject,
role: UserRole,
is_external: bool,
active: bool,
) -> Result<(), DomainError> {
let reason = system_admin_denial_reason(subject, role, is_external, active);
let Some(reason) = reason else {
return Ok(());
};
tracing::info!(
target: "audit",
event = "authz.admin_denied",
reason,
subject_type = subject.type_str(),
caller_id = %subject.id(),
role = role.as_str(),
is_external,
active,
"👮🏻‍♂️ system-administrator permission denied"
);
Err(DomainError::access_denied(
"System",
"Admin access required",
))
}
/// Returns true if `subject` has `permission` on `resource`, considering
/// owner short-circuit AND cascading from folder ancestors.
///
@@ -304,3 +358,33 @@ pub trait AuthorizationEngine: Send + Sync + 'static {
/// cleanup this is the canonical role-revocation entry point.
async fn clear_role(&self, subject: Subject, resource: Resource) -> Result<(), DomainError>;
}
#[cfg(test)]
mod system_admin_tests {
use super::*;
#[test]
fn only_active_internal_admin_users_pass_the_system_gate() {
let id = Uuid::new_v4();
assert_eq!(
system_admin_denial_reason(Subject::User(id), UserRole::Admin, false, true),
None
);
assert_eq!(
system_admin_denial_reason(Subject::User(id), UserRole::User, false, true),
Some("not_admin")
);
assert_eq!(
system_admin_denial_reason(Subject::User(id), UserRole::Admin, true, true),
Some("external_account")
);
assert_eq!(
system_admin_denial_reason(Subject::User(id), UserRole::Admin, false, false),
Some("inactive")
);
assert_eq!(
system_admin_denial_reason(Subject::Token(id), UserRole::Admin, false, true),
Some("unsupported_subject")
);
}
}
@@ -1,11 +1,12 @@
use crate::application::dtos::user_dto::{
AuthResponseDto, ChangePasswordDto, LoginDto, RefreshTokenDto, RegisterDto,
UpgradeToInternalDto, UserDto,
AdminUserSummaryDto, AuthResponseDto, ChangePasswordDto, LoginDto, RefreshTokenDto,
RegisterDto, UpgradeToInternalDto, UserDto,
};
use crate::application::ports::auth_ports::{
OidcIdClaims, OidcServicePort, PasswordHasherPort, SessionStoragePort, TokenServicePort,
UserStoragePort,
};
use crate::application::ports::authorization_ports::AuthorizationEngine;
use crate::application::ports::user_lifecycle::{DeletionMode, LogoutReason};
use crate::application::services::user_lifecycle_service::UserLifecycleService;
use crate::common::config::{AuthMethod, OidcConfig};
@@ -14,6 +15,7 @@ use crate::domain::entities::magic_link_token::{MagicLinkResourceKind, MagicLink
use crate::domain::entities::session::Session;
use crate::domain::entities::user::{User, UserFlags, UserRole};
use crate::domain::repositories::magic_link_token_repository::MagicLinkTokenRepository;
use crate::domain::services::authorization::Subject;
use crate::infrastructure::repositories::pg::SessionPgRepository;
use crate::infrastructure::repositories::pg::UserPgRepository;
use crate::infrastructure::services::jwt_service::JwtTokenService;
@@ -2129,7 +2131,7 @@ impl AuthApplicationService {
/// out so that internal-user surfaces — system address book, OCS
/// sharee search, etc. — never expose external identities. Admin
/// surfaces that need the full list should call
/// [`list_users_including_external`] instead.
/// [`list_users_including_external_with_perms`] instead.
pub async fn list_users(&self, limit: i64, offset: i64) -> Result<Vec<UserDto>, DomainError> {
let users = self.user_storage.list_users(limit, offset, false).await?;
Ok(users.into_iter().map(UserDto::from).collect())
@@ -2137,15 +2139,55 @@ impl AuthApplicationService {
/// Admin-only: lists users including external (grant-only) recipients.
/// Used by the admin user-management UI.
pub async fn list_users_including_external(
pub async fn list_users_including_external_with_perms<A: AuthorizationEngine>(
&self,
authorization: &A,
caller_id: Uuid,
limit: i64,
offset: i64,
) -> Result<Vec<UserDto>, DomainError> {
self.require_admin_caller(authorization, caller_id).await?;
let users = self.user_storage.list_users(limit, offset, true).await?;
Ok(users.into_iter().map(UserDto::from).collect())
}
/// Admin-only compact listing. The detail endpoint retains the complete
/// [`UserDto`]; this path projects only what the management table renders so
/// PostgreSQL never detoasts or transfers avatars/preferences for a page.
pub async fn list_user_summaries_including_external_with_perms<A: AuthorizationEngine>(
&self,
authorization: &A,
caller_id: Uuid,
limit: i64,
offset: i64,
) -> Result<Vec<AdminUserSummaryDto>, DomainError> {
self.require_admin_caller(authorization, caller_id).await?;
let users = self
.user_storage
.list_user_summaries(limit, offset, true)
.await?;
Ok(users.into_iter().map(AdminUserSummaryDto::from).collect())
}
/// Service-layer gate for administrator-scoped user-directory operations.
/// The route middleware remains a cheap first line of defence, but the
/// application service is authoritative so alternate callers cannot bypass
/// policy. The lookup is the existing single-flight, image-free flags
/// cache; a hot authorization check does not hydrate the user profile.
async fn require_admin_caller<A: AuthorizationEngine>(
&self,
authorization: &A,
caller_id: Uuid,
) -> Result<(), DomainError> {
let flags = self.get_user_flags(caller_id).await?;
authorization.require_system_admin(
Subject::User(caller_id),
flags.role,
flags.is_external,
flags.active,
)
}
/// Searches internal users only. See [`list_users`] for the rationale.
pub async fn search_users(&self, query: &str, limit: i64) -> Result<Vec<UserDto>, DomainError> {
let users = self.user_storage.search_users(query, limit, false).await?;