infrasynth-backend-kit/infrasynth/features/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

146 lines
5.7 KiB
Python

import hashlib
from django.conf import settings
from django.db.models import Q
from .models import FeatureFlag, FeatureFlagOverride
from .registry import FeatureRegistry
class FeatureService:
"""Evaluates operational feature flags with tenant/user overrides and caching."""
def is_enabled(
self,
slug: str,
*,
user=None,
tenant_id=None,
default: bool | None = None,
ttl_seconds: int = 60,
) -> bool:
config = getattr(settings, "INFRASYNTH_FEATURES", {})
ttl_seconds = ttl_seconds or config.get("CACHE_TTL_SECONDS", 60)
if tenant_id is None:
from infrasynth.tenancy.context import get_current_tenant
tenant = get_current_tenant()
tenant_id = tenant.pk if tenant is not None else None
if user and user.is_authenticated:
override = self._get_user_override(slug, user)
if override is not None:
return override
if user and user.is_authenticated:
override = self._get_group_override(slug, user)
if override is not None:
return override
# Resolution: tenant override → global default → registry default.
flag = None
if tenant_id:
flag = self._get_tenant_flag(slug, tenant_id, ttl_seconds)
if flag is None:
flag = self._get_global_flag(slug, ttl_seconds)
if flag is not None:
return self._evaluate_flag(flag, slug, user=user, tenant_id=tenant_id)
registry_default = FeatureRegistry.get_all().get(slug)
if registry_default:
return registry_default.default
return default if default is not None else False
def _evaluate_flag(self, flag, slug, *, user=None, tenant_id=None) -> bool:
if not flag.is_active:
return False
if flag.environments:
environment = getattr(settings, "ENVIRONMENT", None) or getattr(
settings, "INFRASYNTH_ENVIRONMENT", "development"
)
if environment not in flag.environments:
return False
rollout = int(flag.rollout_percentage or 0)
if rollout >= 100:
return True
if rollout <= 0:
return False
return self._in_rollout(slug, user=user, tenant_id=tenant_id) < rollout
@staticmethod
def _in_rollout(slug: str, *, user=None, tenant_id=None) -> int:
"""Stable bucket in ``[0, 100)`` for a (flag, user/tenant) pair."""
algorithm = str(getattr(settings, "INFRASYNTH_FEATURES", {}).get("ROLLOUT_HASH_ALGORITHM", "md5"))
identity = (
str(getattr(user, "pk", ""))
if user is not None and getattr(user, "is_authenticated", False)
else f"tenant:{tenant_id}"
)
digest = hashlib.new(algorithm, f"{slug}:{identity}".encode()).hexdigest()
return int(digest[:8], 16) % 100
def get_active_flags(self, *, user=None, tenant_id=None) -> dict[str, bool]:
all_slugs = set(FeatureRegistry.get_all().keys())
db_slugs = set(
FeatureFlag.all_objects.filter(Q(tenant_id=tenant_id) | Q(tenant_id__isnull=True)).values_list(
"slug", flat=True
)
)
all_slugs.update(db_slugs)
return {slug: self.is_enabled(slug, user=user, tenant_id=tenant_id) for slug in sorted(all_slugs)}
def _get_user_override(self, slug: str, user) -> bool | None:
override = FeatureFlagOverride.all_objects.filter(flag__slug=slug, user=user).first()
if override:
return override.is_enabled
return None
def _get_group_override(self, slug: str, user) -> bool | None:
groups = list(user.groups.all())
if not groups:
return None
override = FeatureFlagOverride.all_objects.filter(flag__slug=slug, group__in=groups).first()
if override:
return override.is_enabled
return None
def _get_tenant_flag(self, slug: str, tenant_id, ttl_seconds: int) -> FeatureFlag | None:
cache, prefix = self._cache()
cache_key = f"{prefix}:tenant:{tenant_id}:features:{slug}"
cached = cache.get(cache_key)
if cached is not None:
return cached if isinstance(cached, FeatureFlag) else None
flag = FeatureFlag.all_objects.filter(slug=slug, tenant_id=tenant_id).first()
cache.set(cache_key, flag, ttl_seconds)
return flag
def _get_global_flag(self, slug: str, ttl_seconds: int) -> FeatureFlag | None:
cache, prefix = self._cache()
cache_key = f"{prefix}:tenant:global:features:{slug}"
cached = cache.get(cache_key)
if cached is not None:
return cached if isinstance(cached, FeatureFlag) else None
flag = FeatureFlag.all_objects.filter(slug=slug, tenant_id__isnull=True).first()
cache.set(cache_key, flag, ttl_seconds)
return flag
@staticmethod
def _cache():
"""Returns ``(cache_client, key_prefix)`` from ``INFRASYNTH_FEATURES``."""
from django.core.cache import caches
config = getattr(settings, "INFRASYNTH_FEATURES", {})
alias = config.get("CACHE_BACKEND", "default") or "default"
return caches[alias], config.get("CACHE_KEY_PREFIX", "features")
def invalidate(self, slug: str, tenant_id=None) -> None:
"""Busts the cached tenant and global rows for ``slug``.
Called from flag/override mutations so a moved flag is visible
immediately instead of after ``CACHE_TTL_SECONDS``.
"""
cache, prefix = self._cache()
cache.delete(f"{prefix}:tenant:{tenant_id}:features:{slug}")
cache.delete(f"{prefix}:tenant:global:features:{slug}")