feat: add kit_auth_pg project from Kit.Auth.Db + ER diagrams + diagram rules

This commit is contained in:
Витковских Евгений 2026-07-30 19:32:04 +05:00
parent a306e27a18
commit 0d839a4e37
41 changed files with 1202 additions and 0 deletions

View File

@ -0,0 +1,75 @@
# Архитектура Kit.Auth.Db
## Обзор
База данных авторизации платформы Kit. Хранит данные пользователей и связанные сущности (кабинеты — LEGACY).
## Схема
Единая схема `auth` — изолирует объекты авторизации от других доменов.
## Таблицы
### auth."user"
Основная таблица пользователей. Зарезервированное слово PostgreSQL, поэтому имя экранируется кавычками.
**Ключевые колонки:**
- `id` (serial PK) — идентификатор
- `login` (text, NOT NULL) — логин для входа
- `email` (text) — электронная почта (дополнительный вход)
- `password` (text) — хеш PBKDF2 (формат: `pbkdf2-sha256$iter$salt$hash`)
- `password_temp` (text) — DEPRECATED, временный пароль (оставлен для совместимости)
- `outer_user_id` (text) — идентификатор во внешней системе
- `is_blocked` (boolean) — флаг блокировки
- `is_deleted` / `date_deleted` — мягкое удаление
**Примечание:** Состояние аутентификации (подтверждение email, lockout, счётчик неудач) и сессии вынесены в токен-БД (`Kit.Auth.Token.Db`).
### auth.cabinet (LEGACY)
Кабинеты/организации. Будет убрана в будущем.
### auth.user_cabinet (LEGACY)
Связь пользователей с кабинетами. Без PK и FK (legacy).
## Функции
### user
| Функция | Описание |
|---------|----------|
| `user_insert` | Вставка пользователя |
| `user_select` | Выборка с пейджингом и фильтрами |
| `user_select_by_ids` | Batch-выборка по списку id |
| `user_get_by_login` | Поиск по логину и паролю |
| `user_get_for_auth` | Поиск для аутентификации (без проверки пароля) |
| `user_get_profile` | Профиль владельца токена |
| `user_delete` | Мягкое удаление |
| `user_update` | Обновление профиля |
| `user_update_password` | Установка постоянного пароля |
| `user_update_password_temp` | Установка временного пароля |
| `user_set_is_blocked` | Блокировка/разблокировка |
| `user_login_exists` | Проверка занятости логина |
| `user_email_exists` | Проверка занятости email |
### cabinet (LEGACY)
| Функция | Описание |
|---------|----------|
| `cabinet_insert` | Вставка кабинета |
| `cabinet_select` | Выборка с пейджингом |
| `cabinet_update` | Обновление |
| `cabinet_delete` | Мягкое удаление |
### user_cabinet (LEGACY)
| Функция | Описание |
|---------|----------|
| `user_cabinet_bulk_insert` | Полная замена привязок |
| `user_cabinet_select` | Кабинеты пользователя |
## Связи с другими БД
- **Kit.Auth.Token.Db** (схема `token`) — токены авторизации ссылаются на пользователей по `user_id`

View File

@ -0,0 +1,57 @@
# ER-диаграмма: auth schema (Kit.Auth.Db)
```mermaid
erDiagram
%% Таблица пользователей
auth.user {
int id PK "serial"
text outer_user_id "идентификатор во внешней системе"
text login "логин (NOT NULL)"
text password "хеш PBKDF2"
text password_temp "DEPRECATED: временный пароль"
text first_name "имя"
text middle_name "отчество"
text last_name "фамилия"
text email "электронная почта"
text phone "телефон"
bool sex "пол"
datetime date_created "дата создания"
bool is_blocked "флаг блокировки"
bool is_deleted "мягкое удаление"
datetime date_deleted "дата удаления"
}
%% LEGACY: Кабинет
auth.cabinet {
int id PK "serial"
text title "название кабинета"
text organization_inn "ИНН организации"
text organization_name "название организации"
datetime date_created "дата создания"
bool is_deleted "мягкое удаление"
datetime date_deleted "дата удаления"
}
%% LEGACY: Связь пользователь ↔ кабинет
auth.user_cabinet {
int user_id FK "→ auth.user.id"
int cabinet_id FK "→ auth.cabinet.id"
}
%% Связи
auth.user ||--o{ auth.user_cabinet : "имеет кабинеты"
auth.cabinet ||--o{ auth.user_cabinet : "принадлежит пользователям"
```
## Описание связей
| Связь | Тип | Описание |
|-------|-----|----------|
| `auth.user``auth.user_cabinet` | 1:M | Пользователь может иметь несколько кабинетов |
| `auth.cabinet``auth.user_cabinet` | 1:M | Кабинет может принадлежать нескольким пользователям |
## Примечания
- `auth.user_cabinet` — LEGACY-таблица без PK и FK (совместимость со старым кодом)
- `auth.cabinet` — LEGACY-таблица, будет убрана в будущем
- Состояние аутентификации и сессии вынесены в `Kit.Auth.Token.Db` (схема `token`)

View File

