feat(admin): per-user + per-enclave storage quotas (LC-93) #153

Merged
nrupard merged 2 commits from feat/lc-93-storage-quotas into main 2026-05-19 19:30:02 +02:00
Owner

Summary

Implements LC-93. Admins can cap storage per user and per enclave; uploads or message-attachments that would push a user or an enclave over its cap return 413.

  • New chat migration 0031_storage_quotas.sql: enclaves.quota_bytes (nullable = unlimited) and user_storage_quotas (absence of row = unlimited).
  • db::quota: get/set helpers for both, plus live SUMs (sum_user_usage, sum_enclave_usage). Usage excludes messages.is_system = 1 rows per the spec; orphan uploads count toward the uploader but not toward any enclave.
  • Upload-time enforcement in routes/uploads.rs: check after streaming, before the image re-encode, so a rejected upload does not burn CPU on the pipeline.
  • Message-create enforcement in routes/room.rs: enclave quota is checked when an attachment is being attached to a message. DMs (enclave_id IS NULL) skip. New AppError::PayloadTooLarge returns 413.
  • Admin UI: /admin/users and /admin/enclaves grow a Storage column with the current usage + a quota_mib input. POST /admin/users/{id}/quota (HTMX row replace) and POST /admin/enclaves/{id}/quota (redirect). Both audit-log via mod_actions (quota_set_user / quota_set_enclave).
  • User-facing: /settings gains a Storage usage card with usage + cap (or "no quota set").

Open questions from the issue, resolved before coding:

  • Originals only, not previews.
  • System uploads exempt (via messages.is_system join filter).
  • Hard cap, immediate 413; no grace period.

Test plan

  • just check (both feature builds, clippy -D warnings, fmt --check).
  • just test: new routes_quotas (10 cases) plus existing 49 binaries green. Hand-rolled-migration test files (7) all updated to include the new migration; common::pool auto-picks it via sqlx::migrate!.
  • just test-saas: green; the three admin-route tests are #[cfg(feature = "standalone")] per the CLAUDE.md test-drift guide. Pre-existing flake dedup_upload_heals_missing_preview_on_disk (CLAUDE.md "out of scope") passes in isolation.
  • Manual smoke: set a 5 MiB user quota, upload a 6 MiB file → 413; lower quota to 0, upload again → 413; clear quota → upload succeeds; same for enclave-side via a multi-user enclave.
## Summary Implements LC-93. Admins can cap storage per user and per enclave; uploads or message-attachments that would push a user or an enclave over its cap return 413. - New chat migration `0031_storage_quotas.sql`: `enclaves.quota_bytes` (nullable = unlimited) and `user_storage_quotas` (absence of row = unlimited). - `db::quota`: get/set helpers for both, plus live SUMs (`sum_user_usage`, `sum_enclave_usage`). Usage excludes `messages.is_system = 1` rows per the spec; orphan uploads count toward the uploader but not toward any enclave. - Upload-time enforcement in `routes/uploads.rs`: check after streaming, before the image re-encode, so a rejected upload does not burn CPU on the pipeline. - Message-create enforcement in `routes/room.rs`: enclave quota is checked when an attachment is being attached to a message. DMs (`enclave_id IS NULL`) skip. New `AppError::PayloadTooLarge` returns 413. - Admin UI: `/admin/users` and `/admin/enclaves` grow a Storage column with the current usage + a `quota_mib` input. `POST /admin/users/{id}/quota` (HTMX row replace) and `POST /admin/enclaves/{id}/quota` (redirect). Both audit-log via `mod_actions` (`quota_set_user` / `quota_set_enclave`). - User-facing: `/settings` gains a Storage usage card with usage + cap (or "no quota set"). Open questions from the issue, resolved before coding: - Originals only, not previews. - System uploads exempt (via `messages.is_system` join filter). - Hard cap, immediate 413; no grace period. ## Test plan - [x] `just check` (both feature builds, clippy -D warnings, fmt --check). - [x] `just test`: new `routes_quotas` (10 cases) plus existing 49 binaries green. Hand-rolled-migration test files (7) all updated to include the new migration; `common::pool` auto-picks it via `sqlx::migrate!`. - [x] `just test-saas`: green; the three admin-route tests are `#[cfg(feature = "standalone")]` per the CLAUDE.md test-drift guide. Pre-existing flake `dedup_upload_heals_missing_preview_on_disk` (CLAUDE.md "out of scope") passes in isolation. - [ ] Manual smoke: set a 5 MiB user quota, upload a 6 MiB file → 413; lower quota to 0, upload again → 413; clear quota → upload succeeds; same for enclave-side via a multi-user enclave.
feat(admin): per-user + per-enclave storage quotas (LC-93)
Some checks failed
Check / clippy + fmt + tests (pull_request) Failing after 49s
e4cb63c24d
Quotas live in chat.db so the upload-time check stays single-domain. `user_storage_quotas` holds per-user caps (absence = unlimited); the new `enclaves.quota_bytes` column does the same for enclaves. Usage is a live SUM over `file_uploads` rather than a cached counter: SQLite handles the size on a self-hosted deployment fine, and live counts auto-recompute on delete with no triggers or sweep coordination. System messages are excluded from both the user and enclave usage figures per the LC-93 acceptance criterion.

Enforcement:

- `routes/uploads.rs::post_upload` checks the per-user quota right after streaming to the temp file but before the (expensive) image re-encode, so a rejected upload does not burn CPU.
- `routes/room.rs::post_message` checks the per-enclave quota after validating attachment ownership; DMs (`enclave_id IS NULL`) skip it. New `AppError::PayloadTooLarge` returns 413 from the existing `IntoResponse` impl.

Admin UI:

- `POST /admin/users/{id}/quota` and `POST /admin/enclaves/{id}/quota` accept a `quota_mib` field (empty = clear the cap). The user form re-renders the row HTMX-style; the enclave form redirects back to the page. Both write a `quota_set_user` / `quota_set_enclave` row to `mod_actions`.
- `/admin/users` and `/admin/enclaves` grow a Storage column showing current usage alongside the form input.

User-facing:

- `/settings` shows a "Storage usage" card with the current usage and either the user's quota or an "no quota set" note.
fix(quotas): code-review follow-ups (LC-93)
All checks were successful
Check / clippy + fmt + tests (pull_request) Successful in 1m16s
9a2b3f35a4
- Align user and enclave usage SUMs: enclave SUM no longer filters `m.deleted_at IS NULL`. Soft-deleting your own message must not free quota headroom on either side, since the upload row + bytes stay until the orphan sweeper claims them. Closes a self-delete-to-bypass-cap loophole and matches the per-user SUM that already counts soft-deleted rows.
- `post_user_quota` 404s when the path id doesn't match an auth.users row. Without this, a typo'd id would silently insert an orphan `user_storage_quotas` row (no FK across DB domains) and then the response would 404 on render anyway.
- `post_enclave_quota` 404s when the path id doesn't match an enclaves row. Prevents the audit log from recording a no-op against a non-existent enclave.
- Document the SUM-then-INSERT race at both upload-time and message-attach-time. Acceptable for self-hosted volumes; flagged so the next reader knows it is intentional.
- Three new regression tests cover the soft-delete consistency and the two 404 paths.
nrupard deleted branch feat/lc-93-storage-quotas 2026-05-19 19:30:02 +02:00
Sign in to join this conversation.
No reviewers
No labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
psa-systems/lets-chat!153
No description provided.