22. RBAC в БД

Введение

В реальном мире никому не придет в голову выдать мастер-ключ от всех сейфов банка каждому стажеру или водителю инкассаторской машины. Инженерный допуск строго разграничен: кассир имеет доступ к операционной кассе, кладовщик — к инвентарному складу, а доступ к главному хранилищу требует совместного присутствия двух старших офицеров. В архитектуре программного обеспечения этот фундаментальный принцип разделения полномочий называется управлением доступом на основе ролей (Role-Based Access Control, RBAC).

В микросервисной архитектуре на Go десятки независимых сервисов взаимодействуют с общей реляционной СУБД. Подключение всех микросервисов под единой учетной записью с широкими правами — это колоссальный архитектурный просчет. Если злоумышленник находит уязвимость типа SQL-инъекции (21. SQL Injection) в сервисе формирования чеков или компрометирует его токены в Kubernetes, он автоматически получает доступ ко всей корпоративной базе данных: финансовым транзакциям, персональным данным клиентов и системным каталогам.

RBAC превращает базу данных в многосекционную герметичную конструкцию с отсеками (Bulkhead Pattern). Если один сервис скомпрометирован, радиус поражения (blast radius) строго ограничен рамками его роли: сервис заказов может читать и обновлять таблицу orders, но физически не имеет прав на чтение хэшей паролей в таблице users; сервис аналитики читает исключительно реплику базы данных в режиме read-only; а опасные операции изменения структуры таблиц (DDL) разрешены только специализированной роли migrator, запускаемой изолированно в пайплайне CI/CD.

В этой главе мы разберем:

  • Как устроена ролевая модель RBAC и механизмы наследования привилегий.
  • Внутреннее устройство подсистемы прав PostgreSQL (pg_catalog, списки контроля доступа relacl, фазы планировщика setrefs.c).
  • Механику фильтрации строк на уровне ядра СУБД с помощью Row-Level Security (RLS).
  • Безопасную интеграцию с Go: паттерны разделения пулов ROPool / RWPool, проведение миграций и динамическое переключение ролей.
  • Цену безопасности на аппаратном уровне (Mechanical Sympathy): кэширование ACL и накладные расходы предикатов RLS.

Модель RBAC: роли, привилегии и наследование

Классическая модель RBAC в реляционных базах данных строится на трех базовых концепциях:

  1. Пользователь / Субъект (User / Role): Учетная запись, под которой клиентское приложение или DBA устанавливает TCP-соединение с сервером СУБД. В современных версиях PostgreSQL понятия «пользователь» и «группа» унифицированы: любая учетная запись создается единой командой CREATE ROLE, а флаг WITH LOGIN определяет, разрешено ли под ней аутентифицироваться.
  2. Роль (Role): Именованный контейнер привилегий. Роли можно объединять в иерархические деревья, назначая одни роли другим.
  3. Привилегия (Privilege): Атомарное право на выполнение конкретной операции над объектом базы данных (таблицей, представлением, схемой, функцией или генератором последовательностей SEQUENCE). Ключевые привилегии: SELECT, INSERT, UPDATE, DELETE, EXECUTE, USAGE.

Механизм наследования ролей (INHERIT) позволяет строить элегантные, масштабируемые графы прав: базовая роль readonly получает права SELECT на схемы, роль readwrite наследует readonly и добавляет права модификации данных, а конечные сервисные аккаунты наследуют нужный уровень доступа.

flowchart TD
    Admin["admin<br>полный доступ"] --> AppOwner["app_owner<br>владелец схемы"]
    AppOwner --> AppReadWrite["app_readwrite<br>чтение и запись"]
    AppOwner --> AppReadOnly["app_readonly<br>только чтение"]
    AppReadWrite --> SvcOrders["svc_orders<br>сервис заказов"]
    AppReadOnly --> SvcAnalytics["svc_analytics<br>сервис аналитики"]

    classDef admin fill:#1e293b,stroke:#f43f5e,stroke-width:1px,color:#f8fafc;
    classDef role fill:#1e293b,stroke:#818cf8,stroke-width:1px,color:#f8fafc;
    classDef svc fill:#0f172a,stroke:#34d399,stroke-width:1px,color:#f8fafc;
    class Admin admin;
    class AppOwner,AppReadWrite,AppReadOnly role;
    class SvcOrders,SvcAnalytics svc;
  

RBAC в PostgreSQL: синтаксис и подкапотная работа

PostgreSQL обладает одной из самых мощных и гибких реализаций ролевого доступа в индустрии.

Создание ролей и предоставление привилегий

Настроим эталонную иерархию прав для производственной базы данных:

SQL
-- 1. Создаем абстрактные роли без права прямого логина (группы)
CREATE ROLE readonly;
CREATE ROLE readwrite;
CREATE ROLE app_owner WITH LOGIN PASSWORD 'strong_owner_password' INHERIT;

