Поделиться
Поделиться

Многие команды начинают SaaS с монолита, который «потом разделим на тенанты», и обнаруживают, что ретрофит мультитенантности обходится дороже, чем построить с нуля. В этой статье — архитектурные решения, которые стоит принять заранее, с практическими примерами реализации.

Три модели изоляции данных

Сравнение подходов

Подход Изоляция Стоимость инфры Сложность Подходит для
DB per tenant Максимальная Высокая Высокая Enterprise, регуляторные требования
Schema per tenant Высокая Средняя Средняя Mid-market, умеренное число тенантов
Row-level (RLS) Умеренная Низкая Низкая SMB, высокое число тенантов, стартап

DB per tenant

Каждый клиент — отдельная база данных. Полная физическая изоляция, простые бэкапы и compliance.

# Django: динамический роутер баз данных
class TenantDatabaseRouter:
    def db_for_read(self, model, **hints):
        tenant = get_current_tenant()
        return f'tenant_{tenant.slug}' if tenant else 'default'

    def db_for_write(self, model, **hints):
        return self.db_for_read(model)

    def allow_migrate(self, db, app_label, model_name=None, **hints):
        return db == 'default' or db.startswith('tenant_')

# Создание новой базы при онбординге
def provision_tenant_database(tenant):
    db_name = f'tenant_{tenant.slug}'
    with connection.cursor() as cursor:
        cursor.execute(f'CREATE DATABASE {db_name}')
    
    # Добавляем в DATABASES настройки Django
    settings.DATABASES[db_name] = {
        **settings.DATABASES['template_tenant'],
        'NAME': db_name,
    }
    
    # Применяем миграции
    call_command('migrate', '--database', db_name)

Проблема: при 10,000+ тенантах управление тысячами баз данных становится операционным кошмаром. Используйте этот подход только для enterprise-сегмента (десятки/сотни клиентов).

Schema per tenant (PostgreSQL)

Один кластер PostgreSQL, отдельная схема для каждого тенанта. Хороший баланс изоляции и управляемости.

-- Создание схемы при онбординге
CREATE SCHEMA tenant_acme;
SET search_path TO tenant_acme, public;

-- Таблицы создаются в схеме тенанта
CREATE TABLE users (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    email TEXT NOT NULL UNIQUE,
    created_at TIMESTAMPTZ DEFAULT now()
);

-- В коде: устанавливаем search_path перед каждым запросом
// Node.js с pg: middleware для установки search_path
async function tenantMiddleware(req, res, next) {
  const tenant = await getTenantFromRequest(req);
  req.tenant = tenant;
  req.db = await pool.connect();
  await req.db.query(`SET search_path TO tenant_${tenant.slug}, public`);
  res.on('finish', () => req.db.release());
  next();
}

Row-Level Security (RLS)

Все тенанты в одних таблицах, PostgreSQL RLS автоматически фильтрует строки.

-- Включаем RLS на таблице
ALTER TABLE projects ENABLE ROW LEVEL SECURITY;

-- Политика: пользователь видит только проекты своего тенанта
CREATE POLICY tenant_isolation ON projects
    USING (tenant_id = current_setting('app.tenant_id')::uuid);

-- Таблица users
ALTER TABLE users ENABLE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation ON users
    USING (tenant_id = current_setting('app.tenant_id')::uuid);
// Перед каждым запросом устанавливаем tenant_id
async function withTenant(tenantId, queryFn) {
  const client = await pool.connect();
  try {
    await client.query(`SET app.tenant_id = '${tenantId}'`);
    return await queryFn(client);
  } finally {
    client.release();
  }
}

// Использование
const projects = await withTenant(req.tenant.id, async (db) => {
  return db.query('SELECT * FROM projects ORDER BY created_at DESC');
});

Предостережение: RLS не защищает от SQL-инъекций через SET параметры. Всегда валидируйте tenant_id как UUID перед вставкой в SET.

Auth и RBAC (Role-Based Access Control)

В мультитенантном SaaS роли всегда контекстуальны: пользователь может быть Admin в тенанте A и Member в тенанте B.

