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

237 lines
9.2 KiB
Python

"""``TenantService`` — tenant lifecycle and membership (``TENANCY.md`` §3, §6, §8)."""
from __future__ import annotations
import secrets
from datetime import timedelta
from typing import Any
from django.utils import timezone
from django.utils.text import slugify
from infrasynth.shared.exceptions import (
ENTITLEMENT_TENANT_SUSPENDED,
AuthError,
EntitlementError,
NotFoundError,
ValidationAppError,
)
from infrasynth.shared.settings_utils import get_setting
from .models import PlatformStaff, Tenant, TenantInvitation, TenantMembership
from .signals import (
membership_created,
membership_revoked,
tenant_archived,
tenant_created,
tenant_reinstated,
tenant_suspended,
tenant_switched,
tenant_updated,
)
__all__ = ["TenantService"]
_INVITATION_TTL_DAYS = 7
class TenantService:
"""Public API for tenant resolution, membership, and lifecycle."""
# --- resolution ---------------------------------------------------------
def get_active_memberships(self, user: Any) -> list[TenantMembership]:
if not user or not user.is_authenticated:
return []
return list(
TenantMembership.objects.filter(
user=user,
is_active=True,
tenant__status__in=[Tenant.Status.ACTIVE, Tenant.Status.TRIALING],
).select_related("tenant")
)
def get_membership(self, user: Any, tenant_id: Any) -> TenantMembership | None:
if not user or not user.is_authenticated:
return None
return (
TenantMembership.objects.filter(user=user, tenant_id=tenant_id, is_active=True)
.select_related("tenant")
.first()
)
def select_tenant(self, user: Any, tenant_id: Any) -> Tenant:
"""Validates active membership and returns the tenant (or raises)."""
membership = self.get_membership(user, tenant_id)
if membership is None:
# A cross-tenant id must not reveal existence.
raise NotFoundError("No active workspace membership for this user.")
tenant = membership.tenant
if tenant.status == Tenant.Status.ARCHIVED:
raise AuthError(
"This workspace has been archived.",
code="AUTH_TENANT_ARCHIVED",
status=403,
)
if tenant.status == Tenant.Status.SUSPENDED:
raise EntitlementError(
"This workspace is suspended.",
code=ENTITLEMENT_TENANT_SUSPENDED,
status=403,
app=None,
)
return tenant
def switch_tenant(self, user: Any, tenant_id: Any, *, previous_tenant_id: Any = None) -> Tenant:
tenant = self.select_tenant(user, tenant_id)
tenant_switched.send(
sender=Tenant,
tenant_id=str(tenant.pk),
user_id=getattr(user, "pk", None),
previous_tenant_id=str(previous_tenant_id) if previous_tenant_id else None,
)
return tenant
def is_platform_staff(self, user: Any) -> bool:
if not user or not user.is_authenticated:
return False
return PlatformStaff.objects.filter(user=user).exists()
# --- creation / membership ---------------------------------------------
def create_tenant(self, name: str, owner: Any, slug: str | None = None) -> Tenant:
if not owner or not owner.is_authenticated:
raise ValidationAppError("An authenticated owner is required.", code="VALIDATION_OWNER_REQUIRED")
tenant = Tenant.objects.create(
slug=self._unique_slug(slug or slugify(name) or "workspace"),
name=name,
locale=get_setting("INFRASYNTH_TENANCY", "DEFAULT_LOCALE", "es"),
timezone=get_setting("INFRASYNTH_TENANCY", "DEFAULT_TIMEZONE", "UTC"),
)
membership = TenantMembership.objects.create(
tenant=tenant,
user=owner,
role="owner",
is_owner=True,
)
tenant_created.send(sender=Tenant, tenant_id=str(tenant.pk), slug=tenant.slug, name=tenant.name)
membership_created.send(
sender=TenantMembership,
tenant_id=str(tenant.pk),
membership_id=membership.pk,
user_id=getattr(owner, "pk", None),
role="owner",
)
return tenant
def add_member(self, tenant: Tenant, user: Any, role: str = "member", is_owner: bool = False) -> TenantMembership:
membership, created = TenantMembership.objects.get_or_create(
tenant=tenant,
user=user,
defaults={"role": role, "is_owner": is_owner, "is_active": True},
)
if not created and not membership.is_active:
membership.is_active = True
membership.role = role
membership.save(update_fields=["is_active", "role"])
membership_created.send(
sender=TenantMembership,
tenant_id=str(tenant.pk),
membership_id=membership.pk,
user_id=getattr(user, "pk", None),
role=role,
)
return membership
def remove_member(self, tenant: Tenant, user: Any) -> bool:
membership = TenantMembership.objects.filter(tenant=tenant, user=user, is_active=True).first()
if membership is None:
return False
membership.is_active = False
membership.save(update_fields=["is_active"])
membership_revoked.send(
sender=TenantMembership,
tenant_id=str(tenant.pk),
membership_id=membership.pk,
user_id=getattr(user, "pk", None),
)
return True
# --- invitations --------------------------------------------------------
def invite(self, tenant: Tenant, email: str, role: str = "member", invited_by: Any = None) -> TenantInvitation:
invitation = TenantInvitation.objects.create(
tenant=tenant,
email=email,
role=role,
token=secrets.token_urlsafe(32),
invited_by=invited_by if getattr(invited_by, "is_authenticated", False) else None,
expires_at=timezone.now() + timedelta(days=_INVITATION_TTL_DAYS),
)
return invitation
def accept_invitation(self, token: str, user: Any) -> TenantMembership:
# Invitations are looked up by an unguessable token, so all_objects is safe here.
invitation = TenantInvitation.all_objects.filter(token=token).select_related("tenant").first()
if invitation is None or invitation.is_expired() or invitation.accepted_at is not None:
raise NotFoundError("This invitation is invalid or has expired.")
membership = self.add_member(invitation.tenant, user, role=invitation.role)
invitation.accepted_at = timezone.now()
invitation.save(update_fields=["accepted_at"])
return membership
# --- lifecycle ----------------------------------------------------------
def update_tenant(self, tenant: Tenant, *, actor: Any = None, **changes: Any) -> Tenant:
"""Updates the editable tenant fields and emits ``tenant_updated``.
Only ``name``/``locale``/``timezone``/``metadata`` are applied; ``None``
values are ignored. No signal (and no write) when nothing changes.
"""
allowed = {"name", "locale", "timezone", "metadata"}
applied = {key: value for key, value in changes.items() if key in allowed and value is not None}
if not applied:
return tenant
for field, value in applied.items():
setattr(tenant, field, value)
tenant.save(update_fields=list(applied))
tenant_updated.send(
sender=Tenant,
tenant_id=str(tenant.pk),
changes={key: str(value) for key, value in applied.items()},
actor_id=getattr(actor, "pk", None),
)
return tenant
def suspend(self, tenant: Tenant, reason: str = "") -> None:
if tenant.status == Tenant.Status.SUSPENDED:
return
tenant.status = Tenant.Status.SUSPENDED
tenant.suspended_at = timezone.now()
tenant.save(update_fields=["status", "suspended_at"])
tenant_suspended.send(sender=Tenant, tenant_id=str(tenant.pk), reason=reason)
def reinstate(self, tenant: Tenant) -> None:
tenant.status = Tenant.Status.ACTIVE
tenant.suspended_at = None
tenant.save(update_fields=["status", "suspended_at"])
tenant_reinstated.send(sender=Tenant, tenant_id=str(tenant.pk))
def offboard(self, tenant: Tenant) -> None:
"""Archives a tenant and revokes memberships. Hard delete is a later step."""
TenantMembership.objects.filter(tenant=tenant, is_active=True).update(is_active=False)
tenant.status = Tenant.Status.ARCHIVED
tenant.archived_at = timezone.now()
tenant.save(update_fields=["status", "archived_at"])
tenant_archived.send(sender=Tenant, tenant_id=str(tenant.pk))
# --- helpers ------------------------------------------------------------
def _unique_slug(self, base: str) -> str:
base = base or "workspace"
slug = base
suffix = 1
while Tenant.objects.filter(slug=slug).exists():
suffix += 1
slug = f"{base}-{suffix}"
return slug