Перейти к основному контенту
Tech Path Finder
КурсыИнтервьюКод-ревьюБлог
Tech Path Finder

Персонализированный путеводитель в IT. Квизы, мок-интервью, код ревью и аналитика прогресса.

@potapov_me

Платформа

  • Курсы
  • Прогресс
  • Мок-интервью
  • Код ревью
  • Живое ревью с ИИ
  • Тренажёр переговоров
  • Закладки

Контент

  • Блог
  • Главная
  • Обратная связь

Компания

  • О проекте
  • Тарифы
  • Условия использования
  • Конфиденциальность
  • Согласие на обработку данных
  • Cookie
  • Реквизиты

Аккаунт

  • Войти
  • Зарегистрироваться
  • Профиль

© 2026 Tech Path Finder. Все права защищены.

·ИП Потапов К.С.·Политика конфиденциальности·
Сделано с ❤️ в России
  1. Финальный проект: SaaS
real_project_saas

Финальный проект: SaaS

Связный SaaS-кейс: tenant isolation, роли, подписки и usage limits

Финальный проект: production-minded SaaS на Django

Статус: актуально для Django 5.2/6.0. Это учебный case study, а не готовый платёжный продукт. Перед запуском нужны собственная модель угроз, договоры с провайдерами, требования к налогам/персональным данным и проверка восстановления из backup.

Построим ProjectHub — сервис, в котором организации ведут проекты, приглашают участников и оплачивают план. Цель главы не собрать самый длинный models.py, а связать темы курса в несколько проверяемых инвариантов:

  • запрос одной организации никогда не читает и не меняет данные другой;
  • роль проверяется на сервере для каждого use case;
  • платёжный provider и локальная projection сходятся после повторов и нарушения порядка событий;
  • quota, rate limit и billable usage учитываются раздельно;
  • фоновые задачи можно безопасно выполнить больше одного раза;
  • любой критичный переход имеет audit trail и repair path.

#Архитектура без преждевременных микросервисов

Потоки production SaaS на Django

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 не влияли на другой контур.

#1. Tenant — граница безопасности

Есть три распространённых стратегии:

СтратегияСильная сторонаЦена
organization_id в общих tablesпростые migrations и умеренная стоимостькаждый query обязан соблюдать tenant boundary
отдельная PostgreSQL schemaдополнительная логическая изоляцияorchestration migrations и pool state сложнее
отдельная databaserestore/compliance и blast radiusмного connections, migrations и operational work

Для ProjectHub выберем row-level isolation. Это нормальный вариант, если граница встроена в API, constraints, tests и observability. Одного middleware или «разработчики помнят фильтр» недостаточно.

#Базовые models

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 магии.

#Как определить tenant из host

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».

#Tenant boundary в DRF

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

#2. Membership, RBAC и приглашения

Роль — удобная упаковка 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 останется.

#Invitation как одноразовый credential

Ссылка приглашения — 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 membership

Email normalization — продуктовая политика: Unicode/alias rules у providers различаются, поэтому не пытайтесь самовольно «исправлять» адрес сверх выбранных правил. Для смены email и SSO invitation flow проектируют отдельно.

#3. Billing — асинхронная интеграция

Платёжный 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.

#Создание Checkout

Зафиксируйте версию 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.

#Webhook: подпись, deduplication, быстрый ответ

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

В 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 не меняются задним числом.

#4. Entitlements, quota, rate и metering

Эти понятия отвечают на разные вопросы:

МеханизмВопросТипичная реализация
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.

#5. Celery: доставка как минимум один раз

Жизненный цикл Celery task

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 как отдельный ресурс

Большой 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 и не создаёт бесконечные копии.

#6. Audit и security boundaries

Audit event отвечает: кто, в какой organization, что сделал, над каким объектом, когда и с каким request/trace ID. Это append-only журнал безопасности, а не print() и не копия всех secrets.

Минимально фиксируют:

  • membership/role changes и invitation lifecycle;
  • billing plan/cancellation transitions;
  • export и массовые операции;
  • admin impersonation и смену identity settings;
  • provider event ID и результат обработки.

Не записывайте raw password, token, payment details или полный request body. Доступ к audit log сам является привилегированным и тоже наблюдается.

