perf(auth): cached image-free user-flags lookup for per-request guards

Every WebDAV / CalDAV / CardDAV request paid one full-row user fetch in
require_internal_user_layer just to read `is_external` (and the NC Basic
Auth middleware repeated it right after its own cache hit). That SELECT
includes the `image` column — a data URI of up to 512 KiB — so a sync
client issuing hundreds of PROPFINDs per minute dragged hundreds of MB
of avatar bytes out of Postgres to evaluate a boolean.

- New `UserFlags { role, is_external, active }` + a repo query selecting
  only those three columns (inherent method, mirroring `update_image`).
- `AuthApplicationService::get_user_flags`: moka cache, 30 s TTL,
  10k capacity. `change_user_role` / `set_user_active` invalidate
  eagerly, so admin changes still apply immediately; anything else is
  visible within the TTL — preserving the documented "no token rotation
  needed" semantics at a per-request cost of zero DB round-trips when
  warm.
- `require_internal_user`, `require_admin_user` and the NC Basic Auth
  external check now go through the flags lookup.

https://claude.ai/code/session_01Dp3oWon5GBMVn4j3QXZdgx
This commit is contained in:
Claude
2026-06-10 09:27:32 +00:00
parent fd80a3de67
commit 8a42b07cbe
5 changed files with 108 additions and 20 deletions
@@ -5,7 +5,7 @@ use uuid::Uuid;
use crate::application::ports::auth_ports::UserStoragePort;
use crate::common::errors::DomainError;
use crate::domain::entities::user::{User, UserRole};
use crate::domain::entities::user::{User, UserFlags, UserRole};
use crate::domain::repositories::user_repository::{
StorageStats, UserRepository, UserRepositoryError, UserRepositoryResult,
};
@@ -51,6 +51,40 @@ impl UserPgRepository {
}
}
/// Fetch only the authorization-relevant flags of a user. Not part of
/// the `UserRepository` trait — called directly from
/// `AuthApplicationService::get_user_flags`.
///
/// Deliberately selects three tiny columns instead of the full row:
/// the full-row SELECT includes `image` (a data URI of up to 512 KiB),
/// which per-request middleware guards were paying on every WebDAV /
/// CalDAV / CardDAV request just to read `is_external` or `role`.
pub async fn get_user_flags(&self, id: Uuid) -> UserRepositoryResult<UserFlags> {
let row = sqlx::query(
r#"
SELECT role::text as role_text, is_external, active
FROM auth.users
WHERE id = $1
"#,
)
.bind(id)
.fetch_one(&*self.pool)
.await
.map_err(Self::map_sqlx_error)?;
let role_str: Option<String> = row.try_get("role_text").unwrap_or(None);
let role = match role_str.as_deref() {
Some("admin") => UserRole::Admin,
_ => UserRole::User,
};
Ok(UserFlags {
role,
is_external: row.get("is_external"),
active: row.get("active"),
})
}
/// Updates a user's profile image (URL or data URI). Not part of the
/// `UserRepository` trait — called directly from `AuthApplicationService`.
pub async fn update_image(