This commit is contained in:
2026-04-09 23:43:58 +03:30
parent 73ea9875fd
commit 20fd3842b6
8 changed files with 359 additions and 15 deletions
+95
View File
@@ -0,0 +1,95 @@
from django.http import JsonResponse
from django.utils.deprecation import MiddlewareMixin
from rest_framework.permissions import AllowAny
from rest_framework_simplejwt.authentication import JWTAuthentication
from rest_framework_simplejwt.exceptions import InvalidToken, TokenError
from farm_hub.models import FarmHub
from .services import (
AccessControlServiceUnavailable,
authorize_feature,
get_authorization_action,
get_request_data,
get_route_feature_code,
)
class RouteFeatureAccessMiddleware(MiddlewareMixin):
def process_view(self, request, view_func, view_args, view_kwargs):
view_class = getattr(view_func, "view_class", None)
if view_class is None:
return None
if self._allows_anonymous(view_class):
return None
user = self._get_authenticated_user(request)
if user is None:
return None
app_label = view_class.__module__.split(".", 1)[0]
feature_code = get_route_feature_code(app_label)
if not feature_code:
return None
farm_uuid = view_kwargs.get("farm_uuid") or request.GET.get("farm_uuid") or get_request_data(request).get("farm_uuid")
farm = None
if farm_uuid:
try:
farm = FarmHub.objects.select_related("farm_type", "subscription_plan").prefetch_related(
"products",
"sensors",
"sensors__sensor_catalog",
).get(farm_uuid=farm_uuid, owner=user)
except FarmHub.DoesNotExist:
return JsonResponse(
{"code": 403, "msg": f"Access to route feature `{feature_code}` is denied."},
status=403,
)
try:
allowed = authorize_feature(
farm=farm,
user=user,
feature_code=feature_code,
action=get_authorization_action(request.method),
route=request.path,
)
except AccessControlServiceUnavailable as exc:
return JsonResponse({"code": 503, "msg": str(exc)}, status=503)
if not allowed:
return JsonResponse(
{"code": 403, "msg": f"Access to route feature `{feature_code}` is denied."},
status=403,
)
request.route_feature_code = feature_code
return None
@staticmethod
def _allows_anonymous(view_class):
for permission_class in getattr(view_class, "permission_classes", []):
if permission_class is AllowAny:
return True
return False
@staticmethod
def _get_authenticated_user(request):
if getattr(request, "user", None) is not None and request.user.is_authenticated:
return request.user
authenticator = JWTAuthentication()
try:
auth_result = authenticator.authenticate(request)
except (InvalidToken, TokenError):
return None
if auth_result is None:
return None
user, _token = auth_result
request.user = user
request._cached_user = user
return user
+7 -1
View File
@@ -33,7 +33,13 @@ class FeatureAccessPermission(BasePermission):
return False
try:
allowed = authorize_feature(farm, request.user, feature_code, get_authorization_action(request.method))
allowed = authorize_feature(
farm,
request.user,
feature_code,
get_authorization_action(request.method),
route=request.path,
)
except AccessControlServiceUnavailable as exc:
self.message = str(exc)
return False
+114 -12
View File
@@ -1,7 +1,12 @@
import hashlib
import json
from functools import lru_cache
from pathlib import Path
from urllib.parse import urljoin
import requests
from django.conf import settings
from django.core.cache import cache
from django.core.exceptions import ImproperlyConfigured
from django.http import QueryDict
from farm_hub.models import FarmHub
@@ -29,6 +34,38 @@ ACTION_MAP = {
}
def _get_authz_cache_timeout():
return int(getattr(settings, "ACCESS_CONTROL_AUTHZ_CACHE_TIMEOUT", 300))
@lru_cache(maxsize=1)
def load_route_feature_map():
feature_map_path = Path(settings.BASE_DIR) / "config" / "feature.json"
with feature_map_path.open("r", encoding="utf-8") as feature_map_file:
return json.load(feature_map_file)
def get_route_feature_code(app_label):
if not app_label:
return None
return load_route_feature_map().get(app_label)
def _get_authorization_cache_key(farm, user, features, action, route):
raw_key = json.dumps(
{
"farm_uuid": str(getattr(farm, "farm_uuid", "")),
"user_id": getattr(user, "id", None),
"features": sorted(features),
"action": action,
"route": route or "",
},
sort_keys=True,
)
digest = hashlib.sha256(raw_key.encode("utf-8")).hexdigest()
return f"access-control:authz:{digest}"
def get_default_subscription_plan():
return SubscriptionPlan.objects.filter(is_active=True, metadata__is_default=True).order_by("name").first()
@@ -153,6 +190,18 @@ def build_farm_access_profile(farm):
def build_opa_resource(farm):
if farm is None:
return {
"farm_id": None,
"subscription_plan_codes": [],
"farm_types": [],
"crop_types": [],
"cultivation_types": [],
"sensor_codes": [],
"power_sensor": [],
"customization": [],
}
subscription_plan = get_effective_subscription_plan(farm)
sensor_codes = list(
farm.sensors.exclude(sensor_catalog__isnull=True).values_list("sensor_catalog__code", flat=True)
@@ -199,23 +248,24 @@ def _opa_url(path):
return urljoin(f"{base_url.rstrip('/')}/", path.lstrip("/"))
def build_authorization_input(farm, user, features, action):
def build_authorization_input(farm, user, features, action, route=None):
return {
"user": build_opa_user(user),
"resource": build_opa_resource(farm),
"features": list(features),
"action": action,
"route": route,
}
def request_opa_batch_authorization(farm, user, features, action):
def request_opa_batch_authorization(farm, user, features, action, route=None):
if not getattr(settings, "ACCESS_CONTROL_AUTHZ_ENABLED", True):
return {"decisions": {feature: True for feature in features}}
if not features:
return {"decisions": {}}
payload = {"input": build_authorization_input(farm, user, features, action)}
payload = {"input": build_authorization_input(farm, user, features, action, route=route)}
try:
response = requests.post(
@@ -238,6 +288,17 @@ def normalize_opa_batch_result(data, features):
if isinstance(decisions, dict):
return {feature: bool(decisions.get(feature, False)) for feature in features}
feature_results = data.get("features")
if isinstance(feature_results, dict):
normalized = {}
for feature in features:
feature_result = feature_results.get(feature, {})
if isinstance(feature_result, dict):
normalized[feature] = bool(feature_result.get("allow", False))
else:
normalized[feature] = bool(feature_result)
return normalized
allowed_features = data.get("allowed_features")
if isinstance(allowed_features, list):
allowed = set(allowed_features)
@@ -249,18 +310,59 @@ def normalize_opa_batch_result(data, features):
raise AccessControlServiceUnavailable("OPA authorization service returned an unsupported payload.")
def batch_authorize_features(farm, user, features, action):
result = request_opa_batch_authorization(farm, user, features, action)
return normalize_opa_batch_result(result, features)
def batch_authorize_features(farm, user, features, action, route=None):
if not features:
return {}
cache_key = _get_authorization_cache_key(farm, user, features, action, route)
try:
cached_result = cache.get(cache_key)
except Exception:
cached_result = None
if isinstance(cached_result, dict):
return {feature: bool(cached_result.get(feature, False)) for feature in features}
result = request_opa_batch_authorization(farm, user, features, action, route=route)
decisions = normalize_opa_batch_result(result, features)
try:
cache.set(cache_key, decisions, timeout=_get_authz_cache_timeout())
except Exception:
pass
return decisions
def authorize_feature(farm, user, feature_code, action):
return batch_authorize_features(farm, user, [feature_code], action).get(feature_code, False)
def authorize_feature(farm, user, feature_code, action, route=None):
return batch_authorize_features(farm, user, [feature_code], action, route=route).get(feature_code, False)
def get_request_data(request):
if isinstance(request.data, QueryDict):
return request.data
if isinstance(request.data, dict):
return request.data
request_data = getattr(request, "data", None)
if isinstance(request_data, QueryDict):
return request_data
if isinstance(request_data, dict):
return request_data
cached_body = getattr(request, "_access_control_request_data", None)
if isinstance(cached_body, dict):
return cached_body
content_type = (getattr(request, "content_type", "") or "").split(";")[0].strip().lower()
body = getattr(request, "body", b"") or b""
if not body:
return {}
if content_type == "application/json":
try:
parsed_body = json.loads(body.decode("utf-8"))
except (UnicodeDecodeError, json.JSONDecodeError):
return {}
if isinstance(parsed_body, dict):
request._access_control_request_data = parsed_body
return parsed_body
return {}
return {}
+118
View File
@@ -0,0 +1,118 @@
from types import SimpleNamespace
from unittest.mock import patch
from django.test import RequestFactory, SimpleTestCase, override_settings
from account.views import ProfileView
from .middleware import RouteFeatureAccessMiddleware
from .services import batch_authorize_features, build_authorization_input
TEST_CACHES = {
"default": {
"BACKEND": "django.core.cache.backends.locmem.LocMemCache",
"LOCATION": "access-control-tests",
}
}
@override_settings(CACHES=TEST_CACHES, ACCESS_CONTROL_AUTHZ_CACHE_TIMEOUT=300)
class AccessControlServiceTests(SimpleTestCase):
def test_batch_authorize_features_uses_cache_for_same_route(self):
farm = SimpleNamespace(farm_uuid="farm-uuid")
user = SimpleNamespace(id=7)
with patch("access_control.services.request_opa_batch_authorization") as mock_request:
mock_request.return_value = {"decisions": {"farm_dashboard": True}}
first_result = batch_authorize_features(
farm=farm,
user=user,
features=["farm_dashboard"],
action="view",
route="/api/farm-dashboard/",
)
second_result = batch_authorize_features(
farm=farm,
user=user,
features=["farm_dashboard"],
action="view",
route="/api/farm-dashboard/",
)
self.assertEqual(first_result, {"farm_dashboard": True})
self.assertEqual(second_result, {"farm_dashboard": True})
self.assertEqual(mock_request.call_count, 1)
def test_build_authorization_input_includes_route(self):
user = SimpleNamespace(
id=3,
username="tester",
email="tester@example.com",
phone_number="09120000000",
is_staff=False,
is_superuser=False,
)
payload = build_authorization_input(
farm=None,
user=user,
features=["account_management"],
action="view",
route="/api/account/profile/",
)
self.assertEqual(payload["route"], "/api/account/profile/")
self.assertEqual(payload["resource"]["sensor_codes"], [])
def test_batch_authorize_features_supports_nested_opa_feature_payload(self):
farm = SimpleNamespace(farm_uuid="farm-uuid")
user = SimpleNamespace(id=9)
with patch("access_control.services.request_opa_batch_authorization") as mock_request:
mock_request.return_value = {
"features": {
"feature1": {"allow": True, "allow_rules": [], "deny_rules": []},
"feature2": {"allow": False, "allow_rules": [], "deny_rules": []},
}
}
result = batch_authorize_features(
farm=farm,
user=user,
features=["feature1", "feature2", "feature3"],
action="view",
route="/api/farm-dashboard/",
)
self.assertEqual(
result,
{
"feature1": True,
"feature2": False,
"feature3": False,
},
)
class RouteFeatureAccessMiddlewareTests(SimpleTestCase):
def test_middleware_passes_route_feature_and_method_to_service(self):
factory = RequestFactory()
request = factory.patch("/api/account/profile/")
request.user = SimpleNamespace(is_authenticated=True, id=11)
middleware = RouteFeatureAccessMiddleware(lambda req: None)
view = ProfileView.as_view()
with patch("access_control.middleware.authorize_feature", return_value=True) as mock_authorize:
response = middleware.process_view(request, view, (), {})
self.assertIsNone(response)
mock_authorize.assert_called_once_with(
farm=None,
user=request.user,
feature_code="account_management",
action="edit",
route="/api/account/profile/",
)
+1
View File
@@ -41,6 +41,7 @@ class FarmFeatureAuthorizationView(APIView):
user=request.user,
features=serializer.validated_data["features"],
action=serializer.validated_data["action"],
route=request.path,
)
except AccessControlServiceUnavailable as exc:
return Response(