-- 2. Базовые права для чтения
GRANT CONNECT ON DATABASE mydb TO readonly;
GRANT USAGE ON SCHEMA public TO readonly;
GRANT SELECT ON ALL TABLES IN SCHEMA public TO readonly;

-- КРИТИЧЕСКИ ВАЖНО: Права по умолчанию для будущих таблиц миграций!
ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT SELECT ON TABLES TO readonly;

-- 3. Роль записи наследует чтение и получает права DML
GRANT readonly TO readwrite;
GRANT INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA public TO readwrite;
ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT INSERT, UPDATE, DELETE ON TABLES TO readwrite;

-- 4. Сервисный аккаунт приложения
CREATE ROLE svc_orders WITH LOGIN PASSWORD 'service_secret' INHERIT;
GRANT readwrite TO svc_orders;

Row-Level Security (RLS)

Для максимальной изоляции можно использовать RLS — политики, ограничивающие видимость строк на основе логического выражения. Например, сервис арендатора tenant_A должен видеть исключительно строки, где tenant_id = 'A':

SQL
-- Активируем политику RLS для таблицы документов
ALTER TABLE documents ENABLE ROW LEVEL SECURITY;

-- Создаем предикат изоляции строк
CREATE POLICY tenant_isolation ON documents
    FOR ALL
    TO app_user
    USING (tenant_id = current_setting('app.tenant_id'));

Перед выполнением запросов клиентское Go-приложение задает локальный параметр сессии:

SQL
SET app.tenant_id = 'A';

При выполнении любого запроса ядро СУБД прозрачно внедряет условие проверки в дерево плана запроса. Это работает аналогично неявному предикату WHERE, но с абсолютной гарантией: даже если небрежно написанный код выполнит прямой SELECT * FROM documents, планировщик вернет исключительно строки арендатора 'A', физически исключая утечку чужих данных!

Роли и привилегии для функций и расширений

Нередко приложению требуется вызывать специализированные хранимые процедуры или генерировать автоинкрементные ключи:

SQL
-- Разрешить выполнение функции расчета скидки
GRANT EXECUTE ON FUNCTION calculate_discount TO readwrite;

-- Доступ к генераторам последовательностей для SERIAL / IDENTITY колонок
GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA public TO readwrite;
ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT USAGE, SELECT ON SEQUENCES TO readwrite;

-- Использование расширений (например, pgcrypto для хэширования)
GRANT USAGE ON SCHEMA extensions TO app_user;

Go и управление RBAC

Инженерная дисциплина в Go требует строгого разделения учетных записей по функциональным назначениям:

  1. Роль миграций (migrator): Обладает правами DDL (CREATE, ALTER, DROP). Используется исключительно инструментами миграций (golang-migrate, goose) в момент деплоя.
  2. Сервисная роль рантайма (svc_orders): Обладает только DML-правами (SELECT, INSERT, UPDATE, DELETE) на нужные таблицы.
  3. Роль аналитики (svc_analytics): Строго SELECT, часто подключается к выделенной читающей реплике с агрессивными таймаутами выполнения запросов statement_timeout.

Динамическая смена роли в Go

Некоторые архитектуры используют временное переключение роли внутри открытой сессии с помощью команды SET ROLE:

Go
package database

import (
    "context"
    "database/sql"
    "fmt"

    _ "github.com/jackc/pgx/v5/stdlib"
)

func initDB(ctx context.Context) (*sql.DB, error) {
    db, err := sql.Open("pgx", "postgres://migrator:secret@host:5432/mydb?sslmode=require")
    if err != nil {
        return nil, err
    }

    // Понижаем привилегии сессии до сервисной роли
    if _, err := db.ExecContext(ctx, "SET ROLE svc_orders"); err != nil {
        db.Close()
        return nil, fmt.Errorf("set role failed: %w", err)
    }

    return db, nil
}

Однако в пулах соединений (database/sql, pgxpool) использование динамического SET ROLE — опасный антипаттерн! Если соединение возвращается в пул без очистки роли (RESET ROLE), следующая горутина случайно унаследует чужой контекст безопасности.

Золотое правило: подключайтесь сразу под целевой сервисной ролью:

Go
config, err := pgxpool.ParseConfig("postgres://svc_orders:password@host/db?sslmode=require")
if err != nil {
    return nil, err
}
pool, err := pgxpool.NewWithConfig(ctx, config)

Миграции из Go

Запуск миграций выполняется под отдельной строкой подключения с правами владельца схемы:

Go
package migrations

import (
    "errors"
    "log"

    "github.com/golang-migrate/migrate/v4"
    _ "github.com/golang-migrate/migrate/v4/database/postgres"
    _ "github.com/golang-migrate/migrate/v4/source/file"
)

// запуск миграций под отдельными креденшелами
func runMigrations(migrationsURL string) error {
    m, err := migrate.New("file://migrations", migrationsURL)
    if err != nil {
        return err
    }
    defer m.Close()

    if err := m.Up(); err != nil && !errors.Is(err, migrate.ErrNoChange) {
        return err
    }
    return nil
}

