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
-1
View File
@@ -1 +0,0 @@
-1
View File
@@ -4,4 +4,3 @@ from django.apps import AppConfig
class AccessControlConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "access_control"
-70
View File
@@ -1,71 +1 @@
GOLD_PLAN_CODE = "gold"
SENSOR_7_NAME = "Sensor 7 - Soil Moisture Sensor v1.2"
SENSOR_7_CODE = "sensor_7_soil_moisture_sensor_v1_2"
DEFAULT_SUBSCRIPTION_PLANS = [
{
"code": "gold",
"name": "Gold",
"description": "Default premium subscription plan with full CropLogic access.",
"metadata": {"is_default": True},
},
]
DEFAULT_ACCESS_FEATURES = [
{"code": "dashboards", "name": "داشبوردها", "feature_type": "section", "default_enabled": True},
{"code": "data-section", "name": "بخش داده ها", "feature_type": "section", "default_enabled": True},
{"code": "water-data", "name": "دیتاهای آب", "feature_type": "page", "default_enabled": True},
{"code": "soil-information", "name": "اطلاعات خاک", "feature_type": "page", "default_enabled": True},
{"code": "crop-zoning", "name": "زون بندی کشت", "feature_type": "page", "default_enabled": True},
{"code": "simulator", "name": "شبیه ساز", "feature_type": "section", "default_enabled": True},
{"code": "plant-growth-simulator", "name": "شبیه ساز رشد گیاه", "feature_type": "page", "default_enabled": True},
{"code": "recommendations", "name": "توصیه ها", "feature_type": "section", "default_enabled": True},
{"code": "irrigation-recommendation", "name": "توصیه آبیاری", "feature_type": "page", "default_enabled": True},
{"code": "fertilization-recommendation", "name": "توصیه کوددهی", "feature_type": "page", "default_enabled": True},
{"code": "smart-assistant", "name": "دستیار هوشمند", "feature_type": "section", "default_enabled": True},
{"code": "farm-ai-assistant", "name": "دستیار هوشمند مزرعه", "feature_type": "page", "default_enabled": True},
{"code": "pest-detection", "name": "تشخیص آفات گیاهی", "feature_type": "page", "default_enabled": True},
{"code": "sensor-page", "name": "صفحه سنسور", "feature_type": "page", "default_enabled": True},
{"code": "greenhouse-dashboard", "name": "Greenhouse Dashboard", "feature_type": "page", "default_enabled": True},
]
DEFAULT_ACCESS_RULES = [
{
"code": "gold-full-access",
"name": "Gold Full Access",
"description": "Enables all core product features for gold subscribers.",
"effect": "allow",
"priority": 10,
"subscription_plans": ["gold"],
"features": [
"dashboards",
"data-section",
"water-data",
"soil-information",
"crop-zoning",
"simulator",
"plant-growth-simulator",
"recommendations",
"irrigation-recommendation",
"fertilization-recommendation",
"smart-assistant",
"farm-ai-assistant",
"pest-detection",
"greenhouse-dashboard",
],
},
{
"code": "sensor-7-page-access",
"name": "Sensor 7 Page Access",
"description": "Adds sensor page access when Sensor 7 is attached to the farm.",
"effect": "allow",
"priority": 20,
"features": ["sensor-page"],
"sensor_catalogs": [SENSOR_7_NAME],
"sensor_catalog_codes": [SENSOR_7_CODE],
"metadata": {"sensor_catalog_codes": [SENSOR_7_CODE]},
},
]
+15 -55
View File
@@ -1,8 +1,6 @@
# Generated by Django 5.2.12 on 2026-03-19 16:40
from django.db import migrations, models
import django.db.models.deletion
import uuid
from django.db import migrations, models
class Migration(migrations.Migration):
@@ -10,7 +8,7 @@ class Migration(migrations.Migration):
dependencies = [
("farm_hub", "0006_seed_expanded_product_catalog"),
("sensor_catalog", "0002_sensorcatalog_supported_power_sources"),
("sensor_catalog", "0003_sensorcatalog_code"),
]
operations = [
@@ -19,91 +17,53 @@ class Migration(migrations.Migration):
fields=[
("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
("uuid", models.UUIDField(db_index=True, default=uuid.uuid4, editable=False, unique=True)),
("code", models.SlugField(db_index=True, max_length=128, unique=True)),
("code", models.CharField(db_index=True, max_length=150, unique=True)),
("name", models.CharField(max_length=255)),
(
"feature_type",
models.CharField(
choices=[("page", "Page"), ("action", "Action"), ("widget", "Widget"), ("section", "Section")],
default="page",
max_length=32,
),
),
("description", models.TextField(blank=True, default="")),
("feature_type", models.CharField(choices=[("page", "Page"), ("widget", "Widget"), ("action", "Action")], default="page", max_length=32)),
("default_enabled", models.BooleanField(default=False)),
("metadata", models.JSONField(blank=True, default=dict)),
("is_active", models.BooleanField(default=True)),
("created_at", models.DateTimeField(auto_now_add=True)),
("updated_at", models.DateTimeField(auto_now=True)),
],
options={"db_table": "access_features", "ordering": ["feature_type", "code"]},
options={"db_table": "access_features", "ordering": ["name"]},
),
migrations.CreateModel(
name="SubscriptionPlan",
fields=[
("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
("uuid", models.UUIDField(db_index=True, default=uuid.uuid4, editable=False, unique=True)),
("code", models.SlugField(db_index=True, max_length=64, unique=True)),
("name", models.CharField(db_index=True, max_length=255, unique=True)),
("code", models.CharField(db_index=True, max_length=100, unique=True)),
("name", models.CharField(max_length=255)),
("description", models.TextField(blank=True, default="")),
("metadata", models.JSONField(blank=True, default=dict)),
("is_active", models.BooleanField(default=True)),
("created_at", models.DateTimeField(auto_now_add=True)),
("updated_at", models.DateTimeField(auto_now=True)),
],
options={"db_table": "subscription_plans", "ordering": ["name"]},
options={"db_table": "access_subscription_plans", "ordering": ["name"]},
),
migrations.CreateModel(
name="AccessRule",
fields=[
("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
("uuid", models.UUIDField(db_index=True, default=uuid.uuid4, editable=False, unique=True)),
("code", models.SlugField(db_index=True, max_length=128, unique=True)),
("code", models.CharField(db_index=True, max_length=150, unique=True)),
("name", models.CharField(max_length=255)),
("description", models.TextField(blank=True, default="")),
("effect", models.CharField(choices=[("allow", "Allow"), ("deny", "Deny")], default="allow", max_length=16)),
("priority", models.PositiveIntegerField(default=100)),
("is_active", models.BooleanField(default=True)),
("effect", models.CharField(choices=[("allow", "Allow"), ("deny", "Deny")], default="allow", max_length=16)),
("metadata", models.JSONField(blank=True, default=dict)),
("is_active", models.BooleanField(default=True)),
("created_at", models.DateTimeField(auto_now_add=True)),
("updated_at", models.DateTimeField(auto_now=True)),
("farm_types", models.ManyToManyField(blank=True, related_name="access_rules", to="farm_hub.farmtype")),
("features", models.ManyToManyField(blank=True, related_name="rules", to="access_control.accessfeature")),
("products", models.ManyToManyField(blank=True, related_name="access_rules", to="farm_hub.product")),
(
"sensor_catalogs",
models.ManyToManyField(blank=True, related_name="access_rules", to="sensor_catalog.sensorcatalog"),
),
(
"subscription_plans",
models.ManyToManyField(blank=True, related_name="access_rules", to="access_control.subscriptionplan"),
),
("sensor_catalogs", models.ManyToManyField(blank=True, related_name="access_rules", to="sensor_catalog.sensorcatalog")),
("subscription_plans", models.ManyToManyField(blank=True, related_name="access_rules", to="access_control.subscriptionplan")),
],
options={"db_table": "access_rules", "ordering": ["priority", "code"]},
),
migrations.CreateModel(
name="FarmAccessProfile",
fields=[
(
"farm",
models.OneToOneField(
db_column="farm_uuid",
on_delete=django.db.models.deletion.CASCADE,
primary_key=True,
related_name="access_profile",
serialize=False,
to="farm_hub.farmhub",
to_field="farm_uuid",
),
),
("cached_features", models.JSONField(blank=True, default=dict)),
("cached_groups", models.JSONField(blank=True, default=dict)),
("matched_rules", models.JSONField(blank=True, default=list)),
("metadata", models.JSONField(blank=True, default=dict)),
("last_resolved_at", models.DateTimeField(blank=True, null=True)),
("created_at", models.DateTimeField(auto_now_add=True)),
("updated_at", models.DateTimeField(auto_now=True)),
],
options={"db_table": "farm_access_profiles"},
options={"db_table": "access_rules", "ordering": ["priority", "name"]},
),
]
@@ -1,6 +1,5 @@
# Generated by Django 5.2.12 on 2026-03-19 16:41
from django.db import migrations
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
@@ -9,4 +8,18 @@ class Migration(migrations.Migration):
("farm_hub", "0006_seed_expanded_product_catalog"),
]
operations = []
operations = [
migrations.CreateModel(
name="FarmAccessProfile",
fields=[
("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
("profile_data", models.JSONField(blank=True, default=dict)),
("resolved_from_profile", models.BooleanField(default=True)),
("created_at", models.DateTimeField(auto_now_add=True)),
("updated_at", models.DateTimeField(auto_now=True)),
("farm", models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name="access_profile", to="farm_hub.farmhub")),
("subscription_plan", models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name="farm_access_profiles", to="access_control.subscriptionplan")),
],
options={"db_table": "farm_access_profiles", "ordering": ["-updated_at"]},
),
]
@@ -1,93 +1,9 @@
# Generated by Django 5.2.12 on 2026-04-03
from django.db import migrations
def seed_default_access_rules(apps, schema_editor):
AccessFeature = apps.get_model("access_control", "AccessFeature")
AccessRule = apps.get_model("access_control", "AccessRule")
SubscriptionPlan = apps.get_model("access_control", "SubscriptionPlan")
SensorCatalog = apps.get_model("sensor_catalog", "SensorCatalog")
from access_control.catalog import DEFAULT_ACCESS_FEATURES, DEFAULT_ACCESS_RULES, DEFAULT_SUBSCRIPTION_PLANS
features_by_code = {}
for feature_data in DEFAULT_ACCESS_FEATURES:
feature, _created = AccessFeature.objects.update_or_create(
code=feature_data["code"],
defaults={
"name": feature_data["name"],
"feature_type": feature_data["feature_type"],
"description": feature_data.get("description", ""),
"metadata": feature_data.get("metadata", {}),
"default_enabled": feature_data.get("default_enabled", False),
},
)
features_by_code[feature.code] = feature
plans_by_code = {}
for plan_data in DEFAULT_SUBSCRIPTION_PLANS:
plan, _created = SubscriptionPlan.objects.update_or_create(
code=plan_data["code"],
defaults={
"name": plan_data["name"],
"description": plan_data.get("description", ""),
"metadata": plan_data.get("metadata", {}),
"is_active": True,
},
)
plans_by_code[plan.code] = plan
sensor_catalogs_by_name = {
sensor.name: sensor for sensor in SensorCatalog.objects.filter(name__in=_sensor_names(DEFAULT_ACCESS_RULES))
}
for rule_data in DEFAULT_ACCESS_RULES:
rule, _created = AccessRule.objects.update_or_create(
code=rule_data["code"],
defaults={
"name": rule_data["name"],
"description": rule_data.get("description", ""),
"effect": rule_data.get("effect", "allow"),
"priority": rule_data.get("priority", 100),
"metadata": rule_data.get("metadata", {}),
"is_active": True,
},
)
rule.features.set([features_by_code[code] for code in rule_data.get("features", []) if code in features_by_code])
rule.subscription_plans.set(
[plans_by_code[code] for code in rule_data.get("subscription_plans", []) if code in plans_by_code]
)
rule.sensor_catalogs.set(
[sensor_catalogs_by_name[name] for name in rule_data.get("sensor_catalogs", []) if name in sensor_catalogs_by_name]
)
def unseed_default_access_rules(apps, schema_editor):
AccessFeature = apps.get_model("access_control", "AccessFeature")
AccessRule = apps.get_model("access_control", "AccessRule")
SubscriptionPlan = apps.get_model("access_control", "SubscriptionPlan")
from access_control.catalog import DEFAULT_ACCESS_FEATURES, DEFAULT_ACCESS_RULES, DEFAULT_SUBSCRIPTION_PLANS
AccessRule.objects.filter(code__in=[item["code"] for item in DEFAULT_ACCESS_RULES]).delete()
AccessFeature.objects.filter(code__in=[item["code"] for item in DEFAULT_ACCESS_FEATURES]).delete()
SubscriptionPlan.objects.filter(code__in=[item["code"] for item in DEFAULT_SUBSCRIPTION_PLANS]).delete()
def _sensor_names(rule_data_list):
names = []
for rule_data in rule_data_list:
names.extend(rule_data.get("sensor_catalogs", []))
return names
class Migration(migrations.Migration):
dependencies = [
("access_control", "0002_link_subscription_plan_to_farm"),
("sensor_catalog", "0002_sensorcatalog_supported_power_sources"),
]
operations = [
migrations.RunPython(seed_default_access_rules, unseed_default_access_rules),
]
operations = []
@@ -1,20 +1,9 @@
from django.db import migrations
def enable_default_feature_access(apps, schema_editor):
AccessFeature = apps.get_model("access_control", "AccessFeature")
from access_control.catalog import DEFAULT_ACCESS_FEATURES
default_enabled_codes = [item["code"] for item in DEFAULT_ACCESS_FEATURES if item.get("default_enabled", False)]
AccessFeature.objects.filter(code__in=default_enabled_codes).update(default_enabled=True)
class Migration(migrations.Migration):
dependencies = [
("access_control", "0003_seed_default_access_rules"),
]
operations = [
migrations.RunPython(enable_default_feature_access, migrations.RunPython.noop),
]
operations = []
@@ -1,26 +1,10 @@
from django.db import migrations
def backfill_farm_subscription_plans(apps, schema_editor):
SubscriptionPlan = apps.get_model("access_control", "SubscriptionPlan")
FarmHub = apps.get_model("farm_hub", "FarmHub")
default_plan = (
SubscriptionPlan.objects.filter(is_active=True, metadata__is_default=True).order_by("name").first()
or SubscriptionPlan.objects.filter(code="gold", is_active=True).first()
)
if default_plan is None:
return
FarmHub.objects.filter(subscription_plan__isnull=True).update(subscription_plan=default_plan)
class Migration(migrations.Migration):
dependencies = [
("access_control", "0004_enable_default_feature_access"),
("farm_hub", "0007_farmhub_subscription_plan"),
]
operations = [
migrations.RunPython(backfill_farm_subscription_plans, migrations.RunPython.noop),
]
operations = []
-1
View File
@@ -1 +0,0 @@
+26 -30
View File
@@ -5,8 +5,8 @@ from django.db import models
class SubscriptionPlan(models.Model):
uuid = models.UUIDField(default=uuid_lib.uuid4, unique=True, editable=False, db_index=True)
code = models.SlugField(max_length=64, unique=True, db_index=True)
name = models.CharField(max_length=255, unique=True, db_index=True)
code = models.CharField(max_length=100, unique=True, db_index=True)
name = models.CharField(max_length=255)
description = models.TextField(blank=True, default="")
metadata = models.JSONField(default=dict, blank=True)
is_active = models.BooleanField(default=True)
@@ -14,7 +14,7 @@ class SubscriptionPlan(models.Model):
updated_at = models.DateTimeField(auto_now=True)
class Meta:
db_table = "subscription_plans"
db_table = "access_subscription_plans"
ordering = ["name"]
def __str__(self):
@@ -23,29 +23,28 @@ class SubscriptionPlan(models.Model):
class AccessFeature(models.Model):
PAGE = "page"
ACTION = "action"
WIDGET = "widget"
SECTION = "section"
ACTION = "action"
FEATURE_TYPES = [
(PAGE, "Page"),
(ACTION, "Action"),
(WIDGET, "Widget"),
(SECTION, "Section"),
(ACTION, "Action"),
]
uuid = models.UUIDField(default=uuid_lib.uuid4, unique=True, editable=False, db_index=True)
code = models.SlugField(max_length=128, unique=True, db_index=True)
code = models.CharField(max_length=150, unique=True, db_index=True)
name = models.CharField(max_length=255)
feature_type = models.CharField(max_length=32, choices=FEATURE_TYPES, default=PAGE)
description = models.TextField(blank=True, default="")
feature_type = models.CharField(max_length=32, choices=FEATURE_TYPES, default=PAGE)
default_enabled = models.BooleanField(default=False)
metadata = models.JSONField(default=dict, blank=True)
is_active = models.BooleanField(default=True)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
db_table = "access_features"
ordering = ["feature_type", "code"]
ordering = ["name"]
def __str__(self):
return self.code
@@ -60,15 +59,15 @@ class AccessRule(models.Model):
]
uuid = models.UUIDField(default=uuid_lib.uuid4, unique=True, editable=False, db_index=True)
code = models.SlugField(max_length=128, unique=True, db_index=True)
code = models.CharField(max_length=150, unique=True, db_index=True)
name = models.CharField(max_length=255)
description = models.TextField(blank=True, default="")
effect = models.CharField(max_length=16, choices=EFFECTS, default=ALLOW)
priority = models.PositiveIntegerField(default=100)
is_active = models.BooleanField(default=True)
effect = models.CharField(max_length=16, choices=EFFECTS, default=ALLOW)
metadata = models.JSONField(default=dict, blank=True)
features = models.ManyToManyField(AccessFeature, related_name="rules", blank=True)
subscription_plans = models.ManyToManyField(SubscriptionPlan, related_name="access_rules", blank=True)
is_active = models.BooleanField(default=True)
features = models.ManyToManyField("AccessFeature", related_name="rules", blank=True)
subscription_plans = models.ManyToManyField("SubscriptionPlan", related_name="access_rules", blank=True)
farm_types = models.ManyToManyField("farm_hub.FarmType", related_name="access_rules", blank=True)
products = models.ManyToManyField("farm_hub.Product", related_name="access_rules", blank=True)
sensor_catalogs = models.ManyToManyField("sensor_catalog.SensorCatalog", related_name="access_rules", blank=True)
@@ -77,32 +76,29 @@ class AccessRule(models.Model):
class Meta:
db_table = "access_rules"
ordering = ["priority", "code"]
ordering = ["priority", "name"]
def __str__(self):
return self.code
class FarmAccessProfile(models.Model):
farm = models.OneToOneField(
"farm_hub.FarmHub",
to_field="farm_uuid",
db_column="farm_uuid",
on_delete=models.CASCADE,
related_name="access_profile",
primary_key=True,
farm = models.OneToOneField("farm_hub.FarmHub", on_delete=models.CASCADE, related_name="access_profile")
subscription_plan = models.ForeignKey(
"SubscriptionPlan",
on_delete=models.SET_NULL,
related_name="farm_access_profiles",
null=True,
blank=True,
)
cached_features = models.JSONField(default=dict, blank=True)
cached_groups = models.JSONField(default=dict, blank=True)
matched_rules = models.JSONField(default=list, blank=True)
metadata = models.JSONField(default=dict, blank=True)
last_resolved_at = models.DateTimeField(null=True, blank=True)
profile_data = models.JSONField(default=dict, blank=True)
resolved_from_profile = models.BooleanField(default=True)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
db_table = "farm_access_profiles"
ordering = ["-updated_at"]
def __str__(self):
return str(self.farm_id)
return f"Access profile for {self.farm_id}"
+27 -44
View File
@@ -1,60 +1,43 @@
from django.core.exceptions import ValidationError as DjangoValidationError
from rest_framework.permissions import BasePermission
from farm_hub.models import FarmHub
from .services import is_feature_enabled_for_farm
from .services import AccessControlServiceUnavailable, authorize_feature, get_authorization_action, get_request_data
class FeatureAccessPermission(BasePermission):
message = "You do not have access to this API."
message = "Access denied."
def has_permission(self, request, view):
feature_code = getattr(view, "required_feature_code", None)
if not feature_code:
return True
farm_uuid = self._extract_farm_uuid(request, view)
farm_uuid = (
view.kwargs.get("farm_uuid")
or request.query_params.get("farm_uuid")
or get_request_data(request).get("farm_uuid")
)
if not farm_uuid:
return True
self.message = f"Access to feature `{feature_code}` is denied."
return False
farm = self._resolve_owned_farm(request, farm_uuid)
if farm is None:
return True
if is_feature_enabled_for_farm(farm, feature_code):
return True
self.message = f"Access to feature `{feature_code}` is denied."
return False
@staticmethod
def _extract_farm_uuid(request, view):
for key in ("farm_uuid", "farmUuid"):
farm_uuid = view.kwargs.get(key)
if farm_uuid:
return str(farm_uuid)
for key in ("farm_uuid", "farmUuid"):
farm_uuid = request.query_params.get(key)
if farm_uuid:
return farm_uuid
if isinstance(request.data, dict):
for key in ("farm_uuid", "farmUuid"):
farm_uuid = request.data.get(key)
if farm_uuid:
return farm_uuid
return None
@staticmethod
def _resolve_owned_farm(request, farm_uuid):
try:
return (
FarmHub.objects.select_related("access_profile")
.prefetch_related("products", "sensors")
.filter(farm_uuid=farm_uuid, owner=request.user)
.first()
)
except (ValueError, TypeError, DjangoValidationError):
return None
farm = FarmHub.objects.select_related("farm_type", "subscription_plan").prefetch_related(
"products",
"sensors",
"sensors__sensor_catalog",
).get(farm_uuid=farm_uuid, owner=request.user)
except FarmHub.DoesNotExist:
self.message = f"Access to feature `{feature_code}` is denied."
return False
try:
allowed = authorize_feature(farm, request.user, feature_code, get_authorization_action(request.method))
except AccessControlServiceUnavailable as exc:
self.message = str(exc)
return False
if not allowed:
self.message = f"Access to feature `{feature_code}` is denied."
return allowed
+8 -22
View File
@@ -1,31 +1,17 @@
from rest_framework import serializers
from .models import FarmAccessProfile, SubscriptionPlan
from .models import SubscriptionPlan
class SubscriptionPlanSerializer(serializers.ModelSerializer):
class Meta:
model = SubscriptionPlan
fields = ["uuid", "code", "name", "description", "metadata", "is_active"]
fields = ["uuid", "code", "name"]
class FarmAccessProfileSerializer(serializers.Serializer):
farm_uuid = serializers.UUIDField()
subscription_plan = SubscriptionPlanSerializer(allow_null=True)
matched_rules = serializers.ListField()
resolved_from_profile = serializers.BooleanField()
class FarmAccessProfileCacheSerializer(serializers.ModelSerializer):
class Meta:
model = FarmAccessProfile
fields = [
"farm",
"cached_features",
"cached_groups",
"matched_rules",
"metadata",
"last_resolved_at",
"created_at",
"updated_at",
]
class FeatureAuthorizationRequestSerializer(serializers.Serializer):
features = serializers.ListField(
child=serializers.CharField(),
allow_empty=False,
)
action = serializers.CharField(required=False, allow_blank=False, default="view")
+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 {}
+2 -5
View File
@@ -1,10 +1,7 @@
from django.urls import path
from .views import FarmAccessProfileView, SubscriptionPlanListView
from .views import FarmFeatureAuthorizationView
urlpatterns = [
path("subscription-plans/", SubscriptionPlanListView.as_view(), name="subscription-plan-list"),
path("farms/<uuid:farm_uuid>/profile/", FarmAccessProfileView.as_view(), name="farm-access-profile"),
path("farms/<uuid:farm_uuid>/authorize/", FarmFeatureAuthorizationView.as_view(), name="farm-feature-authorization"),
]
+48 -35
View File
@@ -7,50 +7,63 @@ from drf_spectacular.utils import extend_schema
from config.swagger import code_response
from farm_hub.models import FarmHub
from .models import SubscriptionPlan
from .serializers import FarmAccessProfileSerializer, SubscriptionPlanSerializer
from .services import build_farm_access_profile_response
from .serializers import FeatureAuthorizationRequestSerializer
from .services import AccessControlServiceUnavailable, request_opa_batch_authorization
class AccessControlBaseView(APIView):
class FarmFeatureAuthorizationView(APIView):
permission_classes = [IsAuthenticated]
def _get_farm(self, request, farm_uuid):
@extend_schema(
tags=["Access Control"],
request=FeatureAuthorizationRequestSerializer,
responses={200: code_response("FarmFeatureAuthorizationResponse")},
)
def post(self, request, farm_uuid):
serializer = FeatureAuthorizationRequestSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
try:
return FarmHub.objects.prefetch_related("products", "sensors", "sensors__sensor_catalog").select_related(
"farm_type",
"subscription_plan",
farm = FarmHub.objects.select_related("subscription_plan", "farm_type").prefetch_related(
"products",
"sensors",
"sensors__sensor_catalog",
).get(
farm_uuid=farm_uuid,
owner=request.user,
)
except FarmHub.DoesNotExist:
return None
class SubscriptionPlanListView(AccessControlBaseView):
@extend_schema(
tags=["Access Control"],
responses={200: code_response("SubscriptionPlanListResponse", data=SubscriptionPlanSerializer(many=True))},
)
def get(self, request):
plans = SubscriptionPlan.objects.filter(is_active=True).order_by("name")
data = SubscriptionPlanSerializer(plans, many=True).data
return Response({"code": 200, "msg": "success", "data": data}, status=status.HTTP_200_OK)
class FarmAccessProfileView(AccessControlBaseView):
@extend_schema(
tags=["Access Control"],
responses={
200: code_response("FarmAccessProfileResponse", data=FarmAccessProfileSerializer()),
404: code_response("FarmAccessProfileNotFoundResponse"),
},
)
def get(self, request, farm_uuid):
farm = self._get_farm(request, farm_uuid)
if farm is None:
return Response({"code": 404, "msg": "Farm not found."}, status=status.HTTP_404_NOT_FOUND)
data = build_farm_access_profile_response(farm)
return Response({"code": 200, "msg": "success", "data": data}, status=status.HTTP_200_OK)
try:
opa_result = request_opa_batch_authorization(
farm=farm,
user=request.user,
features=serializer.validated_data["features"],
action=serializer.validated_data["action"],
)
except AccessControlServiceUnavailable as exc:
return Response(
{"code": 503, "msg": str(exc)},
status=status.HTTP_503_SERVICE_UNAVAILABLE,
)
return Response(
{
"code": 200,
"msg": "success",
"data": {
"farm_uuid": str(farm.farm_uuid),
"user": {
"id": request.user.id,
"username": request.user.username,
"email": request.user.email,
"phone_number": getattr(request.user, "phone_number", ""),
},
"features": serializer.validated_data["features"],
"action": serializer.validated_data["action"],
"decision": opa_result,
},
},
status=status.HTTP_200_OK,
)