UPDATE
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
@@ -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 {}
|
||||
|
||||
@@ -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/",
|
||||
)
|
||||
@@ -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(
|
||||
|
||||
@@ -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
@@ -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": {
|
||||
|
||||
Reference in New Issue
Block a user