UPDATE
This commit is contained in:
+77
-16
@@ -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
|
||||
|
||||
|
||||
@@ -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):
|
||||
|
||||
Reference in New Issue
Block a user