Остальные обязательные границы:

  • uploads проверяются по размеру/type/content, хранятся вне static root и скачиваются после authorization;
  • object storage запрещает public listing и использует scoped service credentials;
  • secrets ротируются, а Stripe test/live endpoints имеют разные keys/secrets;
  • tenant ID, user ID и operation попадают в structured logs, но не в низкокардинальные metric labels;
  • destructive account deletion выполняется как отслеживаемый workflow с retention/legal hold;
  • backup restore тестируется, включая tenant-specific recovery policy.

#7. Operations и выпуск

Для запуска нужны не только containers:

  • migration выполняется как one-off release job, schema меняется expand/contract;
  • readiness проверяет способность обслуживать traffic, liveness не делает тяжёлые dependency calls;
  • web и worker имеют отдельные concurrency/queue limits;
  • SLO охватывает API, task queue lag, webhook age и billing reconciliation;
  • alerts привязаны к пользовательскому ущербу и runbook;
  • deploy version присутствует в logs/traces/events;
  • periodic jobs защищены от overlap или допускают его идемпотентно;
  • restore, provider outage и stuck webhook отрепетированы.

#Порядок роста проекта

  1. Первый release: одна organization на account, explicit tenant filters, memberships, один план, ручной support repair.
  2. Платный beta: Checkout, durable webhook inbox, idempotent projection, audit, reconciliation.
  3. Командный продукт: invitations, capabilities, owner invariants, tenant isolation suite.
  4. Usage plans: quota reservations, durable meters, customer-visible usage и disputes workflow.
  5. Scale: profiling, queue isolation, replicas/cache только по измеренному bottleneck.

Не начинайте с Kubernetes, sharding и пяти billing services. Сначала докажите изоляцию, идемпотентность и восстановление — именно эти свойства сложнее добавить после инцидента.

#Legacy и исправления прошлой версии

  • Удалено из примера: module-level _current_organization. Он смешивал concurrent requests и не работал как безопасный thread/async context.
  • Исправлена ошибка: TenantManager.get_queryset() вызывал сам себя для .none() и уходил в бесконечную рекурсию.
  • Удалено из примера: middleware, которое устанавливало global tenant и не очищало его после exception/response.
  • Уточнено: разбор tenant через host.split(".") не поддерживает custom domains и public suffixes; используется подтверждённое точное mapping.
  • Актуально, но не предпочтительно: unique_together продолжает встречаться в legacy; в новом коде показан именованный UniqueConstraint.
  • Удалено в Stripe 2025-03-31.basil: subscription-level current_period_start/end; replacement — периоды Subscription Items.
  • Исправлено: browser success redirect больше не активирует план, provisioning идёт из проверенного webhook/provider state.
  • Исправлено: webhook сохраняется с unique event ID, обрабатывается идемпотентно и не полагается на порядок delivery.
  • Разделено: rate limiting, quota и billable usage больше не реализуются одним middleware counter.
  • Уточнено: Celery result backend не обязателен каждой задаче, а Beat не выполняет задачи сам.
  • Исправлено: invitation принимает token под row lock, хранит digest и выдерживает повторный request.

#Финальная проверка проекта

  • tenant передаётся явно и scope применяется до загрузки объекта;
  • cross-tenant tests покрывают все операции и background paths;
  • DB constraints включают organization для локальной уникальности;
  • capabilities проверяются backend policy, последний owner защищён transaction;
  • invitation token одноразовый, ограничен сроком и не хранится открыто;
  • provider API/webhook versions закреплены и имеют contract fixtures;
  • webhook signature проверяется по raw body, event ID дедуплицируется;
  • provisioning не зависит от browser redirect или event order;
  • quota, rate limit, meter и analytics имеют разные модели точности;
  • tasks идемпотентны, retries ограничены transient errors;
  • exports tenant-scoped, имеют retention и проверяемый download;
  • audit, SLO, reconciliation, backup restore и runbooks существуют до incident.

#Материалы

  • Django: constraints
  • Django: transactions
  • Celery: Django integration
  • Stripe: webhook delivery and security
  • Stripe: subscription webhooks
  • Stripe: item-level billing periods