From cb63423b59769e02db5528ac239e57572f81524a Mon Sep 17 00:00:00 2001 From: deku-m <37069737+deku-m@users.noreply.github.com> Date: Thu, 24 Sep 2026 10:25:32 +0200 Subject: [PATCH 1/7] IP address list loads each row's interface and device separately --- api/viewsets.py | 1 + 1 file changed, 1 insertion(+) diff --git a/api/viewsets.py b/api/viewsets.py index 1f27351e..7c944b9b 100644 --- a/api/viewsets.py +++ b/api/viewsets.py @@ -1605,6 +1605,7 @@ class IPAddressViewSet(FieldWriteAllowList, CloneableMixin, TenantScopedViewSet) # assigned_vm: is_primary_for_vm reads the VM's primary_ip_id, so # without the join every VM-assigned row lazy-loads its VM (#122). "assigned_vm", "prefix__vrf", "prefix__site", "site", + "assigned_interface__device", ) .prefetch_related("tags") .all() From 0e9a0ce72c685a6792ca2d5c51c0f36f0632e3c1 Mon Sep 17 00:00:00 2001 From: deku-m <37069737+deku-m@users.noreply.github.com> Date: Thu, 24 Sep 2026 10:28:23 +0200 Subject: [PATCH 2/7] =?UTF-8?q?**Row-permission=20check=20is=20O(n=C2=B2)?= =?UTF-8?q?=20for=20site-scoped=20users**?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit **Describe the bug** `ObjectPermsSerializerMixin._rbac_allowed_pks` (the #218 fix) caches its result per page, but copies the whole page into a list and collects all its primary keys before it looks at the cache. It runs once per row per action, so a list of N rows does N × N work just to reach the cached answer. `api/serializers.py`, `_rbac_allowed_pks`: `page = list(rows)` and `pks = [r.pk for r in page ...]` run on every call, and only then is `key in cache` checked. **To Reproduce** 1. Log in as a user with a site-scoped grant (for example `seed_multisite`'s `site1`). 2. GET `/api/ips/` with about 1,000 rows and profile it. Measured on 0.16.12 (960 rows): ``` _rbac_allowed_pks: 1920 calls, 1.84 s total (0.70 s spent inside the function itself) site-scoped /api/ips/: 3.7 s, versus 2.9 s for an admin on the same list ``` **Expected behavior** After the first row, each check is a cache lookup. **Screenshots** N/A **Danbyte Info:** - OS: Linux - Browser: any - Version: 0.16.12 - Plugins installed: none **Additional context** Performance gain, measured together with the IP list fix: site-scoped `/api/ips/` 3.7 s → 0.9 s (4.2×), with an identical response. This affects every list endpoint that uses the mixin, for every user with a site-scoped or constrained grant. Suggested fix (from Claude AI): compute the cache key before building the page, as `(label, action, id(parent), id(rows))` for a list and `(label, action, None, obj.pk)` for a single object, and return early on a hit. Build `page`/`pks` only on a miss. --- api/serializers.py | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/api/serializers.py b/api/serializers.py index 114415c7..2d5fe2b9 100644 --- a/api/serializers.py +++ b/api/serializers.py @@ -402,6 +402,15 @@ def _rbac_allowed_pks(self, request, obj, action, filt) -> set: cache = request._rbac_allowed_pks_cache = {} parent = getattr(self, "parent", None) rows = getattr(parent, "instance", None) if parent is not None else None + # Look the page up before building it: this runs once per row per + # action, and listing + collecting the whole page's pks on every call + # made a list of N rows cost O(N^2) before the cache was consulted. + key = ( + (model._meta.label_lower, action, None, obj.pk) if rows is None + else (model._meta.label_lower, action, id(parent), id(rows)) + ) + if key in cache: + return cache[key] if rows is None: page = [obj] else: @@ -410,7 +419,6 @@ def _rbac_allowed_pks(self, request, obj, action, filt) -> set: except TypeError: # a single instance, not a list page = [rows] pks = [r.pk for r in page if isinstance(r, model)] or [obj.pk] - key = (model._meta.label_lower, action, id(parent), len(pks)) if key not in cache: cache[key] = set( model._default_manager.filter(pk__in=pks) From b8f1d2f6dab71d0ba49e5fb310dac397894f3aef Mon Sep 17 00:00:00 2001 From: deku-m <37069737+deku-m@users.noreply.github.com> Date: Thu, 24 Sep 2026 10:30:03 +0200 Subject: [PATCH 3/7] **Status list runs 14 COUNT queries per status** MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit **Describe the bug** `StatusSerializer.get_usage_count` sums `.count()` over 14 reverse relations for every status, because `StatusViewSet` sets `usage_relation = None` and never fills in `usage_count_annotated`, the value the serializer already checks for first. **To Reproduce** GET `/api/ip-statuses/` and count queries. Measured on 0.16.12 (33 statuses): ``` 470 queries, 331 ms, including 33 x SELECT COUNT(*) FROM api_ipaddress WHERE status_id = ?, and the same for devices, prefixes and so on ``` **Expected behavior** One statement for the whole list. **Screenshots** N/A **Danbyte Info:** - OS: Linux - Browser: any - Version: 0.16.12 - Plugins installed: none **Additional context** Performance gain, measured: 470 → 8 queries, 331 → 28 ms (11.8×), with an identical response. The cost grows with the number of statuses times the number of models that use them, and the statuses list is loaded by many forms and pickers. Suggested fix (from Claude AI): in `StatusViewSet.get_queryset`, annotate `usage_count_annotated` as the sum of one correlated `COUNT` subquery per relation in `StatusSerializer._USAGE_RELS` (each wrapped in `Coalesce(..., 0)`). The serializer's existing fast path then applies. --- api/viewsets.py | 22 +++++++++++++++++++++- 1 file changed, 21 insertions(+), 1 deletion(-) diff --git a/api/viewsets.py b/api/viewsets.py index 7c944b9b..299138e6 100644 --- a/api/viewsets.py +++ b/api/viewsets.py @@ -2530,6 +2530,26 @@ def _after_save(self, obj): pass +def _status_usage_expr(): + """Sum of every relation StatusSerializer counts, as correlated subqueries, + so a list of N statuses is one statement instead of 14 COUNT(*) per row.""" + from django.db.models import Count, IntegerField, OuterRef, Subquery, Value + from django.db.models.functions import Coalesce + + from .serializers import StatusSerializer + + expr = Value(0) + for rn in StatusSerializer._USAGE_RELS: + rel = Status._meta.get_field(rn) + fk = rel.field.name + sub = ( + rel.related_model._default_manager.filter(**{fk: OuterRef("pk")}) + .order_by().values(fk).annotate(c=Count("pk")).values("c") + ) + expr = expr + Coalesce(Subquery(sub, output_field=IntegerField()), Value(0)) + return expr + + class StatusViewSet(_IpCatalogViewSet): queryset = Status.objects.all().order_by("weight", "name") serializer_class = StatusSerializer @@ -2544,7 +2564,7 @@ def get_queryset(self): avail = self.request.query_params.get("available_to") if avail: qs = qs.filter(available_to__contains=[avail]) - return qs + return qs.annotate(usage_count_annotated=_status_usage_expr()) def _after_save(self, obj): # At most one default status per (tenant, object-type): strip each slug From facc6a521e37c8d0ef84b2869aa33e222a28aa3d Mon Sep 17 00:00:00 2001 From: deku-m <37069737+deku-m@users.noreply.github.com> Date: Thu, 24 Sep 2026 10:31:05 +0200 Subject: [PATCH 4/7] **VM interface list loads status, VLAN and tagged VLANs per row** MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit **Describe the bug** `VMInterfaceViewSet.get_queryset` joins only `vm`. The serializer reads the VM's status, the interface's `vlan` and its `tagged_vlans` many-to-many, so each row runs extra queries. **To Reproduce** GET `/api/vm-interfaces/` and count queries. Measured on 0.16.12 (240 VM interfaces): ``` 490 queries, 652 ms (about 2 per row: api_status and api_vlan) ``` **Expected behavior** A constant number of queries. **Screenshots** N/A **Danbyte Info:** - OS: Linux - Browser: any - Version: 0.16.12 - Plugins installed: none **Additional context** Performance gain, measured: 490 → 11 queries, 652 → 165 ms (4×), with an identical response. Suggested fix (from Claude AI): `.select_related("vm__status", "vlan", "vrf").prefetch_related("tags", "ip_addresses", "tagged_vlans")`. --- api/viewsets.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/api/viewsets.py b/api/viewsets.py index 299138e6..698f8b16 100644 --- a/api/viewsets.py +++ b/api/viewsets.py @@ -5707,8 +5707,8 @@ def get_queryset(self): qs = ( super() .get_queryset() - .select_related("vm") - .prefetch_related("tags", "ip_addresses") + .select_related("vm__status", "vlan", "vrf") + .prefetch_related("tags", "ip_addresses", "tagged_vlans") ) if self.request: vm = self.request.query_params.get("vm") From ae8d919f9945f06ef0b2d0c12f3681104d1c4c8a Mon Sep 17 00:00:00 2001 From: deku-m <37069737+deku-m@users.noreply.github.com> Date: Thu, 24 Sep 2026 10:32:00 +0200 Subject: [PATCH 5/7] **Interface and port lists: cable status per cable, and per-row overhead** MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit **Describe the bug** The interface list (and the six other port lists that share the same helpers) has four per-row costs: 1. The prefetch is `terminations__cable` without `__status`, so each cable's status is a separate query (190 queries for 190 cables). 2. `_point_cable` and `_point_reservation` call `.all().first()` on prefetched relations, which clones and re-slices a queryset on every row (0.36 s and 0.33 s across 1,600 rows). 3. `get_type_display` calls Django's `get_type_display()`, which rebuilds a dict of all 216 interface types on every call (0.46 s). 4. `CableMiniSerializer(t.cable).data` builds a new ModelSerializer for every cabled port (about 2.4 ms each). **To Reproduce** GET `/api/interfaces/` on a tenant with about 1,600 interfaces and 190 cables, and profile it. Measured on 0.16.12: ``` 211 queries (190 x api_status), 3.2 s ``` **Expected behavior** No per-cable query and no rebuilding of the same data on each row. **Screenshots** N/A **Danbyte Info:** - OS: Linux - Browser: any - Version: 0.16.12 - Plugins installed: none **Additional context** Performance gain, measured: 211 → 22 queries, 3.2 s → 1.85 s (1.7×), with an identical response. The same prefetch gap is in the rear-port, front-port, console, console-server, power-port and power-outlet lists; those weren't measured, because the test data had no such ports. Suggested fix (from Claude AI): 1. Prefetch `terminations__cable__status` in the 7 component viewsets. 2. Use `next(iter(point.terminations.all()), None)` (and the same for `reservations`). At most one row exists per port, so the result is unchanged. 3. Cache `{value: label}` per model field once, and look labels up there. 4. Keep one `CableMiniSerializer()` in the serializer context per request, and call `.to_representation(cable)`. --- api/serializers.py | 48 +++++++++++++++++++++++++++++++++++----------- api/viewsets.py | 14 +++++++------- 2 files changed, 44 insertions(+), 18 deletions(-) diff --git a/api/serializers.py b/api/serializers.py index 2d5fe2b9..1eecbe9f 100644 --- a/api/serializers.py +++ b/api/serializers.py @@ -2976,9 +2976,35 @@ def far_end(term): ) -def _point_cable(point): - t = point.terminations.all().first() # ≤1 due to the per-port unique rule - return CableMiniSerializer(t.cable).data if t is not None else None +_CHOICE_LABELS: dict = {} + + +def _choice_label(obj, field: str): + """``obj.get__display()`` without Django rebuilding the whole choices + dict on every call (216 interface types, once per row of a list).""" + key = (type(obj), field) + labels = _CHOICE_LABELS.get(key) + if labels is None: + f = type(obj)._meta.get_field(field) + labels = _CHOICE_LABELS[key] = {k: str(v) for k, v in f.flatchoices} + value = getattr(obj, field) + return labels.get(value, value) + + +def _point_cable(point, context=None): + # ≤1 due to the per-port unique rule. Read the prefetch directly: .first() + # clones and re-slices the queryset on every row of a component list. + t = next(iter(point.terminations.all()), None) + if t is None: + return None + if context is None: + return CableMiniSerializer(t.cable).data + # One serializer per request: building a ModelSerializer's fields per + # cabled row was most of a large component list's time. + ser = context.get("_cable_mini_serializer") + if ser is None: + ser = context["_cable_mini_serializer"] = CableMiniSerializer() + return ser.to_representation(t.cable) def _point_far_end(point): @@ -2986,7 +3012,7 @@ def _point_far_end(point): def _point_reservation(point): - r = point.reservations.all().first() # ≤1 due to the per-port unique rule + r = next(iter(point.reservations.all()), None) # ≤1 (per-port unique rule) if r is None: return None return { @@ -3088,7 +3114,7 @@ def _mini(rel): @extend_schema_field(OpenApiTypes.OBJECT) def get_cable(self, obj): - return _point_cable(obj) + return _point_cable(obj, self.context) # What the cable reaches: the far end's device and port, for the port # labels a faceplate can print ("far-end device"), the same names the @@ -3134,7 +3160,7 @@ def get_lag_member_count(self, obj) -> int: return len(obj.lag_members.all()) def get_type_display(self, obj) -> str: - return obj.get_type_display() + return _choice_label(obj, "type") @extend_schema_field(serializers.ListField()) def get_ip_addresses(self, obj): @@ -3384,7 +3410,7 @@ def get_reservation(self, obj): @extend_schema_field(OpenApiTypes.OBJECT) def get_cable(self, obj): - return _point_cable(obj) + return _point_cable(obj, self.context) def get_front_port_count(self, obj) -> int: return obj.front_ports.count() @@ -3458,7 +3484,7 @@ def get_reservation(self, obj): @extend_schema_field(OpenApiTypes.OBJECT) def get_cable(self, obj): - return _point_cable(obj) + return _point_cable(obj, self.context) def validate(self, attrs): attrs = super().validate(attrs) @@ -3516,14 +3542,14 @@ class _DevicePortSerializer(TaggableSerializerMixin, NumIdModelSerializer): @extend_schema_field(OpenApiTypes.OBJECT) def get_cable(self, obj): - return _point_cable(obj) + return _point_cable(obj, self.context) @extend_schema_field(OpenApiTypes.OBJECT) def get_reservation(self, obj): return _point_reservation(obj) def get_type_display(self, obj) -> str: - return obj.get_type_display() if obj.type else "" + return _choice_label(obj, "type") if obj.type else "" class ConsolePortSerializer(_DevicePortSerializer): @@ -6453,7 +6479,7 @@ class CircuitTerminationSerializer(serializers.ModelSerializer): @extend_schema_field(CableMiniSerializer(allow_null=True)) def get_cable(self, obj): - return _point_cable(obj) + return _point_cable(obj, self.context) @extend_schema_field(OpenApiTypes.OBJECT) def get_connected_to(self, obj): diff --git a/api/viewsets.py b/api/viewsets.py index 698f8b16..d87fd49b 100644 --- a/api/viewsets.py +++ b/api/viewsets.py @@ -4239,7 +4239,7 @@ def normalize_bulk_updates(self, updates): "parent__device", "lag__device", "bridge__device", ) .prefetch_related( - "tags", "terminations__cable", "reservations", "ip_addresses", "children", + "tags", "terminations__cable__status", "reservations", "ip_addresses", "children", "lag_members", "tagged_vlans", "mac_addresses", "tunnel_terminations__tunnel", *FAR_END_PREFETCH, @@ -4959,7 +4959,7 @@ def create(self, request): class RearPortViewSet(_DevicePortViewSet): queryset = ( RearPort.objects.select_related("device") - .prefetch_related("tags", "terminations__cable", "reservations", "front_ports") + .prefetch_related("tags", "terminations__cable__status", "reservations", "front_ports") .order_by("device__name", NATURAL_NAME) ) serializer_class = RearPortSerializer @@ -4970,7 +4970,7 @@ class RearPortViewSet(_DevicePortViewSet): class FrontPortViewSet(_DevicePortViewSet): queryset = ( FrontPort.objects.select_related("device", "rear_port") - .prefetch_related("tags", "terminations__cable", "reservations") + .prefetch_related("tags", "terminations__cable__status", "reservations") .order_by("device__name", NATURAL_NAME) ) serializer_class = FrontPortSerializer @@ -4980,7 +4980,7 @@ class FrontPortViewSet(_DevicePortViewSet): class ConsolePortViewSet(_DevicePortViewSet): queryset = ( ConsolePort.objects.select_related("device") - .prefetch_related("tags", "terminations__cable", "reservations") + .prefetch_related("tags", "terminations__cable__status", "reservations") .order_by("device__name", NATURAL_NAME) ) serializer_class = ConsolePortSerializer @@ -5012,7 +5012,7 @@ class AntennaViewSet(_DevicePortViewSet): class ConsoleServerPortViewSet(_DevicePortViewSet): queryset = ( ConsoleServerPort.objects.select_related("device") - .prefetch_related("tags", "terminations__cable", "reservations") + .prefetch_related("tags", "terminations__cable__status", "reservations") .order_by("device__name", NATURAL_NAME) ) serializer_class = ConsoleServerPortSerializer @@ -5022,7 +5022,7 @@ class ConsoleServerPortViewSet(_DevicePortViewSet): class PowerPortViewSet(_DevicePortViewSet): queryset = ( PowerPort.objects.select_related("device") - .prefetch_related("tags", "terminations__cable", "reservations", "outlets") + .prefetch_related("tags", "terminations__cable__status", "reservations", "outlets") .order_by("device__name", NATURAL_NAME) ) serializer_class = PowerPortSerializer @@ -5031,7 +5031,7 @@ class PowerPortViewSet(_DevicePortViewSet): class PowerOutletViewSet(_DevicePortViewSet): queryset = ( PowerOutlet.objects.select_related("device", "power_port") - .prefetch_related("tags", "terminations__cable", "reservations") + .prefetch_related("tags", "terminations__cable__status", "reservations") .order_by("device__name", NATURAL_NAME) ) serializer_class = PowerOutletSerializer From 7f34f37d57c0f16660a9aa475bf24955c5d10101 Mon Sep 17 00:00:00 2001 From: deku-m <37069737+deku-m@users.noreply.github.com> Date: Thu, 24 Sep 2026 10:32:56 +0200 Subject: [PATCH 6/7] **Cables, virtual machines and object permissions load status/sites per row** MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit **Describe the bug** Three list endpoints miss a join or prefetch: - `CableViewSet`: no `select_related("status")`, so one status query per cable. - `VirtualMachineViewSet`: `VirtualMachine.objects.all()`, so one status query per VM. - `ObjectPermissionViewSet`: prefetches `groups`, `users` and `tenants` but not `sites`, so one query per permission. **To Reproduce** GET `/api/cables/`, `/api/virtual-machines/` and `/api/object-permissions/`, and count queries. Measured on 0.16.12: ``` /api/cables/ 190 rows: 202 queries, 314 ms /api/virtual-machines/ 120 rows: 130 queries, 175 ms /api/object-permissions/ 14 rows: 24 queries ``` **Expected behavior** A constant number of queries. **Screenshots** N/A **Danbyte Info:** - OS: Linux - Browser: any - Version: 0.16.12 - Plugins installed: none **Additional context** Performance gain, measured: cables 202 → 12 queries (2.3×), VMs 130 → 10 (2.2×), object permissions 24 → 11 (2×). All responses are identical before and after. Suggested fix (from Claude AI): `Cable.objects.select_related("status")`, `VirtualMachine.objects.select_related("status")`, and add `"sites"` to the object permission prefetch. --- api/viewsets.py | 4 ++-- auth_api/api.py | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/api/viewsets.py b/api/viewsets.py index d87fd49b..b8e91aef 100644 --- a/api/viewsets.py +++ b/api/viewsets.py @@ -4566,7 +4566,7 @@ def perform_update(self, serializer): class CableViewSet(TenantScopedViewSet): queryset = ( - Cable.objects.prefetch_related( + Cable.objects.select_related("status").prefetch_related( "terminations__interface__device", "terminations__front_port__device", "terminations__rear_port__device", @@ -5556,7 +5556,7 @@ def get_serializer_class(self): class VirtualMachineViewSet(CloneableMixin, TenantScopedViewSet): - queryset = VirtualMachine.objects.all().order_by(NATURAL_NAME) + queryset = VirtualMachine.objects.select_related("status").order_by(NATURAL_NAME) serializer_class = VirtualMachineSerializer def perform_create(self, serializer): diff --git a/auth_api/api.py b/auth_api/api.py index 9b2a4584..e610d33d 100644 --- a/auth_api/api.py +++ b/auth_api/api.py @@ -492,7 +492,7 @@ class Meta: class ObjectPermissionViewSet(viewsets.ModelViewSet): queryset = ( ObjectPermission.objects.all() - .prefetch_related("groups", "users", "tenants") + .prefetch_related("groups", "users", "tenants", "sites") .order_by("name") ) serializer_class = ObjectPermissionSerializer From 93943c7d3b20de2a0993e2dd8c2ff6af92b72e8f Mon Sep 17 00:00:00 2001 From: deku-m <37069737+deku-m@users.noreply.github.com> Date: Thu, 24 Sep 2026 12:34:31 +0200 Subject: [PATCH 7/7] **Permissions reloaded from the database on every check for site-scoped users** MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit **Describe the bug** `auth_api.rbac.applicable_permissions()` queries the user's ObjectPermissions (plus prefetches of tenants and sites) on every call, and every `has_action` / `row_filter` / `effective_actions` check calls it. One request for a site-scoped user therefore loads the same permissions many times. **To Reproduce** Log in as a site-scoped user (for example `seed_multisite`'s `site1`) and count queries. Measured on 0.16.12: ``` /api/me/ 178 queries, 277 ms (53 x the ObjectPermission query + 2 prefetches) /api/dashboard/ 115 queries (13 x) detail pages 4 x per request ``` **Expected behavior** Permissions are loaded once per request. **Screenshots** N/A **Danbyte Info:** - OS: Linux - Browser: any - Version: 0.16.12 - Plugins installed: none **Additional context** Performance gain, measured: `/api/me/` 178 → 25 queries, 277 → 34 ms (8×, loaded on every page); dashboard 115 → 79; every other request for a scoped user saves 3–9 queries. Responses are byte-identical. Superusers are unaffected. The cache must be scoped to one HTTP request. A cache on the user object is unsafe: WebSocket consumers keep one user object for the life of the socket, so a revoked grant would persist on an open SSH terminal (`auth_api.tests_rbac_sites.test_site_scope_anded_with_constraint` fails with that approach). Suggested fix (from Claude AI): a `ContextVar` memo in `rbac.py`, keyed by `(user.pk, tenant.pk)`, activated by a small `RequestCacheMiddleware` after `AuthenticationMiddleware` and cleared by signals when an ObjectPermission, its users/groups/tenants/sites, or a user's groups change. Background jobs, management commands and WebSockets get no cache. Verified: a grant or revoke made mid-request is seen immediately; the 295 RBAC tests and 1,158 of 1,159 related tests pass (the one failure is pre-existing on unpatched 0.16.12). --- auth_api/apps.py | 17 +++++++++++++++++ auth_api/rbac.py | 40 ++++++++++++++++++++++++++++++++++++++++ danbyte/settings.py | 1 + 3 files changed, 58 insertions(+) diff --git a/auth_api/apps.py b/auth_api/apps.py index 8fb7aa79..13d3a0c9 100644 --- a/auth_api/apps.py +++ b/auth_api/apps.py @@ -8,3 +8,20 @@ class AuthApiConfig(AppConfig): def ready(self): # Register the drf-spectacular auth extension for our token scheme. from . import schema # noqa: F401 + + # A grant, its scope or a user's groups changing mid-request must not + # be answered from the per-request RBAC memo. + from django.contrib.auth import get_user_model + from django.db.models.signals import m2m_changed, post_delete, post_save + + from .models import ObjectPermission + from .rbac import invalidate_request_cache + + for sig in (post_save, post_delete): + sig.connect(invalidate_request_cache, sender=ObjectPermission, + dispatch_uid=f"rbac-memo-{sig}") + for through in (ObjectPermission.users.through, ObjectPermission.groups.through, + ObjectPermission.tenants.through, ObjectPermission.sites.through, + get_user_model().groups.through): + m2m_changed.connect(invalidate_request_cache, sender=through, + dispatch_uid=f"rbac-memo-m2m-{through.__name__}") diff --git a/auth_api/rbac.py b/auth_api/rbac.py index 8e080c55..07635ce3 100644 --- a/auth_api/rbac.py +++ b/auth_api/rbac.py @@ -8,6 +8,7 @@ """ from __future__ import annotations +import contextvars import logging from django.core.exceptions import FieldError @@ -20,6 +21,36 @@ def _is_super(user) -> bool: return bool(getattr(user, "is_authenticated", False) and user.is_superuser) +# Per-request memo for applicable_permissions(). Set by RequestCacheMiddleware +# for the duration of one HTTP request only: background jobs, management +# commands and WebSocket consumers (which keep one user object for the life of +# the socket) never see a cache, so a revoked grant can't linger there. +_request_cache: contextvars.ContextVar = contextvars.ContextVar( + "rbac_request_cache", default=None +) + + +class RequestCacheMiddleware: + """Scope the RBAC permission memo to a single request.""" + + def __init__(self, get_response): + self.get_response = get_response + + def __call__(self, request): + token = _request_cache.set({}) + try: + return self.get_response(request) + finally: + _request_cache.reset(token) + + +def invalidate_request_cache(*args, **kwargs): + """Drop the memo when a grant or group membership changes mid-request.""" + cache = _request_cache.get() + if cache is not None: + cache.clear() + + def applicable_permissions(user, tenant): """Enabled ObjectPermissions that apply to ``user`` in ``tenant``. @@ -30,6 +61,13 @@ def applicable_permissions(user, tenant): if not getattr(user, "is_authenticated", False): return ObjectPermission.objects.none() + # has_action/row_filter/effective_actions each call this; without the memo + # one /api/me/ of a site-scoped user ran this query and its two prefetches + # 53 times. + cache = _request_cache.get() + key = (user.pk, tenant.pk if tenant is not None else None) + if cache is not None and key in cache: + return list(cache[key]) qs = ( ObjectPermission.objects.filter(enabled=True) .filter(Q(users=user) | Q(groups__in=user.groups.all())) @@ -42,6 +80,8 @@ def applicable_permissions(user, tenant): if scoped and (tenant is None or tenant.pk not in {t.pk for t in scoped}): continue out.append(perm) + if cache is not None: + cache[key] = tuple(out) return out diff --git a/danbyte/settings.py b/danbyte/settings.py index e2e73853..4dffff68 100644 --- a/danbyte/settings.py +++ b/danbyte/settings.py @@ -155,6 +155,7 @@ "django.middleware.common.CommonMiddleware", "django.middleware.csrf.CsrfViewMiddleware", "django.contrib.auth.middleware.AuthenticationMiddleware", + "auth_api.rbac.RequestCacheMiddleware", # Rolling idle-session timeout (admin-configurable; no-op when unset). After # auth so request.user is resolved. "core.middleware.SessionIdleTimeoutMiddleware",