infrasynth-backend-kit/infrasynth/tenancy/mixins.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

91 lines
2.9 KiB
Python

"""Abstract model mixins for tenant-owned and global+override resources.
Using a mixin keeps the ``tenant`` field and managers consistent across every
app without repeating them. Concrete models still declare their own ``db_table``
and constraints. The mixin is the sanctioned way for another app to depend on
``infrasynth.tenancy`` (see the dependency graph in ``PLAN.md`` §3).
"""
from __future__ import annotations
from typing import Any
from django.db import models
from .context import get_current_tenant
from .managers import AllObjectsManager, GlobalOrTenantManager, TenantManager
__all__ = ["TenantOwnedModel", "GlobalOrTenantModel", "ContextGlobalOrTenantModel"]
class TenantOwnedModel(models.Model):
"""Base for every tenant-owned model (``TENANCY.md`` §4).
Provides a non-null ``tenant`` FK, the fail-closed default ``TenantManager``,
and the unscoped ``all_objects`` escape hatch. On save, an unset tenant is
filled from the bound context so writes inside a request/task land in the
right tenant; a write with neither is rejected by the database.
"""
tenant = models.ForeignKey(
"tenancy.Tenant",
on_delete=models.CASCADE,
related_name="+",
editable=False,
)
objects = TenantManager()
all_objects = AllObjectsManager()
class Meta:
abstract = True
def save(self, *args: Any, **kwargs: Any) -> None:
if self.tenant_id is None:
tenant = get_current_tenant()
if tenant is not None:
self.tenant = tenant
super().save(*args, **kwargs)
class GlobalOrTenantModel(models.Model):
"""Base for resources that exist globally and can be overridden per tenant.
``tenant IS NULL`` is the platform default; a non-null tenant is that
tenant's override. The default manager only ever exposes the global rows
plus the current tenant's rows, never another tenant's.
"""
tenant = models.ForeignKey(
"tenancy.Tenant",
on_delete=models.CASCADE,
null=True,
blank=True,
related_name="+",
)
objects = GlobalOrTenantManager()
all_objects = AllObjectsManager()
class Meta:
abstract = True
class ContextGlobalOrTenantModel(GlobalOrTenantModel):
"""A global-or-tenant model that fills an unset tenant from context on save.
The global row still exists (``force_global=True`` on :meth:`save`), but a
normal write inside a request/task lands in the current tenant instead of
silently becoming a platform-wide row. Used for per-user overrides
(``Grant``/``Revoke``) where a tenant-scoped write is the safe default.
"""
class Meta:
abstract = True
def save(self, *args: Any, force_global: bool = False, **kwargs: Any) -> None:
if self.tenant_id is None and not force_global:
tenant = get_current_tenant()
if tenant is not None:
self.tenant = tenant
super().save(*args, **kwargs)