infrasynth-backend-kit/infrasynth/configs/services.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

410 lines
15 KiB
Python

"""``ConfigService`` — typed, cached, tenant-scoped configuration access.
Precedence: **tenant row → global row → registry default**. Reads fail closed:
with no tenant context only the global row and the registry default are visible,
never an arbitrary tenant's row. Writes are audited and emit the public
``config_changed``/``config_reset`` signals with secrets masked.
"""
from __future__ import annotations
import builtins
import decimal
import json
from typing import Any
from django.core.cache import caches
from django.db import transaction
from django.db.models import Q
from infrasynth.shared.crypto import decrypt, encrypt
from infrasynth.shared.exceptions import AuthError, NotFoundError, ServerError, ValidationAppError
from infrasynth.shared.settings_utils import get_setting
from infrasynth.tenancy.context import get_current_tenant
from .models import ConfigValue
from .registry import INVALID_CODE, ConfigDefinition, ConfigRegistry
from .signals import config_changed, config_reset
__all__ = ["ConfigService"]
_UNSET: Any = object()
_CACHE_MISS: Any = object()
_DECRYPT_FAILED = "SERVER_CONFIG_DECRYPT_FAILED"
_GLOBAL_WRITES_DISABLED = "AUTH_CONFIG_GLOBAL_WRITES_DISABLED"
_TENANT_REQUIRED = "VALIDATION_TENANT_REQUIRED"
def _unknown_key(key: str) -> NotFoundError:
return NotFoundError(
f"Unknown configuration key '{key}'.",
code="NOT_FOUND",
details=[{"field": "key", "issue": key}],
)
class ConfigService:
"""Reads and writes typed configuration values for a tenant."""
# --- reads --------------------------------------------------------------
def get(self, key: str, *, tenant: Any = None, default: Any = _UNSET) -> Any:
definition = ConfigRegistry.get(key)
resolved_tenant = self._read_tenant(tenant)
if resolved_tenant is not None:
cached = self._cache_get(self._tenant_cache_key(resolved_tenant, key))
if cached is not _CACHE_MISS:
return cached
row = self._find_value(tenant=resolved_tenant, key=key)
if row is not None:
value = self._decode(definition, row.value)
self._cache_set(self._tenant_cache_key(resolved_tenant, key), value)
return value
cached = self._cache_get(self._global_cache_key(key))
if cached is not _CACHE_MISS:
return cached
global_row = self._find_value(tenant=None, key=key)
if global_row is not None:
value = self._decode(definition, global_row.value)
self._cache_set(self._global_cache_key(key), value)
return value
if definition is not None:
return definition.default
if default is not _UNSET:
return default
raise _unknown_key(key)
def get_many(self, keys: list[str], *, tenant: Any = None) -> dict[str, Any]:
"""Resolves the asked keys, skipping unknown/unregistered ones."""
resolved = self._read_tenant(tenant)
result: dict[str, Any] = {}
for key in keys:
try:
result[key] = self.get(key, tenant=resolved)
except NotFoundError:
continue
return result
def get_all(self, *, tenant: Any = None, group: str | None = None) -> dict[str, Any]:
"""Every registered key plus any stored key, resolved for ``tenant``."""
resolved = self._read_tenant(tenant)
keys = set(ConfigRegistry.all().keys())
keys.update(self._visible_rows(resolved).values_list("key", flat=True))
result: dict[str, Any] = {}
for key in sorted(keys):
definition = ConfigRegistry.get(key)
if group is not None and (definition is None or definition.group != group):
continue
result[key] = self.get(key, tenant=resolved)
return result
def get_metadata(self, key: str, *, tenant: Any = None) -> dict[str, Any]:
"""Schema + state for ``key`` (for API/UI forms). Raises for unknown keys."""
resolved = self._read_tenant(tenant)
definition = ConfigRegistry.get(key)
tenant_row = self._find_value(tenant=resolved, key=key) if resolved is not None else None
global_row = self._find_value(tenant=None, key=key)
if definition is None and tenant_row is None and global_row is None:
raise _unknown_key(key)
row = tenant_row or global_row
if definition is None:
return {
"key": key,
"type": None,
"default": None,
"choices": [],
"is_secret": False,
"label": key,
"group": "",
"description": "",
"is_overridden": tenant_row is not None,
"updated_at": row.updated_at if row is not None else None,
}
return {
"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,
"is_overridden": tenant_row is not None,
"updated_at": row.updated_at if row is not None else None,
}
def is_overridden(self, key: str, *, tenant: Any = None) -> bool:
resolved = self._read_tenant(tenant)
if resolved is None:
return False
return ConfigValue.all_objects.filter(tenant_id=resolved.pk, key=key).exists()
# --- writes -------------------------------------------------------------
def set(self, key: str, value: Any, *, tenant: Any, user: Any = None) -> ConfigValue:
"""Sets the tenant override for ``key`` (creating or updating it)."""
resolved = self._require_tenant(tenant)
definition = self._known_definition(key, tenant=resolved)
coerced = self._coerce(definition, key, value)
stored = self._encode(definition, coerced)
old_value = self._effective_value(resolved, key, definition)
with transaction.atomic():
obj, _ = ConfigValue.all_objects.update_or_create(
tenant=resolved,
key=key,
defaults={"value": stored, "updated_by": user},
)
self.invalidate(resolved, key)
self._emit_changed(
tenant_id=str(resolved.pk),
scope="tenant",
key=key,
definition=definition,
old_value=old_value,
new_value=coerced,
actor=user,
)
return obj
def set_global(self, key: str, value: Any, *, user: Any = None) -> ConfigValue:
"""Sets the platform default for ``key`` (``tenant IS NULL``)."""
if not get_setting("INFRASYNTH_CONFIGS", "ALLOW_GLOBAL_WRITES", True):
raise AuthError(
"Global configuration writes are disabled for this deployment.",
code=_GLOBAL_WRITES_DISABLED,
status=403,
)
definition = self._known_definition(key, tenant=None)
coerced = self._coerce(definition, key, value)
stored = self._encode(definition, coerced)
old_value = self._effective_value(None, key, definition)
with transaction.atomic():
obj, _ = ConfigValue.all_objects.update_or_create(
tenant=None,
key=key,
defaults={"value": stored, "updated_by": user},
)
self.invalidate(None, key)
self._emit_changed(
tenant_id=None,
scope="global",
key=key,
definition=definition,
old_value=old_value,
new_value=coerced,
actor=user,
)
return obj
def reset(self, key: str, *, tenant: Any, user: Any = None) -> bool:
"""Deletes the tenant override so reads fall back to global/default."""
resolved = self._require_tenant(tenant)
definition = ConfigRegistry.get(key)
previous = self._effective_value(resolved, key, definition)
deleted, _ = ConfigValue.all_objects.filter(tenant_id=resolved.pk, key=key).delete()
self.invalidate(resolved, key)
if deleted:
config_reset.send(
sender=ConfigValue,
tenant_id=str(resolved.pk),
key=key,
scope="tenant",
previous_value=self._mask(definition, previous),
actor_id=getattr(user, "pk", None),
)
return bool(deleted)
def invalidate(self, tenant: Any, key: str | None = None) -> None:
"""Busts cached values. ``key=None`` clears every known key for the scope."""
cache, _ = self._cache()
if key is not None:
cache_key = self._global_cache_key(key) if tenant is None else self._tenant_cache_key(tenant, key)
cache.delete(cache_key)
return
for known in self._known_keys(tenant):
if tenant is None:
cache.delete(self._global_cache_key(known))
else:
cache.delete(self._tenant_cache_key(tenant, known))
# --- internals ----------------------------------------------------------
def _emit_changed(
self,
*,
tenant_id: str | None,
scope: str,
key: str,
definition: ConfigDefinition | None,
old_value: Any,
new_value: Any,
actor: Any,
) -> None:
config_changed.send(
sender=ConfigValue,
tenant_id=tenant_id,
key=key,
scope=scope,
old_value=self._mask(definition, old_value),
new_value=self._mask(definition, new_value),
actor_id=getattr(actor, "pk", None),
)
@staticmethod
def _mask(definition: ConfigDefinition | None, value: Any) -> Any:
if definition is not None and definition.is_secret:
return None
return value
def _coerce(self, definition: ConfigDefinition | None, key: str, value: Any) -> Any:
if definition is not None:
return ConfigRegistry.coerce(definition, value)
try:
json.dumps(value)
except (TypeError, ValueError) as exc:
raise ValidationAppError(
f"Invalid value for configuration key '{key}'.",
code=INVALID_CODE,
details=[{"field": key, "issue": str(exc)}],
) from exc
return value
def _known_definition(self, key: str, *, tenant: Any) -> ConfigDefinition | None:
"""A definition, or ``None`` when the key is stored but unregistered."""
definition = ConfigRegistry.get(key)
if definition is not None:
return definition
if self._find_value(tenant=tenant, key=key) is not None or self._find_value(tenant=None, key=key) is not None:
return None
raise _unknown_key(key)
@staticmethod
def _encode(definition: ConfigDefinition | None, value: Any) -> Any:
payload = ConfigService._jsonable(value)
if definition is not None and definition.is_secret:
return encrypt(json.dumps(payload))
return payload
@staticmethod
def _decode(definition: ConfigDefinition | None, raw: Any) -> Any:
if definition is not None and definition.is_secret:
if not isinstance(raw, str):
raise ServerError(
"Stored configuration secret is not a ciphertext token.",
code=_DECRYPT_FAILED,
)
try:
value: Any = json.loads(decrypt(raw))
except Exception as exc: # noqa: BLE001 - never leak ciphertext
raise ServerError(
"Unable to decrypt the configuration secret.",
code=_DECRYPT_FAILED,
) from exc
else:
value = raw
return ConfigService._restore_type(definition, value)
@staticmethod
def _restore_type(definition: ConfigDefinition | None, value: Any) -> Any:
if definition is None or value is None:
return value
if definition.type.value == "decimal":
return decimal.Decimal(str(value))
if definition.type.value == "duration":
return int(value)
return value
@staticmethod
def _jsonable(value: Any) -> Any:
if isinstance(value, decimal.Decimal):
return str(value)
if isinstance(value, dict):
return {str(k): ConfigService._jsonable(v) for k, v in value.items()}
if isinstance(value, (list, tuple)):
return [ConfigService._jsonable(v) for v in value]
return value
def _effective_value(self, tenant: Any, key: str, definition: ConfigDefinition | None) -> Any:
row = None
if tenant is not None:
row = self._find_value(tenant=tenant, key=key)
if row is None:
row = self._find_value(tenant=None, key=key)
if row is not None:
return self._decode(definition, row.value)
return definition.default if definition is not None else None
@staticmethod
def _find_value(*, tenant: Any, key: str) -> ConfigValue | None:
qs = ConfigValue.all_objects.filter(key=key)
if tenant is None:
return qs.filter(tenant__isnull=True).first()
return qs.filter(tenant_id=tenant.pk).first()
@staticmethod
def _visible_rows(tenant: Any):
qs = ConfigValue.all_objects.all()
if tenant is None:
return qs.filter(tenant__isnull=True)
return qs.filter(Q(tenant_id=tenant.pk) | Q(tenant__isnull=True))
def _known_keys(self, tenant: Any) -> builtins.set[str]:
keys = set(ConfigRegistry.all().keys())
keys.update(self._visible_rows(tenant).values_list("key", flat=True))
return keys
@staticmethod
def _read_tenant(tenant: Any) -> Any:
if tenant is not None:
return tenant
return get_current_tenant()
@staticmethod
def _require_tenant(tenant: Any) -> Any:
resolved = tenant if tenant is not None else get_current_tenant()
if resolved is None:
raise ValidationAppError(
"A tenant context is required to write configuration.",
code=_TENANT_REQUIRED,
)
return resolved
# --- cache --------------------------------------------------------------
@staticmethod
def _cache():
from django.conf import settings
config = getattr(settings, "INFRASYNTH_CONFIGS", {})
alias = config.get("CACHE_BACKEND", "default") or "default"
return caches[alias], config.get("CACHE_KEY_PREFIX", "configs")
@staticmethod
def _ttl() -> int:
return int(get_setting("INFRASYNTH_CONFIGS", "CACHE_TTL_SECONDS", 60))
@staticmethod
def _tenant_cache_key(tenant: Any, key: str) -> str:
_, prefix = ConfigService._cache()
return f"{prefix}:tenant:{tenant.pk}:configs:{key}"
@staticmethod
def _global_cache_key(key: str) -> str:
_, prefix = ConfigService._cache()
return f"{prefix}:tenant:global:configs:{key}"
def _cache_get(self, cache_key: str) -> Any:
cache, _ = self._cache()
return cache.get(cache_key, _CACHE_MISS)
def _cache_set(self, cache_key: str, value: Any) -> None:
cache, _ = self._cache()
cache.set(cache_key, value, self._ttl())