-- Схема данных для мультитенантного RBAC
CREATE TABLE tenants (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    slug TEXT NOT NULL UNIQUE,
    name TEXT NOT NULL,
    plan TEXT NOT NULL DEFAULT 'starter',
    created_at TIMESTAMPTZ DEFAULT now()
);

CREATE TABLE tenant_members (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
    user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
    role TEXT NOT NULL CHECK (role IN ('owner', 'admin', 'member', 'viewer')),
    created_at TIMESTAMPTZ DEFAULT now(),
    UNIQUE (tenant_id, user_id)
);

CREATE TABLE permissions (
    role TEXT NOT NULL,
    resource TEXT NOT NULL,
    action TEXT NOT NULL,
    PRIMARY KEY (role, resource, action)
);

-- Заполнение матрицы разрешений
INSERT INTO permissions VALUES
    ('owner',  'billing',  'manage'),
    ('owner',  'members',  'manage'),
    ('admin',  'members',  'manage'),
    ('admin',  'projects', 'manage'),
    ('member', 'projects', 'write'),
    ('viewer', 'projects', 'read');
// Middleware проверки разрешений
async function requirePermission(resource, action) {
  return async (req, res, next) => {
    const membership = await TenantMember.findOne({
      tenantId: req.tenant.id,
      userId: req.user.id,
    });

    if (!membership) return res.status(403).json({ error: 'not_a_member' });

    const hasPermission = await Permission.findOne({
      role: membership.role,
      resource,
      action,
    });

    if (!hasPermission) return res.status(403).json({ error: 'insufficient_permissions' });

    req.membership = membership;
    next();
  };
}

// Использование
router.delete('/projects/:id',
  requirePermission('projects', 'manage'),
  deleteProjectHandler
);

Биллинг через Stripe

Stripe — стандарт для SaaS биллинга. Ключевые объекты: Customer, Subscription, Price, Invoice.

const stripe = require('stripe')(process.env.STRIPE_SECRET_KEY);

// Создание Stripe Customer при регистрации тенанта
async function createTenantBilling(tenant, ownerEmail) {
  const customer = await stripe.customers.create({
    email: ownerEmail,
    name: tenant.name,
    metadata: { tenant_id: tenant.id },
  });

  await Tenant.update(tenant.id, { stripeCustomerId: customer.id });
  return customer;
}

// Оформление подписки
async function subscribeTenant(tenantId, priceId, paymentMethodId) {
  const tenant = await Tenant.findById(tenantId);

  await stripe.paymentMethods.attach(paymentMethodId, {
    customer: tenant.stripeCustomerId,
  });

  const subscription = await stripe.subscriptions.create({
    customer: tenant.stripeCustomerId,
    items: [{ price: priceId }],
    default_payment_method: paymentMethodId,
    expand: ['latest_invoice.payment_intent'],
  });

  await Tenant.update(tenantId, {
    plan: getPlanFromPrice(priceId),
    stripeSubscriptionId: subscription.id,
    subscriptionStatus: subscription.status,
  });

  return subscription;
}

// Webhook для обработки событий Stripe
app.post('/webhooks/stripe', express.raw({ type: 'application/json' }), async (req, res) => {
  const event = stripe.webhooks.constructEvent(
    req.body, req.headers['stripe-signature'], process.env.STRIPE_WEBHOOK_SECRET
  );

  switch (event.type) {
    case 'invoice.payment_succeeded':
      await handlePaymentSuccess(event.data.object);
      break;
    case 'invoice.payment_failed':
      await handlePaymentFailure(event.data.object);
      break;
    case 'customer.subscription.deleted':
      await downgradeToFree(event.data.object);
      break;
  }

  res.json({ received: true });
});

Feature Flags

Feature flags позволяют управлять доступностью функций по плану тарифа, тенанту или пользователю без деплоя.

// Простая реализация feature flags через Redis
class FeatureFlags {
  constructor(redis) {
    this.redis = redis;
    this.cache = new Map();
    this.CACHE_TTL = 60000; // 1 минута
  }