@ -0,0 +1,41 @@
-- Стартовые данные схемы auth (правило 03: seed в post-deploy).
-- Демо-пользователи, ВЫРОВНЕННЫЕ по user_id с seed Kit.Partner.Service (cabinet_user.user_id → этот id):
-- 1 портал ОТ5, 2 партнёр ОТ5, 3 партнёр ОТ6, 45 админы контрагентов ОТ5, 67 админы контрагентов ОТ6.
-- Порядок INSERT задаёт serial id (1..7) — менять порядок нельзя, иначе разъедется связь с cabinet_user.
-- login совпадает с outer_user_id. ВНИМАНИЕ: password — legacy-значения в открытом виде, НЕ формат
-- PBKDF2, войти по паролю нельзя (verify=false). Для проверки логина регистрируйте через POST
-- /auth/register (в dev токен подтверждения возвращается в ответе). Профили нужны для наглядности
-- списков пользователей/зависимостей.
INSERT INTO auth."user"(
outer_user_id, login, password, password_temp,
first_name, middle_name, last_name, email, phone, sex,
date_created, is_blocked, is_deleted, date_deleted)
VALUES
-- 1: администратор портала ОТ5
('user_ot5', 'ot5', '', 'ot5_pass',
'Администратор', 'портала', 'ОТ5', 'admin@ot5.app', '11-11-11', true,
'2025-01-01', false, false, null),
-- 2: администратор партнёра ОТ5
('p_ot5', 'p_ot5', '', 'p_ot5_pass',
'Администратор', 'партнёра', 'Kit ОТ5', 'kit_admin@ot5.app', '11-11-11', true,
'2025-01-01', false, false, null),
-- 3: администратор партнёра ОТ6
('p_ot6', 'p_ot6', '', 'p_ot6_pass',
'Администратор', 'партнёра', 'Kit ОТ6', 'kit_admin@ot6.app', '11-11-11', true,
'2025-01-01', false, false, null),
-- 4: админ контрагента ОТ5-1
('c_ot5_1', 'c_ot5_1', '', 'c_ot5_1_pass',
'Админ', 'контрагента', 'ОТ5 Контрагент 1', 'c1@ot5.app', '44-44-44', true,
'2025-01-01', false, false, null),
-- 5: админ контрагента ОТ5-2
('c_ot5_2', 'c_ot5_2', '', 'c_ot5_2_pass',
'Админ', 'контрагента', 'ОТ5 Контрагент 2', 'c2@ot5.app', '55-55-55', false,
'2025-01-01', false, false, null),
-- 6: админ контрагента ОТ6-1
('c_ot6_1', 'c_ot6_1', '', 'c_ot6_1_pass',
'Админ', 'контрагента', 'ОТ6 Контрагент 1', 'c1@ot6.app', '66-66-66', true,
'2025-01-01', false, false, null),
-- 7: админ контрагента ОТ6-2
('c_ot6_2', 'c_ot6_2', '', 'c_ot6_2_pass',
'Админ', 'контрагента', 'ОТ6 Контрагент 2', 'c2@ot6.app', '77-77-77', false,
'2025-01-01', false, false, null);

View File

@ -0,0 +1,3 @@
-- Схема домена Auth. Таблицы и функции (CRUD через хранимки) добавляются в
-- tables/ и functions/{таблица}/ и подключаются в init.sh (правила 0304).
CREATE SCHEMA IF NOT EXISTS auth;

View File

@ -0,0 +1,12 @@
-- LEGACY (таблица auth.cabinet будет убрана). Мягкое удаление (is_deleted/date_deleted).
CREATE OR REPLACE FUNCTION auth.cabinet_delete(_id integer) RETURNS void
LANGUAGE plpgsql
AS $$
begin
update auth.cabinet
set
is_deleted = true,
date_deleted = current_timestamp at time zone 'UTC'
where auth.cabinet.id = _id;
end;
$$;

View File

@ -0,0 +1,30 @@
-- LEGACY (таблица auth.cabinet будет убрана). Вставка кабинета, возврат id.
CREATE OR REPLACE FUNCTION auth.cabinet_insert(_title text, _organization_inn text, _organization_name text, _date_created timestamp with time zone) RETURNS integer
LANGUAGE plpgsql
AS $$
declare
_record_id int;
begin
insert into auth.cabinet
(
title,
organization_inn,
organization_name,
date_created,
is_deleted,
date_deleted
)
values
(
_title,
_organization_inn,
_organization_name,
_date_created,
false,
NULL
)
returning id into _record_id;
return _record_id;
end;
$$;

View File

@ -0,0 +1,68 @@
-- LEGACY (таблица auth.cabinet будет убрана). Выборка с пейджингом, фильтрами и
-- динамической сортировкой (правило 03). Имена колонок сортировки — camelCase, как ждёт API.
CREATE OR REPLACE FUNCTION auth.cabinet_select(_id integer DEFAULT NULL::integer, _is_deleted boolean DEFAULT NULL::boolean, _title text DEFAULT NULL::text, _organization_inn text DEFAULT NULL::text, _organization_name text DEFAULT NULL::text, _start integer DEFAULT 0, _length integer DEFAULT 9999999, _sort_column text DEFAULT 'id'::text, _sort_dir text DEFAULT 'asc'::text) RETURNS SETOF refcursor
LANGUAGE plpgsql
AS $$
DECLARE
_data_rc refcursor;
_paging_rc refcursor;
_total_rows int := 0;
BEGIN
CREATE TEMP TABLE _ids (id int, no int, filtered_rows int) ON COMMIT DROP;
WITH t1 AS (
SELECT
main.id,
ROW_NUMBER() OVER (ORDER BY
CASE WHEN (_sort_column = 'id' AND _sort_dir = 'asc') THEN main.id END ASC,
CASE WHEN (_sort_column = 'id' AND _sort_dir = 'desc') THEN main.id END DESC,
CASE WHEN (_sort_column = 'title' AND _sort_dir = 'asc') THEN main.title END ASC,
CASE WHEN (_sort_column = 'title' AND _sort_dir = 'desc') THEN main.title END DESC,
CASE WHEN (_sort_column = 'organizationInn' AND _sort_dir = 'asc') THEN main.organization_inn END ASC,
CASE WHEN (_sort_column = 'organizationInn' AND _sort_dir = 'desc') THEN main.organization_inn END DESC,
CASE WHEN (_sort_column = 'organizationName' AND _sort_dir = 'asc') THEN main.organization_name END ASC,
CASE WHEN (_sort_column = 'organizationName' AND _sort_dir = 'desc') THEN main.organization_name END DESC
) AS no
FROM auth.cabinet main
WHERE (_id IS NULL OR main.id = _id)
AND (_title IS NULL OR main.title ILIKE concat('%', _title, '%'))
AND (_organization_inn IS NULL OR main.organization_inn ILIKE concat('%', _organization_inn, '%'))
AND (_organization_name IS NULL OR main.organization_name ILIKE concat('%', _organization_name, '%'))
AND (_is_deleted IS NULL OR main.is_deleted = _is_deleted)
),
t2 AS (
SELECT COUNT(*) AS filtered_rows FROM t1
),
t3 AS (
SELECT
t1.id,
t1.no
FROM t1
ORDER BY t1.no
OFFSET _start LIMIT _length
)
INSERT INTO _ids (id, no, filtered_rows)
SELECT
t3.id,
t3.no,
t2.filtered_rows
FROM t3
CROSS JOIN t2;
-- select cabinets joined with filtered ids
OPEN _data_rc FOR
SELECT
main.*
FROM auth.cabinet main
JOIN _ids ON main.id = _ids.id
ORDER BY _ids.no;
RETURN NEXT _data_rc;
-- select paging
OPEN _paging_rc FOR
SELECT
(SELECT COUNT(id)::int FROM auth.cabinet WHERE (is_deleted = false OR is_deleted IS NULL)) AS total_rows,
COALESCE((SELECT filtered_rows::int FROM _ids LIMIT 1), 0) AS filtered_rows;
RETURN NEXT _paging_rc;
END;
$$;

