Связный SaaS-кейс: tenant isolation, роли, подписки и usage limits
Статус: актуально для Django 5.2/6.0. Это учебный case study, а не готовый платёжный продукт. Перед запуском нужны собственная модель угроз, договоры с провайдерами, требования к налогам/персональным данным и проверка восстановления из backup.
Построим ProjectHub — сервис, в котором организации ведут проекты, приглашают участников и оплачивают план. Цель главы не собрать самый длинный models.py, а связать темы курса в несколько проверяемых инвариантов:

HTTP traffic проходит через edge/load balancer только в Django web. Celery worker и Beat не находятся «за HTTP-балансировщиком»: web и Beat публикуют messages в broker, а workers их получают. PostgreSQL остаётся source of truth, cache переживает eviction, object storage хранит exports/uploads.
Начать можно с modular monolith:
accounts/ users, sessions, identity
tenancy/ organizations, memberships, tenant resolution
projects/ project domain and API
billing/ provider adapter, subscriptions, entitlements
usage/ quotas, metering and rollups
exports/ background export jobs
audit/ append-only security eventsГраницы apps нужны не для количества папок. Billing не должен менять Project напрямую, а projects не должны разбирать Stripe payload. Use case связывается через public services и после commit публикует нужное событие.
Redis может одновременно обслуживать cache и Celery broker в маленьком проекте, но это разные нагрузки и политики данных. При росте их разделяют, чтобы cache eviction или тяжёлая queue не влияли на другой контур.
Есть три распространённых стратегии:
| Стратегия | Сильная сторона | Цена |
|---|---|---|
organization_id в общих tables | простые migrations и умеренная стоимость | каждый query обязан соблюдать tenant boundary |
| отдельная PostgreSQL schema | дополнительная логическая изоляция | orchestration migrations и pool state сложнее |
| отдельная database | restore/compliance и blast radius | много connections, migrations и operational work |
Для ProjectHub выберем row-level isolation. Это нормальный вариант, если граница встроена в API, constraints, tests и observability. Одного middleware или «разработчики помнят фильтр» недостаточно.
from django.conf import settings
from django.db import models
class Organization(models.Model):
slug = models.SlugField(unique=True)
name = models.CharField(max_length=200)
is_active = models.BooleanField(default=True)
created_at = models.DateTimeField(auto_now_add=True)
class Membership(models.Model):
class Role(models.TextChoices):
OWNER = "owner", "Owner"
ADMIN = "admin", "Admin"
MEMBER = "member", "Member"
VIEWER = "viewer", "Viewer"
organization = models.ForeignKey(Organization, on_delete=models.CASCADE)
user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE)
role = models.CharField(max_length=20, choices=Role, default=Role.MEMBER)
is_active = models.BooleanField(default=True)
class Meta:
constraints = [
models.UniqueConstraint(
fields=["organization", "user"],
name="membership_one_per_user_and_org",
)
]
class TenantQuerySet(models.QuerySet):
def for_tenant(self, organization):
if organization is None:
raise ValueError("Tenant context is required")
return self.filter(organization=organization)
class Project(models.Model):
organization = models.ForeignKey(
Organization,
on_delete=models.CASCADE,
related_name="projects",
)
name = models.CharField(max_length=200)
created_at = models.DateTimeField(auto_now_add=True)
objects = TenantQuerySet.as_manager()
class Meta:
constraints = [
models.UniqueConstraint(
fields=["organization", "name"],
name="project_name_unique_in_org",
)
]
indexes = [
models.Index(
fields=["organization", "-created_at", "-id"],
name="project_tenant_feed_idx",
)
]Глобальный default manager здесь не фильтрует «текущую» организацию. Вместо скрытого ambient state вызывающий код передаёт tenant явно:
projects = Project.objects.for_tenant(request.organization)Fail-closed ValueError безопаснее незаметного .all(). Явный аргумент также работает в async code, Celery tasks, management commands и tests без thread-local магии.
Custom domains нельзя надёжно извлекать простым host.split("."): существуют порты, IDN, public suffixes и домены вида example.co.uk. Храните точное нормализованное сопоставление подтверждённого host.
class OrganizationDomain(models.Model):
organization = models.ForeignKey(Organization, on_delete=models.CASCADE)
host = models.CharField(max_length=253, unique=True)
verified_at = models.DateTimeField(null=True, blank=True)request.get_host() сначала проверяется через ALLOWED_HOSTS. После этого middleware может добавить объект к request, но не записывает его в module-level variable.
from django.http import Http404
from .models import OrganizationDomain
class TenantMiddleware:
def __init__(self, get_response):
self.get_response = get_response
def __call__(self, request):
host = request.get_host().partition(":")[0].lower().rstrip(".")
mapping = (
OrganizationDomain.objects
.select_related("organization")
.filter(
host=host,
verified_at__isnull=False,
organization__is_active=True,
)
.first()
)
if mapping is None:
raise Http404("Unknown organization")
request.organization = mapping.organization
return self.get_response(request)Публичный landing, health и webhook лучше вынести на отдельный host/URLconf или явно пропустить до tenant middleware. Не допускайте, чтобы неизвестный host случайно выбирал «default organization».
from rest_framework.exceptions import PermissionDenied
from rest_framework.viewsets import ModelViewSet
class ProjectViewSet(ModelViewSet):
serializer_class = ProjectSerializer
def get_queryset(self):
organization = self.request.organization
is_member = Membership.objects.filter(
organization=organization,
user=self.request.user,
is_active=True,
).exists()
if not is_member:
raise PermissionDenied("Active membership required")
return Project.objects.for_tenant(organization).order_by("-id")
def perform_create(self, serializer):
serializer.save(organization=self.request.organization)Никогда не принимайте organization_id из body как источник истины. Даже если serializer скрывает поле, queryset retrieval тоже должен быть tenant-scoped: тогда чужой primary key возвращает 404, а не загружается до проверки.
Constraints должны включать tenant там, где уникальность локальная. Для ForeignKey между двумя tenant-owned models обычная БД не проверит совпадение их organization_id; это валидирует use-case service, а критичные места покрывают database design или PostgreSQL RLS.
RLS даёт хороший второй слой, но требует отдельной экспертизы: tenant variable задают внутри transaction, очищают при возврате connection в pool и применяют политики ко всем roles. Неполная RLS опаснее явного отсутствия, потому что создаёт ложное чувство защиты.
Для каждого endpoint создайте две organizations и два users. Проверьте list/retrieve/update/delete, guessed UUID, nested route, search, export и background job. Важен не только 403: чужой объект часто должен выглядеть как несуществующий.
def test_member_cannot_read_project_from_another_tenant(api_client, tenants):
acme, globex = tenants
foreign_project = Project.objects.create(
organization=globex.organization,
name="Private",
)
api_client.force_authenticate(acme.user)
response = api_client.get(
f"/api/projects/{foreign_project.pk}/",
HTTP_HOST="acme.example.test",
)
assert response.status_code == 404Роль — удобная упаковка capabilities, но проверять нужно действие: project.edit, member.invite, billing.manage. Одна роль admin со временем означает слишком много.
ROLE_CAPABILITIES = {
Membership.Role.OWNER: {
"project.read", "project.edit", "member.invite", "billing.manage"
},
Membership.Role.ADMIN: {
"project.read", "project.edit", "member.invite"
},
Membership.Role.MEMBER: {"project.read", "project.edit"},
Membership.Role.VIEWER: {"project.read"},
}Policy function загружает active membership внутри нужной organization и проверяет capability. Frontend может скрыть кнопку, но решение всегда повторяет backend.
Переходы owner требуют transaction и row locks. Нельзя удалить или понизить последнего owner из двух одновременных requests. select_for_update() блокирует relevant memberships, после чего service проверяет, что активный owner останется.
Ссылка приглашения — credential. В БД храните не raw token, а digest, чтобы утечка backup не превратилась в действующие ссылки. У одного tenant/email должно быть не более одного pending invitation.
from django.db.models import Q
class Invitation(models.Model):
class State(models.TextChoices):
PENDING = "pending", "Pending"
ACCEPTED = "accepted", "Accepted"
REVOKED = "revoked", "Revoked"
EXPIRED = "expired", "Expired"
organization = models.ForeignKey(Organization, on_delete=models.CASCADE)
email_normalized = models.EmailField()
role = models.CharField(max_length=20, choices=Membership.Role)
token_digest = models.CharField(max_length=64, unique=True)
state = models.CharField(max_length=20, choices=State, default=State.PENDING)
expires_at = models.DateTimeField()
accepted_at = models.DateTimeField(null=True, blank=True)
class Meta:
constraints = [
models.UniqueConstraint(
fields=["organization", "email_normalized"],
condition=Q(state="pending"),
name="one_pending_invite_per_org_email",
)
]Raw token создают через secrets.token_urlsafe(), отправляют один раз, а lookup выполняют по SHA-256 digest. Acceptance — атомарный переход:
import hashlib
from django.db import transaction
from django.utils import timezone
@transaction.atomic
def accept_invitation(*, raw_token: str, user) -> Membership:
digest = hashlib.sha256(raw_token.encode()).hexdigest()
invitation = Invitation.objects.select_for_update().get(
token_digest=digest,
state=Invitation.State.PENDING,
)
if invitation.expires_at <= timezone.now():
invitation.state = Invitation.State.EXPIRED
invitation.save(update_fields=["state"])
raise ValueError("Invitation expired")
if user.email.strip().casefold() != invitation.email_normalized:
raise PermissionError("Invitation belongs to another email")
membership, _ = Membership.objects.get_or_create(
organization=invitation.organization,
user=user,
defaults={"role": invitation.role},
)
invitation.state = Invitation.State.ACCEPTED
invitation.accepted_at = timezone.now()
invitation.save(update_fields=["state", "accepted_at"])
return membershipEmail normalization — продуктовая политика: Unicode/alias rules у providers различаются, поэтому не пытайтесь самовольно «исправлять» адрес сверх выбранных правил. Для смены email и SSO invitation flow проектируют отдельно.
Платёжный provider является source of truth для charge/invoice/subscription, а приложение хранит локальную projection, необходимую для быстрого authorization. Browser redirect после Checkout не даёт права включить платный план: пользователь может закрыть вкладку, повторить URL или прийти раньше webhook.
Минимальные локальные сущности:
BillingCustomer(organization, provider_customer_id);Plan(code, provider_price_id, is_active);BillingSubscription(organization, provider_subscription_id, status, billing_period_end);Entitlement(organization, code, is_active, source_version);ProviderEvent(provider, event_id, type, payload, processed_at, last_error);CheckoutAttempt(id, organization, plan, state).Денежную сумму и валюту можно показывать из локального versioned catalog, но не вычисляйте факт оплаты по собственному полю price. Status и entitlements синхронизируются с provider.
Зафиксируйте версию Stripe SDK и API version. Каждый POST к provider получает стабильный idempotency key конкретной попытки.
import stripe
def create_checkout(*, organization, plan, attempt, app_origin):
return stripe.checkout.Session.create(
mode="subscription",
customer=organization.billing_customer.provider_customer_id,
line_items=[{"price": plan.provider_price_id, "quantity": 1}],
client_reference_id=str(organization.pk),
metadata={"organization_id": str(organization.pk)},
success_url=(
f"{app_origin}/billing/return"
"?session_id={CHECKOUT_SESSION_ID}"
),
cancel_url=f"{app_origin}/billing/plans",
idempotency_key=f"checkout:{attempt.pk}",
)success_url показывает «платёж обрабатывается» и polling локального status. Она не выполняет provisioning. Secret key и webhook secret хранятся в secret manager/environment, никогда в repository или frontend.
Stripe проверяет подпись по исходным bytes request.body. JSON нельзя предварительно пересериализовать. Endpoint не использует пользовательскую authentication/CSRF, потому что доверие основано на provider signature.
import stripe
from django.conf import settings
from django.db import transaction
from django.http import HttpResponse
from django.views.decorators.csrf import csrf_exempt
from django.views.decorators.http import require_POST
@csrf_exempt
@require_POST
def stripe_webhook(request):
try:
event = stripe.Webhook.construct_event(
request.body,
request.headers.get("Stripe-Signature", ""),
settings.STRIPE_WEBHOOK_SECRET,
)
except (ValueError, stripe.error.SignatureVerificationError):
return HttpResponse(status=400)
payload = event.to_dict_recursive()
with transaction.atomic():
record, _ = ProviderEvent.objects.get_or_create(
provider="stripe",
event_id=payload["id"],
defaults={
"type": payload["type"],
"payload": payload,
},
)
if record.processed_at is None:
process_provider_event.delay_on_commit(record.pk)
return HttpResponse(status=200)Unique constraint на (provider, event_id) делает delivery duplicate безопасным. Task тоже проверяет processed_at под lock. Если publish в broker не состоялся после commit, периодический recovery job находит unprocessed rows и ставит их снова; более общий вариант — transactional outbox.
Provider не гарантирует порядок webhook. Processor не должен полагаться на последовательность created → updated → paid. Для важных переходов он получает актуальный Subscription/Entitlements у Stripe по ID, обновляет локальную projection идемпотентно и только затем ставит processed_at.
Хранение полного payload упрощает repair, но может содержать персональные данные. Ограничьте retention/access, шифруйте при необходимости и сохраняйте только нужные события.
В Stripe API family Basil, начиная с версии 2025-03-31.basil, current_period_start и current_period_end удалены с верхнего уровня Subscription и находятся у items.data[*]. При mixed intervals периодов несколько. Внутреннее поле billing_period_end поэтому является явно выбранной projection, а не слепой копией subscription.current_period_end.
Webhook endpoint имеет собственную закреплённую API version. Upgrade делают в sandbox: обновляют fixtures/contract tests, SDK, parser и только затем endpoint version. Старые payload не меняются задним числом.
Эти понятия отвечают на разные вопросы:
| Механизм | Вопрос | Типичная реализация |
|---|---|---|
| entitlement | доступна ли функция | локальная projection плана/provider |
| quota | сколько ресурса осталось | transaction/atomic reservation |
| rate limit | как часто можно сейчас | atomic counter/token bucket в Redis |
| billing meter | сколько нужно выставить | durable usage events и reconciliation |
DRF throttle полезен для защиты и fair use, но не является точным денежным счётчиком. Cache может потерять key, два workers могут соревноваться, а retry — повторить request.
Для строгой quota используйте условное атомарное обновление:
from django.db.models import F
def reserve_quota(*, organization, metric, amount=1):
changed = Quota.objects.filter(
organization=organization,
metric=metric,
used__lte=F("limit") - amount,
).update(used=F("used") + amount)
if changed != 1:
raise LimitExceeded(metric)Если последующая операция не состоялась, нужна compensation/release. Для storage quota полезно различать reserved и committed bytes.
Billable event получает уникальный idempotency key бизнес-операции и сохраняется в той же transaction, что создала эффект. Aggregator строит daily/monthly rollups, exporter идемпотентно отправляет usage provider, reconciliation сравнивает totals. Один mutable daily counter без журнала не позволяет доказать, где потерялась сумма.
Небиллинговую продуктовую аналитику можно агрегировать проще, но tenant и retention всё равно обязательны. Metric labels не содержат organization ID: высокая cardinality разрушит monitoring backend.

