This commit is contained in:
2026-04-09 22:48:54 +03:30
parent c60a1555e2
commit 73ea9875fd
19 changed files with 404 additions and 499 deletions
+212 -116
View File
@@ -1,100 +1,109 @@
from django.utils import timezone
from urllib.parse import urljoin
import requests
from django.conf import settings
from django.core.exceptions import ImproperlyConfigured
from django.http import QueryDict
from farm_hub.models import FarmHub
from .catalog import GOLD_PLAN_CODE
from .models import AccessFeature, AccessRule, FarmAccessProfile, SubscriptionPlan
def _manager_id_set(manager):
return {obj.id for obj in manager.all()}
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_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 getattr(farm, "subscription_plan_id", None):
if farm.subscription_plan_id:
return farm.subscription_plan
return (
SubscriptionPlan.objects.filter(is_active=True, metadata__is_default=True).order_by("name").first()
or SubscriptionPlan.objects.filter(code=GOLD_PLAN_CODE, is_active=True).first()
)
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 rule_matches_farm(rule, farm, product_ids=None, sensor_catalog_ids=None):
def _match_rule(rule, farm, subscription_plan, product_ids, sensor_catalog_ids, sensor_catalog_codes):
if not rule.is_active:
return False
subscription_plan_ids = _manager_id_set(rule.subscription_plans)
if subscription_plan_ids:
subscription_plan = get_effective_subscription_plan(farm)
if subscription_plan is None or subscription_plan.id not in subscription_plan_ids:
return False
farm_type_ids = _manager_id_set(rule.farm_types)
if farm_type_ids and farm.farm_type_id not in farm_type_ids:
if rule.subscription_plans.exists() and (subscription_plan is None or not rule.subscription_plans.filter(pk=subscription_plan.pk).exists()):
return False
product_rule_ids = _manager_id_set(rule.products)
if product_rule_ids:
product_ids = product_ids if product_ids is not None else set(farm.products.values_list("id", flat=True))
if not product_ids or product_rule_ids.isdisjoint(product_ids):
return False
if rule.farm_types.exists() and not rule.farm_types.filter(pk=farm.farm_type_id).exists():
return False
sensor_catalog_rule_ids = _manager_id_set(rule.sensor_catalogs)
if sensor_catalog_rule_ids:
sensor_catalog_ids = (
sensor_catalog_ids
if sensor_catalog_ids is not None
else set(farm.sensors.exclude(sensor_catalog_id__isnull=True).values_list("sensor_catalog_id", flat=True))
)
if not sensor_catalog_ids or sensor_catalog_rule_ids.isdisjoint(sensor_catalog_ids):
return False
if rule.products.exists() and not rule.products.filter(pk__in=product_ids).exists():
return False
sensor_catalog_rule_codes = set(rule.metadata.get("sensor_catalog_codes", [])) if isinstance(rule.metadata, dict) else set()
if sensor_catalog_rule_codes:
farm_sensor_catalog_codes = set(
farm.sensors.exclude(sensor_catalog__code__isnull=True).values_list("sensor_catalog__code", flat=True)
)
if not farm_sensor_catalog_codes or sensor_catalog_rule_codes.isdisjoint(farm_sensor_catalog_codes):
return False
if rule.sensor_catalogs.exists() and not rule.sensor_catalogs.filter(pk__in=sensor_catalog_ids).exists():
return False
sensor_catalog_rule_names = set(rule.metadata.get("sensor_catalog_names", [])) if isinstance(rule.metadata, dict) else set()
if sensor_catalog_rule_names:
farm_sensor_catalog_names = set(
farm.sensors.exclude(sensor_catalog__name__isnull=True).values_list("sensor_catalog__name", flat=True)
)
if not farm_sensor_catalog_names or sensor_catalog_rule_names.isdisjoint(farm_sensor_catalog_names):
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",
).get(pk=farm.pk)
subscription_plan = get_effective_subscription_plan(farm)
features = AccessFeature.objects.all().order_by("feature_type", "code")
resolved = {
feature.code: {
"enabled": feature.default_enabled,
"type": feature.feature_type,
"name": feature.name,
"description": feature.description,
"metadata": feature.metadata,
"source": "default",
}
for feature in features
}
product_ids = set(farm.products.values_list("id", flat=True))
sensor_catalog_ids = set(farm.sensors.exclude(sensor_catalog_id__isnull=True).values_list("sensor_catalog_id", flat=True))
rules = (
AccessRule.objects.filter(is_active=True, features__isnull=False)
.distinct()
.prefetch_related("features", "subscription_plans", "farm_types", "products", "sensor_catalogs")
.order_by("priority", "id")
product_ids = list(farm.products.values_list("id", flat=True))
sensor_catalog_ids = list(
farm.sensors.exclude(sensor_catalog__isnull=True).values_list("sensor_catalog_id", flat=True)
)
sensor_catalog_codes = set(
farm.sensors.exclude(sensor_catalog__isnull=True).values_list("sensor_catalog__code", flat=True)
)
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 rule_matches_farm(rule, farm, product_ids=product_ids, sensor_catalog_ids=sensor_catalog_ids):
if not _match_rule(rule, farm, subscription_plan, product_ids, sensor_catalog_ids, sensor_catalog_codes):
continue
matched_rules.append(
@@ -105,66 +114,153 @@ def build_farm_access_profile(farm):
"priority": rule.priority,
}
)
is_enabled = rule.effect == AccessRule.ALLOW
for feature in rule.features.all():
resolved[feature.code] = {
"enabled": is_enabled,
"type": feature.feature_type,
"name": feature.name,
"description": feature.description,
"metadata": feature.metadata,
"source": rule.code,
}
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
grouped = {}
for code, payload in resolved.items():
grouped.setdefault(f"{payload['type']}s", {})[code] = payload
profile, _created = FarmAccessProfile.objects.update_or_create(
farm=farm,
defaults={
"cached_features": resolved,
"cached_groups": grouped,
"matched_rules": matched_rules,
"last_resolved_at": timezone.now(),
},
)
return {
profile = {
"farm_uuid": str(farm.farm_uuid),
"subscription_plan": {
"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,
}
if subscription_plan is not None
else None,
"features": profile.cached_features,
"groups": profile.cached_groups,
"matched_rules": profile.matched_rules,
"resolved_from_profile": True,
}
FarmAccessProfile.objects.update_or_create(
farm=farm,
defaults={
"subscription_plan": subscription_plan,
"profile_data": profile,
"resolved_from_profile": True,
},
)
return profile
def build_farm_access_profile_response(farm):
profile_data = build_farm_access_profile(farm)
def build_opa_resource(farm):
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_uuid": profile_data["farm_uuid"],
"subscription_plan": profile_data["subscription_plan"],
"matched_rules": profile_data["matched_rules"],
"resolved_from_profile": profile_data["resolved_from_profile"],
"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 is_feature_enabled_for_farm(farm, feature_code):
profile = getattr(farm, "access_profile", None)
if profile and isinstance(profile.cached_features, dict):
feature_payload = profile.cached_features.get(feature_code)
if feature_payload is not None:
return bool(feature_payload.get("enabled"))
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",
}
profile_data = build_farm_access_profile(farm)
feature_payload = profile_data["features"].get(feature_code)
if feature_payload is None:
return False
return bool(feature_payload.get("enabled"))
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):
return {
"user": build_opa_user(user),
"resource": build_opa_resource(farm),
"features": list(features),
"action": action,
}
def request_opa_batch_authorization(farm, user, features, action):
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)}
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
try:
return response.json().get("result", {})
except ValueError as exc:
raise AccessControlServiceUnavailable("OPA authorization service returned invalid JSON.") from exc
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}
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):
result = request_opa_batch_authorization(farm, user, features, action)
return normalize_opa_batch_result(result, features)
def authorize_feature(farm, user, feature_code, action):
return batch_authorize_features(farm, user, [feature_code], action).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
return {}