View File

@ -0,0 +1,14 @@
-- LEGACY (таблица auth.cabinet будет убрана). Обновление реквизитов кабинета.
CREATE OR REPLACE FUNCTION auth.cabinet_update(_id integer, _title text, _organization_inn text, _organization_name text, _date_created timestamp with time zone) RETURNS void
LANGUAGE plpgsql
AS $$
begin
update auth.cabinet
set
title = _title,
organization_inn = _organization_inn,
organization_name = _organization_name,
date_created = _date_created
where auth.cabinet.id = _id;
end;
$$;

View File

@ -0,0 +1,15 @@
-- Мягкое удаление пользователя (is_deleted/date_deleted). Добавлено: вызывается из
-- UserRepository.Delete ("auth.User_delete"), но отсутствовало в исходных init-скриптах.
-- Имя в нижнем регистре — Postgres сворачивает неэкранированный идентификатор к lower-case,
-- поэтому вызов "auth.User_delete" резолвится в auth.user_delete.
CREATE OR REPLACE FUNCTION auth.user_delete(_id integer) RETURNS void
LANGUAGE plpgsql
AS $$
begin
update auth.user
set
is_deleted = true,
date_deleted = current_timestamp at time zone 'UTC'
where auth.user.id = _id;
end;
$$;

View File

@ -0,0 +1,14 @@
-- Проверка занятости e-mail.
CREATE OR REPLACE FUNCTION auth.user_email_exists(_email text) RETURNS boolean
LANGUAGE plpgsql
AS $$
begin
IF EXISTS(SELECT id FROM auth.user where email = _email) THEN
RETURN TRUE;
ELSE
RETURN false;
END IF;
end;
$$;

View File

@ -0,0 +1,72 @@
-- Поиск пользователя по логину и паролю (последовательно: password, password_hash,
-- password_temp, затем password_temp = password_hash). Возвращает курсор пользователя
-- и курсор его кабинетов (LEGACY-связка через auth.user_cabinet).
CREATE OR REPLACE FUNCTION auth.user_get_by_login(
_login text DEFAULT NULL::text,
_password text DEFAULT NULL::text,
_password_hash text DEFAULT NULL::text
) RETURNS SETOF refcursor
LANGUAGE plpgsql
AS $$
declare
_data_rc refcursor;
_cabinet_rc refcursor;
begin
CREATE TEMP TABLE _ids (id int) ON COMMIT DROP;
INSERT INTO _ids (id)
SELECT main.id
from auth.user main
where main.login = _login
and main.password = _password;
IF NOT EXISTS(SELECT id FROM _ids) THEN
INSERT INTO _ids (id)
SELECT main.id
from auth.user main
where main.login = _login
and main.password = _password_hash;
END IF;
IF NOT EXISTS(SELECT id FROM _ids) THEN
INSERT INTO _ids (id)
SELECT main.id
from auth.user main
where main.login = _login
and main.password_temp = _password;
END IF;
IF NOT EXISTS(SELECT id FROM _ids) THEN
INSERT INTO _ids (id)
SELECT main.id
from auth.user main
where main.login = _login
and main.password_temp = _password_hash;
END IF;
OPEN _data_rc FOR
SELECT
main.*,
CONCAT(main.first_name, ' ', main.middle_name, ' ', main.last_name) as user_title
from auth.user main
join _ids on main.id = _ids.id;
RETURN NEXT _data_rc;
open _cabinet_rc for
select
main.user_id as user_id,
c.id,
c.title,
c.organization_inn,
c.organization_name,
c.date_created
from auth.user_cabinet main
join _ids on main.user_id = _ids.id
inner join auth.cabinet c ON main.cabinet_id = c.id
where c.is_deleted = false;
return next _cabinet_rc;
end;
$$;

View File

@ -0,0 +1,23 @@
-- Поиск пользователя для аутентификации по логину ИЛИ email (вход допускается обоими).
-- Пароль здесь НЕ проверяется: хеш PBKDF2 солёный, поэтому сверка пароля выполняется в C#
-- (PasswordHasher.Verify). Возвращает один курсор с полной строкой пользователя, включая
-- password (хеш), is_email_confirmed, is_blocked, access_failed_count, lockout_end.
CREATE OR REPLACE FUNCTION auth.user_get_for_auth(
_login text DEFAULT NULL::text
) RETURNS SETOF refcursor
LANGUAGE plpgsql
AS $$
declare
_data_rc refcursor;
begin
OPEN _data_rc FOR
SELECT
main.*,
CONCAT(main.first_name, ' ', main.middle_name, ' ', main.last_name) as user_title
from auth.user main
where main.is_deleted = false
and (main.login = _login OR main.email = _login);
RETURN NEXT _data_rc;
end;
$$;

View File

@ -0,0 +1,33 @@
-- Профиль владельца токена: одна строка пользователя с полями для страницы «Профиль»,
-- ВКЛЮЧАЯ password_temp (открытый временный пароль — отдаётся только владельцу токена).
-- Только по id, один курсор, без кабинетов. В отличие от auth.user_select не тянет пейджинг,
-- кабинеты и хеш пароля — специализированное чтение под AuthService.GetProfile.
CREATE OR REPLACE FUNCTION auth.user_get_profile(
_id integer DEFAULT NULL::integer
) RETURNS SETOF refcursor
LANGUAGE plpgsql
AS $$
declare
_data_rc refcursor;
begin
OPEN _data_rc FOR
SELECT
main.id,
main.outer_user_id,
main.login,
main.first_name,
main.last_name,
main.middle_name,
main.email,
main.phone,
main.sex,
main.date_created,
main.is_blocked,
main.password_temp
from auth.user main
where main.is_deleted = false
and main.id = _id;
RETURN NEXT _data_rc;
end;
$$;

View File

