← Auth / Security

Модель данных и контракты

auth / security · workzone
1 Контракт наружу Формат ошибок API. Что видит клиент.
Problem Details (RFC 9457) + расширения
Content-Type: application/problem+json
{
  "type":       "/errors/invalid-credentials"  тип ошибки (URI)
  "title":      "Invalid credentials"          заголовок типа
  "status":     401                            дублирует HTTP-статус
  "code":       "INVALID_CREDENTIALS"          machine-readable
  "detail":     "Неверные данные"              human, i18n-ready
  "request_id": "req_a1b"                   трейсинг · = заголовок X-Request-Id
}
422 — + errors:      [{ "field", "message" }]
429 — + retry_after: 30 сек · + заголовок Retry-After
Коды по HTTP-статусу
  • 401INVALID_CREDENTIALS
  • 401TOKEN_EXPIRED
  • 401TOKEN_INVALID
  • 403FORBIDDEN
  • 403LAST_OWNER_PROTECTED
  • 403PASSWORD_CHANGE_REQUIRED
  • 404NOT_FOUND
  • 404SETUP_UNAVAILABLE
  • 409CONFLICT
  • 409SMTP_NOT_CONFIGURED
  • 409ALREADY_LINKED
  • 410INVITE_EXPIRED
  • 410INVITE_USED
  • 410RESET_EXPIRED
  • 410LINK_EXPIRED
  • 422VALIDATION_ERROR+ errors
  • 429RATE_LIMITED+ retry_after
