fix(mcp): подготовить доступ и отзыв разрешений для приёмки

This commit is contained in:
Denozordec committed 2026-11-03 03:05:24 +07:00
commit ef7bb79d4e
311 files changed
+73119

No files matched your search

+259
View File
@@ -0,0 +1,259 @@
# Развёртывание Auth Portal + Traefik (один Docker Compose)
Один стек: **Traefik** (HTTPS, Let's Encrypt DNS-01 через Cloudflare) + **auth-portal** (API + SPA, SQLite).
Образ приложения: `git.shx.one/denozord/auth-portal` (тот же манифест публикуется как `authportal`)
Runtime: `node:22-alpine`, API + SPA + SQLite в одном процессе.
Пайплайн: [`.gitea/README.md`](../.gitea/README.md), [`docs/releasing.md`](releasing.md).
Публичный URL: `https://auth.shnt.top`
Compose: [`deploy/docker-compose.traefik.yml`](../deploy/docker-compose.traefik.yml)
Env-шаблон: [`deploy/env.traefik.example`](../deploy/env.traefik.example)
Документация Traefik: [Expose Docker](https://doc.traefik.io/traefik/expose/docker/basic/), [ACME DNS challenge](https://doc.traefik.io/traefik/https/acme/).
```
Internet → :80/:443 (Traefik) → auth-portal:8080
↑
Cloudflare DNS TXT (ACME)
```
## Предпосылки
1. Зона домена в Cloudflare (например `shnt.top`).
2. Docker Engine + Compose plugin на VPS.
3. Свободные порты **80** и **443** на хосте (этот стек сам поднимает Traefik).
4. `docker login git.shx.one`.
> Если на сервере уже крутится другой Traefik на 80/443 — либо остановите его, либо смените `TRAEFIK_HTTP_PORT` / `TRAEFIK_HTTPS_PORT` (и проброс снаружи). Два Traefik на одних портах не запустятся.
---
## 1. Cloudflare: токен и DNS
### API-токен
[API Tokens → Create Token](https://dash.cloudflare.com/profile/api-tokens) — шаблон **Edit zone DNS** или Custom:
| Permission | Access |
|------------|--------|
| Zone → DNS | Edit |
| Zone → Zone | Read (желательно) |
**Zone Resources:** Include → Specific zone → ваша зона.
Токен → `CF_DNS_API_TOKEN` в `.env` стека. Не Global API Key, не в Git.
### DNS-запись
| Type | Name | Content | Proxy |
|------|------|---------|-------|
| `A` / `AAAA` | `auth` | IP VPS | **DNS only** (серое облако) |
```bash
dig +short auth.shnt.top A
```
Traefik для сертификата создаёт TXT `_acme-challenge.auth…` через Cloudflare API (HTTP-01 на :80 не обязателен).
---
## 2. Файлы на сервере
```bash
mkdir -p /opt/auth-portal/data
chown -R 1000:1000 /opt/auth-portal/data # контейнер работает от uid 1000 (node)
cd /opt/auth-portal
# из репозитория или curl:
# deploy/docker-compose.traefik.yml → docker-compose.yml
# deploy/env.traefik.example → .env
curl -fsSL -o docker-compose.yml \
https://git.shx.one/denozord/auth-portal/raw/branch/main/deploy/docker-compose.traefik.yml
curl -fsSL -o .env \
https://git.shx.one/denozord/auth-portal/raw/branch/main/deploy/env.traefik.example
nano .env # заполнить секреты
```
### Обязательные переменные в `.env`
| Переменная | Назначение |
|------------|------------|
| `CF_DNS_API_TOKEN` | Cloudflare token для ACME DNS-01 (env контейнера **Traefik**) |
| `LETSENCRYPT_EMAIL` | Email для Let's Encrypt |
| `JWT_SECRET` | HS256; тот же секрет в VPS Tracker / CFDM (`AUTH_JWT_SECRET`) |
| `ADMIN_PASSWORD` | Пароль bootstrap-админа (только при пустой БД) |
| `AUTH_DOMAIN` | Хост в Traefik rule, по умолчанию `auth.shnt.top` |
| `ISSUER` | `https://auth.shnt.top` — совпадает с `AUTH_ISSUER` приложений **и** OIDC issuer |
| `RETURN_TO_ALLOWLIST` | Origins SSO / App Switcher (`.shnt.top`, `https://dns.shnt.top`, …) |
| `OIDC_ISSUER` | Опционально; если пусто — берётся `ISSUER`. Публичный URL IdP для Technitium |
| `OIDC_RSA_PRIVATE_KEY` | Опционально PKCS8 PEM; иначе RSA-ключ в SQLite (`./data`) |
Опционально: `AUTH_IMAGE_TAG`, `TRAEFIK_IMAGE_TAG`, `TRAEFIK_HTTP_PORT`, `TRAEFIK_HTTPS_PORT`.
В production cookie refresh с флагом `Secure` — нужен HTTPS.
### Проверка OIDC после старта
```bash
curl -fsS https://auth.shnt.top/.well-known/openid-configuration | head
curl -fsS https://auth.shnt.top/.well-known/jwks.json | head
```
Админка: `https://auth.shnt.top/admin/oidc` — создать client для Technitium.
Полная инструкция SSO: [integrate-technitium.md](integrate-technitium.md).
---
## 3. Запуск (один compose)
```bash
cd /opt/auth-portal
docker login git.shx.one
docker compose pull
docker compose up -d
docker compose ps
docker compose logs -f --tail=100
```
Сервисы:
| Service | Контейнер | Роль |
|---------|-----------|------|
| `traefik` | `auth-portal-traefik` | :80 → HTTPS, ACME, роутинг |
| `app` | `auth-portal` | приложение на внутренней сети `auth-portal`, порт 8080 |
Первая выдача сертификата обычно 30–90 с.
### Проверка
```bash
curl -fsS https://auth.shnt.top/health
echo | openssl s_client -connect auth.shnt.top:443 -servername auth.shnt.top 2>/dev/null \
| openssl x509 -noout -issuer -dates -subject
docker compose logs traefik 2>&1 | grep -iE 'acme|certificate|cloudflare|error'
```
### Обновление / остановка
```bash
cd /opt/auth-portal
docker compose pull
docker compose up -d
# остановка (тома и ./data сохраняются)
docker compose down
# НЕ делайте down -v без бэкапа — сотрёт ACME (auth_portal_traefik_letsencrypt)
```
> **Переход с root-образа (версии до не-root).** Контейнер теперь работает от
> uid 1000 (`node`). Если `./data` и `app.db` созданы старым контейнером под
> root, при старте будет `SqliteError: SQLITE_READONLY`. Один раз выполнить:
>
> ```bash
> cd /opt/auth-portal
> docker compose down
> sudo chown -R 1000:1000 ./data
> docker compose up -d
> ```
---
## Что внутри compose
- Сеть Docker **`auth-portal`** (внутренняя, создаётся стеком).
- Том **`auth_portal_traefik_letsencrypt`** → `/letsencrypt/acme.json`.
- Том хоста **`./data`** → SQLite портала.
- Labels на `app`: `Host(AUTH_DOMAIN)`, `entrypoints=websecure`, `certresolver=letsencrypt`, backend port `8080`.
- `CF_DNS_API_TOKEN` только у сервиса `traefik`.
Полный файл: [`deploy/docker-compose.traefik.yml`](../deploy/docker-compose.traefik.yml).
---
## Связка с приложениями
### JWT apps (CFDM / VPS / BGP / FW)
```env
AUTH_REQUIRED=true
AUTH_JWT_SECRET=<тот же JWT_SECRET>
AUTH_ISSUER=https://auth.shnt.top
AUTH_PORTAL_URL=https://auth.shnt.top
```
UI:
```env
VITE_AUTH_ENABLED=true
VITE_AUTH_PORTAL_URL=https://auth.shnt.top
```
См. [integrate-vps-tracker.md](integrate-vps-tracker.md), [integrate-cfdm.md](integrate-cfdm.md), [integrate-evobgp.md](integrate-evobgp.md), [integrate-evofirewall.md](integrate-evofirewall.md), [integrate-cdnmanager.md](integrate-cdnmanager.md), [integrate-mikrotikmanager.md](integrate-mikrotikmanager.md).
Logout SSO: `https://auth.shnt.top/logout`.
### Technitium DNS (OIDC)
Portal — IdP; Technitium — RP. После `docker compose up`:
1. В `.env` добавьте origin DNS в `RETURN_TO_ALLOWLIST` (например `https://dns.shnt.top`).
2. `curl https://auth.shnt.top/.well-known/openid-configuration` — должен отвечать JSON.
3. Admin → OIDC-клиенты → redirect `https://dns.shnt.top/sso/callback`.
4. В Technitium SSO: Metadata = `https://auth.shnt.top/.well-known/openid-configuration`.
Важно: Technitium резолвит IdP **своим** DNS — A/AAAA для `auth.shnt.top` должна быть видна с DNS-сервера.
Детали: [integrate-technitium.md](integrate-technitium.md).
---
## Бэкап
```bash
# SQLite: согласованная онлайн-копия (WAL учитывается, сервис не останавливается),
# проверка integrity_check и ротация (BACKUP_KEEP, по умолчанию 7) → /data/backups/
docker exec auth-portal node dist/server.js backup
# Либо встроенный планировщик: BACKUP_INTERVAL_HOURS=24 в .env (0 = выключен).
# Каталог: BACKUP_DIR (по умолчанию <каталог БД>/backups). Не копируйте app.db
# через `cp` на живой БД — без -wal/-shm копия может быть неполной.
# ACME
docker run --rm -v auth_portal_traefik_letsencrypt:/data -v "$PWD:/backup" alpine \
tar czf /backup/traefik-acme-$(date +%F).tgz -C /data .
```
**Восстановление:** `docker compose stop app`, затем в `./data` удалить
`app.db-wal`/`app.db-shm`, скопировать нужный `backups/app-*.db` как `app.db`,
`chown 1000:1000`, `docker compose up -d`. OIDC-ключ подписи хранится в этой же
БД, если не задан `OIDC_RSA_PRIVATE_KEY` — храните ключ отдельным секретом.
Откат образа: в `.env` `AUTH_IMAGE_TAG=vX.Y.Z` → `docker compose pull && docker compose up -d`.
---
## Troubleshooting
| Симптом | Что проверить |
|---------|----------------|
| `Bind for 0.0.0.0:80/443 failed` | Другой Traefik/nginx занимает порты |
| ACME / нет HTTPS | `CF_DNS_API_TOKEN`, права Zone:DNS:Edit, логи `docker compose logs traefik` |
| `invalid credentials` | Не Global Key; зона в scope токена; пробелы в `.env` |
| Gateway Timeout / 404 | `docker compose ps`; labels; сеть `auth-portal` |
| Login OK, SSO в app падает | `JWT_SECRET` / `ISSUER` |
| `return_to` rejected | `RETURN_TO_ALLOWLIST` |
| Cookie не держится | HTTPS; `NODE_ENV=production` |
| Technitium «Failed to reach SSO provider» | A/AAAA `auth.*` в самом Technitium; `curl` с хоста DNS к discovery URL |
| OIDC discovery 404 | образ без OIDC; `ISSUER`/`OIDC_ISSUER` = публичный HTTPS URL; Traefik Host |
```bash
docker compose logs traefik 2>&1 | grep -iE 'acme|certificate|cloudflare|error'
docker compose logs app --tail=50
```
---
## Альтернатива: Docker CLI (без compose)
Если нужен ручной запуск — создайте сеть и два контейнера с теми же env/labels, что в compose. Предпочтительный путь — **один `docker compose up -d`** выше.
+63
View File
@@ -0,0 +1,63 @@
# Audit ingest — интеграция приложений с Auth Portal
Центральный журнал: `GET /api/v1/admin/audit` (только admin).
Приложения пишут **локально** и дублируют события в portal.
## Endpoint
```
POST /api/v1/ingest/audit
Authorization: Bearer <AUDIT_INGEST_SECRET>
Content-Type: application/json
```
### Body
```json
{
"events": [
{
"event_id": "uuid-unique-per-event",
"source_app": "vps",
"action": "vps.vps.create",
"severity": "info",
"actor_user_id": "…",
"actor_email": "[email protected]",
"actor_name": "Name",
"target_type": "app_resource",
"target_id": "entity-id",
"summary": "Человекочитаемое описание",
"details": { "entity": "vps", "diff": {} },
"ip": "1.2.3.4",
"created_at": "2026-07-21T00:00:00.000Z"
}
]
}
```
- `events`: 1–50 за запрос
- `source_app`: `vps` | `cfdm` | `bgp` | `fw` | `dns` | `cdn` | `mm` (не `portal`)
- `event_id`: идемпотентность (дубликаты → `duplicates++`)
- Ответ: `{ "accepted": N, "duplicates": M }`
## Env
| Где | Переменная |
|-----|------------|
| auth-portal | `AUDIT_INGEST_SECRET` |
| apps (vps / cfdm / bgp / fw / cdn) | `AUTH_PORTAL_URL` + `AUTH_AUDIT_INGEST_SECRET` (тот же секрет) |
Dev default secret: `dev-audit-ingest-secret`.
## Dual-write
1. Записать в локальный `audit_log`
2. Fire-and-forget `POST` в portal (timeout ~2s)
3. Ошибка push **не** должна ломать CRUD
## Фильтры admin UI
- `/admin/audit` — source_app + type tabs + range
- Клик по пользователю на `/admin` — Sheet с событиями, где `actor_user_id` или `target_id` = user id
Preview: [solution-users-1](https://reui.io/preview/base/solution-users-1) · [solution-users-6](https://reui.io/preview/base/solution-users-6)
+141
View File
@@ -0,0 +1,141 @@
# Интеграция auth-portal ↔ CDN Manager
Единый вход: пользователь логинится на auth-portal, получает JWT, переходит в CDNManager с токеном в URL fragment. CDNManager API проверяет JWT и права `cdn:*`.
## Архитектура
```
Browser → CDNManager UI (нет token)
→ redirect AUTH_PORTAL_URL/?return_to=…/auth/callback
→ login
→ redirect return_to#access_token=…
→ CDNManager /auth/callback сохраняет token (cdnmanager_token)
→ API Authorization: Bearer …
```
Общий секрет: `JWT_SECRET` / `AUTH_JWT_SECRET` (HS256). Issuer: `ISSUER` / `AUTH_ISSUER`.
App id в портале: **`cdn`** (каталог permissions).
## Локальный запуск
### 1. auth-portal
```bash
cd auth-portal
pnpm install
# JWT_SECRET=dev-secret-change-me
# RETURN_TO_ALLOWLIST=.shnt.top,localhost,http://localhost:5173,…,http://localhost:5176
pnpm --filter @authportal/api dev # :8080
pnpm --filter web dev # :5175
```
В `apps/web/.env.local` (опционально для App Switcher / SSO open):
```env
VITE_CDN_APP_URL=http://localhost:5176
```
Bootstrap: `[email protected]` / `admin`. В админке выдайте app **cdn** и permissions `cdn:*`.
### 2. CDNManager
```bash
cd CDNManager
pnpm install
```
Корень / API:
```env
AUTH_REQUIRED=true
AUTH_JWT_SECRET=dev-secret-change-me
AUTH_ISSUER=https://auth.shnt.top
AUTH_PORTAL_URL=http://localhost:5175
CLOUDFLARE_API_TOKEN=
```
`apps/web/.env.local`:
```env
VITE_AUTH_ENABLED=true
VITE_AUTH_PORTAL_URL=http://localhost:5175
```
Порт Vite web — `5176` (`apps/web/vite.config.ts`). Добавьте origin в `RETURN_TO_ALLOWLIST` портала.
```bash
pnpm --filter @cdnmanager/api dev
pnpm --filter web dev
```
Откройте CDNManager → редирект на portal → после логина NavUser показывает имя/email.
## Permissions ↔ API / UI
Иерархия: `admin` ⊃ `write` ⊃ `read` в рамках одной секции.
| Permission | API | UI |
|------------|-----|-----|
| `cdn:dashboard:read` | GET `/api/v1/dashboard/*`, `/topology` | `/` |
| `cdn:nodes:read` | GET `/api/v1/nodes*`, `/locations` | `/nodes` |
| `cdn:nodes:write` | POST/PATCH/DELETE nodes | create/edit нод |
| `cdn:aliases:read` | GET `/api/v1/aliases*` | `/aliases` |
| `cdn:aliases:write` | POST/PATCH aliases, retarget | create / Retarget |
| `cdn:zones:read` | GET `/api/v1/zones*` | `/zones` |
| `cdn:zones:write` | POST/PATCH zones, BIND export | создать зону |
| `cdn:sync:write` | POST `…/sync`, `…/apply` | Sync / Apply |
| `cdn:topology:read` | GET `/api/v1/topology` | `/topology` |
| `cdn:settings:admin` | GET/PATCH `/api/v1/settings` | `/settings/*` |
Без app `cdn` в JWT `apps` → **403** на защищённые `/api/v1/*`.
`AUTH_REQUIRED=false` — локальный login (`ADMIN_*`) для тестов/dev без portal; UI `/login`.
## App Switcher
Публичный конфиг: `GET {AUTH_PORTAL_URL}/api/v1/app-switcher` (CORS open). CDNManager chrome (`AppSwitcher` / `AppsMenu`) читает его через `ensureAuthConfig().portalUrl`.
Редактор только на портале: **Админка → Ссылки приложений** (`/admin/apps`). В CDNManager Settings → Интеграции — read-only ссылка на портал.
`CURRENT_APP_ID = cdn`. Если в JWT есть `apps[]` — в меню только пересечение с каталогом.
## Audit ingest
Dual-write локального журнала в portal: [`integrate-audit-ingest.md`](./integrate-audit-ingest.md) (`source_app: cdn`).
## UI аккаунта
SidebarFooter → **NavUser**: Настройки, Тема, Выйти → `AUTH_PORTAL_URL/logout`.
## Logout (SSO)
Очистить `cdnmanager_token` → редирект на **`/logout`** портала (не на `/?return_to=…` — иначе portal сразу выдаст новый SSO-токен).
## Production (Docker)
Рекомендуется Traefik-стек в репозитории CDNManager: `docs/deploy-traefik.md`
(`deploy/docker-compose.traefik.yml` + `deploy/env.traefik.example`).
Ключевые env контейнера:
```env
AUTH_REQUIRED=true
AUTH_JWT_SECRET=<тот же JWT_SECRET портала>
AUTH_ISSUER=https://auth.shnt.top
AUTH_PORTAL_URL=https://auth.shnt.top
AUTH_AUDIT_INGEST_SECRET=<AUDIT_INGEST_SECRET портала>
CLOUDFLARE_API_TOKEN=<Zone DNS Edit + Zone Read>
```
В portal: `RETURN_TO_ALLOWLIST` включает `https://cdn.shnt.top` (или ваш origin).
## Troubleshooting
| Симптом | Причина |
|---------|---------|
| SSO loop / «Сессия не принята» | разный `JWT_SECRET` или `ISSUER` у portal и CDNManager |
| 403 «Нет доступа к приложению» | у пользователя нет app `cdn` в portal |
| 403 «Недостаточно прав» | нет нужного `cdn:…` permission |
| return_to rejected | origin CDNManager не в `RETURN_TO_ALLOWLIST` |
| «Выйти» сразу возвращает в CDNManager | клиент должен открывать `/logout`, не login с `return_to` |
+123
View File
@@ -0,0 +1,123 @@
# Интеграция auth-portal ↔ Cloudflare Domain Manager (CFDM)
Единый вход: пользователь логинится на auth-portal, получает JWT, переходит в CFDM с токеном в URL fragment. CFDM API проверяет JWT и права `cfdm:*`.
## Архитектура
```
Browser → CFDM UI (нет token)
→ redirect AUTH_PORTAL_URL/?return_to=…/auth/callback
→ login
→ redirect return_to#access_token=…
→ CFDM /auth/callback сохраняет token (cfdm_token)
→ API Authorization: Bearer …
```
Общий секрет: `JWT_SECRET` / `AUTH_JWT_SECRET` (HS256). Issuer: `ISSUER` / `AUTH_ISSUER`.
App id в портале: **`cfdm`** (уже в каталоге permissions).
## Локальный запуск
### 1. auth-portal
```bash
cd auth-portal
pnpm install
# JWT_SECRET=dev-secret-change-me
# RETURN_TO_ALLOWLIST=.shnt.top,localhost,http://localhost:5173,http://localhost:5174
pnpm --filter @authportal/api dev # :8080
pnpm --filter web dev # :5175
```
В `apps/web/.env.local`:
```env
VITE_CFDM_APP_URL=http://localhost:5174
```
Bootstrap: `[email protected]` / `admin`. В админке выдайте app **cfdm** и permissions `cfdm:*`.
### 2. cloudflare-domain-manager
```bash
cd cloudflare-domain-manager
pnpm install
```
Корень / API:
```env
AUTH_REQUIRED=true
AUTH_JWT_SECRET=dev-secret-change-me
AUTH_ISSUER=https://auth.shnt.top
AUTH_PORTAL_URL=http://localhost:5175
```
`apps/web/.env.local`:
```env
VITE_AUTH_ENABLED=true
VITE_AUTH_PORTAL_URL=http://localhost:5175
```
Порт Vite web — см. `apps/web/vite.config.ts` (часто `5174`). Добавьте origin в `RETURN_TO_ALLOWLIST` портала.
```bash
pnpm --filter api dev
pnpm --filter web dev
```
Откройте CFDM → редирект на portal → после логина NavUser показывает имя/email.
## Permissions ↔ API / UI
Иерархия: `admin` ⊃ `write` ⊃ `read` в рамках одной секции.
| Permission | API | UI |
|------------|-----|-----|
| `cfdm:domains:read` | GET `/api/v1/domains*`, monitors | `/`, `/domains` |
| `cfdm:domains:write` | мутации domains, sync | create/edit/sync |
| `cfdm:dns:read` | GET dns, subdomains | DNS на карточке домена |
| `cfdm:dns:write` | мутации dns/subdomains | формы DNS |
| `cfdm:certificates:read` | GET certificates | `/certificates` |
| `cfdm:certificates:write` | мутации certificates | действия сертификатов |
| `cfdm:groups:read` | GET groups, service-groups | `/groups` |
| `cfdm:groups:write` | мутации groups | формы групп |
| `cfdm:services:read` | GET services, bindings | `/services` |
| `cfdm:services:write` | мутации services | формы сервисов |
| `cfdm:settings:admin` | settings, notifications, health-check | `/settings/*` |
Без app `cfdm` в JWT `apps` → **403** на защищённые `/api/v1/*`.
`AUTH_REQUIRED=false` — локальный login (`ADMIN_*`) для тестов/dev без portal; UI `/login`.
## App Switcher
Публичный конфиг: `GET {AUTH_PORTAL_URL}/api/v1/app-switcher` (CORS open). CFDM chrome (`AppSwitcher` / `AppsMenu`) читает его через `ensureAuthConfig().portalUrl`; offline fallback — hardcoded defaults с ids `cfdm` | `vps` | `bgp`.
Редактор только на портале: **Админка → Ссылки приложений** (`/admin/apps`). В CFDM Settings → Integrations — read-only ссылка на портал.
`CURRENT_APP_ID = cfdm`. Если в JWT есть `apps[]` — в меню только пересечение с каталогом.
## Audit ingest
Dual-write локального журнала в portal: [`integrate-audit-ingest.md`](./integrate-audit-ingest.md) (`source_app: cfdm`).
## UI аккаунта
SidebarFooter → **NavUser** ([app-shell-1](https://reui.io/preview/base/app-shell-1)): Настройки, Тема, Выйти → `AUTH_PORTAL_URL/logout`.
## Logout (SSO)
Очистить `cfdm_token` → редирект на **`/logout`** портала (не на `/?return_to=…` — иначе portal сразу выдаст новый SSO-токен).
## Troubleshooting
| Симптом | Причина |
|---------|---------|
| SSO loop / «Сессия не принята» | разный `JWT_SECRET` или `ISSUER` у portal и CFDM |
| 403 «Нет доступа к приложению» | у пользователя нет app `cfdm` в portal |
| 403 «Недостаточно прав» | нет нужного `cfdm:…` permission |
| return_to rejected | origin CFDM не в `RETURN_TO_ALLOWLIST` |
| «Выйти» сразу возвращает в CFDM | клиент должен открывать `/logout`, не login с `return_to` |
+104
View File
@@ -0,0 +1,104 @@
# Интеграция auth-portal ↔ EvoBGP
Единый вход: пользователь логинится на auth-portal, получает JWT, переходит в EvoBGP с токеном в URL fragment. EvoBGP API проверяет JWT и права `bgp:*`. API-ключи EvoBGP (viewer/editor/operator/node) остаются для автоматизации и нод. Firewall-клиенты перенесены в **EvoFirewall** (`fw:*`).
## Архитектура
```
Browser → EvoBGP UI (нет token)
→ redirect AUTH_PORTAL_URL/?return_to=…/auth/callback
→ login
→ redirect return_to#access_token=…
→ EvoBGP /auth/callback сохраняет token
→ API Authorization: Bearer <JWT>
```
Общий секрет: `JWT_SECRET` / `AUTH_JWT_SECRET` (HS256). Issuer: `ISSUER` / `AUTH_ISSUER`.
App id в портале: **`bgp`**.
## Permissions ↔ UI
| Permission | UI |
|------------|-----|
| `bgp:dashboard:read` | `/dashboard` |
| `bgp:modules:read` / `write` | `/modules` |
| `bgp:lookup:read` | `/lookup` |
| `bgp:network:read` / `write` | `/network` (пиры и спикеры) |
| `bgp:directories:read` / `write` | `/directories` |
| `bgp:operations:read` / `write` / `admin` | `/operations` (admin = apply/rollback) |
| `bgp:schedule:read` / `write` | `/schedule` |
| `bgp:monitoring:read` | `/monitoring` |
| `bgp:access:admin` | `/access` (API-ключи) |
| `bgp:tenant_settings:admin` | `/tenant-settings` |
| `bgp:settings:read` | `/settings` |
Иерархия: `admin` ⊃ `write` ⊃ `read` в рамках одной секции.
Пример обычного пользователя: app `bgp` + только `bgp:dashboard:read` и `bgp:lookup:read`.
### Ownership
Ресурсы modules / peers, созданные через JWT, имеют `created_by_user_id`. Видят и редактируют: создатель и portal `is_admin` (или API key `operator`). API keys без user id — tenant-wide scope. Firewall — см. EvoFirewall / `docs/integrate-evofirewall.md`.
## Локальный запуск
### 1. auth-portal
```bash
cd auth-portal
pnpm install
# JWT_SECRET=dev-secret-change-me
# RETURN_TO_ALLOWLIST=.shnt.top,localhost,http://localhost:5173,…EvoBGP web origin
pnpm --filter @authportal/api dev # :8080
pnpm --filter web dev # :5175
```
```env
# apps/web/.env.local
VITE_BGP_APP_URL=http://localhost:5176
```
Bootstrap: выдайте app **bgp** и нужные `bgp:*` permissions.
### 2. EvoBGP
```env
AUTH_REQUIRED=true
AUTH_JWT_SECRET=dev-secret-change-me
AUTH_ISSUER=https://auth.shnt.top
AUTH_PORTAL_URL=http://localhost:5175
# Опционально, если tenant не задан в portal /admin/apps для bgp:
# EVOBGP_PORTAL_TENANT_ID=<uuid tenant>
```
```env
# apps/web/.env.local
VITE_AUTH_ENABLED=true
VITE_AUTH_PORTAL_URL=http://localhost:5175
```
В portal **Админ → Приложения → BGP** укажите **EvoBGP tenant ID** (UUID из БД / лога `DemoIDs` / API-ключа). Он попадёт в JWT как `bgp_tenant_id`. Portal `is_admin` получает полный каталог `bgp:*` в JWT.
## App Switcher
Публичный конфиг: `GET {AUTH_PORTAL_URL}/api/v1/app-switcher` (без `tenantId`).
`CURRENT_APP_ID = bgp`. Редактор ссылок и tenant — portal `/admin/apps`.
## Logout (SSO)
Очистить локальный JWT → `AUTH_PORTAL_URL/logout` (не `/?return_to=`).
## Audit ingest
Dual-write локального журнала в portal: [`integrate-audit-ingest.md`](./integrate-audit-ingest.md) (`source_app: bgp`).
## Troubleshooting
| Симптом | Причина |
|---------|---------|
| 401 на API | Нет/битый Bearer; разные `JWT_SECRET` |
| 403 нет доступа к приложению | В portal не выдан app `bgp` |
| 403 на раздел | Нет `bgp:<section>:…` (у admin после обновления портала — полный каталог; перелогиньтесь) |
| portal tenant not configured | Нет tenant в JWT и нет `EVOBGP_PORTAL_TENANT_ID` |
| return_to rejected | origin EvoBGP не в `RETURN_TO_ALLOWLIST` |
+30
View File
@@ -0,0 +1,30 @@
# Интеграция auth-portal ↔ EvoFirewall
App id: **`fw`**.
Единый вход: JWT HS256 (общий `JWT_SECRET`), handoff через `#access_token=…`.
## Permissions
| Permission | UI |
|------------|-----|
| `fw:dashboard:read` | Dashboard |
| `fw:agents:read` / `write` | Agents |
| `fw:lists:read` / `write` | IP lists |
| `fw:policies:read` / `write` | Rules / overrides |
| `fw:stats:read` | Stats |
| `fw:settings:admin` | Settings |
## Portal checklist
1. `APP_IDS` включает `fw` (сделано)
2. Admin → Apps: URL EvoFirewall + выдача прав
3. `RETURN_TO_ALLOWLIST` — origin FW
## App env
См. EvoFirewall `docs/integrate-auth-portal.md`.
## Audit ingest
Dual-write локального журнала в portal: [`integrate-audit-ingest.md`](./integrate-audit-ingest.md) (`source_app: fw`).
+112
View File
@@ -0,0 +1,112 @@
# Интеграция auth-portal ↔ MikrotikManager
Единый вход: пользователь логинится на auth-portal, получает JWT, переходит в MikrotikManager с токеном в URL fragment. Backend MM проверяет JWT и права `mm:*`.
## Архитектура
```
Browser → MikrotikManager UI (нет token)
→ redirect AUTH_PORTAL_URL/?return_to=…/auth/callback
→ login
→ redirect return_to#access_token=…
→ MM /auth/callback сохраняет token (mmapp_token)
→ API Authorization: Bearer …
```
Общий секрет: `JWT_SECRET` / `AUTH_JWT_SECRET` (HS256). Issuer: `ISSUER` / `AUTH_ISSUER`.
App id в портале: **`mm`** (каталог permissions).
## Локальный запуск
### 1. auth-portal
```bash
cd auth-portal
pnpm install
# JWT_SECRET=dev-secret-change-me
# RETURN_TO_ALLOWLIST=.shnt.top,localhost,http://localhost:3000
pnpm --filter @authportal/api dev # :8080
pnpm --filter web dev # :5175
```
Bootstrap: `[email protected]` / `admin`. В админке выдайте app **mm** и permissions `mm:*`.
### 2. MikrotikManager
```bash
cd MikrotikManager
npm install
```
Backend `backend/.env`:
```env
AUTH_REQUIRED=true
AUTH_JWT_SECRET=dev-secret-change-me
AUTH_ISSUER=https://auth.shnt.top
AUTH_PORTAL_URL=http://localhost:5175
CORS_ORIGIN=http://localhost:3000
```
Frontend: `NEXT_PUBLIC_AUTH_PORTAL_URL=http://localhost:5175` (или runtime `GET /api/auth/config` через proxy).
```bash
npm --prefix backend run dev
npm run dev
```
Откройте `http://localhost:3000` → редирект на portal → после логина NavUser показывает имя/email.
## Permissions ↔ API / UI
Иерархия: `admin` ⊃ `write` ⊃ `read` в рамках одной секции.
| Permission | API (prefix) | UI |
|------------|--------------|-----|
| `mm:dashboard:read` | sidebar-counts, events (GET) | `/dashboard` |
| `mm:servers:read` / `write` | `/api/servers*` | `/servers` |
| `mm:filters:read` / `write` | `/api/filters*` | `/filters`, GRE |
| `mm:bgp:read` / `write` | `/api/bgp*` | BGP |
| `mm:uptime:read` / `write` | `/api/uptime*` | `/uptime` |
| `mm:traffic:read` / `write` | `/api/traffic*` | `/traffic` |
| `mm:alerts:read` / `write` | `/api/alerts*` | `/alerts` |
| `mm:backups:read` / `write` | `/api/backups*` | `/backups` |
| `mm:certificates:read` / `write` | `/api/certificates*` | certificates |
| `mm:network:read` / `write` | `/api/network*`, OSPF, recursive, probes | network |
| `mm:settings:admin` | `/api/system/*`, scheduler, evobgp settings | `/settings` |
Без app `mm` в JWT `apps` → **403** на защищённые `/api/*`.
`AUTH_REQUIRED=false` — API открыт (dev без portal).
## App Switcher
`CURRENT_APP_ID = mm`. Публичный конфиг: `GET {AUTH_PORTAL_URL}/api/v1/app-switcher`.
## Logout (SSO)
Очистить `mmapp_token` → редирект на **`{AUTH_PORTAL_URL}/logout`**.
## Production
См. общий стек CDN+MM: `deploy/docker-compose.cdn-mm.yml` / `deploy/env.cdn-mm.example`.
```env
AUTH_REQUIRED=true
AUTH_JWT_SECRET=<тот же JWT_SECRET портала>
AUTH_ISSUER=https://auth.shnt.top
AUTH_PORTAL_URL=https://auth.shnt.top
CORS_ORIGIN=https://mm.shnt.top
```
В portal: `RETURN_TO_ALLOWLIST` включает `https://mm.shnt.top` (или `.shnt.top`).
## Troubleshooting
| Симптом | Причина |
|---------|---------|
| SSO loop | разный `JWT_SECRET` или `ISSUER` |
| 403 «Нет доступа к приложению» | нет app `mm` у пользователя |
| 403 «Недостаточно прав» | нет нужного `mm:…` |
| return_to rejected | origin MM не в `RETURN_TO_ALLOWLIST` |
+99
View File
@@ -0,0 +1,99 @@
# Интеграция auth-portal ↔ Technitium DNS Server
App id: **`dns`**. Auth mode: **OIDC** (не JWT fragment).
Technitium DNS Server v15.2+ поддерживает SSO через OpenID Connect.
Ссылки: [Technitium DNS](https://technitium.com/dns/), [Authelia guide](https://www.authelia.com/integration/openid-connect/clients/technitium/), [Pocket ID](https://pocket-id.org/docs/client-examples/technitium-dns).
## Permissions (portal)
| Permission | OIDC group | Technitium Local Group |
|------------|------------|------------------------|
| `dns:console:admin` (или portal `is_admin`) | `technitium_admins` | Administrators |
| `dns:dns:write` | `technitium_dns_admins` | DNS Administrators |
| `dns:dhcp:write` | `technitium_dhcp_admins` | DHCP Administrators |
Claims `groups` и `roles` отдаются **в id_token и UserInfo**.
## Portal checklist
1. Admin → Пользователи: выдать app `dns` и нужные права
2. Admin → Ссылки приложений: URL Technitium (`https://dns.…`)
3. Admin → **OIDC-клиенты**: создать client
- Redirect URI: `https://<dns-host>/sso/callback`
- Scopes: `openid profile email groups`
4. Сохранить `client_id` / `client_secret` (секрет показывается один раз)
5. `RETURN_TO_ALLOWLIST` — origin Technitium (для App Switcher / аудита)
## Technitium SSO
1. Локальный admin (break-glass) оставить
2. Administration → Sessions → Single Sign-On (или SSO Providers)
3. Включить SSO:
| Field | Value |
|-------|--------|
| Metadata Address | `https://<auth-host>/.well-known/openid-configuration` |
| Authority / Issuer | `https://<auth-host>` (как в Admin → OIDC) |
| Client ID | из portal |
| Client Secret | из portal |
| Scopes | `openid profile email groups` |
| Allow Signup | Enabled |
| Allow Signup Only For Mapped Users | Enabled |
4. Group Map:
| Remote | Local |
|--------|-------|
| `technitium_admins` | Administrators |
| `technitium_dns_admins` | DNS Administrators |
| `technitium_dhcp_admins` | DHCP Administrators |
5. Save (web service перезапустится)
## DNS pitfall
Technitium резолвит back-channel OIDC (discovery / token / JWKS) **через свой DNS**, не через OS `/etc/hosts`.
Если `auth.*` только split-horizon — добавьте A/AAAA на Technitium и проверьте:
```bash
dig +short @127.0.0.1 auth.example.com
```
См. [Discussion #1988](https://github.com/TechnitiumSoftware/DnsServer/discussions/1988).
## Контейнер auth-portal (production)
OIDC поднимается вместе с приложением — отдельный контейнер не нужен.
| Env | Назначение |
|-----|------------|
| `ISSUER` / `OIDC_ISSUER` | Публичный HTTPS URL портала (`https://auth.shnt.top`) |
| `OIDC_RSA_PRIVATE_KEY` | Опционально; иначе ключ в SQLite volume `./data` |
| `RETURN_TO_ALLOWLIST` | Origin Technitium (`https://dns.shnt.top`) |
Traefik-стек: [deploy-traefik.md](deploy-traefik.md) · [`deploy/env.traefik.example`](../deploy/env.traefik.example) · [`deploy/docker-compose.traefik.yml`](../deploy/docker-compose.traefik.yml).
Локально:
```bash
docker compose up -d --build
curl -fsS http://localhost:8080/.well-known/openid-configuration | head
```
После деплоя: Admin → OIDC-клиенты → redirect `https://<dns-host>/sso/callback`.
## App Switcher
Для `dns` режим `authMode: oidc` — открывается базовый URL (кнопка OpenID Connect на логине Technitium), без `#access_token`.
Журнал входов портала: при выдаче authorization code пишется `auth.sso_handoff` с `target_app: dns` (колонка «Приложение» → Technitium DNS).
## Endpoints portal (IdP)
- `GET /.well-known/openid-configuration`
- `GET /.well-known/jwks.json`
- `GET /oauth/authorize`
- `POST /oauth/token` (`client_secret_post` / `client_secret_basic`)
- `GET /oauth/userinfo`
+138
View File
@@ -0,0 +1,138 @@
# Интеграция auth-portal ↔ VPS Tracker
Единый вход: пользователь логинится на auth-portal, получает JWT, переходит в VPS Tracker с токеном в URL fragment. VPS API проверяет JWT и права `vps:*`.
## Архитектура
```
Browser → VPS UI (нет token)
→ redirect AUTH_PORTAL_URL/?return_to=http://localhost:5173/auth/callback
→ login
→ redirect return_to#access_token=…
→ VPS /auth/callback сохраняет token
→ API Authorization: Bearer …
```
Общий секрет: `JWT_SECRET` / `AUTH_JWT_SECRET` (HS256). Issuer: `ISSUER` / `AUTH_ISSUER`.
## Локальный запуск
### 1. auth-portal
```bash
cd auth-portal
pnpm install
cp .env.example .env
# JWT_SECRET=dev-secret-change-me
# RETURN_TO_ALLOWLIST=.shnt.top,localhost,http://localhost:5173
pnpm --filter @authportal/api dev # :8080
pnpm --filter web dev # :5175
```
В `apps/web/.env.local`:
```env
VITE_VPS_APP_URL=http://localhost:5173
```
Bootstrap: `[email protected]` / `admin`. В админке выдайте app **vps** и нужные permissions.
### 2. vps-tracker
```bash
cd vps-tracker
pnpm install
```
Корень / API env (или shell):
```env
AUTH_REQUIRED=true
AUTH_JWT_SECRET=dev-secret-change-me
AUTH_ISSUER=https://auth.shnt.top
AUTH_PORTAL_URL=http://localhost:5175
```
`apps/web/.env.local`:
```env
VITE_AUTH_ENABLED=true
VITE_AUTH_PORTAL_URL=http://localhost:5175
```
```bash
pnpm --filter @cfdm/api dev
pnpm --filter web dev # :5173
```
Откройте http://localhost:5173 → редирект на portal → после логина обратно в VPS.
## Permissions ↔ API / UI
Иерархия: `admin` ⊃ `write` ⊃ `read` в рамках одной секции.
| Permission | API | UI |
|------------|-----|-----|
| `vps:dashboard:read` | `GET /api/dashboard/*` | `/dashboard` |
| `vps:vps:read` | `GET /api/vps`, projects, reports-related reads | `/vps`, `/tariffs`, `/projects`, аналитика |
| `vps:vps:write` | POST/PUT/PATCH/DELETE `/api/vps`, projects | create/edit/delete VPS |
| `vps:accounts:read` | GET providers, provider-accounts | `/providers`, `/accounts` |
| `vps:accounts:write` | мутации providers/accounts | формы аккаунтов |
| `vps:payments:read` | GET payments, balance-ledger | `/payments`, `/balance` |
| `vps:payments:write` | мутации payments / ledger | формы платежей |
| `vps:sync:write` | POST `/api/sync/*`, GET sync status | `/sync-journal`, кнопка sync |
| `vps:spaces:admin` | управление всеми пространствами, grants из main | space switcher (все spaces), share/assign |
| `vps:settings:admin` | `/api/settings`, backup, audit, migrate | `/settings`, `/audit`, `/spaces` |
## Пространства (spaces)
Данные VPS Tracker изолированы по `spaceId`. Заголовок **`X-Space-Id`**.
- **space-main** — основное (админский пул); существующие данные мигрируют сюда.
- **personal** — автосоздание `space-user-{userId}` при первом входе.
- **Share (ACL)** — `POST /api/spaces/:id/vps/:vpsId/share` — VPS остаётся в исходном space.
- **Assign** — `POST /api/spaces/:id/vps/:vpsId/assign` — перенос; `providerAccountId` сбрасывается.
- Участники: `GET/POST/PATCH/DELETE /api/spaces/:id/members` (userId из auth-portal).
- Env: `VPS_MAIN_SPACE_OWNER_USER_ID` — owner для main при bootstrap.
Без app `vps` в JWT `apps` → **403** на весь `/api/*` (кроме health и CFDM integration).
`AUTH_REQUIRED=false` — auth выключен (удобно для локальной разработки без portal); данные в `space-main`.
## App Switcher
Публичный конфиг: `GET {AUTH_PORTAL_URL}/api/v1/app-switcher`. VPS chrome читает его через `ensureAuthConfig().portalUrl`; offline fallback — defaults с ids `cfdm` | `vps` | `bgp`.
Редактор только на портале: **Админка → Ссылки приложений** (`/admin/apps`). В VPS Settings → Integrations — read-only ссылка.
`CURRENT_APP_ID = vps`. Фильтр меню по JWT `apps[]` при наличии claims.
## Audit ingest
Dual-write локального журнала в portal: [`integrate-audit-ingest.md`](./integrate-audit-ingest.md) (`source_app: vps`).
## Troubleshooting
| Симптом | Причина |
|---------|---------|
| 401 на API | Нет/битый Bearer; разные `JWT_SECRET` |
| 403 «нет доступа к приложению» | В portal не выдан app `vps` |
| 403 на write | Только `*:read` в permissions |
| Loop на login | `return_to` не в `RETURN_TO_ALLOWLIST` |
| Infinite SSO / 429 | Просроченный JWT в portal localStorage; или разный `JWT_SECRET`/`ISSUER`. Portal чистит expired token; VPS блокирует повторный handoff 12с |
| «Выйти» сразу возвращает в приложение | Старый клиент редиректил на `/?return_to=…` при живой portal-сессии. Нужен редирект на **`/logout`** (см. ниже) |
| CORS | Portal и VPS на разных origin — fragment handoff не требует CORS для token; public app-switcher GET тоже CORS-open |
## Logout (SSO)
«Выйти» в приложении: очистить локальный JWT → `AUTH_PORTAL_URL/logout` (без `return_to`).
Портал на `/logout`: `POST /api/v1/auth/logout` (revoke refresh cookie) → `clearToken()` → форма логина.
## Production
- Один `JWT_SECRET` в secret store обоих сервисов
- `ISSUER=https://auth.shnt.top`
- `RETURN_TO_ALLOWLIST=.shnt.top,https://vps.shnt.top`
- `AUTH_REQUIRED=true`, `VITE_AUTH_ENABLED=true`
- `VITE_VPS_APP_URL=https://vps.shnt.top` (portal)
+51
View File
@@ -0,0 +1,51 @@
# MCP revocation delivery (default off)
This capability is not enabled by adding the code. Deploy and verify the MM v2
receiver first, using the existing separate `MCP_PORTAL_WEBHOOK_SECRET`. Then
explicitly configure portal:
- `MCP_REVOCATIONS_ENABLED=true`
- `MCP_REVOCATIONS_TARGET=https://<fixed-mm-host>/_internal/mcp/portal/revocations`
- `MCP_REVOCATIONS_SECRET`: the same separate random secret, at least32 characters.
Never reuse JWT or audit-ingest secrets. Do not put credentials/query/fragment in
the target. Only canonical HTTPS is accepted, with certificate verification,
no redirects and a3-second whole request deadline; request/response limit4KiB.
The target fingerprint is pinned in SQLite; changing destination refuses startup
until an explicit operator-reviewed migration. Rotation of the shared secret
does not change queued event bodies or IDs.
Portal SQLite migration2 adds an outbox without a user FK. When enabled, SQLite
synchronous=FULL is required before mutations. Actual access/admin/disable/delete,
logout, single-session and revoke-all functions enqueue in their same synchronous
transaction. Enqueue failure/cap4096 rolls back the mutation. Queue-full prevents
further relevant security mutations until delivery recovers; rows are never
silently expired/discarded. Default-off neither captures new events nor sends;
it does not promise revocation while disabled. Existing pending rows remain.
Delivery is at least once, FIFO, one request at a time, at most4 events per tick;
retry delay grows to60seconds. A valid200 `{ok:true,duplicate:boolean}` ack removes
the exact row/body. Crash after MM commit but before ack causes safe redelivery
with the same eventId/body. Sender shutdown aborts and awaits request close before
SQLite closes. Logs contain only a fixed unavailable event, never secret/body/cause.
V2 signs `timestamp + "." + exactQueuedBody` with HMAC-SHA256. `x-mm-timestamp`
is fresh delivery time, while body `eventOccurredAt` is the immutable original
mutation time. Re-signing/retrying never advances the owner cutoff. MM applies
monotonic owner cutoffs and connection/credential/grant/plan/profile effects only
to claims issued at or before that event. Legacy three-field events remain
accepted with their historical signed-timestamp cutoff; this sender always usesv2.
Legacy JWT iat has second resolution. A JWT minted in the event's same second can
remain rejected; obtain a fresh login after the natural second boundary. No future
iat, artificial millisecond JWT precision or claims-version authority is added.
Logout/single-session revoke is owner-wide for MCP because current portal access
JWTs have no session identifier. Password-only/name/email edits retain their
existing semantics; this capability does not invent a new password/session policy.
Before enabling: use portal's locked dependencies for targeted DB tests and API/DB
typecheck, then verify actual HTTPS and MM PostgreSQL behavior (old JWT rejected,
fresh JWT after boundary preserved by late/out-of-order events, replay/conflict,
outage/restart/drain). Source-only and mock-network tests are not that acceptance.
SQLite backup restore is an explicit operator security operation; an outbox cannot
recover mutations deliberately removed by restoring an older database snapshot.
+62
View File
@@ -0,0 +1,62 @@
# Производительность и ресурсы контейнера
Что сделано для быстродействия, стабильности и минимального потребления
ресурсов, и как это проверять.
## Что включено
| Область | Решение |
|---|---|
| Остановка | SIGTERM/SIGINT → `app.close()` (дожидается запросов) → WAL checkpoint → `close`. Таймаут `SHUTDOWN_TIMEOUT_MS` (8 с), повторный сигнал — немедленный выход. В compose `init: true`, `stop_grace_period: 15s`. |
| SQLite | `busy_timeout`, `cache_size`, `temp_store=MEMORY`, `mmap_size`, лимит WAL; транзакции для записи доступа/аудита; версия схемы в `user_version` (миграции не гоняются на каждом старте); индексы под очистку и фильтры; фильтры/сортировки в SQL. |
| Очистка | Раз в час: истёкшие/отозванные сессии, коды авторизации, WebAuthn-челленджи (пачками по 1000, `SESSION_RETENTION_DAYS`). |
| Горячий путь | Кэш профиля пользователя (инвалидируется любой записью в users/доступ — блокировка действует сразу), кэш App Switcher, мемоизация JWKS. Лимитер argon2 (`ARGON2_CONCURRENCY`). Секреты OIDC-клиентов — SHA-256 (старые argon2 мигрируют при первом обмене). |
| HTTP | Hashed-ассеты `immutable` на год, `index.html` — `no-cache`; сжатие JSON на стороне Traefik; CORS по allowlist; `/health` (liveness, без БД) и `/ready` (БД + ключ OIDC); корректные 404 для `/oauth`, `/.well-known`, `/api`. |
| Образ | API бандлится tsup в один файл, в образ ставятся только нативные модули; статика только `.br`; healthcheck через `wget`; `NODE_OPTIONS=--max-old-space-size=160`; лимиты compose: 256 МиБ, 1 CPU, `read_only`, `cap_drop: ALL`. |
| Бэкап | `docker exec auth-portal node dist/server.js backup` или `BACKUP_INTERVAL_HOURS`; онлайн-копия, `integrity_check`, ротация. |
Дополнительно: подготовленные запросы (`prepare` один раз на соединение),
строгая CSP (`'self'`, без inline-скриптов), опциональное сжатие в Node
(`ENABLE_COMPRESS=true`, если нет Traefik compress), базовый образ закреплён по
digest, `design-preview`-маршруты вырезаются из прод-образа, healthcheck Traefik
и `depends_on: service_healthy`, provenance + SBOM при публикации, Trivy
(информативно), общий composite-action CI, `inputs` в `turbo.json`.
## Замеры (локально, Windows, Node 25; не Linux-контейнер)
| Что | Результат |
|---|---|
| `listActiveSessions` по пользователю на 100k сессий | ~1.6 мс |
| `runHousekeeping`, 90k устаревших сессий | ~220 мс |
| `appendAuditBatch`, 10k событий одной транзакцией | ~610 мс |
| `listAudit(kind=logins)` на 10k | ~5 мс |
| 20k prepared-запросов профиля | ~28 мс |
| HTTP `GET /health` (autocannon, 50 соединений) | ~15 000 rps, p99 7 мс |
| HTTP `GET /api/v1/auth/me` (JWT, кэш профиля) | ~8 300 rps, p99 11 мс |
| web: файлов в `dist/assets` | 87 → 48 без preview-маршрутов; gzip JS 484 → 437 КБ |
| Рабочий набор процесса (Windows) | ~117 МиБ в покое, ~237 МиБ после потока 8 с при куче 160 МБ |
Рабочий набор Windows не равен RSS контейнера Linux. Реальный RSS под лимитом
256 МиБ мерит `scripts/ci/smoke-image.sh` и ночной `nightly-perf` — при
превышении увеличьте `mem_limit` и `NODE_OPTIONS` вместе.
## Проверка
```bash
pnpm turbo test:coverage --filter=@authportal/api --filter=@authportal/db --filter=@authportal/shared
pnpm --filter web build && node scripts/ci/web-budget.mjs apps/web/dist/assets
bash scripts/ci/smoke-image.sh <image> # размер, RSS, /ready, docker stop, данные после рестарта
pnpm --filter @authportal/db test:perf # бюджеты производительности БД
node scripts/perf/load.mjs http://127.0.0.1:8080 [container] # autocannon (ночью: nightly-perf.yaml)
```
Бюджеты smoke-теста (env): `MAX_IMAGE_MB=250`, `MAX_RSS_MB=200`, `MAX_START_S=15`,
`MAX_STOP_S=5`. Подтяните их после первого замера на CI.
## Эксплуатационные заметки
- В production `JWT_SECRET` должен быть уникальным (≥ 16 символов), а
`ADMIN_PASSWORD` для первого запуска — не короче 8 символов и не `admin`.
- Если нужен другой лимит памяти, меняйте `mem_limit` и `NODE_OPTIONS` вместе
(куча ≈ 60–65 % лимита).
- Одна реплика: SQLite не поддерживает несколько писателей.
+84
View File
@@ -0,0 +1,84 @@
# Релизы и версионирование Auth Portal
Auth Portal использует [Conventional Commits](https://www.conventionalcommits.org/) и [semantic-release](https://semantic-release.gitbook.io/) для полностью автоматических релизов на Gitea (`git.shx.one`). Ручное повышение версии в коде не требуется.
Заголовок после `тип(scope):` — **на русском** (см. `.cursor/rules/commit-messages-ru.mdc`). Analyzer смотрит только префикс `feat` / `fix` / …
## Как определяется версия
| Тип коммита | Bump |
|-------------|------|
| `feat` | minor (1.0.0 → 1.1.0) |
| `fix`, `perf`, `ci`, `refactor` | patch (1.5.1 → 1.5.2) |
| `feat!`, `fix!` или `BREAKING CHANGE:` в теле | major (1.0.0 → 2.0.0) |
| `docs`, `chore`, `test` | без релиза |
**Scope:** один идентификатор **без запятых** (`web`, `api`, `ci`). Заголовок `refactor(a, b): …` **не парсится** semantic-release → релиз не создаётся (commitlint на PR это тоже отклонит).
`refactor` — patch без новых функций: перестройка кода/UI при том же поведении для пользователя.
Первый релиз при отсутствии git-тегов — **1.0.0**, если есть releasable-коммиты.
## CI-пайплайн (push в main)
```text
push/merge в main
→ workflow CD: quality (web, api)
→ job publish:
→ semantic-release: git tag vX.Y.Z на текущий commit (без доп. commit)
→ Gitea Release + CHANGELOG.md как attachment
→ зеркало base-образов в authportal-buildcache:base-*
→ docker buildx bake с VERSION=X.Y.Z (pull=false, named builder authportal)
→ образы: latest, vX.Y.Z, X.Y.Z, sha-*, короткий SHA
```
Pull request: workflow **CI** — quality gates + commitlint; релиз и образы **не** публикуются.
Workflows: [.gitea/workflows/ci.yaml](../.gitea/workflows/ci.yaml), [.gitea/workflows/cd.yaml](../.gitea/workflows/cd.yaml), reusable [.gitea/workflows/quality.yaml](../.gitea/workflows/quality.yaml).
Конфиг semantic-release: [.releaserc.json](../.releaserc.json) — без `@semantic-release/git` (CHANGELOG не коммитится в репозиторий).
## Секреты Gitea
Один PAT — **`ACTIONS_PAT`** (Settings → Actions → Secrets).
| Право PAT | Зачем |
|-----------|--------|
| push tags | git tag `vX.Y.Z` на commit merge |
| releases | Gitea Release + notes |
| packages (Container Registry) | push образов |
Fallback для **git tag**: `gitea.token`, если PAT недоступен. Push образов в Container Registry — **только `ACTIONS_PAT`** (у job token Gitea нет права packages).
## Источник правды для версии
Semver из git-тега пробрасывается в образ как `APP_VERSION` / `GIT_SHA` / `BUILD_TIME` при bake (`deploy/docker/authportal/Dockerfile`).
## Scope и semantic-release
Парсер conventional-commits **не понимает запятые в scope**. Правило: **один scope** (`web`, `api`, `ci`, `docker`).
На push в `main` job **publish** запускает `scripts/commit/verify-release-commits.mjs` — в логе будут предупреждения о непарсящихся коммитах.
Если релиз «не создался», а CI зелёный: смотрите лог release — часто `No releasable commits`. Исправление: новый коммит с корректным заголовком (например `fix(web): …`).
## Перезапуск упавшего job publish
semantic-release пишет `.release-version` только в `successCmd` при **новом** релизе. Если тег `vX.Y.Z` уже создан, а `docker buildx bake` упал, повторный run того же SHA делает semantic-release no-op (файла нет). Job **publish** тогда берёт версию из git-тега на `HEAD` и публикует образы.
Перезапускать нужно **весь job publish**, не отдельный шаг bake: checkout + semantic-release + detect + bake идут подряд.
## CHANGELOG
Release notes — в Gitea Release; файл `CHANGELOG.md` генерируется в CI и прикрепляется как asset, **не** попадает в git history.
## Проверка после релиза
1. Один run workflow **CD** на push в main: job **publish** зелёный.
2. Gitea: тег `vX.Y.Z` на том же commit, что и merge; Release с notes.
3. Container Registry: `authportal:vX.Y.Z`, `authportal:X.Y.Z`, `authportal:latest` и алиас `auth-portal` с теми же тегами.
4. `curl http://localhost:8080/health` → `"status":"ok"`.
## Первый релиз (bootstrap)
Merge в `main` с `feat(release): …` или `ci: …` → **v1.0.0** в том же CI run (`ci` даёт patch → 1.0.0 при отсутствии тегов).
+53
View File
@@ -0,0 +1,53 @@
# Обновление ReUI до последней версии
Ветка: `chore/reui-latest` (от `feat/design-preview`). План составлен по ReUI MCP
(`get_agent_skill`, `get_component data-grid|filters`, `get_block data-grid-drag-drop-2`,
`get_project_context`) и документации reui.io.
## Было → стало
| Что | Было | Стало |
|---|---|---|
| Skill ReUI (`.claude/.agents/.cursor/.opencode`) | `3bdbad788a` (в AGENTS.md значилась `668fb463eb`), 20 компонентов | `68f0e6fa91`, 27 компонентов |
| `@tanstack/react-table` | 8.21.3 (`useReactTable`) | 9.3.1 (`useTable` + `dataGridFeatures`) |
| `reui/data-grid/*` | v8-вариант | v9: DnD-строки и колонки, виртуализация, выбор ячеек, `i18n`, `getRowStatus` |
| `reui/filters` | один файл `filters.tsx`, плоский `Filter[]` | каталог `filters/`, дерево `FilterQuery` (группы и правила) |
| `reui/{badge,frame,alert,icon-tile,icon-stack,timeline}` | прежние | обновлены из реестра |
| Примитивы `packages/ui` (button, checkbox, dropdown-menu, input, popover, select, separator, skeleton, spinner, tooltip, scroll-area, button-group) | прежние | обновлены (зависимости реестра) |
| Новые зависимости | — | `@dnd-kit/{core,sortable,modifiers,utilities}`, `@tanstack/react-virtual`, `date-fns` |
## Решения
- Установка через `shadcn add @reui/<имя> --overwrite` из `apps/web` с ключом из `apps/web/.env.local`
(в Git не попадает). Блоки и компоненты ставились целиком: частичное обновление ломает связку
data-grid ↔ TanStack v9.
- Реестр прописывает зависимость `cn` как npm-пакет; в проекте `cn` живёт в
`@authportal/ui/lib/utils`, поэтому импорты `from "cn"` заменены, пакет `cn` не добавлялся.
- Код реестра использует `process.env.NODE_ENV` и неиспользуемые параметры. В `tsconfig.app.json`
добавлен тип `node` и выключены `noUnusedLocals`/`noUnusedParameters` (контроль остаётся за ESLint).
- Старый плоский API фильтров заменён деревом: `flattenFilterConditions(query)` даёт
`{field, operator, values, negated}`. Операторы: `is_not_any_of` → `is_none_of`.
- Русские подписи DataGrid и Filters вынесены в `apps/web/src/lib/reui-ru.ts`
(`DATA_GRID_RU`, `FILTERS_RU`, `FILTER_OPERATORS_RU`) и передаются пропами `i18n`/`labels`/`operatorLabels`.
## Миграция потребителей
1. `PreviewSectionGrid` (`design-preview/section-grid.tsx`) — `useTable`, `ColumnDef<DataGridFeatures, T>`.
2. `AdminUsersGrid` (`reui-kit`) — `useTable`, `ColumnVisibilityState`, `Filters` на `FilterQuery`.
3. Страница входов (`routes/_auth.admin.logins.tsx`) — `useTable` для истории и сессий.
4. «Ссылки приложений» (макет) — настоящее перетаскивание строк через `rowsDraggable` и dnd-kit.
## Проверка
`pnpm --filter web build`, `pnpm --filter web lint`, `pnpm --filter @authportal/api test`;
браузер: все разделы `/design-preview`, ширины 390/820/1180/1440, консоль без ошибок.
Боевые страницы (`/`, `/apps`, `/account`, `/admin/*`) проверяются сборкой и типами:
запуск API не требовался.
## Как обновлять дальше
```bash
cd apps/web
pnpm dlx shadcn@latest add @reui/<имя> --overwrite --yes # REUI_LICENSE_KEY из .env.local
curl.exe -fsSL https://mcp.reui.io/install | node - # skill и правила для агентов (из корня)
```
+80
View File
@@ -0,0 +1,80 @@
# UI Design Contract — Auth Portal
Единый контракт ops-семейства (CFDM / vps / EvoBGP / EvoFirewall / **auth-portal**).
Surface: **ReUI Frame**. Иерархия: **ReUI PRO > shadcn primitives**.
Карта: [docs](https://reui.io/docs) · [llms.txt](https://reui.io/llms.txt) · [Get Started](https://reui.io/docs/get-started) · [Styling](https://reui.io/docs/styling) · [Registry](https://reui.io/docs/registry) · [License](https://reui.io/docs/license-setup) · [Blocks](https://reui.io/blocks) · [MCP](https://reui.io/docs/mcp) · [Agent Skills](https://reui.io/docs/agent-skills)
Эталоны: [app-shell-12](https://reui.io/preview/base/app-shell-12) · [solution-users-1](https://reui.io/preview/base/solution-users-1) · [solution-users-6](https://reui.io/preview/base/solution-users-6) · [data-grid-filtering-2](https://reui.io/preview/base/data-grid-filtering-2) · [empty-state-14](https://reui.io/preview/base/empty-state-14) · [empty-state-12](https://reui.io/preview/base/empty-state-12) · KPI [stats-12](https://reui.io/preview/base/stats-12)
## Surface
Project lock: **`surface: frame`**. Ops / list / settings — только Frame. Не смешивать Card и Frame.
## Kit (`reui-kit/`)
| Component | Role |
|-----------|------|
| `AdminUsersGrid` | Frame + Filters + DataGrid (admin users, solution-users-1) |
| `KpiStatGrid` | EvoBGP hybrid KPI (если нужен dashboard) |
| `QuickActionGrid` | Frame quick actions (sibling KPI) |
| `SettingsShell` | settings nav |
Markup KPI/QuickActions — SoT EvoBGP (diff только `@authportal/ui`).
## Shared App Shell chrome
При переключении portal / CFDM / EvoBGP / vps / fw меняются **только** sidebar nav и `main`.
| Токен / зона | Значение |
|--------------|----------|
| `--sidebar-width` | `240px` |
| Sidebar / hover | theme `--sidebar` / `--sidebar-accent` — без `color-mix` override |
| Header | `h-12`, sticky, `border-b`, `px-4 md:px-6` |
| Header left | `SidebarTrigger` + `Separator` + Breadcrumb |
| Header right | **AppsMenu → SystemMonitorPopover** (тема — в NavUser) |
| Sidebar footer | **NavUser** — аватар, имя, email, тема (segmented), выход · [app-shell-1](https://reui.io/preview/base/app-shell-1) |
| `main` | `gap-4 md:gap-6`, `px-4 py-4 md:px-6 md:py-5` |
Запрещено в chrome: `SidebarRail`, Search pill в header, `ModeToggle` в header (тема только в NavUser).
Nav groups Auth Portal:
- **Портал:** Приложения (`/apps`)
- **Админ** (только `is_admin`): Пользователи (`/admin`), Журнал (`/admin/audit`), Ссылки приложений (`/admin/apps`)
App Switcher: `portal_settings.app_switcher_json` → public `GET /api/v1/app-switcher`, admin `GET/PUT /api/v1/admin/app-switcher`. Ids: `cfdm` · `vps` · `bgp` · `fw` · `dns` · `cdn`.
## MCP workflow
1. MCP `user-reui` — `search` / `get_block` / `get_component` with `surface: "frame"`
2. Cite `previewUrl` + `docsUrl`
3. CLI from `apps/web`: `pnpm dlx shadcn@latest add @reui/...`
4. Adapt into kit
5. `validate_usage` / `get_audit_checklist`
Primitives: MCP `plugin-shadcn-shadcn` + `@authportal/ui`.
## License
```env
# apps/web/.env.local
REUI_LICENSE_KEY=
```
`apps/web/components.json` → `Authorization: Bearer ${REUI_LICENSE_KEY}`.
## Spacing
- AppShell main / PageShell: `gap-4 md:gap-6`
- No `space-y-*` / `space-x-*` — use `flex` + `gap-*`
- Max 1 primary CTA per screen
- Semantic tokens only — no raw `bg-emerald-*`
## Forbidden
- Card as ops list/dashboard shell
- Hand-rolled data tables when ReUI DataGrid exists
- Hand-rolled KPI / Quick Actions when kit exists
- Mixing Card and Frame on the same ops screen
- ModeToggle in header