@ -0,0 +1,46 @@
-- Вставка пользователя, возврат id. is_blocked/is_deleted при вставке = false.
CREATE OR REPLACE FUNCTION auth.user_insert(_outer_user_id text, _login text, _password text, _password_temp text, _email text,_phone text, _first_name text, _middle_name text, _last_name text, _date_created timestamp with time zone, _sex boolean) RETURNS integer
LANGUAGE plpgsql
AS $$
declare
_record_id int;
begin
insert into auth.user
(
outer_user_id,
login,
password,
password_temp,
email,
phone,
first_name,
middle_name,
last_name,
date_created,
is_blocked,
is_deleted,
date_deleted,
sex
)
values
(
_outer_user_id,
_login,
_password,
_password_temp,
_email,
_phone,
_first_name,
_middle_name,
_last_name,
_date_created,
false,
false,
NULL,
_sex
)
returning id into _record_id;
return _record_id;
end;
$$;

View File

@ -0,0 +1,14 @@
-- Проверка занятости логина.
CREATE OR REPLACE FUNCTION auth.user_login_exists(_login text) RETURNS boolean
LANGUAGE plpgsql
AS $$
begin
IF EXISTS(SELECT id FROM auth.user where login = _login) THEN
RETURN TRUE;
ELSE
RETURN false;
END IF;
end;
$$;

View File

@ -0,0 +1,125 @@
-- Выборка пользователей: первый курсор — пользователи (+ user_title), второй — их кабинеты
-- (LEGACY-связка через auth.user_cabinet), третий — пейджинг. Имена колонок сортировки —
-- camelCase, как ждёт API. Тело сохранено как в исходных init-скриптах.
CREATE OR REPLACE FUNCTION auth.user_select(_id integer DEFAULT NULL::integer,
_outer_user_id text DEFAULT NULL::text,
_cabinet_id integer DEFAULT NULL::integer,
_login text DEFAULT NULL::text,
_password text DEFAULT NULL::text,
_first_name text DEFAULT NULL::text,
_middle_name text DEFAULT NULL::text,
_last_name text DEFAULT NULL::text,
_email text DEFAULT NULL::text,
_is_blocked boolean DEFAULT NULL::boolean,
_is_deleted boolean DEFAULT NULL::boolean,
_start integer DEFAULT 0, _length integer DEFAULT 9999999,
_sort_column text DEFAULT 'id'::text,
_sort_dir text DEFAULT 'asc'::text) RETURNS SETOF refcursor
LANGUAGE plpgsql
AS $$
declare
_data_rc refcursor;
_cabinet_rc refcursor;
--_roles_rc refcursor;
_paging_rc refcursor;
_totalRows int := 0;
begin
CREATE TEMP TABLE _ids (id int, no int, filtered_rows int) ON COMMIT DROP;
WITH t1 AS (
SELECT
main.id,
ROW_NUMBER() OVER (ORDER BY
CASE WHEN (_sort_column = 'id' AND _sort_dir = 'asc') THEN main.id END ASC,
CASE WHEN (_sort_column = 'id' AND _sort_dir = 'desc') THEN main.id END DESC,
CASE WHEN (_sort_column = 'firstName' AND _sort_dir = 'asc') THEN main.first_name END ASC,
CASE WHEN (_sort_column = 'firstName' AND _sort_dir = 'desc') THEN main.first_name END DESC,
CASE WHEN (_sort_column = 'middleName' AND _sort_dir = 'asc') THEN main.middle_name END ASC,
CASE WHEN (_sort_column = 'middleName' AND _sort_dir = 'desc') THEN main.middle_name END DESC,
CASE WHEN (_sort_column = 'lastName' AND _sort_dir = 'asc') THEN main.last_name END ASC,
CASE WHEN (_sort_column = 'lastName' AND _sort_dir = 'desc') THEN main.last_name END DESC,
CASE WHEN (_sort_column = 'email' AND _sort_dir = 'asc') THEN main.email END ASC,
CASE WHEN (_sort_column = 'email' AND _sort_dir = 'desc') THEN main.email END DESC
) AS no
from auth.user main
where (_id is null or main.id = _id)
and (_outer_user_id is null or main.outer_user_id = _outer_user_id)
and (_login is null or main.login = _login)
and ((_password is null or main.password_temp = _password or main.password = _password) OR (_password is null or main.password_temp = _password or main.password = _password))
AND (_first_name IS NULL OR main.first_name ILIKE concat('%', _first_name, '%'))
AND (_middle_name IS NULL OR main.middle_name ILIKE concat('%', _middle_name, '%'))
AND (_last_name IS NULL OR main.last_name ILIKE concat('%', _last_name, '%'))
AND (_email IS NULL OR main.email ILIKE concat('%', _email, '%'))
and (_is_deleted is null or main.is_deleted = _is_deleted)
and (_is_blocked is null or main.is_blocked = _is_blocked)
and (_cabinet_id is null or main.id IN (
SELECT user_id
FROM auth.user_cabinet
WHERE cabinet_id = _cabinet_id
))
),
t2 AS (
SELECT COUNT(*) AS filtered_rows FROM t1
),
t3 AS (
SELECT
t1.id,
t1.no
FROM t1
ORDER BY t1.no
OFFSET _start LIMIT _length
)
INSERT INTO _ids (id, no, filtered_rows)
SELECT
t3.id,
t3.no,
t2.filtered_rows
FROM t3
CROSS JOIN t2;
OPEN _data_rc FOR
SELECT
main.*,
CONCAT(main.first_name, ' ', main.middle_name, ' ', main.last_name) as user_title
from auth.user main
join _ids on main.id = _ids.id
ORDER BY _ids.no;
RETURN NEXT _data_rc;
open _cabinet_rc for
select
main.user_id as user_id,
c.id,
c.title,
c.organization_inn,
c.organization_name,
c.date_created
from auth.user_cabinet main
join _ids on main.user_id = _ids.id
inner join auth.cabinet c ON main.cabinet_id = c.id
where c.is_deleted = false
;
return next _cabinet_rc;
/*
open _roles_rc for
select
main.cabinet_id as cabinet_id,
main.user_id as user_id,
r.id,
r.title
from auth.user_cabinet_role main
join _ids on main.user_id = _ids.id
inner join auth.user_cabinet uc ON main.cabinet_id = uc.cabinet_id AND main.user_id = uc.user_id
inner join auth.role r ON main.role_id = r.id;
return next _roles_rc;
*/
-- select paging
OPEN _paging_rc FOR
SELECT
(SELECT COUNT(id)::int from auth.user WHERE (is_deleted = false OR is_deleted IS NULL)) AS total_rows,
COALESCE((SELECT filtered_rows::int FROM _ids LIMIT 1), 0) AS filtered_rows;
RETURN NEXT _paging_rc;
end;
$$;

