- 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)
410 lines
15 KiB
Python
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())
|