Принцип → Brute-force
Не раскрывать внутренние детали. 401 — всегда generic (не «пользователь не найден» vs «неверный пароль»). Ошибки валидации (422) возвращаются в том же формате с массивом errors, не в дефолтном виде фреймворка.
почему так
FastAPI по умолчанию отдаёт ValidationError своим форматом — перехватываем и оборачиваем в Problem Details.
Формат · RFC 9457
Тело ошибки — application/problem+json по RFC 9457 (type · title · status · detail) + расширения code (короткий стабильный токен, по которому матчит клиент) и request_id в роли instance (идентификатор конкретного случая).
2 Identity-ядро Личность. Корень всех связей.
users учётные записи
MFA v2
id BigInteger PK
email Text UNIQUENOT NULL логин-идентификатор · lower()
password_hash Text NULLCHECK NULL при SSO · CHECK ↔ auth_provider
full_name Text NOT NULL
role Text NOT NULLCHECK owner · admin · member
status Text NOT NULLDEFAULTCHECK DEFAULT 'active' · active · deactivated
auth_provider Text NOT NULLDEFAULTCHECK DEFAULT 'local' · local · okta · azure_ad
must_change_password Boolean NOT NULLDEFAULT true после выдачи временного пароля · гейт при входе
created_at DateTime(tz) DEFAULT server_default=now()
updated_at DateTime(tz) DEFAULT now() + trigger
last_login_at DateTime(tz) NULL денормализация → Last login
timezone Text NULL override · IANA tz · NULL = наследует org
locale Text NULLCHECK override · ru · en · NULL = наследует org
date_format Text NULLCHECK override · NULL = наследует org
last_chat_model Text NULL личный липкий выбор чат-модели · заводит новые беседы · NULL = дефолт админа
mfa_enabled Boolean NOT NULLDEFAULT true после подтверждения 1-го кода v2
mfa_secret Text NULL AES-256-GCM v2
mfa_recovery JSONB NULL хэши одноразовых кодов (argon2id) v2
User status
Сознательно два состояния, без промежуточных (suspended, pending). deactivated — блокировка администратором, не самоудаление.
Гейт временного пароля → Принудительная смена пароля
must_change_password ставится в true при выдаче временного пароля и снимается при успешной смене на гейте. Пока флаг true, сервер отклоняет любой аутентифицированный запрос, кроме смены пароля и выхода (403 PASSWORD_CHANGE_REQUIRED) — в приложение не пускают ни UI, ни прямой вызов API. Ответ login / refresh несёт флаг, чтобы клиент по нему сразу увёл на экран смены.
Локаль — личный override → Язык и регион
timezone / locale / date_format — nullable: NULL означает «наследовать дефолт организации», задаётся в профиле. Влияют только на отображение во фронтенде; хранение и API — UTC. Полная цепочка приоритета — в Admin Panel.
Last login → Admin Panel
Денормализация — обновляется при успешном входе.
почему так
Полная история входов — в audit_log. Дубль на users нужен, чтобы список пользователей открывался без JOIN к журналу.
Нормализация email
Email приводится к нижнему регистру на входе, уникальность — UNIQUE (lower(email)) (или citext). Регистрозависимый UNIQUE пропустил бы Alice@x и alice@x как две учётки.
почему так
Та же нормализованная форма — в invite_tokens.email, identity_mapping.source_email и в ключе brute-force (brute:account:{email_hash}): иначе перебор обнуляет счётчик сменой регистра.
CHECK vs ENUM
Поля с фиксированным набором значений (role, status, auth_provider, result) — обычный Text с ограничением CHECK, а не native ENUM PostgreSQL.
почему так
Проще мигрировать через Alembic.
3 Сессии и доступ Жизненный цикл входа. → Крипто-ядро
refresh_tokens ротация JWT
FK → users
id BigInteger PK
user_id BigInteger FK→usersIDX CASCADE
token_hash Text NOT NULLUNIQUE криптослучайный (в отличие от family_id)
family_id UUIDv7 NOT NULLIDX reuse detection → Token family
is_revoked Boolean NOT NULLDEFAULT DEFAULT false
expires_at DateTime(tz) NOT NULL sliding 30d
absolute_expires_at DateTime(tz) NOT NULL ceiling 90d
remember_me Boolean NOT NULL DEFAULT false выбор при входе · ротация переиздаёт cookie того же вида
user_agent Text NULL устройство · захват при входе и refresh
ip Text NULL адрес · реальный IP от nginx · гео на отрисовке
created_at DateTime(tz) DEFAULT now() · «последняя активность» сессии
invite_tokens приглашения 48h
FK → users
id BigInteger PK
email Text NOT NULL кого приглашают
role Text NOT NULLCHECK назначаемая роль
token_hash Text NOT NULLUNIQUE
invited_by BigInteger FK→usersIDX CASCADE
expires_at DateTime(tz) NOT NULL 48h
accepted_at DateTime(tz) NULL NULL = ещё не принято
created_at DateTime(tz) DEFAULT now()
reset_tokens сброс пароля 1h
FK → users
id BigInteger PK
user_id BigInteger FK→usersIDX CASCADE
token_hash Text NOT NULLUNIQUE SHA-256 · в БД только хэш
expires_at DateTime(tz) NOT NULL 1h
used_at DateTime(tz) NULL NULL = ещё не использован
created_at DateTime(tz) DEFAULT now()
Reset-токен — одноразовый → Сброс пароля
Использование ставит used_at; повторная отправка письма гасит прежний токен пользователя. Истёкший и использованный неотличимы для клиента — один ответ 410 RESET_EXPIRED. Машинерия flow — в аутентификации.
api_keys машинный доступ
FK → users
id BigInteger PK
user_id BigInteger FK→usersIDX владелец · CASCADE · keyuser_id → роль / ACL
key_hash Text NOT NULLUNIQUE SHA-256 · в БД только хэш
prefix Text NOT NULLIDX ach_xxxx · опознание в списке, не секрет
scope JSONB NOT NULL доступ (read-only) + источники (null = все доступные) · шире прав владельца не бывает
expires_at DateTime(tz) NULL 30 / 90 / 365 дней · NULL = бессрочный
last_used_at DateTime(tz) NULL факт и время использования · «Использован» в списке
is_revoked Boolean NOT NULLDEFAULT DEFAULT false · отзыв вручную или каскадом при деактивации
revoked_at DateTime(tz) NULL момент отзыва · проставляется однажды и не перезаписывается · «Отозванные ключи» в профиле
created_at DateTime(tz) DEFAULT now() · момент выпуска
updated_at DateTime(tz) DEFAULT now() + trigger · ключ мутабелен in-place (is_revoked, last_used_at)
Код привязки мессенджера — одноразовый → Привязка мессенджера
Рождается во вошедшей сессии (user_id известен сразу), гасится возвратом боту в DM: привязывает вернувший source_user_id к выдавшей учётке через identity_mapping. Сам код канально-нейтрален — source (slack · telegram · mattermost) проставляет принявший бот; UNIQUE(source, source_user_id) ловит повторную привязку (409 ALREADY_LINKED). Истёкший и использованный для клиента неотличимы — один 410 LINK_EXPIRED.
Token family
family_id — UUIDv7, назначается при входе, наследуется через ротацию. Reuse detection действует в пределах цепочки, не затрагивая все сессии пользователя.
почему так
Reuse detection удаляет по family_id — обрывает одну цепочку ротации, а не все сессии пользователя. UUIDv7, а не v4: метка времени в префиксе упорядочивает ключи, вставки ложатся в конец индекса (как автоинкремент). Хранится нативным типом uuid (16 байт), не текстом. Внутренний ключ группировки, не секрет — криптослучайность не нужна (в отличие от token_hash).
Сессия = token family → Параллельные сессии
В терминах UI одна сессия = одна family — один вход с устройства или браузера. Активная сессия — family с живым refresh-токеном (не is_revoked, не истёкшим); по этому же признаку считается и счётчик в карточке пользователя, и список в управлении сессиями. Карточка сессии в UI — последняя строка family: её created_at даёт «последнюю активность», user_agent и ip — устройство и адрес.
кто и когда заполняет
user_agent и ip пишет бэкенд при входе и при каждом refresh из текущего запроса (заголовок User-Agent и реальный IP от nginx — X-Forwarded-For), не со слов клиента. Отдельного поля «последняя активность» нет: ротация и так вставляет новую строку, её created_at и есть последняя активность. Гео не хранится — выводится из ip при отрисовке (приблизительно, VPN / прокси искажают). Текущую сессию опознаём, сопоставляя refresh-cookie запроса с family_id.
4 Федерация Связь канала входа source ↔ user.
identity_mapping внешние личности
FK → users UNIQUE(source, source_user_id)
id BigInteger PK
user_id BigInteger FK→usersIDX CASCADE
source Text NOT NULLUNIQUE* канал входа: SSO · slack · telegram · mattermost (часть составного UNIQUE)
source_user_id Text NOT NULLUNIQUE* id у провайдера (часть составного UNIQUE)
source_email Text NULL авто-матч — только verified
created_at DateTime(tz) DEFAULT now()
Это федерация входа, не контент-личности → Knowledge Store
source здесь — канал входа: SSO-провайдер (Okta, Azure AD) или бот мессенджера (source='slack' → Slack, source='telegram' → Telegram, source='mattermost' → Mattermost), а не контент-источник. Кто автор контента в Jira / Slack / Confluence и свод людей в личность — это source_principal / identity в Knowledge Store; мост — identity.user_id → users.
5 Наблюдаемость Append-only. Независим от lifecycle users. → Аудит-лог
audit_log журнал событий
no UPDATE / DELETE
id BigInteger PK
actor_id BigInteger NULLIDX NULL = system · без FK (независим от lifecycle users)
action Text NOT NULL
target_type Text NULL
target_id Text NULL
result Text NOT NULLCHECK success · failure
ip Text NULL
user_agent Text NULL
meta JSONB NULL произвольный контекст
created_at DateTime(tz) DEFAULTIDX now() · indexed
Решение слоя · append-only
Без UPDATE / DELETE. actor_id = NULL означает system; без FK на users — аудит сохраняется при удалении учётной записи (независим от lifecycle users). result CHECK success | failure.