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
+2
View File
@@ -25,6 +25,7 @@ ACCESS_CONTROL_AUTHZ_ENABLED=true
ACCESS_CONTROL_AUTHZ_BASE_URL=http://croplogic-accsess-opa:8181 ACCESS_CONTROL_AUTHZ_BASE_URL=http://croplogic-accsess-opa:8181
ACCESS_CONTROL_AUTHZ_BATCH_PATH=/v1/data/croplogic/authz/batch_decision ACCESS_CONTROL_AUTHZ_BATCH_PATH=/v1/data/croplogic/authz/batch_decision
ACCESS_CONTROL_AUTHZ_TIMEOUT=30 ACCESS_CONTROL_AUTHZ_TIMEOUT=30
ACCESS_CONTROL_AUTHZ_CACHE_TIMEOUT=300
AI_SERVICE_BASE_URL=https://ai.example.com AI_SERVICE_BASE_URL=https://ai.example.com
AI_SERVICE_API_KEY= AI_SERVICE_API_KEY=
@@ -43,6 +44,7 @@ CROP_ZONE_TASK_STALE_SECONDS=300
CELERY_BROKER_URL=redis://redis:6379/0 CELERY_BROKER_URL=redis://redis:6379/0
CELERY_RESULT_BACKEND=redis://redis:6379/0 CELERY_RESULT_BACKEND=redis://redis:6379/0
CACHE_URL=redis://redis:6379/0
CELERY_BROKER_CONNECTION_RETRY_ON_STARTUP=true CELERY_BROKER_CONNECTION_RETRY_ON_STARTUP=true
QDRANT_HOST=qdrant QDRANT_HOST=qdrant
QDRANT_PORT=6333 QDRANT_PORT=6333
+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 return False
try: 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: except AccessControlServiceUnavailable as exc:
self.message = str(exc) self.message = str(exc)
return False 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 from urllib.parse import urljoin
import requests import requests
from django.conf import settings from django.conf import settings
from django.core.cache import cache
from django.core.exceptions import ImproperlyConfigured from django.core.exceptions import ImproperlyConfigured
from django.http import QueryDict from django.http import QueryDict
from farm_hub.models import FarmHub 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(): def get_default_subscription_plan():
return SubscriptionPlan.objects.filter(is_active=True, metadata__is_default=True).order_by("name").first() 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): 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) subscription_plan = get_effective_subscription_plan(farm)
sensor_codes = list( sensor_codes = list(
farm.sensors.exclude(sensor_catalog__isnull=True).values_list("sensor_catalog__code", flat=True) 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("/")) 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 { return {
"user": build_opa_user(user), "user": build_opa_user(user),
"resource": build_opa_resource(farm), "resource": build_opa_resource(farm),
"features": list(features), "features": list(features),
"action": action, "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): if not getattr(settings, "ACCESS_CONTROL_AUTHZ_ENABLED", True):
return {"decisions": {feature: True for feature in features}} return {"decisions": {feature: True for feature in features}}
if not features: if not features:
return {"decisions": {}} return {"decisions": {}}
payload = {"input": build_authorization_input(farm, user, features, action)} payload = {"input": build_authorization_input(farm, user, features, action, route=route)}
try: try:
response = requests.post( response = requests.post(
@@ -238,6 +288,17 @@ def normalize_opa_batch_result(data, features):
if isinstance(decisions, dict): if isinstance(decisions, dict):
return {feature: bool(decisions.get(feature, False)) for feature in features} 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") allowed_features = data.get("allowed_features")
if isinstance(allowed_features, list): if isinstance(allowed_features, list):
allowed = set(allowed_features) allowed = set(allowed_features)
@@ -249,18 +310,59 @@ def normalize_opa_batch_result(data, features):
raise AccessControlServiceUnavailable("OPA authorization service returned an unsupported payload.") raise AccessControlServiceUnavailable("OPA authorization service returned an unsupported payload.")
def batch_authorize_features(farm, user, features, action): def batch_authorize_features(farm, user, features, action, route=None):
result = request_opa_batch_authorization(farm, user, features, action) if not features:
return normalize_opa_batch_result(result, 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): def authorize_feature(farm, user, feature_code, action, route=None):
return batch_authorize_features(farm, user, [feature_code], action).get(feature_code, False) return batch_authorize_features(farm, user, [feature_code], action, route=route).get(feature_code, False)
def get_request_data(request): def get_request_data(request):
if isinstance(request.data, QueryDict): request_data = getattr(request, "data", None)
return request.data if isinstance(request_data, QueryDict):
if isinstance(request.data, dict): return request_data
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 {} 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, user=request.user,
features=serializer.validated_data["features"], features=serializer.validated_data["features"],
action=serializer.validated_data["action"], action=serializer.validated_data["action"],
route=request.path,
) )
except AccessControlServiceUnavailable as exc: except AccessControlServiceUnavailable as exc:
return Response( return Response(
+17
View File
@@ -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"
}
+5 -2
View File
@@ -53,6 +53,7 @@ MIDDLEWARE = [
"django.middleware.common.CommonMiddleware", "django.middleware.common.CommonMiddleware",
"django.middleware.csrf.CsrfViewMiddleware", "django.middleware.csrf.CsrfViewMiddleware",
"django.contrib.auth.middleware.AuthenticationMiddleware", "django.contrib.auth.middleware.AuthenticationMiddleware",
"access_control.middleware.RouteFeatureAccessMiddleware",
"django.contrib.messages.middleware.MessageMiddleware", "django.contrib.messages.middleware.MessageMiddleware",
"django.middleware.clickjacking.XFrameOptionsMiddleware", "django.middleware.clickjacking.XFrameOptionsMiddleware",
] ]
@@ -109,8 +110,9 @@ DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField"
CACHES = { CACHES = {
"default": { "default": {
"BACKEND": "django.core.cache.backends.locmem.LocMemCache", "BACKEND": "django.core.cache.backends.redis.RedisCache",
"LOCATION": "croplogic-auth-otp", "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_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_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 = { EXTERNAL_SERVICES = {
"ai": { "ai": {