Beat только публикует scheduled task. Broker передаёт message worker, а worker выполняет handler. При падении до acknowledgement сообщение может прийти повторно, поэтому задача проектируется idempotent.
from celery import shared_task
@shared_task(
autoretry_for=(EmailProviderTemporaryError,),
retry_backoff=True,
retry_jitter=True,
max_retries=5,
)
def deliver_invitation(invitation_id):
invitation = Invitation.objects.select_related("organization").get(
pk=invitation_id
)
if invitation.state != Invitation.State.PENDING:
return
email_provider.send_template(
to=invitation.email_normalized,
template="organization-invitation",
context={"organization": invitation.organization.name},
idempotency_key=f"invitation:{invitation.pk}",
)Retry применяется только к временным provider/network errors. Неверный email, отсутствующая модель или нарушение бизнес-инварианта не исправятся от пяти повторов. Если provider не поддерживает idempotency, храните собственный delivery record и принимайте неизбежный небольшой ambiguity window после внешнего side effect.
Task ставят после commit:
deliver_invitation.delay_on_commit(invitation.pk)Это предотвращает чтение незакоммиченной строки. Для критичной доставки остаётся commit/publish gap — его закрывает outbox/recovery scanner.
Большой export не должен удерживать HTTP connection. Endpoint создаёт ExportJob со status queued, tenant, автором, форматом и параметрами; task читает данные только через tenant-scoped service, пишет файл в object storage и меняет status на ready.
Download endpoint снова проверяет membership и выдаёт короткоживущий signed URL. Storage key не является authorization. Файл имеет retention/expiry, а audit фиксирует создание и скачивание. Повтор task использует тот же job ID и не создаёт бесконечные копии.
Audit event отвечает: кто, в какой organization, что сделал, над каким объектом, когда и с каким request/trace ID. Это append-only журнал безопасности, а не print() и не копия всех secrets.
Минимально фиксируют:
Не записывайте raw password, token, payment details или полный request body. Доступ к audit log сам является привилегированным и тоже наблюдается.
Остальные обязательные границы:
Для запуска нужны не только containers:
Не начинайте с Kubernetes, sharding и пяти billing services. Сначала докажите изоляцию, идемпотентность и восстановление — именно эти свойства сложнее добавить после инцидента.
_current_organization. Он смешивал concurrent requests и не работал как безопасный thread/async context.TenantManager.get_queryset() вызывал сам себя для .none() и уходил в бесконечную рекурсию.host.split(".") не поддерживает custom domains и public suffixes; используется подтверждённое точное mapping.unique_together продолжает встречаться в legacy; в новом коде показан именованный UniqueConstraint.2025-03-31.basil: subscription-level current_period_start/end; replacement — периоды Subscription Items.