This commit is contained in:
2026-05-05 21:01:58 +03:30
parent 39efd537bf
commit 4e28bacad6
54 changed files with 2729 additions and 1115 deletions
+77 -16
View File
@@ -1,5 +1,7 @@
import hashlib
import json
import logging
import time
from functools import lru_cache
from pathlib import Path
from urllib.parse import urljoin
@@ -10,11 +12,15 @@ from django.core.cache import cache
from django.core.exceptions import ImproperlyConfigured
from django.http import QueryDict
from farm_hub.models import FarmHub
from config.observability import classify_exception, log_event, observe_operation, record_metric
from .catalog import GOLD_PLAN_CODE
from .models import AccessFeature, AccessRule, FarmAccessProfile, SubscriptionPlan
logger = logging.getLogger(__name__)
class AccessControlError(Exception):
pass
@@ -268,20 +274,54 @@ def request_opa_batch_authorization(farm, user, features, action, route=None):
payload = {"input": build_authorization_input(farm, user, features, action, route=route)}
try:
response = requests.post(
_opa_url(settings.ACCESS_CONTROL_AUTHZ_BATCH_PATH),
json=payload,
timeout=settings.ACCESS_CONTROL_AUTHZ_TIMEOUT,
)
response.raise_for_status()
except requests.RequestException as exc:
raise AccessControlServiceUnavailable("OPA authorization service is unavailable.") from exc
with observe_operation(source="backend.access_control", provider="opa", operation="batch_authorization"):
started_at = time.monotonic()
try:
response = requests.post(
_opa_url(settings.ACCESS_CONTROL_AUTHZ_BATCH_PATH),
json=payload,
timeout=settings.ACCESS_CONTROL_AUTHZ_TIMEOUT,
)
response.raise_for_status()
except requests.RequestException as exc:
failure = classify_exception(exc)
log_event(
level=logging.ERROR,
message="opa batch authorization request failed",
source="backend.access_control",
provider="opa",
operation="batch_authorization",
result_status="error",
duration_ms=(time.monotonic() - started_at) * 1000,
error_code=failure.error_code,
route=route,
feature_count=len(features),
)
record_metric("access_control.opa.failure", error_code=failure.error_code)
raise AccessControlServiceUnavailable("OPA authorization service is unavailable.") from exc
try:
return response.json().get("result", {})
except ValueError as exc:
raise AccessControlServiceUnavailable("OPA authorization service returned invalid JSON.") from exc
try:
result = response.json().get("result", {})
except ValueError as exc:
log_event(
level=logging.ERROR,
message="opa batch authorization returned invalid json",
source="backend.access_control",
provider="opa",
operation="batch_authorization",
result_status="error",
duration_ms=(time.monotonic() - started_at) * 1000,
error_code="parse_error",
route=route,
feature_count=len(features),
status_code=response.status_code,
)
record_metric("access_control.opa.invalid_json")
raise AccessControlServiceUnavailable("OPA authorization service returned invalid JSON.") from exc
if not result:
record_metric("access_control.opa.empty_result")
logger.warning("OPA returned empty authorization result for route=%s", route)
return result
def normalize_opa_batch_result(data, features):
@@ -319,7 +359,18 @@ def batch_authorize_features(farm, user, features, action, route=None):
try:
cached_result = cache.get(cache_key)
except Exception:
except Exception as exc:
failure = classify_exception(exc)
log_event(
level=logging.WARNING,
message="authorization cache read failed",
source="backend.access_control",
provider="cache",
operation="batch_authorize_features",
result_status="error",
error_code=failure.error_code,
route=route,
)
cached_result = None
if isinstance(cached_result, dict):
@@ -330,8 +381,18 @@ def batch_authorize_features(farm, user, features, action, route=None):
try:
cache.set(cache_key, decisions, timeout=_get_authz_cache_timeout())
except Exception:
pass
except Exception as exc:
failure = classify_exception(exc)
log_event(
level=logging.WARNING,
message="authorization cache write failed",
source="backend.access_control",
provider="cache",
operation="batch_authorize_features",
result_status="error",
error_code=failure.error_code,
route=route,
)
return decisions
+24
View File
@@ -4,6 +4,7 @@ from unittest.mock import patch
from django.test import RequestFactory, SimpleTestCase, override_settings
from account.views import ProfileView
from config.observability import METRICS
from .middleware import RouteFeatureAccessMiddleware
from .services import batch_authorize_features, build_authorization_input
@@ -19,6 +20,9 @@ TEST_CACHES = {
@override_settings(CACHES=TEST_CACHES, ACCESS_CONTROL_AUTHZ_CACHE_TIMEOUT=300)
class AccessControlServiceTests(SimpleTestCase):
def tearDown(self):
METRICS.clear()
def test_batch_authorize_features_uses_cache_for_same_route(self):
farm = SimpleNamespace(farm_uuid="farm-uuid")
user = SimpleNamespace(id=7)
@@ -95,6 +99,26 @@ class AccessControlServiceTests(SimpleTestCase):
},
)
@patch("access_control.services.requests.post")
@override_settings(ACCESS_CONTROL_AUTHZ_ENABLED=True, ACCESS_CONTROL_AUTHZ_BASE_URL="https://opa.example", ACCESS_CONTROL_AUTHZ_BATCH_PATH="/v1/data/authz", ACCESS_CONTROL_AUTHZ_TIMEOUT=1)
def test_request_opa_batch_authorization_records_invalid_json_metric(self, mock_post):
response = mock_post.return_value
response.raise_for_status.return_value = None
response.json.side_effect = ValueError("bad json")
farm = SimpleNamespace(farm_uuid="farm-uuid")
user = SimpleNamespace(id=7, username="u", email="", phone_number="", is_staff=False, is_superuser=False)
with self.assertRaises(Exception):
batch_authorize_features(
farm=farm,
user=user,
features=["farm_dashboard"],
action="view",
route="/api/farm-dashboard/",
)
self.assertEqual(METRICS["access_control.opa.invalid_json"], 1)
class RouteFeatureAccessMiddlewareTests(SimpleTestCase):
def test_middleware_passes_route_feature_and_method_to_service(self):