Optimize storage, GC, and upload hot paths
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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?;
|
||||
|
||||
Reference in New Issue
Block a user