
Право доступа — это не только про «закрыть от лишних глаз». От него напрямую зависят:
Хорошая модель делает доступы предсказуемыми, проверяемыми и объяснимыми. А ещё — масштабируемыми: не ломаются при росте команд и клиентов.
Практика: начинайте с чистого RBAC, оставьте «крючки» для атрибутов (tenant_id, owner_id, region). Когда бизнес попросит «руководителю — все за свой отдел», добавьте слой ABAC для отдельных ресурсов, не переписывая всё ядро.
Пример на Postgres: роли и права в разрезе арендатора (tenant). Роли и назначения — внутри арендатора, чтобы клиенты не видели друг друга.
-- Арендаторы (компании)
CREATE TABLE tenants (
id uuid PRIMARY KEY,
name text NOT NULL,
created_at timestamptz NOT NULL DEFAULT now()
);
-- Пользователи: могут состоять в нескольких арендаторах
CREATE TABLE users (
id uuid PRIMARY KEY,
email citext UNIQUE NOT NULL,
full_name text,
created_at timestamptz NOT NULL DEFAULT now(),
is_active boolean NOT NULL DEFAULT true
);
-- Связка пользователь-арендатор (учёт членства и атрибутов)
CREATE TABLE user_tenants (
user_id uuid REFERENCES users(id) ON DELETE CASCADE,
tenant_id uuid REFERENCES tenants(id) ON DELETE CASCADE,
department text, -- атрибут для ABAC
region text, -- атрибут для ABAC
PRIMARY KEY (user_id, tenant_id)
);
-- Права как действия над типом ресурса
CREATE TABLE permissions (
id serial PRIMARY KEY,
code text UNIQUE NOT NULL, -- например, invoice.read, invoice.write
description text
);
-- Роли внутри арендатора
CREATE TABLE roles (
id uuid PRIMARY KEY,
tenant_id uuid REFERENCES tenants(id) ON DELETE CASCADE,
name text NOT NULL,
is_system boolean NOT NULL DEFAULT false, -- системные роли, нельзя удалять
UNIQUE (tenant_id, name)
);
-- Разрешения, включённые в роль
CREATE TABLE role_permissions (
role_id uuid REFERENCES roles(id) ON DELETE CASCADE,
permission_id int REFERENCES permissions(id) ON DELETE RESTRICT,
PRIMARY KEY (role_id, permission_id)
);
-- Роли, назначенные пользователю в рамках арендатора
CREATE TABLE user_roles (
user_id uuid,
tenant_id uuid,
role_id uuid REFERENCES roles(id) ON DELETE CASCADE,
expires_at timestamptz,
PRIMARY KEY (user_id, tenant_id, role_id),
FOREIGN KEY (user_id, tenant_id) REFERENCES user_tenants(user_id, tenant_id) ON DELETE CASCADE,
FOREIGN KEY (role_id) REFERENCES roles(id) ON DELETE CASCADE
);
-- Пример бизнес-ресурса: счета
CREATE TABLE invoices (
id uuid PRIMARY KEY,
tenant_id uuid REFERENCES tenants(id) ON DELETE CASCADE,
owner_id uuid REFERENCES users(id), -- для ABAC: свои/чужие
region text, -- для ABAC
amount_cents int NOT NULL,
status text NOT NULL CHECK (status IN ('draft','sent','paid','canceled')),
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX idx_user_tenants_by_user ON user_tenants (user_id);
CREATE INDEX idx_user_roles_by_user_tenant ON user_roles (user_id, tenant_id);
CREATE INDEX idx_invoices_tenant ON invoices (tenant_id);
Что важно:
Минимальный, но быстрый путь: вытянуть разрешения пользователя в рамках арендатора, кешировать на короткий срок (например, 60 секунд) с инвалидацией через Pub/Sub. Не кладите список разрешений навечно в JWT — лишитесь мгновенного отзыва.
Пример на Python (FastAPI) с локальным LRU и Redis для инвалидации:
# requirements: fastapi, uvicorn, redis, asyncpg, cachetools
import asyncio
import json
from typing import Set
import asyncpg
from cachetools import TTLCache
from fastapi import FastAPI, Request, HTTPException, Depends
import redis.asyncio as redis
app = FastAPI()
POOL: asyncpg.Pool
R = redis.Redis(host="localhost", port=6379, decode_responses=True)
PERM_CACHE = TTLCache(maxsize=10000, ttl=60) # 60 секунд
async def init_pool():
global POOL
POOL = await asyncpg.create_pool(dsn="postgresql://user:pass@localhost/db")
@app.on_event("startup")
async def startup():
await init_pool()
# Подписываемся на канал инвалидации
async def listener():
pubsub = R.pubsub()
await pubsub.subscribe("perm:invalidate")
async for msg in pubsub.listen():
if msg.get("type") == "message":
key = msg.get("data")
PERM_CACHE.pop(key, None)
asyncio.create_task(listener())
async def fetch_permissions(user_id: str, tenant_id: str) -> Set[str]:
cache_key = f"{user_id}:{tenant_id}"
if cache_key in PERM_CACHE:
return PERM_CACHE[cache_key]
async with POOL.acquire() as conn:
rows = await conn.fetch(
"""
SELECT p.code
FROM user_roles ur
JOIN role_permissions rp ON rp.role_id = ur.role_id
JOIN permissions p ON p.id = rp.permission_id
WHERE ur.user_id = $1 AND ur.tenant_id = $2
AND (ur.expires_at IS NULL OR ur.expires_at > now())
""",
user_id, tenant_id,
)
perms = {r["code"] for r in rows}
PERM_CACHE[cache_key] = perms
return perms
async def require_permission(code: str):
async def dep(request: Request):
# Получите user_id и tenant_id из токена/сессии
user_id = request.headers.get("X-User-Id")
tenant_id = request.headers.get("X-Tenant-Id")
if not user_id or not tenant_id:
raise HTTPException(status_code=401, detail="unauthorized")
perms = await fetch_permissions(user_id, tenant_id)
if code not in perms:
raise HTTPException(status_code=403, detail="forbidden")
return {"user_id": user_id, "tenant_id": tenant_id}
return dep
@app.post("/invoices/{invoice_id}:send")
async def send_invoice(invoice_id: str, ctx=Depends(require_permission("invoice.write"))):
# Дальше — бизнес-логика
return {"status": "ok"}
# Инвалидация при изменении ролей: R.publish("perm:invalidate", f"{user_id}:{tenant_id}")
Где брать user_id/tenant_id:
Когда RBAC уже не хватает, добавьте слой политик. Один из вариантов — отдельный движок политик (например, OPA). Он получает входные атрибуты и отвечает «permit/deny» плюс обоснование.
Пример политики на Rego: «Пользователь может читать счёт, если он админ, или владелец, или из того же региона; редактировать — только владелец или роль бухгалтер».
package access.invoice
default allow = false
# Входные данные (пример):
# input = {
# "perms": ["invoice.read", "invoice.write"],
# "user": {"id": "u1", "department": "sales", "region": "EMEA"},
# "tenant_id": "t1",
# "resource": {"owner_id": "u2", "region": "EMEA", "status": "draft"},
# "action": "read"
# }
is_admin {
some p
p := input.perms[_]
p == "admin"
}
allow {
input.action == "read"
("invoice.read" == input.perms[_])
}
allow {
input.action == "read"
is_admin
}
allow {
input.action == "read"
input.user.region == input.resource.region
}
allow {
input.action == "write"
("invoice.write" == input.perms[_])
input.user.id == input.resource.owner_id
}
allow {
input.action == "write"
("accountant" == input.perms[_])
}
Как вызывать:
Важно: политики — код. Держите их в репозитории, с обзорами, версиями и тестами.
Проверка одного объекта — половина дела. Вторая половина — выдавать правильные списки. Иначе пользователь увидит лишние айдишники или «дыры» в пагинации.
Подходы:
Пример RLS для таблицы invoices:
ALTER TABLE invoices ENABLE ROW LEVEL SECURITY;
-- Роль приложения (используйте SET ROLE в пуле)
CREATE ROLE app_user NOLOGIN;
GRANT SELECT, INSERT, UPDATE, DELETE ON invoices TO app_user;
-- Функция возвращает контекст запроса (tenant_id, user_id, region)
CREATE OR REPLACE FUNCTION current_ctx() RETURNS jsonb LANGUAGE sql STABLE AS $$
SELECT current_setting('app.ctx', true)::jsonb;
$$;
-- Политика чтения: только строки своего арендатора и либо свой регион, либо свой объект
CREATE POLICY inv_read ON invoices FOR SELECT TO app_user USING (
(current_ctx()->>'tenant_id')::uuid = tenant_id
AND (
region = current_ctx()->>'region'
OR owner_id = (current_ctx()->>'user_id')::uuid
)
);
-- Политика изменения: только владелец в статусе draft
CREATE POLICY inv_update ON invoices FOR UPDATE TO app_user USING (
(current_ctx()->>'tenant_id')::uuid = tenant_id
AND owner_id = (current_ctx()->>'user_id')::uuid
AND status = 'draft'
);
В коде при взятии соединения перед каждым запросом устанавливайте контекст:
SELECT set_config('app.ctx', '{"tenant_id":"t1","user_id":"u1","region":"EMEA"}', false);
Плюсы RLS: «не забыть фильтр». Минусы: сложнее отлаживать, нужны строгие практики миграций и тестов. Комбинируйте: простые условия — RLS, сложные сценарии — слой политик (OPA) перед обращением к БД.
Enterprise‑клиенты ждут: вход через корпоративный SSO (SAML/OIDC), группы и отделы приходят в токене. Ваша задача — аккуратно сопоставить это ролям внутри арендатора.
Практика:
Схема соответствий:
CREATE TABLE sso_group_mapping (
tenant_id uuid REFERENCES tenants(id) ON DELETE CASCADE,
provider text NOT NULL, -- 'saml' или 'oidc'
ext_group text NOT NULL, -- значение из токена SSO
role_id uuid REFERENCES roles(id) ON DELETE CASCADE,
PRIMARY KEY (tenant_id, provider, ext_group)
);
Алгоритм входа:
Без этого вы упрётесь в проверки служб безопасности клиента.
Минимальный формат события аудита:
{
"ts": "2026-04-12T10:22:31Z",
"tenant_id": "t1",
"user_id": "u1",
"action": "invoice.read",
"resource": {"type": "invoice", "id": "inv-123", "region": "EMEA"},
"decision": "allow",
"reason": ["rbac:invoice.read", "abac:region_match"],
"request_id": "r-9c2e",
"ip": "203.0.113.5"
}
Итог: начинайте с чёткого RBAC, добавляйте ABAC там, где это даёт бизнес‑ценность (правильные списки, изоляция по регионам, чувствительные статусы). Подкрепляйте решения кешем, аудитом и тестами. Так вы закроете вопросы безопасности, ускорите согласование у крупных клиентов и снимете головную боль у поддержки и разработки.