Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
99d3a4d
✨(backend) expose the can_upload reason as the API error code
NathanVss Jul 13, 2026
d9cfb4d
✨(backend) expose the user quota in the entitlements API
NathanVss Jul 13, 2026
780c63b
✨(backend) invalidate per-user storage caches on item writes
NathanVss Jul 13, 2026
6c61275
🐛(backend) exclude hard-deleted items from storage computation
NathanVss Jul 13, 2026
0f7ee3a
✨(backend) add a local entitlements backend with storage limits
NathanVss Jul 13, 2026
a84851e
✨(backend) gate move-to-root and duplicate on the upload entitlement
NathanVss Jul 13, 2026
e50ace2
✨(backend) add a storage gauge information link setting
NathanVss Jul 13, 2026
5ed2639
⬆️(frontend) upgrade ui-kit to 0.27.0
NathanVss Jul 13, 2026
0274332
✨(frontend) show specific quota messages on rejected actions
NathanVss Jul 13, 2026
be6109c
✨(frontend) add the storage gauge and settings modal
NathanVss Jul 13, 2026
24863e9
✅(frontend) keep translations in sync across languages
NathanVss Jul 13, 2026
d3d9dff
📝(changelog) mention the storage gauge and reorder entries
NathanVss Jul 13, 2026
bac1e2d
✨(backend) push usage metrics to DeployCenter entitlements requests
NathanVss Jul 16, 2026
1d14ef0
✏️(backend) fix the spelling of quota exceeded codes
NathanVss Jul 22, 2026
1a7fcd4
✏️(frontend) fix the spelling of quota exceeded codes
NathanVss Jul 22, 2026
060707c
♻️(backend) invalidate the storage cache from Item.save
NathanVss Jul 22, 2026
826ad05
🎨(backend) assign the quota with a walrus operator
NathanVss Jul 22, 2026
d42dd30
✨(frontend) refresh the storage gauge on move and duplicate
NathanVss Jul 22, 2026
635135d
♻️(frontend) reuse the left panel footer on public layouts
NathanVss Jul 22, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,8 @@ and this project adheres to
- ✨(backend) allow converting a file while it is being analyzed
- ✨(frontend) add file type, contact and modification date topbar filters
- ✨(frontend) add location, file type, contact and date search filters
- ✨(backend) add a local entitlements backend with per-user storage limits
- ✨(frontend) add storage gauge and settings modal

### Fixed

