diff --git a/api/src/backend/api/migrations/0048_lighthouseproviderconfiguration_and_more.py b/api/src/backend/api/migrations/0048_lighthouseproviderconfiguration_and_more.py new file mode 100644 index 0000000000..a43357cebd --- /dev/null +++ b/api/src/backend/api/migrations/0048_lighthouseproviderconfiguration_and_more.py @@ -0,0 +1,273 @@ +# Generated by Django 5.1.12 on 2025-09-29 15:52 + +import json +import uuid + +import django.db.models.deletion +from django.db import migrations, models + +import api.rls + + +class Migration(migrations.Migration): + + dependencies = [ + ("api", "0047_remove_integration_unique_configuration_per_tenant"), + ] + + operations = [ + migrations.CreateModel( + name="LighthouseProviderConfiguration", + fields=[ + ( + "id", + models.UUIDField( + default=uuid.uuid4, + editable=False, + primary_key=True, + serialize=False, + ), + ), + ("inserted_at", models.DateTimeField(auto_now_add=True)), + ("updated_at", models.DateTimeField(auto_now=True)), + ( + "provider_type", + models.CharField( + choices=[("openai", "OpenAI")], + help_text="LLM provider name", + max_length=50, + ), + ), + ("base_url", models.URLField(blank=True, null=True)), + ( + "credentials", + models.BinaryField( + help_text="Encrypted JSON credentials for the provider" + ), + ), + ("is_active", models.BooleanField(default=True)), + ( + "tenant", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, to="api.tenant" + ), + ), + ], + options={ + "db_table": "lighthouse_provider_configurations", + "abstract": False, + }, + ), + migrations.CreateModel( + name="LighthouseProviderModels", + fields=[ + ( + "id", + models.UUIDField( + default=uuid.uuid4, + editable=False, + primary_key=True, + serialize=False, + ), + ), + ("inserted_at", models.DateTimeField(auto_now_add=True)), + ("updated_at", models.DateTimeField(auto_now=True)), + ("model_id", models.CharField(max_length=100)), + ("default_parameters", models.JSONField(blank=True, default=dict)), + ( + "provider_configuration", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="available_models", + to="api.lighthouseproviderconfiguration", + ), + ), + ( + "tenant", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, to="api.tenant" + ), + ), + ], + options={ + "db_table": "lighthouse_provider_models", + "abstract": False, + }, + ), + migrations.CreateModel( + name="LighthouseTenantConfiguration", + fields=[ + ( + "id", + models.UUIDField( + default=uuid.uuid4, + editable=False, + primary_key=True, + serialize=False, + ), + ), + ("inserted_at", models.DateTimeField(auto_now_add=True)), + ("updated_at", models.DateTimeField(auto_now=True)), + ("business_context", models.TextField(blank=True, default="")), + ("default_provider", models.CharField(blank=True, max_length=50)), + ("default_models", models.JSONField(default=dict)), + ( + "tenant", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, to="api.tenant" + ), + ), + ], + options={ + "db_table": "lighthouse_tenant_config", + "abstract": False, + }, + ), + migrations.AddIndex( + model_name="lighthouseproviderconfiguration", + index=models.Index( + fields=["tenant_id", "provider_type"], name="lh_pc_tenant_type_idx" + ), + ), + migrations.AddConstraint( + model_name="lighthouseproviderconfiguration", + constraint=api.rls.RowLevelSecurityConstraint( + "tenant_id", name="rls_on_lighthouseproviderconfiguration" + ), + ), + migrations.AddConstraint( + model_name="lighthouseproviderconfiguration", + constraint=models.UniqueConstraint( + fields=("tenant_id", "provider_type"), + name="unique_provider_config_per_tenant", + ), + ), + migrations.AddIndex( + model_name="lighthouseprovidermodels", + index=models.Index( + fields=["tenant_id", "provider_configuration"], + name="lh_prov_models_cfg_idx", + ), + ), + migrations.AddConstraint( + model_name="lighthouseprovidermodels", + constraint=api.rls.RowLevelSecurityConstraint( + "tenant_id", name="rls_on_lighthouseprovidermodels" + ), + ), + migrations.AddConstraint( + model_name="lighthouseprovidermodels", + constraint=models.UniqueConstraint( + fields=("tenant_id", "provider_configuration", "model_id"), + name="unique_provider_model_per_configuration", + ), + ), + migrations.AddConstraint( + model_name="lighthousetenantconfiguration", + constraint=api.rls.RowLevelSecurityConstraint( + "tenant_id", name="rls_on_lighthousetenantconfiguration" + ), + ), + migrations.AddConstraint( + model_name="lighthousetenantconfiguration", + constraint=models.UniqueConstraint( + fields=("tenant_id",), name="unique_tenant_lighthouse_config" + ), + ), + # Data backfill from the previous single-table lighthouse configuration to the new schema + migrations.RunPython( + code=lambda apps, schema_editor: _copy_lighthouse_config_to_new_models( + apps, schema_editor + ), + reverse_code=migrations.RunPython.noop, + ), + ] + + +def _copy_lighthouse_config_to_new_models(apps, schema_editor): + """Idempotent backfill from old LighthouseConfiguration to new models. + + - No-op if old table doesn't exist or has no rows + - Per-tenant RLS context is set before inserts + - Only OpenAI provider is handled for now (future providers can extend) + """ + # Ensure the old table exists before attempting to read + introspection = schema_editor.connection.introspection + if "lighthouse_configurations" not in introspection.table_names(): + return + + # Historical models + OldConfig = apps.get_model("api", "LighthouseConfiguration") + ProviderCfg = apps.get_model("api", "LighthouseProviderConfiguration") + TenantCfg = apps.get_model("api", "LighthouseTenantConfiguration") + + from django.conf import settings + + try: + from cryptography.fernet import Fernet, InvalidToken + except Exception: + # If cryptography isn't available, skip credentials backfill (schema remains valid) + Fernet = None # type: ignore + InvalidToken = Exception # type: ignore + + fernet = None + if getattr(settings, "SECRETS_ENCRYPTION_KEY", None): + try: + fernet = ( + Fernet(settings.SECRETS_ENCRYPTION_KEY.encode()) if Fernet else None + ) + except Exception: + fernet = None + + SET_CONFIG_QUERY = "SELECT set_config(%s, %s::text, TRUE);" + + # Iterate old configs (expected one per tenant) + with schema_editor.connection.cursor() as cursor: + for old in OldConfig.objects.all().only( + "tenant_id", + "business_context", + "model", + "api_key", + "is_active", + "temperature", + "max_tokens", + ): + # Set RLS context for tenant + cursor.execute(SET_CONFIG_QUERY, ["api.tenant_id", str(old.tenant_id)]) + + # Create or get tenant-level settings + if not TenantCfg.objects.filter(tenant_id=old.tenant_id).exists(): + TenantCfg.objects.create( + tenant_id=old.tenant_id, + business_context=old.business_context or "", + default_provider="openai", + default_models={"openai": old.model}, + ) + + # Create or get provider configuration for OpenAI + provider_cfg = ProviderCfg.objects.filter( + tenant_id=old.tenant_id, provider_type="openai" + ).first() + + if provider_cfg is None: + enc_credentials = None + # Best-effort decrypt old api_key and re-encrypt as JSON credentials + if fernet and getattr(old, "api_key", None): + try: + plaintext = fernet.decrypt(bytes(old.api_key)).decode() + creds_json = json.dumps({"api_key": plaintext}).encode() + enc_credentials = fernet.encrypt(creds_json) + except InvalidToken: + enc_credentials = None + except Exception: + enc_credentials = None + + # Only create provider configuration if we have credentials + if enc_credentials is not None: + provider_cfg = ProviderCfg.objects.create( + tenant_id=old.tenant_id, + provider_type="openai", + base_url=None, + credentials=enc_credentials, + is_active=bool(getattr(old, "is_active", True)), + )