8.5 KiB
8.5 KiB
src/AGENTS.md — backend-only notes
Non-obvious rules that trip up new code. Terse on purpose.
Auth policy
- OIDC is the master identity provider. Whenever
AuthApplicationService::oidc_enabled()returns true, magic-link login MUST be off —is_magic_link_login_allowed()returns false regardless ofOXICLOUD_AUTH_METHODS. Rationale: OIDC may enforce 2FA / step-up; a mailbox-possession bypass would silently sidestep it. - Password / magic-link handlers gate via
is_password_login_allowed()/is_magic_link_login_allowed(), never raw config orpassword_login_disabled()alone. The composed helpers merge the legacy OIDC-only flag,OXICLOUD_AUTH_METHODS, SMTP wiring, and the OIDC-master rule in one place. - Magic-link redemption distinguishes login tokens (
resource_kind = None) from invitation tokens (File / Folder). The login gate only applies to the None case; invitations follow their own admin-mediated trust chain. OXICLOUD_REQUIRE_VERIFIED_EMAILgates login onemail_verified_at IS NOT NULL. Admin-created (admin_create_user) and setup-admin (setup_create_admin) users are stamped verified at creation — admin fiat counts. OIDC-JIT already stamps verified. Admins are EXEMPT from the gate at login regardless ofemail_verified_at— pre-existing admin accounts from before this flag shipped must never be locked out of their own instance. Regular users hit the gate; the frontend detects theEmailNotVerifiederror_type and offers a resend-magic-link CTA.- Startup gate in
main.rs: magic-link-only allowlist + no SMTP = panic. Never soften to warn.
New auth surfaces
- Any new endpoint that mints or consumes credentials/tokens must consult one of the
is_*_login_allowed()helpers, not the raw allowlist. - Any new "policy-disabled" refusal must emit an
audit-target line before returning — matchesauth.login_rejected,magic_link.redemption_rejectedconventions.
AuthZ enforcement points
- The extractors are NOT a choke point. Four paths authenticate without ever touching
AuthUser/CurrentUserId:middleware/admin.rs::require_authenticated(re-parses the JWT from header or cookie itself), the three DAV handlers' hand-rolledextract_user(webdav_handler.rs:154,caldav_handler.rs:421,carddav_handler.rs:173),POST /api/auth/refresh(mounted outsideauth_middleware,main.rs:775), andGET /api/rt/ws(self-auths from a raw Bearer and never readsclaims.role,rt_ws.rs:240-255). A rule added to an extractor does not hold until it is added to these too. - Never hand-roll principal extraction.
req.extensions().get::<Arc<CurrentUser>>()inside a handler is exactly the anti-pattern above — it bypasses everyFromRequestPartsguard. Take the extractor, or call the shared assertion. - A method check is not an authorization check. GETs that mint credentials or write exist:
GET /api/wopi/editor-urlreturns a WOPI token usable forPOST /wopi/files/{id}/contents;GET /api/s/{token}writes viaregister_shared_link_access;GET /api/auth/device/verifyis an oracle on live device codes;GET /api/batch/downloadbuilds an arbitrary ZIP from a querystring. Never gate on verb alone. - An auth helper's
_ =>arm must DENY. Two fail-open gates exist and are bugs, not patterns to copy:require_internal_user(middleware/user.rs:64-71) admits the caller on anyget_user_flagserror, anddecide_live_role(:169-176) resurrects the claim role on a transient DB error. The first is the only middleware guarding all three DAV surfaces. - Prefer deny-by-default over assert-in-handler. A restriction enforced inside the extractor covers ~200 call sites with no edits; the same restriction as "handler takes an optional principal and asserts" is one forgotten call away from silently accepting.
OptionalUserId(middleware/auth.rs:85-98) is the cautionary tale — it exists, it is dead code, and nothing ever used it. - Anonymous-session direction (share links as a principal):
docs/plan/rationalize-publicshare.md.
Storage backend access
- Read blob content through
Arc<DedupService>. It's the ONE canonical read abstraction — CDC-manifest-aware (file.blob_hashmay reference a chunk manifest, not a blob), backend-agnostic (Local/S3/Azure), wrapper-transparent (encryption/retry/cache). Never takeArc<dyn BlobStorageBackend>directly in a service that reads content; you'll silently break on any file ≥ 64 KiB (CDC_MIN_CHUNK). Followthumbnail_service,audio_metadata_service,media_metadata_service,face_indexing_service,search_index::content_index_workeras reference impls. - Reads use
DedupServicemethods:dedup.read_blob_bytes(hash)for byte-slice analyzers (ONNX, EXIF, ID3-via-Reader),dedup.stream_blob_to_tempfile(hash, &temp_dir, ".ext")for crates that only accept&Path(mp3_duration, ffprobe,nom-exifvideo),dedup.read_blob_stream(hash)for streaming to a downstreamStreamconsumer. - Never hand-craft blob paths. No
blob_root: PathBuffields, no<storage>/.blobs/<xx>/<hash>.blobconstructions.BlobStorageBackend::local_blob_pathreturnsNoneunderEncryptedBlobBackend; do not rely on it. The three services that did this pre-2026-08 (audio/media/face) are the anti-pattern — see memoryproject_services_bypassing_blob_backend. - Persistent state = backend, not
<storage_path>/*sidecars. Local sidecars (.thumbnails/,.transcoded/,.blob-cache/,.search-index/,.plugin-logs/,.uploads/) are only for caches (regenerable) or truly-temp scratch (deleted on drop). Anything a user would notice losing → blob backend. Tier-2 migration plan:docs/plan/derived-blobs.md. - Temp files use
OXICLOUD_TEMP_DIRvia the shared config path (AppConfig::temp_dir) — not rawstd::env::temp_dir(). Ops point it at real disk on RAM-constrained Linux deployments (default/tmp= tmpfs = RAM).
OpenAPI
- Every new
#[utoipa::path(...)]handler MUST also be added to thepaths(...)list insrc/interfaces/api/mod.rs. utoipa emits ONLY registered paths; the annotation alone is invisible toresources/gen/openapi.json. Historical drift found 21 annotated handlers that never reached the spec (admin drives, admin jobs pause/findings/purge, admin SMTP, admin storage rotate, admin promote-to-internal, admin sessions list/revoke, all OPAQUE endpoints, DPoP bind, magic-link send, profile PATCH, upgrade-to-internal, drive delete/members/policies/quota, grant notify, trash per-drive, user profile, dedup check-batch) — they all had valid#[utoipa::path]blocks but nobody registered them. - Every DTO the new handler touches — request body, response body, path/query params, error shapes — MUST also be added to
components(schemas(...))in the same file, OR be reachable from an already-registered schema. Utoipa only pulls in schemas transitively from registered paths + registered top-level schemas. - After adding:
cargo run --bin generate-openapito regenerateresources/gen/openapi.json, thengit diff resources/gen/openapi.json— the new path + its request/response schemas must be present. Zero-diff means you missed the registration. - Sanity check for the whole surface:
diff <(grep -oE 'path = "/api[^"]+"' src/interfaces/api/handlers/*.rs | grep -oE '/api[^"]+' | sort -u) <(jq -r '.paths | keys | .[]' resources/gen/openapi.json | sort -u)— should always be empty. Non-empty diff = drift. - Handlers referenced by the
paths(...)list MUST bepub(module-visible from the paths list). Privateasync fncompiles at the router mount but breaks the paths list with a visibility error — seeget_smtp_info,send_smtp_test,get_user_profilefor the retrofit.
Security / scope in the spec
security(("bearerAuth" = []))— the empty array is the SCOPES list, not decoration. Every route currently declares the same thing, so the spec claims "a session is required" forGET /api/versionandPUT /api/admin/users/{id}/rolealike: true, and useless. If a route's gate differs from the default, declare it there.- OpenAPI has no field for a minimum role — OAuth2 has no role concept, so the spec has nowhere to put one. Use a pseudo-scope (
["role:admin"]) rather than a vendor extension no tooling renders. - Declaring is not enforcing. utoipa's
securitywires nothing, so it drifts from the real gate silently. Any scope worth declaring is worth a test cross-checking it against the actual mount — otherwise the spec becomes a parallel description of the authorization boundary rather than a picture of it.