func ApplyMigrations() {
    migratorDSN := "postgres://migrator:super_secret@host:5432/db?sslmode=require"
    if err := runMigrations(migratorDSN); err != nil {
        log.Fatalf("Migration failed: %v", err)
    }
}

pgxpool и несколько пулов для разных привилегий

Если бэкенд сочетает регулярные операции пользователей с фоновыми задачами архивирования или аналитики, правильный архитектурный шаблон — разделение пулов соединений:

Go
package repository

import "github.com/jackc/pgx/v5/pgxpool"

type DB struct {
    ROPool *pgxpool.Pool // readonly (подключение svc_analytics к реплике)
    RWPool *pgxpool.Pool // readwrite (подключение svc_orders к мастеру)
}

Это исключает взаимное влияние нагрузок и гарантирует соблюдение прав доступа на сетевом уровне пулов (2. Connection pool).


Mechanical Sympathy: цена проверок прав

С точки зрения кремния и архитектуры СУБД, RBAC и политики доступа не являются бесплатными:

  • Кэш списков контроля доступа (ACL Cache): Серверный бэкенд PostgreSQL не обращается к дисковым таблицам pg_catalog на каждый SQL-запрос. При первом обращении права компилируются в оптимизированную хэш-таблицу в локальной памяти процесса backend'а. Вызовы команд GRANT или REVOKE вызывают отправку широковещательного сигнала инвалидации кэша (Shared Invalidation Message), заставляя все бэкенды перечитывать каталоги. Поэтому частая смена прав в рантайме недопустима.
  • Накладные расходы Row-Level Security (RLS): Условие политики RLS физически вычисляется для каждой просматриваемой строки. Если предикат политики содержит тяжелый вызов current_setting('app.tenant_id') или подзапросы, это создает заметную нагрузку на CPU. Чтобы минимизировать деградацию, обязательно создавайте индекс по колонке селектора (например, CREATE INDEX idx_docs_tenant ON documents(tenant_id)), чтобы планировщик применял RLS на этапе Index Scan, отсекая 99% строк без чтения табличных страниц в память!
  • Аутентификация при старте соединения: Разрешение членства в ролях и проверка пароля (SCRAM-SHA-256) требуют нескольких тысяч тактов CPU. Повторное использование TCP-сокетов через пулы соединений (pgxpool) полностью амортизирует эти расходы.

Разграничение доступа к колонкам и схемам

PostgreSQL позволяет ограничивать доступ с точностью до отдельных полей:

SQL
GRANT SELECT (id, username, created_at) ON users TO public_reader;

Это полезно, когда сервис должен читать публичный профиль, но не имеет права видеть колонки password_hash или phone_number.

Однако учтите инженерный нюанс: если Go-сервис использует ORM или наивный запрос SELECT * FROM users, СУБД немедленно вернет ошибку permission denied for column password_hash. При использовании колоночного RBAC клиентский код обязан явно перечислять только разрешенные колонки в операторе SELECT.


RBAC и CI/CD: автоматизация контроля прав

Конфигурация ролей и привилегий — это неотъемлемая часть инфраструктурного кода (Schema as Code). Права обязаны версионироваться вместе с DDL в миграциях:

SQL
-- +migrate Up
CREATE TABLE orders (
    id SERIAL PRIMARY KEY,
    tenant_id VARCHAR(64) NOT NULL,
    amount NUMERIC(12, 2) NOT NULL,
    created_at TIMESTAMPTZ DEFAULT NOW()
);

-- Выдаем права на чтение и запись
GRANT SELECT, INSERT, UPDATE ON orders TO readwrite;
GRANT USAGE, SELECT ON SEQUENCE orders_id_seq TO readwrite;

-- +migrate Down
DROP TABLE IF EXISTS orders;

Типичные вопросы с собеседований


Итог

Управление доступом на основе ролей (RBAC) в базах данных — это фундаментальный каркас безопасности, превращающий систему из монолитной уязвимой мишени в эшелонированную крепость.

Для Go-разработчика понимание RBAC охватывает:

  • Проектирование изолированных сервисных учетных записей с минимально достаточными правами.
  • Разделение пулов соединений ROPool и RWPool для защиты транзакционного мастера от аналитических нагрузок.
  • Использование Row-Level Security для гарантированной мультитенантной изоляции данных на уровне ядра СУБД.
  • Автоматизацию привилегий через ALTER DEFAULT PRIVILEGES в миграциях CI/CD.

Мы прошли огромный путь: от физического устройства дисковых страниц и структур индексов B-Tree до масштабирования, шардирования, наблюдаемости (19. Observability БД) и безопасности.

В финальной статье раздела мы сведем все полученные знания в единую архитектурную панораму: 23. Итоги раздела. Полная картина работы с базами данных.