infrasynth-backend-kit/infrasynth/security/catalog.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

174 lines
5.8 KiB
Python

"""Permission catalog: auto-derivation + sync.
Every concrete model contributes ``view``/``add``/``change``/``delete``
permissions (Django-style codenames ``{app_label}.{verb}_{model_name}``).
Apps add custom permissions through
:class:`infrasynth.security.registry.PermissionRegistry`. Both are merged into
the :class:`infrasynth.security.models.Permission` catalog so a UI can list and
assign them, and so codenames can be validated.
Enforcement itself does not require the catalog to be populated: the codename is
derived from the model + action at request time. The catalog is metadata.
"""
from __future__ import annotations
import logging
from django.apps import apps
from infrasynth.shared.settings_utils import get_setting
from .registry import PermissionDefinition, PermissionRegistry
logger = logging.getLogger(__name__)
__all__ = [
"DRF_ACTION_VERBS",
"permission_for",
"build_catalog",
"sync_permissions",
]
# DRF viewset action -> Django permission verb.
DRF_ACTION_VERBS: dict[str, str] = {
"list": "view",
"retrieve": "view",
"create": "add",
"update": "change",
"partial_update": "change",
"destroy": "delete",
}
# Apps/models that never expose a permission (framework internals, logs, the
# catalog itself). Everything else — kit and consumer models — is included.
DEFAULT_EXCLUDED_MODELS: frozenset[str] = frozenset(
{
"sessions.Session",
"admin.LogEntry",
"contenttypes.ContentType",
"auth.Permission",
"rest_framework.authtoken.Token",
"token_blacklist.OutstandingToken",
"token_blacklist.BlacklistedToken",
"django_celery_results.TaskResult",
"django_celery_results.GroupResult",
"django_celery_beat.PeriodicTask",
"django_celery_beat.IntervalSchedule",
"django_celery_beat.CrontabSchedule",
"django_celery_beat.SolarSchedule",
"django_celery_beat.ClockedSchedule",
"infrasynth_audit.ModelChangeLog",
"infrasynth_audit.APIInteractionLog",
"infrasynth_audit.SecurityEvent",
"infrasynth_security.Permission",
"infrasynth_security.TwoFactorConfig",
"infrasynth_security.ALTCHAChallenge",
}
)
_VERBS = ("view", "add", "change", "delete")
def permission_for(model, action: str) -> str:
"""Django-style codename for ``model`` and a DRF/verb ``action``."""
verb = DRF_ACTION_VERBS.get(action, action)
opts = model._meta
return f"{opts.app_label}.{verb}_{opts.model_name}"
def _excluded_models() -> frozenset[str]:
configured = get_setting("INFRASYNTH_SECURITY", "PERMISSION_EXCLUDE_MODELS", None)
if not configured:
return DEFAULT_EXCLUDED_MODELS
return DEFAULT_EXCLUDED_MODELS | frozenset(configured)
def _allowed_apps() -> list[str] | None:
return list(get_setting("INFRASYNTH_SECURITY", "PERMISSION_APPS", None) or []) or None
def _model_definitions() -> dict[str, PermissionDefinition]:
excluded = _excluded_models()
allowed_apps = _allowed_apps()
definitions: dict[str, PermissionDefinition] = {}
for model in apps.get_models():
opts = model._meta
if opts.abstract or opts.proxy or opts.auto_created or not opts.managed:
continue
if opts.label in excluded:
continue
if allowed_apps is not None and opts.app_label not in allowed_apps:
continue
group = opts.app_label.replace("_", " ").title()
model_name = opts.model_name or ""
for verb in _VERBS:
codename = f"{opts.app_label}.{verb}_{model_name}"
definitions[codename] = PermissionDefinition(
codename=codename,
name=f"Can {verb} {opts.verbose_name}",
app=opts.app_label,
model=model_name,
action=verb,
group=group,
is_custom=False,
)
return definitions
def build_catalog() -> dict[str, PermissionDefinition]:
"""Merged model-derived + custom-registered definitions (registry wins)."""
catalog = _model_definitions()
catalog.update(PermissionRegistry.all())
return catalog
def sync_permissions(*, deactivate_missing: bool = True) -> dict[str, int]:
"""Upserts the catalog into the ``Permission`` table. Idempotent."""
from .models import Permission
catalog = build_catalog()
existing = {permission.codename: permission for permission in Permission.objects.all()}
created = updated = reactivated = 0
for codename, definition in catalog.items():
fields = {
"name": definition.name,
"app_label": definition.app,
"model": definition.model,
"action": definition.action,
"group": definition.group,
"description": definition.description,
"is_custom": definition.is_custom,
}
obj = existing.get(codename)
if obj is None:
Permission.objects.create(codename=codename, **fields)
created += 1
continue
changed = {key: value for key, value in fields.items() if getattr(obj, key) != value}
if not obj.is_active:
changed["is_active"] = True
reactivated += 1
if changed:
for key, value in changed.items():
setattr(obj, key, value)
obj.save(update_fields=list(changed))
updated += 1
deactivated = 0
if deactivate_missing:
for codename in set(existing) - set(catalog):
permission = existing[codename]
if permission.is_active:
permission.is_active = False
permission.save(update_fields=["is_active"])
deactivated += 1
return {
"created": created,
"updated": updated,
"reactivated": reactivated,
"deactivated": deactivated,
"total": len(catalog),
}