chore: resolve conflicts

This commit is contained in:
Chandrapal Badshah
2025-09-26 17:18:05 +05:30
parent 9761651f8d
commit a02cc6364b
4 changed files with 808 additions and 0 deletions
+359
View File
@@ -1983,3 +1983,362 @@ class Processor(RowLevelSecurityProtectedModel):
class JSONAPIMeta:
resource_name = "processors"
class LighthouseProviderConfiguration(RowLevelSecurityProtectedModel):
"""
Per-tenant configuration for an LLM provider (credentials, base URL, activation).
One configuration per provider type per tenant.
"""
class ProviderChoices(models.TextChoices):
OPENAI = "openai", _("OpenAI")
id = models.UUIDField(primary_key=True, default=uuid4, editable=False)
inserted_at = models.DateTimeField(auto_now_add=True, editable=False)
updated_at = models.DateTimeField(auto_now=True, editable=False)
provider_type = models.CharField(
max_length=50,
choices=ProviderChoices.choices,
help_text="LLM provider name",
)
# For OpenAI-compatible providers
base_url = models.URLField(blank=True, null=True)
# Encrypted JSON for provider-specific auth
credentials = models.BinaryField(
help_text="Encrypted JSON credentials for the provider"
)
is_active = models.BooleanField(default=True)
def __str__(self):
return f"{self.get_provider_type_display()} ({self.tenant_id})"
def clean(self):
super().clean()
@property
def credentials_decoded(self):
if not self.credentials:
return None
try:
decrypted_data = fernet.decrypt(bytes(self.credentials))
return json.loads(decrypted_data.decode())
except (InvalidToken, json.JSONDecodeError) as e:
logger.warning("Failed to decrypt provider credentials: %s", e)
return None
except Exception as e:
logger.exception(
"Unexpected error while decrypting provider credentials: %s", e
)
return None
@credentials_decoded.setter
def credentials_decoded(self, value):
if not value:
raise ModelValidationError(
detail="Credentials are required",
code="invalid_credentials",
pointer="/data/attributes/credentials",
)
self._validate_credentials_for_provider(self.provider_type, value)
self.credentials = fernet.encrypt(json.dumps(value).encode())
def _validate_credentials_for_provider(self, provider_type: str, credentials: dict):
if provider_type == self.ProviderChoices.OPENAI:
api_key = credentials.get("api_key")
if not isinstance(api_key, str) or not api_key:
raise ModelValidationError(
detail="OpenAI credentials must include 'api_key'",
code="invalid_openai_credentials",
pointer="/data/attributes/credentials/api_key",
)
def delete(self, *args, **kwargs):
# Cleanup tenant defaults that reference this provider
try:
tenant_cfg = LighthouseTenantConfiguration.objects.get(
tenant_id=self.tenant_id
)
except LighthouseTenantConfiguration.DoesNotExist:
tenant_cfg = None
if tenant_cfg:
updated = False
defaults = tenant_cfg.default_models or {}
if self.provider_type in defaults:
defaults.pop(self.provider_type, None)
tenant_cfg.default_models = defaults
updated = True
if tenant_cfg.default_provider == self.provider_type:
tenant_cfg.default_provider = ""
updated = True
if updated:
tenant_cfg.save()
return super().delete(*args, **kwargs)
class Meta(RowLevelSecurityProtectedModel.Meta):
db_table = "lighthouse_provider_configurations"
constraints = [
RowLevelSecurityConstraint(
field="tenant_id",
name="rls_on_%(class)s",
statements=["SELECT", "INSERT", "UPDATE", "DELETE"],
),
models.UniqueConstraint(
fields=["tenant_id", "provider_type"],
name="unique_provider_config_per_tenant",
),
]
indexes = [
models.Index(
fields=["tenant_id", "provider_type"],
name="lh_pc_tenant_type_idx",
),
]
class JSONAPIMeta:
resource_name = "lighthouse-providers"
class LighthouseTenantConfiguration(RowLevelSecurityProtectedModel):
"""
Tenant-level Lighthouse settings (business context and defaults).
One record per tenant.
"""
id = models.UUIDField(primary_key=True, default=uuid4, editable=False)
inserted_at = models.DateTimeField(auto_now_add=True, editable=False)
updated_at = models.DateTimeField(auto_now=True, editable=False)
business_context = models.TextField(blank=True, default="")
# Preferred provider key (e.g., "openai", "bedrock", "openai_compatible")
default_provider = models.CharField(max_length=50, blank=True)
# Mapping of provider -> model id, e.g., {"openai": "gpt-4o", "bedrock": "anthropic.claude-v2"}
default_models = models.JSONField(default=dict)
def __str__(self):
return f"Lighthouse Tenant Config for {self.tenant_id}"
def clean(self):
super().clean()
# Validate default_provider is configured and active (if provided)
if self.default_provider:
if not LighthouseProviderConfiguration.objects.filter(
tenant_id=self.tenant_id,
provider_type=self.default_provider,
is_active=True,
).exists():
raise ModelValidationError(
detail=f"No active configuration found for provider '{self.default_provider}'",
code="invalid_default_provider",
pointer="/data/attributes/default_provider",
)
# Validate default_models mapping
if self.default_models is not None and not isinstance(
self.default_models, dict
):
raise ModelValidationError(
detail="default_models must be an object mapping provider->model",
code="invalid_default_models",
pointer="/data/attributes/default_models",
)
for provider_type, model_id in (self.default_models or {}).items():
# Provider must exist and be active
provider_cfg = LighthouseProviderConfiguration.objects.filter(
tenant_id=self.tenant_id,
provider_type=provider_type,
is_active=True,
).first()
if not provider_cfg:
raise ModelValidationError(
detail=f"No active configuration found for provider '{provider_type}'",
code="invalid_default_models_provider",
pointer="/data/attributes/default_models",
)
# Model must exist under that provider configuration
if not LighthouseProviderModels.objects.filter(
tenant_id=self.tenant_id,
provider_configuration=provider_cfg,
model_id=model_id,
).exists():
raise ModelValidationError(
detail=f"Invalid model '{model_id}' for provider '{provider_type}'",
code="invalid_default_models_model",
pointer="/data/attributes/default_models",
)
class Meta(RowLevelSecurityProtectedModel.Meta):
db_table = "lighthouse_tenant_config"
constraints = [
RowLevelSecurityConstraint(
field="tenant_id",
name="rls_on_%(class)s",
statements=["SELECT", "INSERT", "UPDATE", "DELETE"],
),
models.UniqueConstraint(
fields=["tenant_id"], name="unique_tenant_lighthouse_config"
),
]
class JSONAPIMeta:
resource_name = "lighthouse-config"
class LighthouseProviderModels(RowLevelSecurityProtectedModel):
"""
Per-tenant, per-provider configuration list of available LLM models.
RLS-protected; populated via provider API using tenant-scoped credentials.
"""
id = models.UUIDField(primary_key=True, default=uuid4, editable=False)
inserted_at = models.DateTimeField(auto_now_add=True, editable=False)
updated_at = models.DateTimeField(auto_now=True, editable=False)
# Scope to a specific provider configuration within a tenant
provider_configuration = models.ForeignKey(
LighthouseProviderConfiguration,
on_delete=models.CASCADE,
related_name="available_models",
)
model_id = models.CharField(max_length=100)
# Model-specific default parameters (e.g., temperature, max_tokens)
default_parameters = models.JSONField(default=dict, blank=True)
def __str__(self):
return f"{self.provider_configuration.provider_type}:{self.model_id} ({self.tenant_id})"
class Meta(RowLevelSecurityProtectedModel.Meta):
db_table = "lighthouse_provider_models"
constraints = [
RowLevelSecurityConstraint(
field="tenant_id",
name="rls_on_%(class)s",
statements=["SELECT", "INSERT", "UPDATE", "DELETE"],
),
models.UniqueConstraint(
fields=["tenant_id", "provider_configuration", "model_id"],
name="unique_provider_model_per_configuration",
),
]
indexes = [
models.Index(
fields=["tenant_id", "provider_configuration"],
name="lh_prov_models_cfg_idx",
),
]
class JSONAPIMeta:
resource_name = "lighthouse-models"
class LighthouseSettings(RowLevelSecurityProtectedModel):
"""
Stores tenant-level Lighthouse settings including business context and defaults.
"""
id = models.UUIDField(primary_key=True, default=uuid4, editable=False)
inserted_at = models.DateTimeField(auto_now_add=True, editable=False)
updated_at = models.DateTimeField(auto_now=True, editable=False)
business_context = models.TextField(
blank=True,
default="",
help_text="Business context for all AI model configurations in this tenant",
)
default_provider = models.CharField(
max_length=50,
blank=True,
null=True,
help_text="Default LLM provider for this tenant",
)
default_model = models.CharField(
max_length=100,
blank=True,
null=True,
help_text="Default model name for the selected provider",
)
def clean(self):
super().clean()
# Normalize empty strings to None for consistency
if self.default_provider == "":
self.default_provider = None
if self.default_model == "":
self.default_model = None
# Cross-field validation: default_model requires default_provider
if self.default_model and not self.default_provider:
raise ModelValidationError(
detail="Default model cannot be set without specifying a default provider",
code="model_requires_provider",
pointer="/data/attributes/default_model",
)
# Validate that default_provider exists in LighthouseConfiguration if set
if self.default_provider:
if not LighthouseConfiguration.objects.filter(
tenant_id=self.tenant_id,
model_provider=self.default_provider,
is_active=True,
).exists():
raise ModelValidationError(
detail=f"No active configuration found for provider '{self.default_provider}'",
code="invalid_default_provider",
pointer="/data/attributes/default_provider",
)
# Validate that default_model is valid for the provider if both are set
if self.default_provider and self.default_model:
from api.v1.serializers import PROVIDER_ALLOWED_MODELS
allowed_models = PROVIDER_ALLOWED_MODELS.get(self.default_provider, [])
if self.default_model not in allowed_models:
raise ModelValidationError(
detail=f"Invalid model '{self.default_model}' for provider '{self.default_provider}'. "
f"Allowed models: {', '.join(allowed_models)}",
code="invalid_model_for_provider",
pointer="/data/attributes/default_model",
)
def __str__(self):
return f"Lighthouse Settings for {self.tenant_id}"
class Meta(RowLevelSecurityProtectedModel.Meta):
db_table = "lighthouse_settings"
constraints = [
RowLevelSecurityConstraint(
field="tenant_id",
name="rls_on_%(class)s",
statements=["SELECT", "INSERT", "UPDATE", "DELETE"],
),
models.UniqueConstraint(
fields=["tenant_id"], name="unique_lighthouse_settings_per_tenant"
),
]
class JSONAPIMeta:
resource_name = "lighthouse-settings"
+257
View File
@@ -25,6 +25,9 @@ from api.models import (
Invitation,
InvitationRoleRelationship,
LighthouseConfiguration,
LighthouseProviderConfiguration,
LighthouseProviderModels,
LighthouseTenantConfiguration,
Membership,
Processor,
Provider,
@@ -2931,3 +2934,257 @@ class TenantApiKeyUpdateSerializer(RLSSerializer, BaseWriteSerializer):
):
raise ValidationError("An API key with this name already exists.")
return value
# Lighthouse: Provider configurations
class LighthouseProviderConfigSerializer(RLSSerializer):
"""
Read serializer for LighthouseProviderConfiguration.
"""
# Decrypted credentials are only returned in to_representation when requested
credentials = serializers.JSONField(required=False, read_only=True)
class Meta:
model = LighthouseProviderConfiguration
fields = [
"id",
"inserted_at",
"updated_at",
"provider_type",
"base_url",
"is_active",
"credentials",
"url",
]
extra_kwargs = {
"id": {"read_only": True},
"inserted_at": {"read_only": True},
"updated_at": {"read_only": True},
"is_active": {"read_only": True},
"url": {"read_only": True},
}
def to_representation(self, instance):
data = super().to_representation(instance)
# Support JSON:API fields filter: fields[lighthouse-providers]=credentials
fields_param = self.context.get("request", None) and self.context[
"request"
].query_params.get("fields[lighthouse-providers]", "")
creds = instance.credentials_decoded
if fields_param == "credentials":
# Return full decrypted credentials JSON
data["credentials"] = creds
else:
# Return masked credentials by default
def mask_value(value):
if isinstance(value, str):
return "*" * len(value)
if isinstance(value, dict):
return {k: mask_value(v) for k, v in value.items()}
if isinstance(value, list):
return [mask_value(v) for v in value]
return value
data["credentials"] = mask_value(creds) if creds is not None else None
return data
class LighthouseProviderConfigCreateSerializer(RLSSerializer, BaseWriteSerializer):
"""
Create serializer for LighthouseProviderConfiguration.
Accepts credentials as JSON; stored encrypted via credentials_decoded.
"""
credentials = serializers.JSONField(write_only=True)
class Meta:
model = LighthouseProviderConfiguration
fields = [
"provider_type",
"base_url",
"credentials",
"is_active",
]
extra_kwargs = {
"is_active": {"required": False},
"base_url": {"required": False, "allow_null": True},
}
def validate(self, attrs):
tenant_id = self.context.get("tenant_id")
provider_type = attrs.get("provider_type")
if LighthouseProviderConfiguration.objects.filter(
tenant_id=tenant_id, provider_type=provider_type
).exists():
raise ValidationError(
{
"provider_type": "Configuration for this provider already exists for the tenant.",
}
)
return super().validate(attrs)
def create(self, validated_data):
credentials = validated_data.pop("credentials")
instance = super().create(validated_data)
instance.credentials_decoded = credentials
instance.save()
return instance
class LighthouseProviderConfigUpdateSerializer(BaseWriteSerializer):
"""
Update serializer for LighthouseProviderConfiguration.
"""
credentials = serializers.JSONField(write_only=True, required=False)
class Meta:
model = LighthouseProviderConfiguration
fields = [
"id",
"provider_type",
"base_url",
"credentials",
"is_active",
]
extra_kwargs = {
"id": {"read_only": True},
"provider_type": {"read_only": True},
"base_url": {"required": False, "allow_null": True},
"is_active": {"required": False},
}
def update(self, instance, validated_data):
credentials = validated_data.pop("credentials", None)
instance = super().update(instance, validated_data)
if credentials is not None:
instance.credentials_decoded = credentials
instance.save()
return instance
# Lighthouse: Tenant configuration
class LighthouseTenantConfigSerializer(RLSSerializer):
"""
Read serializer for LighthouseTenantConfiguration.
"""
class Meta:
model = LighthouseTenantConfiguration
fields = [
"id",
"inserted_at",
"updated_at",
"business_context",
"default_provider",
"default_models",
"url",
]
extra_kwargs = {
"id": {"read_only": True},
"inserted_at": {"read_only": True},
"updated_at": {"read_only": True},
"url": {"read_only": True},
}
class LighthouseTenantConfigCreateSerializer(RLSSerializer, BaseWriteSerializer):
"""
Create serializer for LighthouseTenantConfiguration.
Ensures one record per tenant.
"""
class Meta:
model = LighthouseTenantConfiguration
fields = [
"business_context",
"default_provider",
"default_models",
]
def validate(self, attrs):
tenant_id = self.context.get("tenant_id")
if LighthouseTenantConfiguration.objects.filter(tenant_id=tenant_id).exists():
raise ValidationError(
{"tenant_id": "Tenant Lighthouse configuration already exists."}
)
return super().validate(attrs)
class LighthouseTenantConfigUpdateSerializer(BaseWriteSerializer):
class Meta:
model = LighthouseTenantConfiguration
fields = [
"id",
"business_context",
"default_provider",
"default_models",
]
extra_kwargs = {
"id": {"read_only": True},
}
# Lighthouse: Provider models
class LighthouseProviderModelsSerializer(RLSSerializer):
"""
Read serializer for LighthouseProviderModels.
"""
provider_configuration = serializers.ResourceRelatedField(read_only=True)
class Meta:
model = LighthouseProviderModels
fields = [
"id",
"inserted_at",
"updated_at",
"provider_configuration",
"model_id",
"default_parameters",
"url",
]
extra_kwargs = {
"id": {"read_only": True},
"inserted_at": {"read_only": True},
"updated_at": {"read_only": True},
"url": {"read_only": True},
}
class LighthouseProviderModelsCreateSerializer(RLSSerializer, BaseWriteSerializer):
provider_configuration = serializers.ResourceRelatedField(
queryset=LighthouseProviderConfiguration.objects.all()
)
class Meta:
model = LighthouseProviderModels
fields = [
"provider_configuration",
"model_id",
"default_parameters",
]
extra_kwargs = {
"default_parameters": {"required": False},
}
class LighthouseProviderModelsUpdateSerializer(BaseWriteSerializer):
class Meta:
model = LighthouseProviderModels
fields = [
"id",
"default_parameters",
]
extra_kwargs = {
"id": {"read_only": True},
}
+21
View File
@@ -17,6 +17,9 @@ from api.v1.views import (
InvitationAcceptViewSet,
InvitationViewSet,
LighthouseConfigViewSet,
LighthouseProviderConfigViewSet,
LighthouseProviderModelsViewSet,
LighthouseTenantConfigViewSet,
MembershipViewSet,
OverviewViewSet,
ProcessorViewSet,
@@ -67,6 +70,16 @@ router.register(
basename="lighthouseconfiguration",
)
router.register(r"api-keys", TenantApiKeyViewSet, basename="api-key")
router.register(
r"lighthouse/providers",
LighthouseProviderConfigViewSet,
basename="lighthouse-providers",
)
router.register(
r"lighthouse/models",
LighthouseProviderModelsViewSet,
basename="lighthouse-models",
)
tenants_router = routers.NestedSimpleRouter(router, r"tenants", lookup="tenant")
tenants_router.register(
@@ -137,6 +150,14 @@ urlpatterns = [
),
name="provider_group-providers-relationship",
),
# Lighthouse tenant config as singleton endpoint
path(
"lighthouse/config",
LighthouseTenantConfigViewSet.as_view(
{"get": "retrieve", "post": "create", "patch": "partial_update"}
),
name="lighthouse-config",
),
# API endpoint to start SAML SSO flow
path(
"auth/saml/initiate/", SAMLInitiateAPIView.as_view(), name="api_saml_initiate"
+171
View File
@@ -105,6 +105,9 @@ from api.models import (
Integration,
Invitation,
LighthouseConfiguration,
LighthouseProviderConfiguration,
LighthouseProviderModels,
LighthouseTenantConfiguration,
Membership,
Processor,
Provider,
@@ -159,6 +162,13 @@ from api.v1.serializers import (
LighthouseConfigCreateSerializer,
LighthouseConfigSerializer,
LighthouseConfigUpdateSerializer,
LighthouseProviderConfigCreateSerializer,
LighthouseProviderConfigSerializer,
LighthouseProviderConfigUpdateSerializer,
LighthouseProviderModelsSerializer,
LighthouseTenantConfigCreateSerializer,
LighthouseTenantConfigSerializer,
LighthouseTenantConfigUpdateSerializer,
MembershipSerializer,
OverviewFindingSerializer,
OverviewProviderSerializer,
@@ -4176,6 +4186,167 @@ class LighthouseConfigViewSet(BaseRLSViewSet):
)
@extend_schema_view(
list=extend_schema(
tags=["Lighthouse AI"],
summary="List provider configs",
description="Retrieve all LLM provider configurations for the current tenant",
),
retrieve=extend_schema(
tags=["Lighthouse AI"],
summary="Get provider config",
description="Get details for a specific provider configuration in the current tenant.",
),
create=extend_schema(
tags=["Lighthouse AI"],
summary="Create provider config",
description="Create a per-tenant configuration for an LLM provider. Only one configuration per provider type is allowed per tenant.",
),
partial_update=extend_schema(
tags=["Lighthouse AI"],
summary="Update provider config",
description="Partially update a provider configuration (e.g., base_url, is_active).",
),
destroy=extend_schema(
tags=["Lighthouse AI"],
summary="Delete provider config",
description="Delete a provider configuration. Any tenant defaults that reference this provider are cleared during deletion.",
),
)
class LighthouseProviderConfigViewSet(BaseRLSViewSet):
queryset = LighthouseProviderConfiguration.objects.all()
serializer_class = LighthouseProviderConfigSerializer
http_method_names = ["get", "post", "patch", "delete"]
def get_queryset(self):
if getattr(self, "swagger_fake_view", False):
return LighthouseProviderConfiguration.objects.none()
return LighthouseProviderConfiguration.objects.filter(
tenant_id=self.request.tenant_id
)
def get_serializer_class(self):
if self.action == "create":
return LighthouseProviderConfigCreateSerializer
elif self.action == "partial_update":
return LighthouseProviderConfigUpdateSerializer
return super().get_serializer_class()
@extend_schema_view(
list=extend_schema(
tags=["Lighthouse AI"],
summary="List tenant config",
description="List Lighthouse AI tenant-level settings for the current tenant.",
),
retrieve=extend_schema(
tags=["Lighthouse AI"],
summary="Get tenant config",
description="Retrieve tenant Lighthouse AI settings by ID.",
),
create=extend_schema(
tags=["Lighthouse AI"],
summary="Create tenant config",
description="Create Lighthouse AI tenant-level settings. Only one configuration is allowed per tenant.",
),
partial_update=extend_schema(
tags=["Lighthouse AI"],
summary="Update tenant config",
description="Update tenant-level settings. Validates that the default provider is configured and active and that default model IDs exist for the chosen providers.",
),
destroy=extend_schema(
tags=["Lighthouse AI"],
summary="Delete tenant config",
description="Delete tenant Lighthouse AI settings for the current tenant.",
),
)
class LighthouseTenantConfigViewSet(BaseRLSViewSet):
queryset = LighthouseTenantConfiguration.objects.all()
serializer_class = LighthouseTenantConfigSerializer
http_method_names = ["get", "post", "patch", "delete"]
def get_queryset(self):
if getattr(self, "swagger_fake_view", False):
return LighthouseTenantConfiguration.objects.none()
return LighthouseTenantConfiguration.objects.filter(
tenant_id=self.request.tenant_id
)
def get_serializer_class(self):
if self.action == "create":
return LighthouseTenantConfigCreateSerializer
elif self.action == "partial_update":
return LighthouseTenantConfigUpdateSerializer
return super().get_serializer_class()
def get_object(self):
obj = LighthouseTenantConfiguration.objects.filter(
tenant_id=self.request.tenant_id
).first()
if obj is None:
raise NotFound("Tenant Lighthouse configuration not found")
self.check_object_permissions(self.request, obj)
return obj
@extend_schema_view(
list=extend_schema(
tags=["Lighthouse AI"],
summary="List provider models",
description="List available LLM models per configured provider for the current tenant.",
),
retrieve=extend_schema(
tags=["Lighthouse AI"],
summary="Get provider model",
description="Retrieve details for a specific provider model.",
),
create=extend_schema(
tags=["Lighthouse AI"],
summary="Create provider model",
description="Create a provider model entry for a given provider configuration.",
),
partial_update=extend_schema(
tags=["Lighthouse AI"],
summary="Update provider model",
description="Partially update a provider model entry (e.g., default parameters).",
),
destroy=extend_schema(
tags=["Lighthouse AI"],
summary="Delete provider model",
description="Delete a provider model entry.",
),
)
class LighthouseProviderModelsViewSet(BaseRLSViewSet):
queryset = LighthouseProviderModels.objects.all()
serializer_class = LighthouseProviderModelsSerializer
# Expose as read-only catalog collection
http_method_names = ["get"]
def get_queryset(self):
if getattr(self, "swagger_fake_view", False):
return LighthouseProviderModels.objects.none()
return LighthouseProviderModels.objects.filter(tenant_id=self.request.tenant_id)
def get_serializer_class(self):
return super().get_serializer_class()
@extend_schema(exclude=True)
def retrieve(self, request, *args, **kwargs):
raise MethodNotAllowed(method="GET")
@extend_schema(exclude=True)
def create(self, request, *args, **kwargs):
raise MethodNotAllowed(method="POST")
@extend_schema(exclude=True)
def partial_update(self, request, *args, **kwargs):
raise MethodNotAllowed(method="PATCH")
@extend_schema(exclude=True)
def destroy(self, request, *args, **kwargs):
raise MethodNotAllowed(method="DELETE")
@extend_schema_view(
list=extend_schema(
tags=["Processor"],