View File

@ -0,0 +1,29 @@
-- Пользователи по списку id (batch для межсервисного обогащения списков — JoinUserProfiles).
-- Один курсор, только публичные поля профиля (без пароля/кабинетов). Пустой/NULL массив → пусто
-- (id = ANY(NULL) → NULL → строк нет). Только неудалённые.
CREATE OR REPLACE FUNCTION auth.user_select_by_ids(_ids integer[] DEFAULT NULL::integer[])
RETURNS SETOF refcursor
LANGUAGE plpgsql
AS $$
DECLARE
_rc refcursor;
BEGIN
OPEN _rc FOR
SELECT
main.id,
main.outer_user_id,
main.login,
main.first_name,
main.last_name,
main.middle_name,
main.email,
main.phone,
main.sex,
main.date_created,
main.is_blocked
FROM auth.user main
WHERE main.is_deleted = false
AND main.id = ANY(_ids);
RETURN NEXT _rc;
END;
$$;

View File

@ -0,0 +1,11 @@
-- Блокировка/разблокировка пользователя.
CREATE OR REPLACE FUNCTION auth.user_set_is_blocked(_id integer, _is_blocked boolean) RETURNS void
LANGUAGE plpgsql
AS $$
begin
update auth.user
set
is_blocked = _is_blocked
where auth.user.id = _id;
end;
$$;

View File

@ -0,0 +1,17 @@
-- Обновление профиля пользователя (без логина/пароля).
CREATE OR REPLACE FUNCTION auth.user_update(_id integer, _outer_user_id text, _email text, _phone text,_first_name text, _middle_name text, _last_name text, _sex boolean) RETURNS void
LANGUAGE plpgsql
AS $$
begin
update auth.user
set
outer_user_id = _outer_user_id,
email = _email,
phone = _phone,
first_name = _first_name,
middle_name = _middle_name,
last_name = _last_name,
sex = _sex
where auth.user.id = _id;
end;
$$;

View File

@ -0,0 +1,15 @@
-- Установка постоянного пароля (сбрасывает password_temp). Только для активного
-- (не удалён, не заблокирован) пользователя.
CREATE OR REPLACE FUNCTION auth.user_update_password(_id integer, _password text) RETURNS void
LANGUAGE plpgsql
AS $$
begin
update auth.user
set
password_temp = null,
password = _password
where auth.user.id = _id
and auth.user.is_deleted = false
and auth.user.is_blocked = false;
end;
$$;

View File

@ -0,0 +1,11 @@
-- Установка временного пароля (password_temp).
CREATE OR REPLACE FUNCTION auth.user_update_password_temp(_id integer, _password_temp text) RETURNS void
LANGUAGE plpgsql
AS $$
begin
update auth.user
set
password_temp = _password_temp
where auth.user.id = _id;
end;
$$;

View File

@ -0,0 +1,14 @@
-- LEGACY (таблица auth.user_cabinet будет убрана). Полная замена привязок пользователя
-- к кабинетам: удаляет старые и вставляет переданный массив cabinet_ids.
CREATE OR REPLACE FUNCTION auth.user_cabinet_bulk_insert(_user_id integer, _cabinet_ids integer[]) RETURNS void
LANGUAGE plpgsql
AS $$
begin
-- Удаляем старые привязки к кабинету пользователя (опционально)
DELETE FROM auth.user_cabinet WHERE user_id = _user_id;
-- Вставляем новые привязки к кабинету
INSERT INTO auth.user_cabinet (user_id, cabinet_id)
values (_user_id, unnest(_cabinet_ids));
end;
$$;

View File

@ -0,0 +1,20 @@
-- LEGACY (таблица auth.user_cabinet будет убрана). Кабинеты пользователя одним курсором.
CREATE OR REPLACE FUNCTION auth.user_cabinet_select(_user_id integer DEFAULT NULL::integer) RETURNS SETOF refcursor
LANGUAGE plpgsql
AS $$
declare
_data_rc refcursor;
begin
open _data_rc for
select
c.*
from auth.user_cabinet main
inner join auth.cabinet c ON main.cabinet_id = c.id
inner join auth.user u ON main.user_id = u.id
where main.user_id = _user_id
;
return next _data_rc;
end;
$$;

View File

@ -0,0 +1,2 @@
-- Создание схемы auth (выполняется один раз при первом развёртывании).
CREATE SCHEMA IF NOT EXISTS auth;

View File

@ -0,0 +1,37 @@
-- Delta-миграция (auth-БД): функция auth.user_select_by_ids — batch-выборка публичных профилей
-- пользователей по списку id. Нужна для межсервисного обогащения списков в потребителях
-- (Kit.Auth.Client.JoinUserProfiles → POST auth/user/by-ids).
--
-- Идемпотентна: CREATE OR REPLACE. Безопасно запускать повторно; подходит для прода без
-- пересоздания БД. Соответствует init/schemas/auth/functions/user/user_select_by_ids.psql.
BEGIN;
CREATE OR REPLACE FUNCTION auth.user_select_by_ids(_ids integer[] DEFAULT NULL::integer[])
RETURNS SETOF refcursor
LANGUAGE plpgsql
AS $$
DECLARE
_rc refcursor;
BEGIN
OPEN _rc FOR
SELECT
main.id,
main.outer_user_id,
main.login,
main.first_name,
main.last_name,
main.middle_name,
main.email,
main.phone,
main.sex,
main.date_created,
main.is_blocked
FROM auth.user main
WHERE main.is_deleted = false
AND main.id = ANY(_ids);
RETURN NEXT _rc;
END;
$$;
COMMIT;

View File

