- 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)
148 lines
5.4 KiB
Python
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)))
|