infrasynth-backend-kit/infrasynth/configs/views.py
jcv-dev a2930426b4 feat: tenant configs, live signals, and automatic permission management
- infrasynth.configs: typed multi-tenant config store (registry, service,
  secrets, cache) + public config_changed/config_reset signals and API
- emit the declared-but-dead signals (features flags/overrides, scheduler
  task_completed/task_failed, tenancy tenant_updated, audit model_changed)
  and per-model audit field exclusions
- security: permission catalog (security_permission), Django-style
  model-derived AutoPermission, PermissionRegistry, RoleAssignment,
  global-or-tenant Grant/Revoke, catalog API
- consolidate the permission surface: PermissionRegistry only (drop the
  settings dict), IsAuthenticatedAndPermitted aliases HybridPermission,
  require_permission replaced by required_permissions + require_all
- packaging: add [build-system]; add Forgejo publish workflow (.forgejo)
2026-09-29 17:06:54 -05:00

148 lines
5.4 KiB
Python

"""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)))