@ -0,0 +1,78 @@
-- ============================================================================
-- Обновление демо-пользователей auth."user" на проде kit-auth2 (МЕРЖ по id).
--
-- Как применять: открыть в pgAdmin -> Query Tool на подключении к БД kit-auth2
-- (роль user_auth / user_auth2 — владелец таблицы, либо postgres) и выполнить (F5).
--
-- Зачем: init.sh отрабатывает только на пустом томе, поэтому правки seed в
-- init/schemas/auth/auth.post-deploy.psql не доезжают до уже инициализированной прод-БД.
--
-- Что делает: идемпотентный UPSERT по PK id для строк 1..7 (INSERT ... ON CONFLICT
-- (id) DO UPDATE). Строки 1..4 обновляются до нового вида, 5..7 добавляются; любые
-- строки с id вне 1..7 (реальные зарегистрированные пользователи) НЕ трогаются.
-- Можно запускать повторно — результат тот же. TRUNCATE не выполняется.
--
-- id заданы ЯВНО (1..7) для выравнивания с cabinet_user.user_id в Kit.Partner.Service.
-- Значения синхронны с init/schemas/auth/auth.post-deploy.psql (там id неявный serial).
-- ============================================================================
BEGIN;
INSERT INTO auth."user"(
id, outer_user_id, login, password, password_temp,
first_name, middle_name, last_name, email, phone, sex,
date_created, is_blocked, is_deleted, date_deleted)
VALUES
-- 1: администратор портала ОТ5
(1, 'user_ot5', 'ot5', '', 'ot5_pass',
'Администратор', 'портала', 'ОТ5', 'admin@ot5.app', '11-11-11', true,
'2025-01-01', false, false, null),
-- 2: администратор партнёра ОТ5
(2, 'p_ot5', 'p_ot5', '', 'p_ot5_pass',
'Администратор', 'партнёра', 'Kit ОТ5', 'kit_admin@ot5.app', '11-11-11', true,
'2025-01-01', false, false, null),
-- 3: администратор партнёра ОТ6
(3, 'p_ot6', 'p_ot6', '', 'p_ot6_pass',
'Администратор', 'партнёра', 'Kit ОТ6', 'kit_admin@ot6.app', '11-11-11', true,
'2025-01-01', false, false, null),
-- 4: админ контрагента ОТ5-1
(4, 'c_ot5_1', 'c_ot5_1', '', 'c_ot5_1_pass',
'Админ', 'контрагента', 'ОТ5 Контрагент 1', 'c1@ot5.app', '44-44-44', true,
'2025-01-01', false, false, null),
-- 5: админ контрагента ОТ5-2
(5, 'c_ot5_2', 'c_ot5_2', '', 'c_ot5_2_pass',
'Админ', 'контрагента', 'ОТ5 Контрагент 2', 'c2@ot5.app', '55-55-55', false,
'2025-01-01', false, false, null),
-- 6: админ контрагента ОТ6-1
(6, 'c_ot6_1', 'c_ot6_1', '', 'c_ot6_1_pass',
'Админ', 'контрагента', 'ОТ6 Контрагент 1', 'c1@ot6.app', '66-66-66', true,
'2025-01-01', false, false, null),
-- 7: админ контрагента ОТ6-2
(7, 'c_ot6_2', 'c_ot6_2', '', 'c_ot6_2_pass',
'Админ', 'контрагента', 'ОТ6 Контрагент 2', 'c2@ot6.app', '77-77-77', false,
'2025-01-01', false, false, null)
ON CONFLICT (id) DO UPDATE SET
outer_user_id = EXCLUDED.outer_user_id,
login = EXCLUDED.login,
password = EXCLUDED.password,
password_temp = EXCLUDED.password_temp,
first_name = EXCLUDED.first_name,
middle_name = EXCLUDED.middle_name,
last_name = EXCLUDED.last_name,
email = EXCLUDED.email,
phone = EXCLUDED.phone,
sex = EXCLUDED.sex,
date_created = EXCLUDED.date_created,
is_blocked = EXCLUDED.is_blocked,
is_deleted = EXCLUDED.is_deleted,
date_deleted = EXCLUDED.date_deleted;
-- Досбросить serial, чтобы будущий INSERT без id (напр. /auth/register) не наткнулся
-- на занятые id 1..7.
SELECT setval(pg_get_serial_sequence('auth."user"', 'id'),
(SELECT COALESCE(MAX(id), 1) FROM auth."user"));
COMMIT;
-- Проверка результата (выполнить отдельно после COMMIT при необходимости):
-- SELECT id, outer_user_id, login, email, is_blocked FROM auth."user" ORDER BY id;

View File

@ -0,0 +1,13 @@
-- LEGACY (оставлена для совместимости, в дальнейшем будет убрана).
-- Кабинет: обычная таблица (entity) с мягким удалением (is_deleted/date_deleted) — правило 03.
-- id serial PK; organization_inn/organization_name — необязательные реквизиты организации.
CREATE TABLE auth.cabinet
(
id serial not null primary key,
title text not null,
organization_inn text null,
organization_name text null,
date_created timestamptz not null,
is_deleted boolean not null default false,
date_deleted timestamptz null
);

View File

@ -0,0 +1,26 @@
-- Пользователь: обычная таблица (entity) с мягким удалением и флагом блокировки.
-- "user" — зарезервированное слово PostgreSQL, поэтому имя экранируется кавычками.
-- password — закодированный хеш пароля в формате PBKDF2 (см. PasswordHasher: "pbkdf2-sha256$iter$salt$hash").
-- password_temp — DEPRECATED: временный пароль не используется (восстановление пароля идёт через
-- токен-БД token.verification_token), колонка оставлена для совместимости со старыми данными.
-- outer_user_id — идентификатор во внешней системе.
-- ПРИМ.: состояние аутентификации (подтверждение email, lockout, счётчик неудач) и сессии
-- вынесены в отдельную токен-БД (проект Kit.Auth.Token.Db) — эта таблица намеренно не изменена.
CREATE TABLE auth."user"
(
id serial not null primary key,
outer_user_id text null,
login text not null,
password text null,
password_temp text null,
first_name text not null,
middle_name text not null,
last_name text not null,
email text null,
phone text null,
sex boolean null,
date_created timestamptz not null,
is_blocked boolean not null default false,
is_deleted boolean not null default false,
date_deleted timestamptz null
);

View File

@ -0,0 +1,10 @@
-- LEGACY (оставлена для совместимости, в дальнейшем будет убрана).
-- Соединительная таблица «пользователь ↔ кабинет». Структура сохранена как в исходных
-- init-скриптах: без первичного ключа и внешних ключей — чтобы не менять поведение
-- существующего кода (auth.user_cabinet_bulk_insert / auth.user_cabinet_select).
-- Конвенциям junction (правило 03: serial id / FK) намеренно НЕ приводится — это legacy.
CREATE TABLE auth.user_cabinet
(
user_id integer not null,
cabinet_id integer not null
);

