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
errors, не в дефолтном виде фреймворка.
ValidationError своим
форматом — перехватываем и оборачиваем в Problem Details.
application/problem+json по
RFC 9457 (type · title · status ·
detail) + расширения code (короткий
стабильный токен, по которому матчит клиент) и
request_id в роли instance
(идентификатор конкретного случая).
| id | BigInteger | PK | — |
| 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 |
suspended, pending).
deactivated — блокировка администратором, не
самоудаление.
must_change_password ставится в true
при выдаче временного пароля и снимается при успешной смене на
гейте. Пока флаг true, сервер отклоняет любой
аутентифицированный запрос, кроме смены пароля и выхода
(403 PASSWORD_CHANGE_REQUIRED) — в приложение не
пускают ни UI, ни прямой вызов API. Ответ login / refresh несёт
флаг, чтобы клиент по нему сразу увёл на экран смены.
timezone / locale /
date_format — nullable: NULL означает
«наследовать дефолт организации», задаётся в профиле. Влияют
только на отображение во фронтенде; хранение и API — UTC. Полная
цепочка приоритета — в Admin Panel.
audit_log. Дубль на
users нужен, чтобы список пользователей
открывался без JOIN к журналу.
UNIQUE (lower(email)) (или citext).
Регистрозависимый UNIQUE пропустил бы
Alice@x и alice@x как две учётки.
invite_tokens.email,
identity_mapping.source_email и в ключе brute-force
(brute:account:{email_hash}): иначе перебор
обнуляет счётчик сменой регистра.
role,
status, auth_provider,
result) — обычный Text с ограничением
CHECK, а не native ENUM PostgreSQL.
| 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() · «последняя активность» сессии |
| id | BigInteger | PK | — |
| 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() |
| 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() |
| id | BigInteger | PK | — |
| user_id | BigInteger | FK→usersIDX | CASCADE · выдавшая учётка |
| code_hash | Text | NOT NULLUNIQUE | SHA-256 · в БД только хэш |
| expires_at | DateTime(tz) | NOT NULL | 15м |
| used_at | DateTime(tz) | NULL | NULL = ещё не использован |
| created_at | DateTime(tz) | DEFAULT | now() |
used_at; повторная отправка
письма гасит прежний токен пользователя. Истёкший и
использованный неотличимы для клиента — один ответ
410 RESET_EXPIRED. Машинерия flow — в
аутентификации.
| id | BigInteger | PK | — |
| user_id | BigInteger | FK→usersIDX | владелец · CASCADE · key → user_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.
family_id — UUIDv7, назначается при входе,
наследуется через ротацию. Reuse detection действует в пределах
цепочки, не затрагивая все сессии пользователя.
family_id — обрывает
одну цепочку ротации, а не все сессии пользователя. UUIDv7, а
не v4: метка времени в префиксе упорядочивает ключи, вставки
ложатся в конец индекса (как автоинкремент). Хранится нативным
типом uuid (16 байт), не текстом. Внутренний ключ
группировки, не секрет — криптослучайность не нужна (в отличие
от token_hash).
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.
| 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() |
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.
| 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 |
actor_id = NULL означает
system; без FK на users — аудит сохраняется при
удалении учётной записи (независим от lifecycle users).
result CHECK success | failure.