From 73ea9875fd4430a2450329353f9cff18fd3afad9 Mon Sep 17 00:00:00 2001 From: Mohammad Sajad Pourajam Date: Thu, 9 Apr 2026 22:48:54 +0330 Subject: [PATCH] UPDATE --- .env.example | 4 + access_control/__init__.py | 1 - access_control/apps.py | 1 - access_control/catalog.py | 70 ---- access_control/migrations/0001_initial.py | 70 +--- .../0002_link_subscription_plan_to_farm.py | 21 +- .../0003_seed_default_access_rules.py | 86 +---- .../0004_enable_default_feature_access.py | 13 +- .../0005_backfill_farm_subscription_plans.py | 18 +- access_control/migrations/__init__.py | 1 - access_control/models.py | 56 ++- access_control/permissions.py | 71 ++-- access_control/serializers.py | 30 +- access_control/services.py | 328 +++++++++++------- access_control/urls.py | 7 +- access_control/views.py | 83 +++-- config/settings.py | 8 + docker-compose-prod.yaml | 17 + docker-compose.yaml | 18 +- 19 files changed, 404 insertions(+), 499 deletions(-) diff --git a/.env.example b/.env.example index 9a71f0f..d9d0843 100644 --- a/.env.example +++ b/.env.example @@ -21,6 +21,10 @@ USE_EXTERNAL_API_MOCK=true # External API adapter USE_EXTERNAL_API_MOCK=true EXTERNAL_API_TIMEOUT=30 +ACCESS_CONTROL_AUTHZ_ENABLED=true +ACCESS_CONTROL_AUTHZ_BASE_URL=http://croplogic-accsess-opa:8181 +ACCESS_CONTROL_AUTHZ_BATCH_PATH=/v1/data/croplogic/authz/batch_decision +ACCESS_CONTROL_AUTHZ_TIMEOUT=30 AI_SERVICE_BASE_URL=https://ai.example.com AI_SERVICE_API_KEY= diff --git a/access_control/__init__.py b/access_control/__init__.py index 8b13789..e69de29 100644 --- a/access_control/__init__.py +++ b/access_control/__init__.py @@ -1 +0,0 @@ - diff --git a/access_control/apps.py b/access_control/apps.py index 484e262..19c7abc 100644 --- a/access_control/apps.py +++ b/access_control/apps.py @@ -4,4 +4,3 @@ from django.apps import AppConfig class AccessControlConfig(AppConfig): default_auto_field = "django.db.models.BigAutoField" name = "access_control" - diff --git a/access_control/catalog.py b/access_control/catalog.py index 1060faf..0d23647 100644 --- a/access_control/catalog.py +++ b/access_control/catalog.py @@ -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]}, - }, -] diff --git a/access_control/migrations/0001_initial.py b/access_control/migrations/0001_initial.py index dd84816..bffea3f 100644 --- a/access_control/migrations/0001_initial.py +++ b/access_control/migrations/0001_initial.py @@ -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"]}, ), ] - diff --git a/access_control/migrations/0002_link_subscription_plan_to_farm.py b/access_control/migrations/0002_link_subscription_plan_to_farm.py index 8d37e44..326b4ce 100644 --- a/access_control/migrations/0002_link_subscription_plan_to_farm.py +++ b/access_control/migrations/0002_link_subscription_plan_to_farm.py @@ -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"]}, + ), + ] diff --git a/access_control/migrations/0003_seed_default_access_rules.py b/access_control/migrations/0003_seed_default_access_rules.py index 52db4c0..c2137be 100644 --- a/access_control/migrations/0003_seed_default_access_rules.py +++ b/access_control/migrations/0003_seed_default_access_rules.py @@ -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 = [] diff --git a/access_control/migrations/0004_enable_default_feature_access.py b/access_control/migrations/0004_enable_default_feature_access.py index ef7eea2..aaeb6e7 100644 --- a/access_control/migrations/0004_enable_default_feature_access.py +++ b/access_control/migrations/0004_enable_default_feature_access.py @@ -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 = [] diff --git a/access_control/migrations/0005_backfill_farm_subscription_plans.py b/access_control/migrations/0005_backfill_farm_subscription_plans.py index f30c2ab..47bd529 100644 --- a/access_control/migrations/0005_backfill_farm_subscription_plans.py +++ b/access_control/migrations/0005_backfill_farm_subscription_plans.py @@ -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 = [] diff --git a/access_control/migrations/__init__.py b/access_control/migrations/__init__.py index 8b13789..e69de29 100644 --- a/access_control/migrations/__init__.py +++ b/access_control/migrations/__init__.py @@ -1 +0,0 @@ - diff --git a/access_control/models.py b/access_control/models.py index 68e5aa5..5c1f4e0 100644 --- a/access_control/models.py +++ b/access_control/models.py @@ -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}" diff --git a/access_control/permissions.py b/access_control/permissions.py index 0cb4c50..1015b0f 100644 --- a/access_control/permissions.py +++ b/access_control/permissions.py @@ -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 diff --git a/access_control/serializers.py b/access_control/serializers.py index d0dbc6f..df5052f 100644 --- a/access_control/serializers.py +++ b/access_control/serializers.py @@ -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") diff --git a/access_control/services.py b/access_control/services.py index 4e06891..358cb78 100644 --- a/access_control/services.py +++ b/access_control/services.py @@ -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 {} diff --git a/access_control/urls.py b/access_control/urls.py index 3db4847..60819b2 100644 --- a/access_control/urls.py +++ b/access_control/urls.py @@ -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//profile/", FarmAccessProfileView.as_view(), name="farm-access-profile"), + path("farms//authorize/", FarmFeatureAuthorizationView.as_view(), name="farm-feature-authorization"), ] - diff --git a/access_control/views.py b/access_control/views.py index a060c52..add96df 100644 --- a/access_control/views.py +++ b/access_control/views.py @@ -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, + ) diff --git a/config/settings.py b/config/settings.py index 3f5ede2..d3b6643 100644 --- a/config/settings.py +++ b/config/settings.py @@ -159,6 +159,14 @@ CORS_ALLOW_ALL_ORIGINS = DEBUG USE_EXTERNAL_API_MOCK = os.getenv("USE_EXTERNAL_API_MOCK", "false").lower() == "true" EXTERNAL_API_TIMEOUT = int(os.getenv("EXTERNAL_API_TIMEOUT", "30")) +ACCESS_CONTROL_AUTHZ_ENABLED = os.getenv("ACCESS_CONTROL_AUTHZ_ENABLED", "true").lower() == "true" +ACCESS_CONTROL_AUTHZ_BASE_URL = os.getenv( + "ACCESS_CONTROL_AUTHZ_BASE_URL", + "http://croplogic-accsess-opa:8181", +) +ACCESS_CONTROL_AUTHZ_BATCH_PATH = os.getenv("ACCESS_CONTROL_AUTHZ_BATCH_PATH", "/v1/data/croplogic/authz/batch_decision") +ACCESS_CONTROL_AUTHZ_TIMEOUT = int(os.getenv("ACCESS_CONTROL_AUTHZ_TIMEOUT", str(EXTERNAL_API_TIMEOUT))) + EXTERNAL_SERVICES = { "ai": { "base_url": os.getenv("AI_SERVICE_BASE_URL", ""), diff --git a/docker-compose-prod.yaml b/docker-compose-prod.yaml index 5a68f9b..80f31c0 100644 --- a/docker-compose-prod.yaml +++ b/docker-compose-prod.yaml @@ -33,6 +33,17 @@ services: networks: - crop_network + accsess: + image: openpolicyagent/opa:0.67.1-static + container_name: backend-accsess + command: ["run", "--server", "--addr=0.0.0.0:8181", "/policies/authz.rego"] + volumes: + - ../accsess/policies:/policies:ro + restart: unless-stopped + networks: + - crop_network + + web: container_name: backend-web build: . @@ -46,11 +57,14 @@ services: QDRANT_HOST: ${QDRANT_HOST:-qdrant} QDRANT_PORT: ${QDRANT_PORT:-6333} SKIP_MIGRATE: "0" + ACCESS_CONTROL_AUTHZ_BASE_URL: http://croplogic-accsess-opa:8181 depends_on: db: condition: service_healthy redis: condition: service_healthy + accsess: + condition: service_started restart: unless-stopped networks: - crop_network @@ -68,11 +82,14 @@ services: CELERY_RESULT_BACKEND: ${CELERY_RESULT_BACKEND:-redis://redis:6379/0} CELERY_BROKER_CONNECTION_RETRY_ON_STARTUP: "true" SKIP_MIGRATE: "1" + ACCESS_CONTROL_AUTHZ_BASE_URL: http://croplogic-accsess-opa:8181 depends_on: db: condition: service_healthy redis: condition: service_healthy + accsess: + condition: service_started restart: unless-stopped networks: - crop_network diff --git a/docker-compose.yaml b/docker-compose.yaml index 6ada480..2cbc988 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -14,6 +14,8 @@ services: interval: 5s timeout: 5s retries: 5 + networks: + - crop_network phpmyadmin: image: docker-mirror.liara.ir/phpmyadmin:latest @@ -27,6 +29,8 @@ services: depends_on: db: condition: service_healthy + networks: + - crop_network redis: image: redis:7-alpine @@ -42,7 +46,8 @@ services: - "6380:6379" volumes: - backend_redis_data:/data - + networks: + - crop_network web: build: @@ -69,12 +74,15 @@ services: QDRANT_HOST: qdrant QDRANT_PORT: 6333 SKIP_MIGRATE: "0" + ACCESS_CONTROL_AUTHZ_BASE_URL: http://croplogic-accsess-opa:8181 depends_on: db: condition: service_healthy redis: condition: service_healthy restart: unless-stopped + networks: + - crop_network celery: build: @@ -98,14 +106,22 @@ services: CELERY_RESULT_BACKEND: redis://redis:6379/0 CELERY_BROKER_CONNECTION_RETRY_ON_STARTUP: "true" SKIP_MIGRATE: "1" + ACCESS_CONTROL_AUTHZ_BASE_URL: http://croplogic-accsess-opa:8181 depends_on: db: condition: service_healthy redis: condition: service_healthy restart: unless-stopped + networks: + - crop_network volumes: backend_mysql_data: backend_redis_data: backend_qdrant_data: + + +networks: + crop_network: + external: true