View File

@ -0,0 +1,45 @@
#!/usr/bin/env bash
#
# Точечное применение одного .psql (идемпотентный CREATE OR REPLACE FUNCTION и т.п.)
# к работающей БД auth — БЕЗ полной замены схемы (в отличие от sync-to-prod.sh).
# Для «хирургических» изменений: добавить/переопределить функцию, не трогая данные.
#
# psql берётся из docker (локальный клиент не нужен).
#
# Использование:
# ./apply-psql.sh auth/functions/user/user_get_profile.psql # локальная БД
# TARGET=prod PROD_APP_PASS=... ./apply-psql.sh <файл.psql> # прод
#
# Локально идём в контейнер kit-auth-db напрямую (docker exec); на прод — по host-адресу.
# Любой параметр ниже переопределяется одноимённой env-переменной.
set -euo pipefail
FILE="${1:?укажи путь к .psql (например auth/functions/user/user_get_profile.psql)}"
[ -f "$FILE" ] || { echo "ОШИБКА: нет файла: $FILE" >&2; exit 1; }
command -v docker >/dev/null || { echo "ОШИБКА: нужен docker" >&2; exit 1; }
TARGET="${TARGET:-local}"
PG_IMAGE="${PG_IMAGE:-postgres:17-alpine}"
if [ "$TARGET" = "local" ]; then
# локальная БД — прямо в контейнере
LOCAL_CONTAINER="${LOCAL_CONTAINER:-kit-auth-db}"
LOCAL_DB="${LOCAL_DB:-kit-auth2}"
LOCAL_USER="${LOCAL_USER:-user_auth}"
echo "==> Применяю $(basename "$FILE") -> контейнер $LOCAL_CONTAINER, БД $LOCAL_DB (пользователь $LOCAL_USER)"
docker exec -i "$LOCAL_CONTAINER" \
psql -v ON_ERROR_STOP=1 -U "$LOCAL_USER" -d "$LOCAL_DB" < "$FILE"
else
# прод — по host-адресу, прикладным пользователем (он же владелец объектов)
PROD_HOST="${PROD_HOST:-176.99.5.31}"
PROD_PORT="${PROD_PORT:-5832}"
PROD_DB="${PROD_DB:-kit-auth2}"
PROD_APP_USER="${PROD_APP_USER:-user_auth}"
PROD_APP_PASS="${PROD_APP_PASS:?нужен PROD_APP_PASS (пароль прикладного пользователя прода)}"
echo "==> Применяю $(basename "$FILE") -> $PROD_APP_USER@$PROD_HOST:$PROD_PORT/$PROD_DB (prod)"
docker run --rm -i -e PGPASSWORD="$PROD_APP_PASS" "$PG_IMAGE" \
psql -v ON_ERROR_STOP=1 -h "$PROD_HOST" -p "$PROD_PORT" -U "$PROD_APP_USER" -d "$PROD_DB" < "$FILE"
fi
echo "==> Готово."

View File

@ -0,0 +1,25 @@
name: kit-auth-db
networks:
default:
external: true
name: sdi_server
services:
sdi-auth-db:
image: postgres
restart: always
ports:
- 5479:5432
container_name: kit-auth-db
environment:
POSTGRES_DB: kit-auth2
POSTGRES_USER: user_auth
POSTGRES_PASSWORD: f0e87ce7-b857-4bbc-907c-ff0ab90514ad
PGDATA: "/data"
healthcheck:
test: ["CMD-SHELL", "pg_isready -d $${POSTGRES_DB} -U $${POSTGRES_USER}"]
interval: 10s
timeout: 5s
retries: 5
volumes:
- ./pg_data:/var/lib/postgresql/data
- ./init:/docker-entrypoint-initdb.d

View File

@ -0,0 +1,36 @@
@echo off
setlocal
set reset_data=0
if "%~1"=="" (
goto end
)
:checkArguments
if "%~1"=="" (
goto end
)
if "%~1"=="--reset-data" (
set reset_data=1
)
if "%~1"=="-r" (
set reset_data=1
)
shift
goto checkArguments
:end
@echo on
docker compose down
@echo off
if "%reset_data%"=="1" (
rmdir /s /q .\pg_data
echo.
echo volume removed
echo.
)
endlocal

View File

@ -0,0 +1,13 @@
@echo off
setlocal
REM Снимок работающего контейнера БД в образ и сохранение в tar для переноса на сервер.
docker commit sdi-auth-db sdi-auth-db
docker save -o sdi-auth-db.tar sdi-auth-db:latest
REM На сервере (см. import.bat):
REM sudo docker rmi sdi-auth-db
REM sudo docker load -i sdi-auth-db.tar
REM sudo docker compose up -d
endlocal

View File

@ -0,0 +1,9 @@
@echo off
setlocal
REM Загрузка перенесённого образа БД на сервере и подъём контейнера.
sudo docker rmi sdi-auth-db
sudo docker load -i sdi-auth-db.tar
sudo docker compose up -d
endlocal

View File

