Close the gaps between the documented contract (API-STANDARD, TENANCY, ENTITLEMENTS) and the implementation, and remove committed build artifacts. Security: - verify + process inbound webhooks (HMAC/handler verify, size limit, timestamp tolerance, idempotency via InboundEvent.external_id) - real 2FA login flow (pre-auth challenge; tokens only after verify/recovery) - wire HybridPermission into security/audit views; add API-key rotate and users/<id>/permissions|roles endpoints - tenant-scoped throttling on by default; webhook replay protection - verify MercadoPago webhook signatures - login brute-force guard, configurable password policy, real ALTCHA PoW Correctness: - apply verified billing webhooks idempotently (subscription/entitlement/ invoice/PaymentTransaction); scheduled payment lifecycle jobs - capture audit update diffs automatically; add audit retention purge - working notification retries, per-channel rate limits, log retention - pluggable virus scanner, upload-size limit, pipeline toggle - feature rollout %/environment targeting; settings-driven registrations - workflow guards (instance cap, route depth, self-assignment, clone on re-entry) - wire every previously-dead INFRASYNTH_* setting; drop truly dead ones Delivery: - README + CHANGELOG; CI format check + coverage gate - keep test media out of the tree; untrack .coverage, __pycache__, egg-info, docs/ and invoice artifacts
136 lines
5.2 KiB
Python
136 lines
5.2 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")
|