UPDATE
This commit is contained in:
@@ -21,6 +21,10 @@ USE_EXTERNAL_API_MOCK=true
|
|||||||
# External API adapter
|
# External API adapter
|
||||||
USE_EXTERNAL_API_MOCK=true
|
USE_EXTERNAL_API_MOCK=true
|
||||||
EXTERNAL_API_TIMEOUT=30
|
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_BASE_URL=https://ai.example.com
|
||||||
AI_SERVICE_API_KEY=
|
AI_SERVICE_API_KEY=
|
||||||
|
|||||||
@@ -1 +0,0 @@
|
|||||||
|
|
||||||
|
|||||||
@@ -4,4 +4,3 @@ from django.apps import AppConfig
|
|||||||
class AccessControlConfig(AppConfig):
|
class AccessControlConfig(AppConfig):
|
||||||
default_auto_field = "django.db.models.BigAutoField"
|
default_auto_field = "django.db.models.BigAutoField"
|
||||||
name = "access_control"
|
name = "access_control"
|
||||||
|
|
||||||
|
|||||||
@@ -1,71 +1 @@
|
|||||||
GOLD_PLAN_CODE = "gold"
|
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]},
|
|
||||||
},
|
|
||||||
]
|
|
||||||
|
|||||||
@@ -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 django.db.models.deletion
|
||||||
import uuid
|
import uuid
|
||||||
from django.db import migrations, models
|
|
||||||
|
|
||||||
|
|
||||||
class Migration(migrations.Migration):
|
class Migration(migrations.Migration):
|
||||||
@@ -10,7 +8,7 @@ class Migration(migrations.Migration):
|
|||||||
|
|
||||||
dependencies = [
|
dependencies = [
|
||||||
("farm_hub", "0006_seed_expanded_product_catalog"),
|
("farm_hub", "0006_seed_expanded_product_catalog"),
|
||||||
("sensor_catalog", "0002_sensorcatalog_supported_power_sources"),
|
("sensor_catalog", "0003_sensorcatalog_code"),
|
||||||
]
|
]
|
||||||
|
|
||||||
operations = [
|
operations = [
|
||||||
@@ -19,91 +17,53 @@ class Migration(migrations.Migration):
|
|||||||
fields=[
|
fields=[
|
||||||
("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
|
("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)),
|
("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)),
|
("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="")),
|
("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)),
|
("default_enabled", models.BooleanField(default=False)),
|
||||||
("metadata", models.JSONField(blank=True, default=dict)),
|
("metadata", models.JSONField(blank=True, default=dict)),
|
||||||
|
("is_active", models.BooleanField(default=True)),
|
||||||
("created_at", models.DateTimeField(auto_now_add=True)),
|
("created_at", models.DateTimeField(auto_now_add=True)),
|
||||||
("updated_at", models.DateTimeField(auto_now=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(
|
migrations.CreateModel(
|
||||||
name="SubscriptionPlan",
|
name="SubscriptionPlan",
|
||||||
fields=[
|
fields=[
|
||||||
("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
|
("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)),
|
("uuid", models.UUIDField(db_index=True, default=uuid.uuid4, editable=False, unique=True)),
|
||||||
("code", models.SlugField(db_index=True, max_length=64, unique=True)),
|
("code", models.CharField(db_index=True, max_length=100, unique=True)),
|
||||||
("name", models.CharField(db_index=True, max_length=255, unique=True)),
|
("name", models.CharField(max_length=255)),
|
||||||
("description", models.TextField(blank=True, default="")),
|
("description", models.TextField(blank=True, default="")),
|
||||||
("metadata", models.JSONField(blank=True, default=dict)),
|
("metadata", models.JSONField(blank=True, default=dict)),
|
||||||
("is_active", models.BooleanField(default=True)),
|
("is_active", models.BooleanField(default=True)),
|
||||||
("created_at", models.DateTimeField(auto_now_add=True)),
|
("created_at", models.DateTimeField(auto_now_add=True)),
|
||||||
("updated_at", models.DateTimeField(auto_now=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(
|
migrations.CreateModel(
|
||||||
name="AccessRule",
|
name="AccessRule",
|
||||||
fields=[
|
fields=[
|
||||||
("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
|
("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)),
|
("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)),
|
("name", models.CharField(max_length=255)),
|
||||||
("description", models.TextField(blank=True, default="")),
|
("description", models.TextField(blank=True, default="")),
|
||||||
("effect", models.CharField(choices=[("allow", "Allow"), ("deny", "Deny")], default="allow", max_length=16)),
|
|
||||||
("priority", models.PositiveIntegerField(default=100)),
|
("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)),
|
("metadata", models.JSONField(blank=True, default=dict)),
|
||||||
|
("is_active", models.BooleanField(default=True)),
|
||||||
("created_at", models.DateTimeField(auto_now_add=True)),
|
("created_at", models.DateTimeField(auto_now_add=True)),
|
||||||
("updated_at", models.DateTimeField(auto_now=True)),
|
("updated_at", models.DateTimeField(auto_now=True)),
|
||||||
("farm_types", models.ManyToManyField(blank=True, related_name="access_rules", to="farm_hub.farmtype")),
|
("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")),
|
("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")),
|
("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")),
|
||||||
"sensor_catalogs",
|
("subscription_plans", models.ManyToManyField(blank=True, related_name="access_rules", to="access_control.subscriptionplan")),
|
||||||
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"]},
|
options={"db_table": "access_rules", "ordering": ["priority", "name"]},
|
||||||
),
|
|
||||||
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"},
|
|
||||||
),
|
),
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
# Generated by Django 5.2.12 on 2026-03-19 16:41
|
from django.db import migrations, models
|
||||||
|
import django.db.models.deletion
|
||||||
from django.db import migrations
|
|
||||||
|
|
||||||
|
|
||||||
class Migration(migrations.Migration):
|
class Migration(migrations.Migration):
|
||||||
@@ -9,4 +8,18 @@ class Migration(migrations.Migration):
|
|||||||
("farm_hub", "0006_seed_expanded_product_catalog"),
|
("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
|
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):
|
class Migration(migrations.Migration):
|
||||||
dependencies = [
|
dependencies = [
|
||||||
("access_control", "0002_link_subscription_plan_to_farm"),
|
("access_control", "0002_link_subscription_plan_to_farm"),
|
||||||
("sensor_catalog", "0002_sensorcatalog_supported_power_sources"),
|
|
||||||
]
|
]
|
||||||
|
|
||||||
operations = [
|
operations = []
|
||||||
migrations.RunPython(seed_default_access_rules, unseed_default_access_rules),
|
|
||||||
]
|
|
||||||
|
|||||||
@@ -1,20 +1,9 @@
|
|||||||
from django.db import migrations
|
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):
|
class Migration(migrations.Migration):
|
||||||
dependencies = [
|
dependencies = [
|
||||||
("access_control", "0003_seed_default_access_rules"),
|
("access_control", "0003_seed_default_access_rules"),
|
||||||
]
|
]
|
||||||
|
|
||||||
operations = [
|
operations = []
|
||||||
migrations.RunPython(enable_default_feature_access, migrations.RunPython.noop),
|
|
||||||
]
|
|
||||||
|
|||||||
@@ -1,26 +1,10 @@
|
|||||||
from django.db import migrations
|
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):
|
class Migration(migrations.Migration):
|
||||||
dependencies = [
|
dependencies = [
|
||||||
("access_control", "0004_enable_default_feature_access"),
|
("access_control", "0004_enable_default_feature_access"),
|
||||||
("farm_hub", "0007_farmhub_subscription_plan"),
|
("farm_hub", "0007_farmhub_subscription_plan"),
|
||||||
]
|
]
|
||||||
|
|
||||||
operations = [
|
operations = []
|
||||||
migrations.RunPython(backfill_farm_subscription_plans, migrations.RunPython.noop),
|
|
||||||
]
|
|
||||||
|
|||||||
@@ -1 +0,0 @@
|
|||||||
|
|
||||||
|
|||||||
+26
-30
@@ -5,8 +5,8 @@ from django.db import models
|
|||||||
|
|
||||||
class SubscriptionPlan(models.Model):
|
class SubscriptionPlan(models.Model):
|
||||||
uuid = models.UUIDField(default=uuid_lib.uuid4, unique=True, editable=False, db_index=True)
|
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)
|
code = models.CharField(max_length=100, unique=True, db_index=True)
|
||||||
name = models.CharField(max_length=255, unique=True, db_index=True)
|
name = models.CharField(max_length=255)
|
||||||
description = models.TextField(blank=True, default="")
|
description = models.TextField(blank=True, default="")
|
||||||
metadata = models.JSONField(default=dict, blank=True)
|
metadata = models.JSONField(default=dict, blank=True)
|
||||||
is_active = models.BooleanField(default=True)
|
is_active = models.BooleanField(default=True)
|
||||||
@@ -14,7 +14,7 @@ class SubscriptionPlan(models.Model):
|
|||||||
updated_at = models.DateTimeField(auto_now=True)
|
updated_at = models.DateTimeField(auto_now=True)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
db_table = "subscription_plans"
|
db_table = "access_subscription_plans"
|
||||||
ordering = ["name"]
|
ordering = ["name"]
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
@@ -23,29 +23,28 @@ class SubscriptionPlan(models.Model):
|
|||||||
|
|
||||||
class AccessFeature(models.Model):
|
class AccessFeature(models.Model):
|
||||||
PAGE = "page"
|
PAGE = "page"
|
||||||
ACTION = "action"
|
|
||||||
WIDGET = "widget"
|
WIDGET = "widget"
|
||||||
SECTION = "section"
|
ACTION = "action"
|
||||||
FEATURE_TYPES = [
|
FEATURE_TYPES = [
|
||||||
(PAGE, "Page"),
|
(PAGE, "Page"),
|
||||||
(ACTION, "Action"),
|
|
||||||
(WIDGET, "Widget"),
|
(WIDGET, "Widget"),
|
||||||
(SECTION, "Section"),
|
(ACTION, "Action"),
|
||||||
]
|
]
|
||||||
|
|
||||||
uuid = models.UUIDField(default=uuid_lib.uuid4, unique=True, editable=False, db_index=True)
|
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)
|
name = models.CharField(max_length=255)
|
||||||
feature_type = models.CharField(max_length=32, choices=FEATURE_TYPES, default=PAGE)
|
|
||||||
description = models.TextField(blank=True, default="")
|
description = models.TextField(blank=True, default="")
|
||||||
|
feature_type = models.CharField(max_length=32, choices=FEATURE_TYPES, default=PAGE)
|
||||||
default_enabled = models.BooleanField(default=False)
|
default_enabled = models.BooleanField(default=False)
|
||||||
metadata = models.JSONField(default=dict, blank=True)
|
metadata = models.JSONField(default=dict, blank=True)
|
||||||
|
is_active = models.BooleanField(default=True)
|
||||||
created_at = models.DateTimeField(auto_now_add=True)
|
created_at = models.DateTimeField(auto_now_add=True)
|
||||||
updated_at = models.DateTimeField(auto_now=True)
|
updated_at = models.DateTimeField(auto_now=True)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
db_table = "access_features"
|
db_table = "access_features"
|
||||||
ordering = ["feature_type", "code"]
|
ordering = ["name"]
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return self.code
|
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)
|
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)
|
name = models.CharField(max_length=255)
|
||||||
description = models.TextField(blank=True, default="")
|
description = models.TextField(blank=True, default="")
|
||||||
effect = models.CharField(max_length=16, choices=EFFECTS, default=ALLOW)
|
|
||||||
priority = models.PositiveIntegerField(default=100)
|
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)
|
metadata = models.JSONField(default=dict, blank=True)
|
||||||
features = models.ManyToManyField(AccessFeature, related_name="rules", blank=True)
|
is_active = models.BooleanField(default=True)
|
||||||
subscription_plans = models.ManyToManyField(SubscriptionPlan, related_name="access_rules", blank=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)
|
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)
|
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)
|
sensor_catalogs = models.ManyToManyField("sensor_catalog.SensorCatalog", related_name="access_rules", blank=True)
|
||||||
@@ -77,32 +76,29 @@ class AccessRule(models.Model):
|
|||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
db_table = "access_rules"
|
db_table = "access_rules"
|
||||||
ordering = ["priority", "code"]
|
ordering = ["priority", "name"]
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return self.code
|
return self.code
|
||||||
|
|
||||||
|
|
||||||
class FarmAccessProfile(models.Model):
|
class FarmAccessProfile(models.Model):
|
||||||
farm = models.OneToOneField(
|
farm = models.OneToOneField("farm_hub.FarmHub", on_delete=models.CASCADE, related_name="access_profile")
|
||||||
"farm_hub.FarmHub",
|
subscription_plan = models.ForeignKey(
|
||||||
to_field="farm_uuid",
|
"SubscriptionPlan",
|
||||||
db_column="farm_uuid",
|
on_delete=models.SET_NULL,
|
||||||
on_delete=models.CASCADE,
|
related_name="farm_access_profiles",
|
||||||
related_name="access_profile",
|
null=True,
|
||||||
primary_key=True,
|
blank=True,
|
||||||
)
|
)
|
||||||
cached_features = models.JSONField(default=dict, blank=True)
|
profile_data = models.JSONField(default=dict, blank=True)
|
||||||
cached_groups = models.JSONField(default=dict, blank=True)
|
resolved_from_profile = models.BooleanField(default=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)
|
|
||||||
created_at = models.DateTimeField(auto_now_add=True)
|
created_at = models.DateTimeField(auto_now_add=True)
|
||||||
updated_at = models.DateTimeField(auto_now=True)
|
updated_at = models.DateTimeField(auto_now=True)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
db_table = "farm_access_profiles"
|
db_table = "farm_access_profiles"
|
||||||
|
ordering = ["-updated_at"]
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return str(self.farm_id)
|
return f"Access profile for {self.farm_id}"
|
||||||
|
|
||||||
|
|||||||
@@ -1,60 +1,43 @@
|
|||||||
from django.core.exceptions import ValidationError as DjangoValidationError
|
|
||||||
from rest_framework.permissions import BasePermission
|
from rest_framework.permissions import BasePermission
|
||||||
|
|
||||||
from farm_hub.models import FarmHub
|
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):
|
class FeatureAccessPermission(BasePermission):
|
||||||
message = "You do not have access to this API."
|
message = "Access denied."
|
||||||
|
|
||||||
def has_permission(self, request, view):
|
def has_permission(self, request, view):
|
||||||
feature_code = getattr(view, "required_feature_code", None)
|
feature_code = getattr(view, "required_feature_code", None)
|
||||||
if not feature_code:
|
if not feature_code:
|
||||||
return True
|
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:
|
if not farm_uuid:
|
||||||
return True
|
|
||||||
|
|
||||||
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."
|
self.message = f"Access to feature `{feature_code}` is denied."
|
||||||
return False
|
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:
|
try:
|
||||||
return (
|
farm = FarmHub.objects.select_related("farm_type", "subscription_plan").prefetch_related(
|
||||||
FarmHub.objects.select_related("access_profile")
|
"products",
|
||||||
.prefetch_related("products", "sensors")
|
"sensors",
|
||||||
.filter(farm_uuid=farm_uuid, owner=request.user)
|
"sensors__sensor_catalog",
|
||||||
.first()
|
).get(farm_uuid=farm_uuid, owner=request.user)
|
||||||
)
|
except FarmHub.DoesNotExist:
|
||||||
except (ValueError, TypeError, DjangoValidationError):
|
self.message = f"Access to feature `{feature_code}` is denied."
|
||||||
return None
|
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
|
||||||
|
|||||||
@@ -1,31 +1,17 @@
|
|||||||
from rest_framework import serializers
|
from rest_framework import serializers
|
||||||
|
|
||||||
from .models import FarmAccessProfile, SubscriptionPlan
|
from .models import SubscriptionPlan
|
||||||
|
|
||||||
|
|
||||||
class SubscriptionPlanSerializer(serializers.ModelSerializer):
|
class SubscriptionPlanSerializer(serializers.ModelSerializer):
|
||||||
class Meta:
|
class Meta:
|
||||||
model = SubscriptionPlan
|
model = SubscriptionPlan
|
||||||
fields = ["uuid", "code", "name", "description", "metadata", "is_active"]
|
fields = ["uuid", "code", "name"]
|
||||||
|
|
||||||
|
|
||||||
class FarmAccessProfileSerializer(serializers.Serializer):
|
class FeatureAuthorizationRequestSerializer(serializers.Serializer):
|
||||||
farm_uuid = serializers.UUIDField()
|
features = serializers.ListField(
|
||||||
subscription_plan = SubscriptionPlanSerializer(allow_null=True)
|
child=serializers.CharField(),
|
||||||
matched_rules = serializers.ListField()
|
allow_empty=False,
|
||||||
resolved_from_profile = serializers.BooleanField()
|
)
|
||||||
|
action = serializers.CharField(required=False, allow_blank=False, default="view")
|
||||||
|
|
||||||
class FarmAccessProfileCacheSerializer(serializers.ModelSerializer):
|
|
||||||
class Meta:
|
|
||||||
model = FarmAccessProfile
|
|
||||||
fields = [
|
|
||||||
"farm",
|
|
||||||
"cached_features",
|
|
||||||
"cached_groups",
|
|
||||||
"matched_rules",
|
|
||||||
"metadata",
|
|
||||||
"last_resolved_at",
|
|
||||||
"created_at",
|
|
||||||
"updated_at",
|
|
||||||
]
|
|
||||||
|
|||||||
+204
-108
@@ -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 .catalog import GOLD_PLAN_CODE
|
||||||
from .models import AccessFeature, AccessRule, FarmAccessProfile, SubscriptionPlan
|
from .models import AccessFeature, AccessRule, FarmAccessProfile, SubscriptionPlan
|
||||||
|
|
||||||
|
|
||||||
def _manager_id_set(manager):
|
class AccessControlError(Exception):
|
||||||
return {obj.id for obj in manager.all()}
|
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):
|
def get_effective_subscription_plan(farm):
|
||||||
if getattr(farm, "subscription_plan_id", None):
|
if farm.subscription_plan_id:
|
||||||
return farm.subscription_plan
|
return farm.subscription_plan
|
||||||
|
|
||||||
return (
|
default_plan = get_default_subscription_plan()
|
||||||
SubscriptionPlan.objects.filter(is_active=True, metadata__is_default=True).order_by("name").first()
|
if default_plan is not None:
|
||||||
or SubscriptionPlan.objects.filter(code=GOLD_PLAN_CODE, is_active=True).first()
|
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:
|
if not rule.is_active:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
subscription_plan_ids = _manager_id_set(rule.subscription_plans)
|
if rule.subscription_plans.exists() and (subscription_plan is None or not rule.subscription_plans.filter(pk=subscription_plan.pk).exists()):
|
||||||
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
|
return False
|
||||||
|
|
||||||
farm_type_ids = _manager_id_set(rule.farm_types)
|
if rule.farm_types.exists() and not rule.farm_types.filter(pk=farm.farm_type_id).exists():
|
||||||
if farm_type_ids and farm.farm_type_id not in farm_type_ids:
|
|
||||||
return False
|
return False
|
||||||
|
|
||||||
product_rule_ids = _manager_id_set(rule.products)
|
if rule.products.exists() and not rule.products.filter(pk__in=product_ids).exists():
|
||||||
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
|
return False
|
||||||
|
|
||||||
sensor_catalog_rule_ids = _manager_id_set(rule.sensor_catalogs)
|
if rule.sensor_catalogs.exists() and not rule.sensor_catalogs.filter(pk__in=sensor_catalog_ids).exists():
|
||||||
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
|
return False
|
||||||
|
|
||||||
sensor_catalog_rule_codes = set(rule.metadata.get("sensor_catalog_codes", [])) if isinstance(rule.metadata, dict) else set()
|
metadata_sensor_codes = rule.metadata.get("sensor_catalog_codes", [])
|
||||||
if sensor_catalog_rule_codes:
|
if metadata_sensor_codes and not set(metadata_sensor_codes).intersection(sensor_catalog_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
|
|
||||||
|
|
||||||
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
|
return False
|
||||||
|
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
|
||||||
def build_farm_access_profile(farm):
|
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)
|
subscription_plan = get_effective_subscription_plan(farm)
|
||||||
features = AccessFeature.objects.all().order_by("feature_type", "code")
|
product_ids = list(farm.products.values_list("id", flat=True))
|
||||||
resolved = {
|
sensor_catalog_ids = list(
|
||||||
feature.code: {
|
farm.sensors.exclude(sensor_catalog__isnull=True).values_list("sensor_catalog_id", flat=True)
|
||||||
"enabled": feature.default_enabled,
|
)
|
||||||
"type": feature.feature_type,
|
sensor_catalog_codes = set(
|
||||||
"name": feature.name,
|
farm.sensors.exclude(sensor_catalog__isnull=True).values_list("sensor_catalog__code", flat=True)
|
||||||
"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")
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
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 = []
|
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:
|
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
|
continue
|
||||||
|
|
||||||
matched_rules.append(
|
matched_rules.append(
|
||||||
@@ -105,66 +114,153 @@ def build_farm_access_profile(farm):
|
|||||||
"priority": rule.priority,
|
"priority": rule.priority,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
is_enabled = rule.effect == AccessRule.ALLOW
|
|
||||||
for feature in rule.features.all():
|
for feature in rule.features.all():
|
||||||
resolved[feature.code] = {
|
feature_state = features.setdefault(
|
||||||
"enabled": is_enabled,
|
feature.code,
|
||||||
"type": feature.feature_type,
|
{
|
||||||
"name": feature.name,
|
"name": feature.name,
|
||||||
"description": feature.description,
|
"type": feature.feature_type,
|
||||||
"metadata": feature.metadata,
|
"enabled": feature.default_enabled,
|
||||||
"source": rule.code,
|
"source": "default" if feature.default_enabled else None,
|
||||||
}
|
|
||||||
|
|
||||||
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(),
|
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
feature_state["enabled"] = rule.effect == AccessRule.ALLOW
|
||||||
|
feature_state["source"] = rule.code
|
||||||
|
|
||||||
return {
|
profile = {
|
||||||
"farm_uuid": str(farm.farm_uuid),
|
"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),
|
"uuid": str(subscription_plan.uuid),
|
||||||
"code": subscription_plan.code,
|
"code": subscription_plan.code,
|
||||||
"name": subscription_plan.name,
|
"name": subscription_plan.name,
|
||||||
}
|
}
|
||||||
if subscription_plan is not None
|
|
||||||
else None,
|
FarmAccessProfile.objects.update_or_create(
|
||||||
"features": profile.cached_features,
|
farm=farm,
|
||||||
"groups": profile.cached_groups,
|
defaults={
|
||||||
"matched_rules": profile.matched_rules,
|
"subscription_plan": subscription_plan,
|
||||||
|
"profile_data": profile,
|
||||||
"resolved_from_profile": True,
|
"resolved_from_profile": True,
|
||||||
}
|
},
|
||||||
|
)
|
||||||
|
return profile
|
||||||
|
|
||||||
|
|
||||||
def build_farm_access_profile_response(farm):
|
def build_opa_resource(farm):
|
||||||
profile_data = build_farm_access_profile(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 {
|
return {
|
||||||
"farm_uuid": profile_data["farm_uuid"],
|
"farm_id": str(farm.farm_uuid),
|
||||||
"subscription_plan": profile_data["subscription_plan"],
|
"subscription_plan_codes": [subscription_plan.code] if subscription_plan else [],
|
||||||
"matched_rules": profile_data["matched_rules"],
|
"farm_types": [farm.farm_type.name] if farm.farm_type_id else [],
|
||||||
"resolved_from_profile": profile_data["resolved_from_profile"],
|
"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):
|
def build_opa_user(user):
|
||||||
profile = getattr(farm, "access_profile", None)
|
return {
|
||||||
if profile and isinstance(profile.cached_features, dict):
|
"id": getattr(user, "id", None),
|
||||||
feature_payload = profile.cached_features.get(feature_code)
|
"username": getattr(user, "username", ""),
|
||||||
if feature_payload is not None:
|
"email": getattr(user, "email", ""),
|
||||||
return bool(feature_payload.get("enabled"))
|
"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)
|
def get_authorization_action(method):
|
||||||
if feature_payload is None:
|
return ACTION_MAP.get(method.upper(), "view")
|
||||||
return False
|
|
||||||
return bool(feature_payload.get("enabled"))
|
|
||||||
|
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 {}
|
||||||
|
|||||||
@@ -1,10 +1,7 @@
|
|||||||
from django.urls import path
|
from django.urls import path
|
||||||
|
|
||||||
from .views import FarmAccessProfileView, SubscriptionPlanListView
|
from .views import FarmFeatureAuthorizationView
|
||||||
|
|
||||||
|
|
||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
path("subscription-plans/", SubscriptionPlanListView.as_view(), name="subscription-plan-list"),
|
path("farms/<uuid:farm_uuid>/authorize/", FarmFeatureAuthorizationView.as_view(), name="farm-feature-authorization"),
|
||||||
path("farms/<uuid:farm_uuid>/profile/", FarmAccessProfileView.as_view(), name="farm-access-profile"),
|
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
+48
-35
@@ -7,50 +7,63 @@ from drf_spectacular.utils import extend_schema
|
|||||||
from config.swagger import code_response
|
from config.swagger import code_response
|
||||||
from farm_hub.models import FarmHub
|
from farm_hub.models import FarmHub
|
||||||
|
|
||||||
from .models import SubscriptionPlan
|
from .serializers import FeatureAuthorizationRequestSerializer
|
||||||
from .serializers import FarmAccessProfileSerializer, SubscriptionPlanSerializer
|
from .services import AccessControlServiceUnavailable, request_opa_batch_authorization
|
||||||
from .services import build_farm_access_profile_response
|
|
||||||
|
|
||||||
|
|
||||||
class AccessControlBaseView(APIView):
|
class FarmFeatureAuthorizationView(APIView):
|
||||||
permission_classes = [IsAuthenticated]
|
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:
|
try:
|
||||||
return FarmHub.objects.prefetch_related("products", "sensors", "sensors__sensor_catalog").select_related(
|
farm = FarmHub.objects.select_related("subscription_plan", "farm_type").prefetch_related(
|
||||||
"farm_type",
|
"products",
|
||||||
"subscription_plan",
|
"sensors",
|
||||||
|
"sensors__sensor_catalog",
|
||||||
).get(
|
).get(
|
||||||
farm_uuid=farm_uuid,
|
farm_uuid=farm_uuid,
|
||||||
owner=request.user,
|
owner=request.user,
|
||||||
)
|
)
|
||||||
except FarmHub.DoesNotExist:
|
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)
|
return Response({"code": 404, "msg": "Farm not found."}, status=status.HTTP_404_NOT_FOUND)
|
||||||
|
|
||||||
data = build_farm_access_profile_response(farm)
|
try:
|
||||||
return Response({"code": 200, "msg": "success", "data": data}, status=status.HTTP_200_OK)
|
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,
|
||||||
|
)
|
||||||
|
|||||||
@@ -159,6 +159,14 @@ CORS_ALLOW_ALL_ORIGINS = DEBUG
|
|||||||
USE_EXTERNAL_API_MOCK = os.getenv("USE_EXTERNAL_API_MOCK", "false").lower() == "true"
|
USE_EXTERNAL_API_MOCK = os.getenv("USE_EXTERNAL_API_MOCK", "false").lower() == "true"
|
||||||
EXTERNAL_API_TIMEOUT = int(os.getenv("EXTERNAL_API_TIMEOUT", "30"))
|
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 = {
|
EXTERNAL_SERVICES = {
|
||||||
"ai": {
|
"ai": {
|
||||||
"base_url": os.getenv("AI_SERVICE_BASE_URL", ""),
|
"base_url": os.getenv("AI_SERVICE_BASE_URL", ""),
|
||||||
|
|||||||
@@ -33,6 +33,17 @@ services:
|
|||||||
networks:
|
networks:
|
||||||
- crop_network
|
- 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:
|
web:
|
||||||
container_name: backend-web
|
container_name: backend-web
|
||||||
build: .
|
build: .
|
||||||
@@ -46,11 +57,14 @@ services:
|
|||||||
QDRANT_HOST: ${QDRANT_HOST:-qdrant}
|
QDRANT_HOST: ${QDRANT_HOST:-qdrant}
|
||||||
QDRANT_PORT: ${QDRANT_PORT:-6333}
|
QDRANT_PORT: ${QDRANT_PORT:-6333}
|
||||||
SKIP_MIGRATE: "0"
|
SKIP_MIGRATE: "0"
|
||||||
|
ACCESS_CONTROL_AUTHZ_BASE_URL: http://croplogic-accsess-opa:8181
|
||||||
depends_on:
|
depends_on:
|
||||||
db:
|
db:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
redis:
|
redis:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
|
accsess:
|
||||||
|
condition: service_started
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
networks:
|
networks:
|
||||||
- crop_network
|
- crop_network
|
||||||
@@ -68,11 +82,14 @@ services:
|
|||||||
CELERY_RESULT_BACKEND: ${CELERY_RESULT_BACKEND:-redis://redis:6379/0}
|
CELERY_RESULT_BACKEND: ${CELERY_RESULT_BACKEND:-redis://redis:6379/0}
|
||||||
CELERY_BROKER_CONNECTION_RETRY_ON_STARTUP: "true"
|
CELERY_BROKER_CONNECTION_RETRY_ON_STARTUP: "true"
|
||||||
SKIP_MIGRATE: "1"
|
SKIP_MIGRATE: "1"
|
||||||
|
ACCESS_CONTROL_AUTHZ_BASE_URL: http://croplogic-accsess-opa:8181
|
||||||
depends_on:
|
depends_on:
|
||||||
db:
|
db:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
redis:
|
redis:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
|
accsess:
|
||||||
|
condition: service_started
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
networks:
|
networks:
|
||||||
- crop_network
|
- crop_network
|
||||||
|
|||||||
+17
-1
@@ -14,6 +14,8 @@ services:
|
|||||||
interval: 5s
|
interval: 5s
|
||||||
timeout: 5s
|
timeout: 5s
|
||||||
retries: 5
|
retries: 5
|
||||||
|
networks:
|
||||||
|
- crop_network
|
||||||
|
|
||||||
phpmyadmin:
|
phpmyadmin:
|
||||||
image: docker-mirror.liara.ir/phpmyadmin:latest
|
image: docker-mirror.liara.ir/phpmyadmin:latest
|
||||||
@@ -27,6 +29,8 @@ services:
|
|||||||
depends_on:
|
depends_on:
|
||||||
db:
|
db:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
|
networks:
|
||||||
|
- crop_network
|
||||||
|
|
||||||
redis:
|
redis:
|
||||||
image: redis:7-alpine
|
image: redis:7-alpine
|
||||||
@@ -42,7 +46,8 @@ services:
|
|||||||
- "6380:6379"
|
- "6380:6379"
|
||||||
volumes:
|
volumes:
|
||||||
- backend_redis_data:/data
|
- backend_redis_data:/data
|
||||||
|
networks:
|
||||||
|
- crop_network
|
||||||
|
|
||||||
web:
|
web:
|
||||||
build:
|
build:
|
||||||
@@ -69,12 +74,15 @@ services:
|
|||||||
QDRANT_HOST: qdrant
|
QDRANT_HOST: qdrant
|
||||||
QDRANT_PORT: 6333
|
QDRANT_PORT: 6333
|
||||||
SKIP_MIGRATE: "0"
|
SKIP_MIGRATE: "0"
|
||||||
|
ACCESS_CONTROL_AUTHZ_BASE_URL: http://croplogic-accsess-opa:8181
|
||||||
depends_on:
|
depends_on:
|
||||||
db:
|
db:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
redis:
|
redis:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
networks:
|
||||||
|
- crop_network
|
||||||
|
|
||||||
celery:
|
celery:
|
||||||
build:
|
build:
|
||||||
@@ -98,14 +106,22 @@ services:
|
|||||||
CELERY_RESULT_BACKEND: redis://redis:6379/0
|
CELERY_RESULT_BACKEND: redis://redis:6379/0
|
||||||
CELERY_BROKER_CONNECTION_RETRY_ON_STARTUP: "true"
|
CELERY_BROKER_CONNECTION_RETRY_ON_STARTUP: "true"
|
||||||
SKIP_MIGRATE: "1"
|
SKIP_MIGRATE: "1"
|
||||||
|
ACCESS_CONTROL_AUTHZ_BASE_URL: http://croplogic-accsess-opa:8181
|
||||||
depends_on:
|
depends_on:
|
||||||
db:
|
db:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
redis:
|
redis:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
networks:
|
||||||
|
- crop_network
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
backend_mysql_data:
|
backend_mysql_data:
|
||||||
backend_redis_data:
|
backend_redis_data:
|
||||||
backend_qdrant_data:
|
backend_qdrant_data:
|
||||||
|
|
||||||
|
|
||||||
|
networks:
|
||||||
|
crop_network:
|
||||||
|
external: true
|
||||||
|
|||||||
Reference in New Issue
Block a user