@ -0,0 +1,60 @@
file_publish="/docker-entrypoint-initdb.d/scripts/publish.psql"
files=(
##########################################################################
# schemas
"/docker-entrypoint-initdb.d/schemas/auth/auth.psql"
##########################################################################
# tables
# cabinet и user — раньше user_cabinet (на них ссылаются user_id / cabinet_id)
"/docker-entrypoint-initdb.d/schemas/auth/tables/cabinet.psql"
"/docker-entrypoint-initdb.d/schemas/auth/tables/user.psql"
"/docker-entrypoint-initdb.d/schemas/auth/tables/user_cabinet.psql"
##########################################################################
# functions
# cabinet
"/docker-entrypoint-initdb.d/schemas/auth/functions/cabinet/cabinet_insert.psql"
"/docker-entrypoint-initdb.d/schemas/auth/functions/cabinet/cabinet_update.psql"
"/docker-entrypoint-initdb.d/schemas/auth/functions/cabinet/cabinet_delete.psql"
"/docker-entrypoint-initdb.d/schemas/auth/functions/cabinet/cabinet_select.psql"
# user
"/docker-entrypoint-initdb.d/schemas/auth/functions/user/user_insert.psql"
"/docker-entrypoint-initdb.d/schemas/auth/functions/user/user_select.psql"
"/docker-entrypoint-initdb.d/schemas/auth/functions/user/user_select_by_ids.psql"
"/docker-entrypoint-initdb.d/schemas/auth/functions/user/user_get_by_login.psql"
# user_get_for_auth — единственная новая функция в схеме auth: поиск по login/email без сверки
# пароля (PBKDF2 сверяется в C#); сама таблица auth.user намеренно не изменена.
"/docker-entrypoint-initdb.d/schemas/auth/functions/user/user_get_for_auth.psql"
# user_get_profile — специализированное чтение профиля владельца токена (+ password_temp)
"/docker-entrypoint-initdb.d/schemas/auth/functions/user/user_get_profile.psql"
"/docker-entrypoint-initdb.d/schemas/auth/functions/user/user_delete.psql"
"/docker-entrypoint-initdb.d/schemas/auth/functions/user/user_set_is_blocked.psql"
"/docker-entrypoint-initdb.d/schemas/auth/functions/user/user_update.psql"
"/docker-entrypoint-initdb.d/schemas/auth/functions/user/user_update_password.psql"
"/docker-entrypoint-initdb.d/schemas/auth/functions/user/user_update_password_temp.psql"
"/docker-entrypoint-initdb.d/schemas/auth/functions/user/user_login_exists.psql"
"/docker-entrypoint-initdb.d/schemas/auth/functions/user/user_email_exists.psql"
# user_cabinet
"/docker-entrypoint-initdb.d/schemas/auth/functions/user_cabinet/user_cabinet_bulk_insert.psql"
"/docker-entrypoint-initdb.d/schemas/auth/functions/user_cabinet/user_cabinet_select.psql"
##########################################################################
# post-deploy (seed)
"/docker-entrypoint-initdb.d/schemas/auth/auth.post-deploy.psql"
)
> ${file_publish}
# ВАЖНО: явно устанавливаем UTF-8 перед вставкой seed-данных с кириллицей.
# Без этого psql может использовать системную кодировку клиента (LC_CTYPE),
# что приводит к двойному кодированию UTF-8 → Latin-1/CP1251 → UTF-8.
echo "SET client_encoding TO 'UTF8';" >> ${file_publish}
printf "\n\n" >> ${file_publish}
for item in "${files[@]}"; do
cat "${item}" >> "${file_publish}" && printf "\n\n\n" >> "${file_publish}"
done
# run publish.psql (база auth — POSTGRES_DB)
psql -v ON_ERROR_STOP=1 --username "$POSTGRES_USER" --dbname "$POSTGRES_DB" -f "${file_publish}"

View File

@ -0,0 +1,7 @@
@echo off
setlocal
CALL "down.bat" %*
CALL "up.bat"
endlocal

View File

@ -0,0 +1,8 @@
@echo off
setlocal
docker network create -d bridge sdi_server
docker compose up -d
endlocal

View File

@ -0,0 +1,40 @@
# Kit.Auth.Db
## Назначение
База данных авторизации. Хранит пользователей, кабинеты (LEGACY) и связи пользователей с кабинетами (LEGACY). CRUD-операции выполняются через хранимые функции.
## Движок
PostgreSQL
## Схема
`auth`
## Модули
### auth
Основной модуль. Содержит таблицы и функции для работы с авторизацией.
**Таблицы:**
- `user` — пользователи (мягкое удаление, блокировка)
- `cabinet` — кабинеты (LEGACY, будет убрана)
- `user_cabinet` — связь пользователей с кабинетами (LEGACY, будет убрана)
**Функции (user):**
- CRUD: user_insert, user_select, user_select_by_ids, user_get_by_login, user_get_for_auth, user_get_profile, user_delete, user_update
- Управление паролями: user_update_password, user_update_password_temp
- Блокировка: user_set_is_blocked
- Проверка: user_login_exists, user_email_exists
**Функции (cabinet):**
- CRUD: cabinet_insert, cabinet_select, cabinet_update, cabinet_delete
**Функции (user_cabinet):**
- user_cabinet_bulk_insert, user_cabinet_select
## Связи
- Связана с `Kit.Auth.Token.Db` (схема `token`) — токены авторизации ссылаются на пользователей

View File

@ -0,0 +1,7 @@
{
"host": "localhost",
"port": 5432,
"database": "kit-auth2",
"username": "postgres",
"password": ""
}

View File

@ -74,6 +74,20 @@ projects/pg/{project_name}/
- Путь: `{schema}/{schema}.post-deploy.psql`
- Скрипты, выполняемые после деплоя (seed-данные, обновление зависимостей)
### 9. Диаграммы
- Путь: `_docs/diagrams/`
- Формат: Mermaid `.md` файлы
- Обязательные диаграммы:
- `er-diagram.md` — ER-диаграмма всех таблиц схемы (связи, типы, ключи)
- Правила формирования:
- Каждая таблица — блок `erDiagram` с перечислением полей и типов
- Связи между таблицами отображаются через `||--o{`, `|--|{` и т.д.
- Внешние ключи помечаются комментариями
- LEGACY-таблицы помечаются `%% LEGACY` в комментарии
- Имена таблиц: `schema.table` (например `auth.user`)
- Типы данных: PostgreSQL → Mermaid (serial→int, text→string, timestamptz→datetime, boolean→bool, integer[]→int[])
---
## Пример: проект kit_auth_pg

View File

@ -63,6 +63,19 @@ SQLite не поддерживает схемы, поэтому модуль =
- Точка входа — подключает все таблицы и скрипты модуля
- Порядок: сначала таблицы, потом скрипты
### 7. Диаграммы
- Путь: `_docs/diagrams/`
- Формат: Mermaid `.md` файлы
- Обязательные диаграммы:
- `er-diagram.md` — ER-диаграмма всех таблиц модуля (связи, типы, ключи)
- Правила формирования:
- Каждая таблица — блок `erDiagram` с перечислением полей и типов
- Связи между таблицами отображаются через `||--o{`, `|--|{` и т.д.
- Внешние ключи помечаются комментариями
- LEGACY-таблицы помечаются `%% LEGACY` в комментарии
- Типы данных: SQLite → Mermaid (INTEGER→int, TEXT→string, REAL→real, BLOB→blob)
---
## Особенности SQLite