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
@@ -64,8 +64,7 @@ pub async fn basic_auth_middleware(
// Check account lockout before attempting password verification (saves CPU).
// The lockout is per (account, IP), see #323 for rationale.
let client_ip =
crate::interfaces::middleware::rate_limit::extract_client_ip(&request);
let client_ip = crate::interfaces::middleware::rate_limit::extract_client_ip(&request);
if let Some(auth_svc) = state.auth_service.as_ref()
&& let Err(secs) = auth_svc.login_lockout.check(&username, &client_ip)
{
@@ -103,11 +102,11 @@ pub async fn basic_auth_middleware(
// this is the belt-and-braces check in case one slipped
// through (e.g. user later flipped to is_external).
if let Some(auth_svc) = state.auth_service.as_ref()
&& let Ok(user) = auth_svc
&& let Ok(flags) = auth_svc
.auth_application_service
.get_user_by_id(user_id)
.get_user_flags(user_id)
.await
&& user.is_external
&& flags.is_external
{
tracing::info!(
target: "audit",