Expand Down
28 changes: 28 additions & 0 deletions docs/entitlements.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,34 @@ ENTITLEMENTS_BACKEND_PARAMETERS = {
}
```

### Local Backend

The `LocalEntitlementsBackend` enforces a per-user storage quota computed from local data, without relying on any external service. Every user gets a default limit (10 GiB unless configured otherwise), and the usage is computed by the configured storage compute backend (by default, the sum of the sizes of the items the user created — files count against their creator).

**Configuration:**

```python
ENTITLEMENTS_BACKEND = "core.entitlements.backends.local.LocalEntitlementsBackend"
ENTITLEMENTS_BACKEND_PARAMETERS = {
# Default storage limit in bytes (optional, defaults to 10 GiB).
"default_storage_limit": 10737418240,
# Users created before this datetime have no limit (optional).
"exempt_users_created_before": "2026-01-01T00:00:00+00:00",
# Safety net expiry in seconds for the cached usage (optional, defaults to 3600).
"cache_timeout": 3600,
}
```

**Caching:** the storage used by each user is cached (`storage_used:user:<id>` key) and invalidated whenever an item write changes it (upload, collaborative save, conversion, duplication, hard delete, creator reassignment). The `cache_timeout` expiry is only a safety net: a value primed concurrently with a write can stay stale for up to that duration.

**Per-user override:** the limit can be overridden for each user through the `storage_limit_override` field, editable in the Django admin. Leave it empty to apply the configured default limit, set it to `0` for unlimited storage, or set any positive number of bytes. The override always takes precedence over the `exempt_users_created_before` cutoff.

**Grandfathering:** when `exempt_users_created_before` is set, users created before that datetime (and without an override) have no storage limit. This allows rolling out quotas for new users only.

Users without a limit (grandfathered or override set to `0`) get no `quota` entry in the entitlements response, so no quota gauge is rendered.

Note that the quota is soft: `can_upload` is checked before the file size is known, so a single upload can overshoot the limit; the next one is then blocked.

### DeployCenter Backend

The `DeployCenterEntitlementsBackend` integrates with an external [DeployCenter](https://github.com/suitenumerique/st-deploycenter) entitlements service to check user permissions based on their account email and other OIDC claims.
Expand Down
2 changes: 2 additions & 0 deletions src/backend/core/admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@ class UserAdmin(auth_admin.UserAdmin):
),
},
),
(_("Entitlements"), {"fields": ("storage_limit_override",)}),
(_("Important dates"), {"fields": ("created_at", "updated_at")}),
)
add_fieldsets = (
Expand All @@ -82,6 +83,7 @@ class UserAdmin(auth_admin.UserAdmin):
"is_staff",
"is_superuser",
"is_device",
"storage_limit_override",
"created_at",
"updated_at",
)
Expand Down
12 changes: 6 additions & 6 deletions src/backend/core/api/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -88,19 +88,19 @@ def to_representation(self, instance):
return output


class OrganizationUsageMetricSerializer(serializers.Serializer):
# pylint: disable=abstract-method
class OrganizationUsageMetricSerializer(serializers.BaseSerializer):
"""Serialize aggregated usage metrics for an organization."""

account_id_key = serializers.CharField()
account_id_value = serializers.CharField()
total_storage = serializers.IntegerField()

def to_representation(self, instance):
"""Return the organization usage metric."""
storage_compute_backend = get_storage_compute_backend()
return {
"account": {"type": "organization"},
instance["account_id_key"]: instance["account_id_value"],
"metrics": {"storage_used": instance["total_storage"]},
"metrics": {
"storage_used": storage_compute_backend.compute_storage_used(instance["users"])
},
}


Expand Down
43 changes: 35 additions & 8 deletions src/backend/core/api/viewsets.py
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@
get_file_indexer,
get_visited_items_ids_of,
)
from core.storage import get_storage_compute_backend
from core.storage.cache import invalidate_storage_used_cache
from core.tasks.item import duplicate_file, process_item_purge, rename_file
from core.utils.analytics import posthog_capture
from wopi.conversion import exceptions as conversion_exceptions
Expand Down Expand Up @@ -800,7 +800,8 @@
if not can_upload["result"]:
self._complete_item_deletion(item)
raise drf.exceptions.PermissionDenied(
detail=can_upload.get("message", "You do not have permission to upload files.")
detail=can_upload.get("message", "You do not have permission to upload files."),

Check failure on line 803 in src/backend/core/api/viewsets.py

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Define a constant instead of duplicating this literal "You do not have permission to upload files." 3 times.

See more on https://sonarcloud.io/project/issues?id=suitenumerique_drive&issues=AZ9My8Ccngm2Ud5dbOHI&open=AZ9My8Ccngm2Ud5dbOHI&pullRequest=764
code=can_upload.get("reason"),
)

s3_client = default_storage.connection.meta.client
Expand Down Expand Up @@ -975,7 +976,7 @@

@drf.decorators.action(detail=True, methods=["post"])
@transaction.atomic
def move(self, request, *args, **kwargs):

Check failure on line 979 in src/backend/core/api/viewsets.py

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Refactor this function to reduce its Cognitive Complexity from 17 to the 15 allowed.

See more on https://sonarcloud.io/project/issues?id=suitenumerique_drive&issues=AZ9My8Ccngm2Ud5dbOHJ&open=AZ9My8Ccngm2Ud5dbOHJ&pullRequest=764
"""
Move an item to another location within the item tree.

Expand Down Expand Up @@ -1014,17 +1015,33 @@
{"target_item_id": message}, code="item_move_missing_permission"
)

# Moving a file to the root without a direct access reassigns its creator
# (see below), shifting the file size to the mover's storage usage: gate it
# like an upload so an over-quota user cannot take ownership of more storage.
has_direct_access = models.ItemAccess.objects.filter(item=item, user=user).exists()
if not target_item and not has_direct_access and item.type == models.ItemTypeChoices.FILE:
can_upload = get_entitlements_backend().can_upload(user)
if not can_upload["result"]:
raise drf.exceptions.PermissionDenied(
detail=can_upload.get("message", "You cannot take ownership of more storage."),
code=can_upload.get("reason"),
)

item.move(target_item)

# If the item is moved to the root and the user does not have an access on the item,
# create an owner access for the user. Otherwise, the item will be invisible for the user.
update_fields = []
if not target_item and not models.ItemAccess.objects.filter(item=item, user=user).exists():
if not target_item and not has_direct_access:
models.ItemAccess.objects.create(
item=item,
user=self.request.user,
role=models.RoleChoices.OWNER,
)
# Saving the item only invalidates the storage used cache of the
# new creator, the previous one loses this item from its usage.
previous_creator_id = item.creator_id
transaction.on_commit(lambda: invalidate_storage_used_cache([previous_creator_id]))
item.creator = user
update_fields.append("creator")

Expand Down Expand Up @@ -1089,7 +1106,8 @@
and not can_upload["result"]
):
raise drf.exceptions.PermissionDenied(
detail=can_upload.get("message", "You do not have permission to upload files.")
detail=can_upload.get("message", "You do not have permission to upload files."),
code=can_upload.get("reason"),
)

extension = serializer.validated_data.pop("extension", None)
Expand Down Expand Up @@ -1707,6 +1725,15 @@
item_to_duplicate = self.get_object()
user = request.user

# The duplicator becomes the creator of a new sized file: gate it like an
# upload so an over-quota user cannot grow their storage usage.
can_upload = get_entitlements_backend().can_upload(user)
if not can_upload["result"]:
raise drf.exceptions.PermissionDenied(
detail=can_upload.get("message", "You do not have permission to upload files."),
code=can_upload.get("reason"),
)

parent = item_to_duplicate.parent() if item_to_duplicate.depth > 1 else None

if parent and parent.get_role(user) == models.RoleChoices.READER:
Expand Down Expand Up @@ -2262,6 +2289,7 @@
"FRONTEND_EXTERNAL_HOME_URL",
"FRONTEND_RELEASE_NOTE_ENABLED",
"FRONTEND_ENTITLEMENTS_DISCLAIMERS",
"FRONTEND_STORAGE_GAUGE_INFORMATION_LINK",
"FRONTEND_CSS_URL",
"FRONTEND_JS_URL",
"MEDIA_BASE_URL",
Expand Down Expand Up @@ -2406,14 +2434,11 @@
raise drf.exceptions.ValidationError(filterset.errors)
users = filterset.filter_queryset(base_qs)

storage_backend = get_storage_compute_backend()
total_storage = storage_backend.compute_storage_used(users)

serializer = serializers.OrganizationUsageMetricSerializer(
{
"account_id_key": filterset.form.cleaned_data["account_id_key"],
"account_id_value": filterset.form.cleaned_data["account_id_value"],
"total_storage": total_storage,
"users": users,
}
)

Expand Down Expand Up @@ -2443,5 +2468,7 @@
method = getattr(entitlements_backend, method_name)
if callable(method):
entitlements[method_name] = method(request.user)
if quota := entitlements_backend.get_quota(request.user):
entitlements["quota"] = quota
entitlements["context"] = entitlements_backend.get_context(request.user)
return drf.response.Response(entitlements)
39 changes: 39 additions & 0 deletions src/backend/core/entitlements/backends/base.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,38 @@
"""Entitlements Backend base class."""

from abc import ABC, abstractmethod
from enum import StrEnum


class QuotaState(StrEnum):
"""State of a quota gauge returned by get_quota."""

DEFAULT = "default"
EXCEEDED_LOCKED = "exceeded_locked"
ERROR = "error"


class QuotaReason(StrEnum):
"""Reasons explaining why the quota gauge is locked (get_quota output)."""

ORGANIZATION_QUOTA_EXCEEDED = "organization_quota_exceeded"


class CanUploadReason(StrEnum):
"""Reasons explaining why a user cannot upload (can_upload output)."""

NO_ORGANIZATION = "no_organization"
NOT_ACTIVATED = "not_activated"
USER_QUOTA_EXCEEDED = "user_quota_exceeded"
USER_OVERRIDE_QUOTA_EXCEEDED = "user_override_quota_exceeded"
ORGANIZATION_QUOTA_EXCEEDED = "organization_quota_exceeded"


class QuotaError(StrEnum):
"""Errors that can occur while computing a quota."""

METRIC_ACCOUNT_NOT_FOUND = "metric_account_not_found"
MAX_STORAGE_ACCOUNT_NOT_FOUND = "max_storage_account_not_found"


class EntitlementsBackend(ABC):
Expand All @@ -21,3 +53,10 @@ def can_upload(self, user):
def get_context(self, user): # pylint: disable=unused-argument
"""Get context for a user."""
return {}

def get_quota(self, user): # pylint: disable=unused-argument
"""Get quota for a user."""
return {}

def invalidate_cache(self, user_ids): # noqa: B027
"""Invalidate any cached entitlements for these users. No-op by default."""
Loading
Loading