"""API endpoints for tenant configuration (``/api/v1/configs/``). Values are resolved for the bound tenant; secrets are never returned (``value`` is ``None``). Writes require ``configs.manage`` (tenant override) or ``configs.manage_global`` (platform default), with the standard owner/superuser bypass. A cross-tenant key can never be observed because every read goes through :class:`ConfigService` with the request tenant. """ from __future__ import annotations from typing import Any from rest_framework import status from rest_framework.permissions import IsAuthenticated from rest_framework.response import Response from rest_framework.views import APIView from infrasynth.security.permissions import IsAuthenticatedAndPermitted from infrasynth.shared.exceptions import AuthError from infrasynth.tenancy.context import get_current_tenant from .registry import ConfigRegistry from .serializers import ConfigValueSerializer, ConfigWriteSerializer from .services import ConfigService __all__ = ["ConfigListView", "ConfigDetailView", "ConfigDefinitionsView", "ConfigGlobalDetailView"] def _require_tenant(): tenant = get_current_tenant() if tenant is None: raise AuthError( "A workspace context is required for this endpoint.", code="AUTH_TENANT_REQUIRED", status=403, ) return tenant def _entry(service: ConfigService, key: str, tenant: Any, value: Any) -> dict[str, Any]: meta = service.get_metadata(key, tenant=tenant) return ConfigValueSerializer( { "key": key, "value": None if meta["is_secret"] else value, "type": meta["type"], "group": meta["group"], "label": meta["label"], "is_secret": meta["is_secret"], "is_overridden": meta["is_overridden"], "updated_at": meta["updated_at"], } ).data class ConfigListView(APIView): """Effective values for the bound tenant, optionally filtered by group/keys.""" permission_classes = [IsAuthenticated] def get(self, request): tenant = _require_tenant() service = ConfigService() group = request.query_params.get("group") or None keys_param = request.query_params.get("keys") if keys_param: keys = [key.strip() for key in keys_param.split(",") if key.strip()] resolved = service.get_many(keys, tenant=tenant) data = [_entry(service, key, tenant, resolved[key]) for key in keys if key in resolved] else: effective = service.get_all(tenant=tenant, group=group) data = [_entry(service, key, tenant, value) for key, value in effective.items()] return Response({"values": data}) class ConfigDetailView(APIView): """Read/set/reset a single tenant configuration value.""" permission_classes = [IsAuthenticatedAndPermitted] def get_permissions(self): if self.request.method in ("PUT", "DELETE"): self.required_permissions = ["configs.manage"] else: self.required_permissions = [] return [permission() for permission in self.permission_classes] def get(self, request, key): tenant = _require_tenant() service = ConfigService() meta = service.get_metadata(key, tenant=tenant) value = service.get(key, tenant=tenant) payload = dict(meta) payload["value"] = None if meta["is_secret"] else value return Response(payload) def put(self, request, key): tenant = _require_tenant() serializer = ConfigWriteSerializer(data=request.data) serializer.is_valid(raise_exception=True) service = ConfigService() service.set(key, serializer.validated_data["value"], tenant=tenant, user=request.user) return Response(_entry(service, key, tenant, service.get(key, tenant=tenant))) def delete(self, request, key): tenant = _require_tenant() ConfigService().reset(key, tenant=tenant, user=request.user) return Response(status=status.HTTP_204_NO_CONTENT) class ConfigDefinitionsView(APIView): """Registered key schema, for building configuration forms.""" permission_classes = [IsAuthenticated] def get(self, request): definitions = [] for key, definition in sorted(ConfigRegistry.all().items()): definitions.append( { "key": key, "type": definition.type.value, "default": None if definition.is_secret else definition.default, "choices": list(definition.choices), "is_secret": definition.is_secret, "label": definition.label or key, "group": definition.group, "description": definition.description, } ) return Response({"definitions": definitions}) class ConfigGlobalDetailView(APIView): """Set the platform default for a key (``tenant IS NULL``).""" permission_classes = [IsAuthenticatedAndPermitted] required_permissions = ["configs.manage_global"] def put(self, request, key): tenant = _require_tenant() serializer = ConfigWriteSerializer(data=request.data) serializer.is_valid(raise_exception=True) service = ConfigService() service.set_global(key, serializer.validated_data["value"], user=request.user) return Response(_entry(service, key, tenant, service.get(key, tenant=tenant)))