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
215 lines
8.3 KiB
Python
215 lines
8.3 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,
|
|
)
|
|
|
|
__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 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
|