From 20fd3842b6ade930aa3358407003724649f9de90 Mon Sep 17 00:00:00 2001 From: Mohammad Sajad Pourajam Date: Thu, 9 Apr 2026 23:43:58 +0330 Subject: [PATCH] UPDATE --- .env.example | 2 + access_control/middleware.py | 95 +++++++++++++++++++++++++ access_control/permissions.py | 8 ++- access_control/services.py | 126 ++++++++++++++++++++++++++++++---- access_control/tests.py | 118 +++++++++++++++++++++++++++++++ access_control/views.py | 1 + config/feature.json | 17 +++++ config/settings.py | 7 +- 8 files changed, 359 insertions(+), 15 deletions(-) create mode 100644 access_control/middleware.py create mode 100644 access_control/tests.py create mode 100644 config/feature.json diff --git a/.env.example b/.env.example index d9d0843..16dd2cf 100644 --- a/.env.example +++ b/.env.example @@ -25,6 +25,7 @@ ACCESS_CONTROL_AUTHZ_ENABLED=true ACCESS_CONTROL_AUTHZ_BASE_URL=http://croplogic-accsess-opa:8181 ACCESS_CONTROL_AUTHZ_BATCH_PATH=/v1/data/croplogic/authz/batch_decision ACCESS_CONTROL_AUTHZ_TIMEOUT=30 +ACCESS_CONTROL_AUTHZ_CACHE_TIMEOUT=300 AI_SERVICE_BASE_URL=https://ai.example.com AI_SERVICE_API_KEY= @@ -43,6 +44,7 @@ CROP_ZONE_TASK_STALE_SECONDS=300 CELERY_BROKER_URL=redis://redis:6379/0 CELERY_RESULT_BACKEND=redis://redis:6379/0 +CACHE_URL=redis://redis:6379/0 CELERY_BROKER_CONNECTION_RETRY_ON_STARTUP=true QDRANT_HOST=qdrant QDRANT_PORT=6333 diff --git a/access_control/middleware.py b/access_control/middleware.py new file mode 100644 index 0000000..03ec33c --- /dev/null +++ b/access_control/middleware.py @@ -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 diff --git a/access_control/permissions.py b/access_control/permissions.py index 1015b0f..0eb3bb7 100644 --- a/access_control/permissions.py +++ b/access_control/permissions.py @@ -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 diff --git a/access_control/services.py b/access_control/services.py index 358cb78..59a2597 100644 --- a/access_control/services.py +++ b/access_control/services.py @@ -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 {} diff --git a/access_control/tests.py b/access_control/tests.py new file mode 100644 index 0000000..0c40285 --- /dev/null +++ b/access_control/tests.py @@ -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/", + ) diff --git a/access_control/views.py b/access_control/views.py index add96df..e3e1bb9 100644 --- a/access_control/views.py +++ b/access_control/views.py @@ -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( diff --git a/config/feature.json b/config/feature.json new file mode 100644 index 0000000..e201aa8 --- /dev/null +++ b/config/feature.json @@ -0,0 +1,17 @@ +{ + "auth": "auth_access", + "account": "account_management", + "farm_hub": "farm_management", + "access_control": "access_control", + "sensor_catalog": "sensor_catalog", + "dashboard": "farm_dashboard", + "crop_zoning": "crop_zoning", + "plant_simulator": "plant_simulator", + "pest_detection": "pest_detection", + "irrigation_recommendation": "irrigation_recommendation", + "fertilization_recommendation": "fertilization_recommendation", + "farm_ai_assistant": "farm_ai_assistant", + "notifications": "notifications", + "external_api_adapter": "external_api_adapter", + "sensor_external_api": "sensor_external_api" + } diff --git a/config/settings.py b/config/settings.py index d3b6643..d47d81a 100644 --- a/config/settings.py +++ b/config/settings.py @@ -53,6 +53,7 @@ MIDDLEWARE = [ "django.middleware.common.CommonMiddleware", "django.middleware.csrf.CsrfViewMiddleware", "django.contrib.auth.middleware.AuthenticationMiddleware", + "access_control.middleware.RouteFeatureAccessMiddleware", "django.contrib.messages.middleware.MessageMiddleware", "django.middleware.clickjacking.XFrameOptionsMiddleware", ] @@ -109,8 +110,9 @@ DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField" CACHES = { "default": { - "BACKEND": "django.core.cache.backends.locmem.LocMemCache", - "LOCATION": "croplogic-auth-otp", + "BACKEND": "django.core.cache.backends.redis.RedisCache", + "LOCATION": os.getenv("CACHE_URL", os.getenv("CELERY_BROKER_URL", "redis://redis:6379/0")), + "KEY_PREFIX": "croplogic", } } @@ -166,6 +168,7 @@ ACCESS_CONTROL_AUTHZ_BASE_URL = os.getenv( ) ACCESS_CONTROL_AUTHZ_BATCH_PATH = os.getenv("ACCESS_CONTROL_AUTHZ_BATCH_PATH", "/v1/data/croplogic/authz/batch_decision") ACCESS_CONTROL_AUTHZ_TIMEOUT = int(os.getenv("ACCESS_CONTROL_AUTHZ_TIMEOUT", str(EXTERNAL_API_TIMEOUT))) +ACCESS_CONTROL_AUTHZ_CACHE_TIMEOUT = int(os.getenv("ACCESS_CONTROL_AUTHZ_CACHE_TIMEOUT", "300")) EXTERNAL_SERVICES = { "ai": {