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

Многие команды начинают 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: пространственные запросы.