ixg_platform/apps/core/middleware.py
ixgadmin ef6f217e45 Gate the administration area by role, not by is_staff
RBACMiddleware only guarded non-staff users, so any staff account stepped
past the role routing for /admin/ — an ops or buyer account with the flag
set could reach the admin index, which embeds the model registry, and a
later permission grant would have been inherited. Restrict /admin/ to the
admin role (superuser excepted) and hide the ops Admin link from non-admins.

Also close the second finding: the seed set is_staff only on creation, so
a buyer whose flag had drifted stayed staff on every re-run. Derive it from
the role on the update path too, and align existing rows with a one-off
migration so the flag never disagrees with the role.
2026-09-25 16:47:50 +00:00

51 lines
2.1 KiB
Python

import re
from django.conf import settings
from django.shortcuts import redirect
# The administration area is decided by role, not by the staff flag alone.
ADMIN_PATH = re.compile(r"^/admin/")
class RBACMiddleware:
ROLE_ROUTES = {
"admin": [r"^/admin/", r"^/ops/", r"^/portal/"],
"ops": [r"^/ops/"],
"buyer": [r"^/portal/"],
}
def __init__(self, get_response):
self.get_response = get_response
def __call__(self, request):
user = request.user
if user.is_authenticated and not user.is_superuser:
path = request.path_info
user_role = getattr(request.user, "role", None)
# /admin/ is for the admin role. is_staff alone is not enough: an ops
# or buyer account carrying the flag must not reach the
# administration area, whose index embeds the model registry, and
# where a later group or permission grant would be inherited. A
# superuser (above) is the deliberate exception.
if ADMIN_PATH.match(path) and user_role != "admin":
return redirect(settings.LOGIN_REDIRECT_URL)
if not user.is_staff:
allowed = self.ROLE_ROUTES.get(user_role, [])
if any(re.match(pattern, path) for pattern in allowed):
return self.get_response(request)
# The path belongs to a portal this role may not enter. Send them
# to the role-aware landing view rather than straight into a
# portal they cannot use. It used to redirect if *any* role's
# pattern matched the path without belonging to this role, which
# bounced a buyer off /portal/ itself (the admin patterns include
# /portal/), and looped with the landing redirect.
if any(
re.match(pattern, path)
for patterns in self.ROLE_ROUTES.values()
for pattern in patterns
):
return redirect(settings.LOGIN_REDIRECT_URL)
return self.get_response(request)