import hashlib import json import logging import time 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 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 class AccessControlServiceUnavailable(AccessControlError): pass ACTION_MAP = { "GET": "view", "HEAD": "view", "OPTIONS": "view", "POST": "create", "PUT": "edit", "PATCH": "edit", "DELETE": "delete", } 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() def get_effective_subscription_plan(farm): if farm.subscription_plan_id: return farm.subscription_plan default_plan = get_default_subscription_plan() if default_plan is not None: return default_plan return SubscriptionPlan.objects.filter(code=GOLD_PLAN_CODE, is_active=True).order_by("name").first() def _match_rule(rule, farm, subscription_plan, product_ids, sensor_catalog_ids, sensor_catalog_codes): if not rule.is_active: return False if rule.subscription_plans.exists() and (subscription_plan is None or not rule.subscription_plans.filter(pk=subscription_plan.pk).exists()): return False if rule.farm_types.exists() and not rule.farm_types.filter(pk=farm.farm_type_id).exists(): return False if rule.products.exists() and not rule.products.filter(pk__in=product_ids).exists(): return False if rule.sensor_catalogs.exists() and not rule.sensor_catalogs.filter(pk__in=sensor_catalog_ids).exists(): return False metadata_sensor_codes = rule.metadata.get("sensor_catalog_codes", []) if metadata_sensor_codes and not set(metadata_sensor_codes).intersection(sensor_catalog_codes): return False return True def build_farm_access_profile(farm): farm = FarmHub.objects.select_related("farm_type", "subscription_plan").prefetch_related( "products", "sensors", "sensors__sensor_catalog", "sensors__device_catalogs", ).get(pk=farm.pk) subscription_plan = get_effective_subscription_plan(farm) product_ids = list(farm.products.values_list("id", flat=True)) sensor_catalog_ids = set() sensor_catalog_codes = set() for sensor in farm.sensors.all(): for catalog in sensor.get_device_catalogs(): sensor_catalog_ids.add(catalog.id) sensor_catalog_codes.add(catalog.code) features = { feature.code: { "name": feature.name, "type": feature.feature_type, "enabled": feature.default_enabled, "source": "default" if feature.default_enabled else None, } for feature in AccessFeature.objects.filter(is_active=True) } matched_rules = [] rules = AccessRule.objects.filter(is_active=True).prefetch_related( "features", "subscription_plans", "farm_types", "products", "sensor_catalogs", ).order_by("priority", "id") for rule in rules: if not _match_rule(rule, farm, subscription_plan, product_ids, sensor_catalog_ids, sensor_catalog_codes): continue matched_rules.append( { "code": rule.code, "name": rule.name, "effect": rule.effect, "priority": rule.priority, } ) for feature in rule.features.all(): feature_state = features.setdefault( feature.code, { "name": feature.name, "type": feature.feature_type, "enabled": feature.default_enabled, "source": "default" if feature.default_enabled else None, }, ) feature_state["enabled"] = rule.effect == AccessRule.ALLOW feature_state["source"] = rule.code profile = { "farm_uuid": str(farm.farm_uuid), "subscription_plan": None, "features": features, "matched_rules": matched_rules, "resolved_from_profile": True, } if subscription_plan is not None: profile["subscription_plan"] = { "uuid": str(subscription_plan.uuid), "code": subscription_plan.code, "name": subscription_plan.name, } FarmAccessProfile.objects.update_or_create( farm=farm, defaults={ "subscription_plan": subscription_plan, "profile_data": profile, "resolved_from_profile": True, }, ) return profile 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) ) power_sensor = [] for sensor in farm.sensors.all(): if isinstance(sensor.power_source, dict): power_type = sensor.power_source.get("type") if power_type: power_sensor.append(power_type) return { "farm_id": str(farm.farm_uuid), "subscription_plan_codes": [subscription_plan.code] if subscription_plan else [], "farm_types": [farm.farm_type.name] if farm.farm_type_id else [], "crop_types": list(farm.products.values_list("name", flat=True)), "cultivation_types": [], "sensor_codes": sensor_codes, "power_sensor": power_sensor, "customization": [], } def build_opa_user(user): return { "id": getattr(user, "id", None), "username": getattr(user, "username", ""), "email": getattr(user, "email", ""), "phone_number": getattr(user, "phone_number", ""), "is_staff": bool(getattr(user, "is_staff", False)), "is_superuser": bool(getattr(user, "is_superuser", False)), "role": "farmer", } def get_authorization_action(method): return ACTION_MAP.get(method.upper(), "view") def _opa_url(path): base_url = getattr(settings, "ACCESS_CONTROL_AUTHZ_BASE_URL", "").strip() if not base_url: raise ImproperlyConfigured("ACCESS_CONTROL_AUTHZ_BASE_URL is not configured.") return urljoin(f"{base_url.rstrip('/')}/", path.lstrip("/")) 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, 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, route=route)} 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: 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): decisions = data.get("decisions") 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) return {feature: feature in allowed for feature in features} if isinstance(data, dict) and all(isinstance(value, bool) for value in data.values()): return {feature: bool(data.get(feature, False)) for feature in features} raise AccessControlServiceUnavailable("OPA authorization service returned an unsupported payload.") 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 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): 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 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 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): 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 {}