  async isEnabled(flagName, context) {
    const { tenantId, userId, plan } = context;
    const cacheKey = `${flagName}:${tenantId}`;
    
    const cached = this.cache.get(cacheKey);
    if (cached && cached.expiresAt > Date.now()) return cached.value;

    // Проверяем в порядке специфичности
    const checks = [
      `flag:${flagName}:tenant:${tenantId}`,  // переопределение для тенанта
      `flag:${flagName}:plan:${plan}`,          // по плану
      `flag:${flagName}:global`,                // глобальный флаг
    ];

    for (const key of checks) {
      const value = await this.redis.get(key);
      if (value !== null) {
        const result = value === '1';
        this.cache.set(cacheKey, { value: result, expiresAt: Date.now() + this.CACHE_TTL });
        return result;
      }
    }

    return false; // по умолчанию выключено
  }
}

// Использование в коде
const flags = new FeatureFlags(redis);

router.get('/analytics', async (req, res) => {
  const hasAccess = await flags.isEnabled('advanced_analytics', {
    tenantId: req.tenant.id,
    plan: req.tenant.plan,
  });

  if (!hasAccess) {
    return res.status(402).json({ error: 'upgrade_required', feature: 'advanced_analytics' });
  }
  // ...
});

Автоматизация онбординга

Онбординг нового тенанта — это транзакционный процесс с несколькими шагами. Важно делать его идемпотентным (повторный запуск не должен ломать уже готовые шаги):

async function provisionNewTenant(data) {
  const steps = [
    { name: 'create_tenant',      fn: createTenantRecord },
    { name: 'setup_database',     fn: setupTenantDatabase },
    { name: 'create_owner',       fn: createOwnerUser },
    { name: 'setup_billing',      fn: createTenantBilling },
    { name: 'seed_default_data',  fn: seedDefaultData },
    { name: 'send_welcome_email', fn: sendWelcomeEmail },
  ];

  let progress = await OnboardingProgress.findOrCreate(data.email);

  for (const step of steps) {
    if (progress.completedSteps.includes(step.name)) {
      console.log(`Skipping ${step.name} (already done)`);
      continue;
    }

    try {
      await step.fn(data, progress.tenantId);
      await progress.markStepComplete(step.name);
    } catch (err) {
      await progress.markStepFailed(step.name, err.message);
      throw err; // Можно поставить в очередь для retry
    }
  }

  return progress.tenantId;
}

Тенант-осведомлённое кеширование

Кеш должен учитывать tenant_id, иначе данные одного тенанта окажутся у другого — критическая утечка данных.

class TenantAwareCache {
  constructor(redis) {
    this.redis = redis;
  }

  key(tenantId, key) {
    return `t:${tenantId}:${key}`;
  }

  async get(tenantId, key) {
    const value = await this.redis.get(this.key(tenantId, key));
    return value ? JSON.parse(value) : null;
  }

  async set(tenantId, key, value, ttlSeconds = 300) {
    await this.redis.setex(
      this.key(tenantId, key),
      ttlSeconds,
      JSON.stringify(value)
    );
  }

  // Инвалидация всего кеша тенанта
  async invalidateTenant(tenantId) {
    const keys = await this.redis.keys(`t:${tenantId}:*`);
    if (keys.length > 0) {
      await this.redis.del(...keys);
    }
  }
}

// Использование
const cache = new TenantAwareCache(redis);

async function getTenantProjects(tenantId) {
  const cached = await cache.get(tenantId, 'projects:list');
  if (cached) return cached;

  const projects = await db.query(
    'SELECT * FROM projects WHERE tenant_id = $1',
    [tenantId]
  );

  await cache.set(tenantId, 'projects:list', projects.rows, 60);
  return projects.rows;
}

Итог

Мультиарендная архитектура требует принятия ключевых решений в самом начале: модель изоляции данных, RBAC на уровне тенанта, биллинг с первого клиента, идемпотентный онбординг и изолированный кеш. Большинство этих паттернов несложно реализовать сразу, но болезненно добавлять post-factum. Для понимания работы с базой данных в контексте пространственных данных смотрите статью PostGIS: пространственные запросы.