diff --git a/CHANGELOG.md b/CHANGELOG.md index 1e6971fc3..282a3798d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,18 @@ All notable changes to this project are documented in this file. The format is b ## [Unreleased] ### Fixed +- Users (security): disabling or enabling an account, or replacing its roles from the admin + screens, now bumps `session_version` in the same transaction and evicts the cached counter on + every worker after commit (an atomic SQL increment, so overlapping changes cannot lose one), so + existing cookie sessions and bearer access tokens are refused on their next request; disabling + also revokes the account's refresh tokens. Before, a disabled or demoted user kept the roles + cached in their cookie until the session expired. **Behaviour change:** a role change signs that + user out of their existing sessions; a remembered sign-in re-authenticates with the new roles + (#581). +- File storage: **Behaviour change.** `quota_bytes` is now enforced for plain uploads as well as + resumable ones (stored bytes plus active reservations, serialised per tenant on Postgres); an + upload that would exceed it answers 413 and its object is removed. Previously only resumable + sessions were checked, and the setting was described as report-only (#586). - File storage: `DELETE /api/file_storage/files/{id}` and the bulk delete now drop the stored bytes only after the request transaction commits, so a failed commit no longer leaves a live row with no bytes. A new hourly `file_storage.reap_objects` beat task (needs diff --git a/docs/modules/file_storage.md b/docs/modules/file_storage.md index 7633826b1..1e3615b8a 100644 --- a/docs/modules/file_storage.md +++ b/docs/modules/file_storage.md @@ -286,6 +286,7 @@ DB-backed via `register_module_settings`; pydantic defaults seed at boot. Live e | `s3_signature_version` | `"s3v4"` | presign signature version; pinned so custom endpoints get SigV4 | | `s3_presign_ttl_seconds` | `300` | TTL for presigned `GET` URLs | | `max_file_size_bytes` | `100 * 1024 * 1024` | upload limit (100 MB) | +| `quota_bytes` | `None` | Enforced on plain and resumable uploads: stored bytes plus unexpired active session reservations; `None` means unlimited aggregate storage. Usage follows file-listing tenant scope; platform uploads count platform-owned files separately. PostgreSQL serializes quota admissions until the recording transaction commits. Rejected plain uploads delete the uploaded object. | | `allowed_content_types` | `None` | optional MIME-type whitelist; `None` ⇒ any | ## Backends diff --git a/modules/branding/branding/endpoints/api.py b/modules/branding/branding/endpoints/api.py index e1bf9187b..12166e17e 100644 --- a/modules/branding/branding/endpoints/api.py +++ b/modules/branding/branding/endpoints/api.py @@ -10,7 +10,7 @@ from branding import constants from branding.contracts.schemas import BrandingOut, BrandingUpdate from branding.deps import BrandingServiceDep -from branding.images import validate_image +from branding.images import store_image, validate_image from branding.presets import find_preset router = APIRouter() @@ -67,7 +67,7 @@ async def upload_logo( storage: FileStorageService = Depends(get_file_storage_service), ) -> BrandingOut: await validate_image(file) - stored = await storage.upload(file, platform=True) + stored = await store_image(storage, file, platform=True) return await service.set_logo(str(stored.id)) @@ -91,7 +91,7 @@ async def upload_logo_dark( storage: FileStorageService = Depends(get_file_storage_service), ) -> BrandingOut: await validate_image(file) - stored = await storage.upload(file, platform=True) + stored = await store_image(storage, file, platform=True) return await service.set_logo_dark(str(stored.id)) @@ -107,7 +107,7 @@ async def upload_favicon( storage: FileStorageService = Depends(get_file_storage_service), ) -> BrandingOut: await validate_image(file) - stored = await storage.upload(file, platform=True) + stored = await store_image(storage, file, platform=True) return await service.set_favicon(str(stored.id)) diff --git a/modules/branding/branding/endpoints/tenant_api.py b/modules/branding/branding/endpoints/tenant_api.py index 790891fa4..76a073586 100644 --- a/modules/branding/branding/endpoints/tenant_api.py +++ b/modules/branding/branding/endpoints/tenant_api.py @@ -27,7 +27,7 @@ from branding.constants import PACKAGE, PATH_TENANT_ASSET, TENANT_ASSETS, TENANT_IMAGE_FIELDS from branding.contracts.schemas import BrandingOut -from branding.images import validate_image +from branding.images import store_image, validate_image from branding.reaper import schedule_reap from branding.service import to_out from branding.tenant_branding import merge, overrides_from @@ -83,7 +83,7 @@ async def upload_tenant_asset( field = _field(asset) tenant_id = active_tenant(request) await validate_image(file) - stored = await storage.upload(file) + stored = await store_image(storage, file) return await _swap(request, settings, storage, tenant_id, field, str(stored.id)) diff --git a/modules/branding/branding/images.py b/modules/branding/branding/images.py index 334758b6f..eb1323631 100644 --- a/modules/branding/branding/images.py +++ b/modules/branding/branding/images.py @@ -12,10 +12,14 @@ from __future__ import annotations -from typing import Final +from typing import TYPE_CHECKING, Final from fastapi import HTTPException, UploadFile +if TYPE_CHECKING: + from file_storage.contracts.schemas import StoredFileOut + from file_storage.service import FileStorageService + # Raster + icon only. SVG is excluded on purpose: it is an XML document that can # carry