Шхуна не тонет: security, infra и доки на русском.

Безопасность довёл до ума — Cursor-генерацию переписал руками.
IDOR закрыл, CSRF задушил, refresh rotation теперь как надо.
HSTS на staging, ENABLE_DOCS=false, install.env recovery протестил.

Backend:
- jwt_denylist + auth_epoch: мгновенный revoke access JWT (logout/block/reset)
- auth/admin/users: bump epoch, logout с Bearer, forgot_password skip для blocked
- install_secrets: путь всегда apps/api/data/secrets/ (bootstrap из корня не ломает Docker)
- seed: SEED_DEMO_USERS=false на prod/staging
- тесты: jwt revoke, integration, coverage gate 90%

Frontend:
- logout шлёт Bearer, обработка TOKEN_REVOKED
- guards TypeScript fix
- E2E: blocked user → 401 сразу после block

Infra:
- staging/prod compose, TLS nginx, deploy-скрипты
- k6 §17.2, backup/health/smoke scripts

Docs:
- docs/ на русском: project, security, deploy, release (старые md слили)
- README короткий + план ТЗ + стандартные логины dev

Код готов к плаванию. Капитан может идти писать фронт.
This commit is contained in:
влад
2026-07-15 00:06:13 +03:00
parent 86cc3fa541
commit 12c983c0fc
66 changed files with 2377 additions and 520 deletions
+3
View File
@@ -17,3 +17,6 @@ apps/api/.e2e.sqlite
apps/api/.e2e-test.sqlite apps/api/.e2e-test.sqlite
apps/api/data/secrets/install.env apps/api/data/secrets/install.env
apps/api/data/secrets/install.meta.json apps/api/data/secrets/install.meta.json
data/secrets/
apps/api/data/logs/
apps/api/data/compton_settings.json
+92 -406
View File
@@ -1,426 +1,112 @@
# Compton Platform # Compton Platform
Monorepo-lite project that follows the `docs/TZ.md` specification for the Compton platform. Если вы открыли этот файл — поздравляем: перед вами monorepo с React, FastAPI и амбициями выйти в production. Амбиции живут в [docs/release.md](docs/release.md), код — в `apps/`, секреты — **не** в git.
## Stack Платформа Compton Organic Tech: лендинг, auth, профиль, CMS, админка в стиле WESP/zootech. ТЗ: [docs/TZ.md](docs/TZ.md).
- **Frontend:** React 19, TypeScript, Vite, React Router, TanStack Query, Zustand, RHF + Zod, Ant Design (admin panel) ## Быстрый старт
- **Backend:** FastAPI, SQLAlchemy 2, Alembic, Pydantic v2
- **Data/Infra:** PostgreSQL, Redis, MinIO (S3-compatible), Docker Compose
- **Quality:** Vitest, Testing Library, Playwright, pytest, coverage gates in CI
## Repository Layout
- `apps/web` — frontend SPA
- `apps/api` — backend API (`app/core/crypto.py` — unified crypto; `app/core/install_secrets.py` — bootstrap/lock)
- `apps/api/data/secrets/` — per-install secrets (`install.env`, gitignored)
- `apps/api/data/compton_settings.json` — runtime panel settings (superuser-editable via API)
- `packages/shared-types` — generated API types contract target
- `packages/eslint-config` — shared eslint config package
- `infra` — docker/nginx/ci helper files
- `docker-compose.dev-ports.yml` — optional override to expose DB/Redis/MinIO on host
## Quick Start
**Full stack in Docker** (API + DB + frontend — no local `pnpm install` required):
```bash ```bash
# 1. Bootstrap install secrets — REQUIRED before the first docker compose up python apps/api/scripts/bootstrap_install.py # один раз, до первого up
python apps/api/scripts/bootstrap_install.py
# 2. Build and start everything
docker compose --profile docker-web up -d --build docker compose --profile docker-web up -d --build
curl http://localhost:8000/api/v1/health # Windows: curl.exe
# 3. Verify (Windows PowerShell: use curl.exe, not curl — it is an alias for Invoke-WebRequest)
curl.exe http://localhost:8000/api/v1/health
``` ```
Open [http://localhost:5173](http://localhost:5173) — static landing (`index.html`). SPA routes (`/login`, `/admin`, `/profile`, …) are served via `app.html` fallback in Vite dev/preview. | URL | Что там |
The `web` container runs Vite with hot-reload; dependencies are installed inside the container automatically.
Log in as `admin@compton.example` (password `Admin1234` by default) and open `/admin`. See [Seed data](#seed-data) for all demo accounts.
**Optional** — copy env files if you also run API or frontend locally (hybrid mode):
```bash
cp apps/api/.env.example apps/api/.env
cp apps/web/.env.example apps/web/.env
```
> **Important:** Run bootstrap **before** the first `docker compose up`. If Postgres was started without `install.env`, the API will fail with `password authentication failed for user "compton_app"`. Fix: `docker compose --profile docker-web down -v`, then bootstrap + up again. See [Troubleshooting](#troubleshooting).
**Hybrid setup** (Docker API + local frontend):
```bash
cp apps/api/.env.example apps/api/.env
cp apps/web/.env.example apps/web/.env
pnpm install
python apps/api/scripts/bootstrap_install.py
docker compose up -d
pnpm --filter web dev
```
## Local Setup
### Prerequisites
- **Docker + Docker Compose** — enough for the full-stack Docker workflow
- **Node.js 22+ and pnpm 9+** — only if you run the frontend locally (`pnpm --filter web dev`)
- **Python 3.12** — bootstrap script, local API, or tests outside Docker
### Start services
| Mode | Command | What runs |
| ---- | ------- | --------- |
| **Full Docker (recommended)** | `docker compose --profile docker-web up -d --build` | API, Postgres, Redis, MinIO, Vite on `:5173` |
| **API + infra only** | `docker compose up -d` | API, Postgres, Redis, MinIO — frontend locally |
| **Local API + local frontend** | see [Run applications](#run-applications) | everything on host |
| Service | URL / Port | Notes |
| --------- | ---------------------------------------------- | ----- |
| API | [http://localhost:8000](http://localhost:8000) | migrations + seed on startup |
| Web (dev) | [http://localhost:5173](http://localhost:5173) | Docker `--profile docker-web` or `pnpm --filter web dev` |
| Postgres | internal docker network | host access via `docker-compose.dev-ports.yml` |
| Redis | internal docker network | host access via `docker-compose.dev-ports.yml` |
| MinIO | internal docker network | host access via `docker-compose.dev-ports.yml` |
> Do not run Docker `web` and `pnpm --filter web dev` at the same time — both bind port **5173**.
The Docker `web` container bind-mounts source for live-reload on Windows/macOS (`CHOKIDAR_USEPOLLING=true`); `node_modules` stay isolated inside the container.
### Install secrets
Each project copy gets unique runtime secrets generated once and locked forever (prevents accidental rotation and DB credential mismatch).
| File | Purpose |
| ---- | ------- |
| `apps/api/data/secrets/install.env` | PostgreSQL, JWT, S3/MinIO credentials (gitignored) |
| `apps/api/data/secrets/install.meta.json` | Install ID and lock timestamp (gitignored) |
**Bootstrap (required before first `docker compose up`):**
```bash
python apps/api/scripts/bootstrap_install.py
```
Generated keys: `POSTGRES_USER`, `POSTGRES_PASSWORD`, `DATABASE_URL`, `JWT_ACCESS_SECRET`, `JWT_REFRESH_PEPPER`, `S3_SECRET_KEY`, `MINIO_ROOT_PASSWORD`.
Docker Compose passes `install.env` directly into the `api`, `postgres`, and `minio` services — no root `.env` or `--env-file` flag needed.
- Re-run is safe: existing locked secrets are never overwritten.
- API entrypoint also calls `ensure_install_secrets()` on startup (adopts env vars when migrating from an older setup).
- **Do not rotate** `POSTGRES_PASSWORD` / JWT secrets after first bootstrap without a coordinated DB migration — see [docs/secrets-recovery.md](docs/secrets-recovery.md).
**Host access to DB/Redis/MinIO** (DBeaver, pgAdmin, MinIO console):
```bash
docker compose -f docker-compose.yml -f docker-compose.dev-ports.yml up -d
```
| Exposed port | Service |
| ------------ | ------- |
| 5432 | PostgreSQL |
| 6379 | Redis |
| 9000 / 9001 | MinIO API / console |
**Reveal credentials:** Admin → Security → Install Secrets (superuser only, audited). Supports `database_password`, `jwt_access_secret`, `jwt_refresh_pepper`, `s3_secret_key`.
### Configure environment
For hybrid or fully local dev, copy example env files:
```bash
cp apps/api/.env.example apps/api/.env
cp apps/web/.env.example apps/web/.env
```
Key variables:
| Variable | App | Purpose |
| ----------------------------------------- | --- | ---------------------------------------------------- |
| `DATABASE_URL` | api | PostgreSQL connection string |
| `APP_ENV` | api | `development` / `production` startup guards |
| `JWT_ACCESS_SECRET`, `JWT_REFRESH_PEPPER` | api | Token signing (32+ bytes) |
| `VITE_API_URL` | web | Local dev: `http://localhost:8000`. Docker `web`: `http://api:8000` |
| `VITE_USE_API_PROXY` | web | `true` — proxy `/api` through Vite (default in dev) |
| `EMAIL_DELIVERY_MODE` | api | `memory` (Docker dev) or `smtp` (real mail) |
| `STORAGE_MODE` | api | `s3` (MinIO) or `memory` (tests) |
| `S3_*` | api | MinIO/S3 credentials and bucket |
| `CORS_ORIGINS` | api | Must include `http://localhost:5173` |
| `ENABLE_TEST_ROUTES` | api | `true` only for E2E (email token helper) |
| `TRUSTED_PROXY_IPS` | api | Which proxy IPs may set `X-Forwarded-For` |
| `ADMIN_INITIAL_PASSWORD` | api | Seed password for superuser admin |
| `DEMO_USER_PASSWORD` | api | Seed password for regular demo user |
| `DEMO_OPS_PASSWORD` | api | Seed password for ops admin (no superuser) |
| `ENABLE_RATE_LIMIT` | api | Rate limiting (required `true` in production) |
| `COOKIE_SECURE` | api | HttpOnly refresh cookie `Secure` flag |
| `COMPTON_SETTINGS_PATH` | api | Path to runtime settings JSON (default `data/compton_settings.json`) |
In dev the frontend proxies API requests through Vite (`/api` → backend). Locally the target is `localhost:8000`; in Docker Compose it is the `api` service.
### Runtime settings (`compton_settings.json`)
Non-secret runtime toggles live in `apps/api/data/compton_settings.json` and are applied on API startup. Superusers can read/update them via `GET/PATCH /admin/settings`.
Env vars with the same keys (e.g. `ENABLE_RATE_LIMIT`, `CORS_ORIGINS`) act as **locks** — when set, the corresponding JSON field cannot be changed from the admin panel.
Typical fields: rate limit, API docs, secure cookie, JWT TTL, CORS origins, SMTP host/port, avatar limits, audit retention.
### Install dependencies (hybrid / local dev only)
Skip if you use `docker compose --profile docker-web` — dependencies are installed inside the `web` container.
```bash
pnpm install
pip install -r apps/api/requirements-dev.txt
```
### Run applications
**Option A — Full Docker stack:**
```bash
python apps/api/scripts/bootstrap_install.py # first run only
docker compose --profile docker-web up -d --build
```
**Option B — Docker API + local frontend:**
```bash
python apps/api/scripts/bootstrap_install.py # first run only
docker compose up -d
pnpm --filter web dev
```
**Option C — Local API + local frontend:**
```bash
cd apps/api
alembic upgrade head
uvicorn app.main:app --reload --app-dir .
```
In another terminal:
```bash
pnpm --filter web dev
```
Open [http://localhost:5173](http://localhost:5173).
### Seed data
After migrations the database is seeded on every API startup with demo users and CMS pages. Passwords come from env vars (defaults in `.env.example`):
| Email | Env variable | Default (dev) | Role | Superuser | Access |
| ----- | ------------ | --------------- | ---- | --------- | ------ |
| `admin@compton.example` | `ADMIN_INITIAL_PASSWORD` | `Admin1234` | `admin` | yes | Full admin + Security/Diagnostics/Install Secrets |
| `ops@compton.example` | `DEMO_OPS_PASSWORD` | `OpsAdmin1234` | `admin` | no | Users, Content, Activity (no Security/Diagnostics) |
| `user@compton.example` | `DEMO_USER_PASSWORD` | `User1234` | `user` | no | Profile only |
**Content pages:** `about`, `privacy`, `terms` (published).
> Change demo passwords via env before first seed in production. Admin-created users must pass the shared password policy (length, complexity, denylist).
## Frontend Routes
The project uses a **dual-entry** frontend:
| Entry | Served at | Purpose |
| ----- | --------- | ------- |
| `index.html` | `/` | Public marketing landing (static HTML/CSS/JS in `main/`) |
| `app.html` | `/login`, `/register`, `/profile`, `/admin`, `/pages/:slug`, … | React SPA (auth, profile, admin, CMS pages) |
Vite middleware rewrites SPA paths to `app.html` on dev/preview (`vite.main-static.ts`).
| Path | Page | Access |
| ------------------ | --------------------- | ----------------------- |
| `/` | Landing (`index.html`)| public |
| `/login` | Login | guest |
| `/register` | Registration | guest |
| `/verify` | Email verification | public |
| `/forgot-password` | Password reset request| guest |
| `/reset-password` | Set new password | public (with token) |
| `/pages/:slug` | CMS page | public (published only) |
| `/profile` | Profile CRUD + avatar | authenticated |
| `/admin` | Admin panel | role `admin` |
**Auth model:** one login flow for everyone. The `is_superuser` flag on admin accounts controls access to critical panel sections (Security, Diagnostics, Install Secrets reveal, runtime settings). Regular admins see Users, Content, and Activity only.
- **Access JWT** — in memory only (Zustand), not in `localStorage` / `sessionStorage`.
- **Refresh token** — HttpOnly cookie (`Path=/api/v1/auth`, `SameSite=Lax`), rotated on each refresh.
- **Session restore on reload** — `AuthBootstrap` calls a deduplicated `bootstrapSessionRefresh()` when a session hint exists in `sessionStorage` **or** the current path is protected (`/admin`, `/profile`). Guards wait for `bootstrapped` before redirecting.
- **After login** — admins go to `/admin`, regular users to `/profile`.
Admin users can return to the public landing via the **«На сайт»** topbar link (full navigation to `/`, not client-side React routing).
## API Overview
Base URL: `http://localhost:8000/api/v1`
| Area | Endpoints |
| ----------- | ----------------------------------------------------------------------------------------------------------------------------- |
| **Health** | `GET /health` |
| **Auth** | `POST /auth/register`, `/login`, `/logout`, `/refresh`, `/verify-email`, `/forgot-password`, `/reset-password` |
| **Profile** | `GET/PATCH /users/me`, `POST /users/me/password`, `POST /users/me/avatar` |
| **Content** | `GET /content/pages`, `GET /content/pages/{slug}`, admin: `POST/PATCH/DELETE /content/pages`, `GET /content/pages/manage/all` |
| **Admin** | `GET/PATCH /admin/users`, `POST /admin/users`, `PATCH /admin/users/{id}/password`, `GET /admin/stats`, `GET/PATCH /admin/settings`, `GET /admin/diagnostics/report`, `GET /admin/activity-feed`, `POST /admin/ui-activity`, `GET /admin/server-log`, `GET /admin/secrets`, `POST /admin/secrets/reveal` |
| **Media** | `GET /media/files/{path}` |
OpenAPI docs (when `ENABLE_DOCS=true`): [http://localhost:8000/api/v1/docs](http://localhost:8000/api/v1/docs)
## Database Migrations
```bash
cd apps/api
alembic upgrade head # apply schema
alembic downgrade -1 # rollback one revision
```
Tables: `users`, `user_profiles`, `refresh_tokens`, `password_reset_tokens`, `email_verification_tokens`, `content_pages`.
**Security constraints** (PostgreSQL, migration `20260714_0004`):
- `CHECK` on `users.role`, `users.status`, `content_pages.status`
- Superuser rule: `is_superuser=true` only when `role=admin`
- Indexes on `expires_at` for token tables
Expired tokens are cleaned up on API startup (`cleanup_expired_tokens`, 30-day retention).
## Auth Email (SMTP)
- Verify/reset use one-time opaque tokens (SHA-256 hash + pepper in DB, TTL 1 hour); raw token omitted from production email bodies
- Env: `SMTP_HOST`, `SMTP_PORT`, `SMTP_FROM`, `FRONTEND_URL`, `EMAIL_DELIVERY_MODE`
- Docker dev: `EMAIL_DELIVERY_MODE=memory` (no real SMTP required)
- Local SMTP: Mailpit/Mailhog on `localhost:1025` (`SMTP_HOST=localhost`, `SMTP_PORT=1025`)
- Tests: `EMAIL_DELIVERY_MODE=memory` (in-memory outbox)
## Profile & Avatar (MinIO)
- `GET/PATCH /api/v1/users/me`, `POST /api/v1/users/me/password`, `POST /api/v1/users/me/avatar`
- Avatar: jpeg/png/webp, max 2 MB, re-encoded via Pillow (SVG rejected)
- Storage env: `STORAGE_MODE` (`s3` or `memory`), `S3_ENDPOINT`, `S3_ACCESS_KEY`, `S3_SECRET_KEY`, `S3_BUCKET`
- Local dev: MinIO on `localhost:9000` (console `9001`), bucket `compton` (created on API startup)
- Tests: `STORAGE_MODE=memory` (in-memory file store, no MinIO)
## Admin Panel
- Route: `/admin` (`AdminGuard`, role `admin`)
- UI: WESP-style **Ant Design** shell with fixed left sidebar, content topbar, and a dedicated admin theme system
- Topbar actions:
- **Light/Dark** — toggles isolated admin theme (preference persisted in `localStorage`)
- **«На сайт»** — opens the static landing at `/` (`index.html`) via full navigation
- **Logout** — revokes refresh cookie and redirects to `/login`
- Reload UX: no blocking «Loading…» screens; admin routes preload the current admin background (`#f6f6f4` in Light, `#1f2229` in Dark) to avoid flash
- Sections:
- **Users** — list/create/patch role, status, password
- **Content** — CMS CRUD
- **Security** *(superuser only)* — runtime toggles + Install Secrets reveal
- **Diagnostics** *(superuser only)* — security health checks (JWT, CORS, DB credentials, SSL mode, token table size, install secrets lock)
- **Activity** — admin audit feed + server log tail
- Business rules: last admin protected, no self-demotion, no self-block; critical endpoints require `is_superuser`
### Auth UI (zootech)
Auth pages (`/login`, `/register`, `/forgot-password`, `/reset-password`, `/verify`) use a WESP-inspired zootech card layout with organic theme tokens (`#48816d`, centered card, icon inputs). `AppHeader` is hidden on auth routes and in `/admin`.
## Tests
```bash
# Frontend unit/component (coverage ≥ 85%)
pnpm --filter web test:ci
# Same, inside Docker web container (when pnpm is not installed on host)
docker compose --profile docker-web exec web sh -c "cd apps/web && pnpm test:ci"
# Backend unit/integration (coverage ≥ 90%)
cd apps/api && python -m pytest --cov=app --cov-fail-under=90
# E2E regression (Playwright §15.7, 16 critical scenarios)
pnpm --filter web e2e
```
### E2E (Playwright)
- `pnpm --filter web e2e` starts a dedicated API (`:8001`) and Vite (`:5175`) — does not conflict with dev on `:5173` or Docker API on `:8000`
- Uses SQLite + `EMAIL_DELIVERY_MODE=memory` + `ENABLE_TEST_ROUTES=true`
- Test email tokens: `GET /api/v1/test/emails/latest-token` (only when test routes enabled)
- Reuse running API: `E2E_START_API=false E2E_API_URL=http://localhost:8000 pnpm --filter web e2e`
- Install browsers once: `pnpm --filter web exec playwright install chromium`
## Troubleshooting
| Problem | Solution |
| ------- | -------- |
| **`docker compose up` fails: install.env missing** | Run `python apps/api/scripts/bootstrap_install.py` before first start |
| **API exits: `password authentication failed for user "compton_app"`** | Postgres volume was initialized before bootstrap. Reset local dev data: `docker compose --profile docker-web down -v`, bootstrap again, then `docker compose --profile docker-web up -d --build`. See [docs/secrets-recovery.md](docs/secrets-recovery.md) |
| **Login failed** with correct credentials | Check API: `curl http://localhost:8000/api/v1/health`. Ensure `apps/web/.env` exists for local frontend. Restart: `docker compose restart web` or `pnpm --filter web dev` |
| **401 on `/auth/refresh` in browser console (guest)** | Expected for logged-out users on public pages — refresh is skipped unless a session hint or protected path (`/admin`, `/profile`) triggers bootstrap |
| **Logged out after F5 on `/admin`** | Usually a failed refresh (expired cookie) or stale Docker web build. Rebuild: `docker compose --profile docker-web up -d --build web`. Log in again if the refresh cookie expired |
| **«На сайт» in admin does nothing / goes to login** | Must use full navigation to `/` (static landing), not React Router. Ensure topbar action is `href="/"`, then rebuild web container if behavior persists |
| **White flash on admin reload** | `app.html` preloads admin background before React mount. Verify `localStorage.wespAdminTheme` and rebuild web if stale assets are served |
| **Sidebar should stay visible while scrolling** | Admin sidebar is fixed on desktop (`position: fixed`, `height: 100vh`) and switches back to normal flow on mobile (`<=768px`) |
| **Port 5173 already in use** | Stop Docker web: `docker compose --profile docker-web stop web`. Or stop local Vite |
| **Frontend in Docker: missing modules / esbuild errors** | Rebuild: `docker compose --profile docker-web up -d --build web`. Do not run `pnpm install` on the host for the Docker workflow |
| **Hot-reload not working in Docker (Windows/macOS)** | Enabled via `CHOKIDAR_USEPOLLING=true`. Restart: `docker compose --profile docker-web restart web` |
| **Admin panel missing Security/Diagnostics tabs** | Log in as `admin@compton.example` (superuser), not `ops@compton.example` |
| **Need DB password for DBeaver** | Admin → Security → Install Secrets → Reveal DB password (superuser), then `docker-compose.dev-ports.yml` |
| **Lost `install.env` / DB auth failed** | See [docs/secrets-recovery.md](docs/secrets-recovery.md). Do not regenerate secrets if Postgres volume already exists |
| **CORS errors in browser console** | Keep `VITE_USE_API_PROXY=true` (default in dev). Do not call `localhost:8000` directly from the browser |
## Security Highlights
All cryptographic primitives live in a single module (`apps/api/app/core/crypto.py`); `security.py` and `media_signing.py` re-export from it.
| Layer | Mechanism |
|-----|---------| |-----|---------|
| **Passwords** | bcrypt cost 12 with per-user salt (embedded in hash); shared denylist | | http://localhost:5173 | Лендинг `/` + SPA (`/login`, `/admin`, …) |
| **Access JWT** | HS256, short TTL, in-memory on frontend only | | http://localhost:8000/api/v1/health | API жив? |
| **Refresh tokens** | HttpOnly cookie (`Path=/api/v1/auth`), rotation + family reuse detection; SHA-256 hash with server-side **pepper** | | http://localhost:8000/api/v1/docs | OpenAPI (только dev) |
| **Email/reset tokens** | Opaque tokens, SHA-256 + pepper in DB; raw token never stored |
| **Media URLs** | HMAC-SHA256 signed paths with TTL |
| **Install secrets** | `secrets.token_*` generation; generate-once + lock; superuser reveal (audited) |
**Auth & API** **Логин для пробы:** `admin@compton.example` / `Admin1234``/admin`.
- Refresh token rotation and reuse-detection (family revoke) > Bootstrap **до** первого `docker compose up`. Иначе Postgres скажет «password authentication failed» — см. [docs/deploy.md § восстановление](docs/deploy.md#восстановление-секретов).
- Rate limiting and brute-force lockout (`ENABLE_RATE_LIMIT=true` in Docker compose)
- Pending/blocked users rejected on refresh; frontend clears session on `403` (`ACCOUNT_BLOCKED`, `EMAIL_NOT_VERIFIED`)
- Origin/Referer validation on cookie-based auth endpoints
- `TRUSTED_PROXY_IPS` — only listed proxies may influence client IP via `X-Forwarded-For`
- Email bodies omit raw tokens in production (`EMAIL_DELIVERY_MODE=memory` or `ENABLE_TEST_ROUTES=true` only)
**Data & infra** **Не делайте:** Docker `web` и `pnpm --filter web dev` одновременно — оба хотят порт **5173**.
- PostgreSQL/Redis/MinIO on internal Docker network by default (no host ports) ## Демо-аккаунты (dev)
- DB CHECK constraints, connection pool tuning, startup token cleanup
- CMS HTML sanitization (bleach) with allowed URL protocols: `http`, `https`, `mailto`
- Signed avatar URLs; RBAC + superuser guards on admin routes
- Admin password policy on user create/reset
**Production guards** (`APP_ENV=production` — API refuses to start if): Подробнее: [docs/deploy.md § стандартные логины](docs/deploy.md#стандартные-логины-dev).
- `ENABLE_TEST_ROUTES=true` | Email | Пароль | Env | Роль | Superuser |
- `ENABLE_DOCS=true` |-------|--------|-----|------|-----------|
- `ENABLE_RATE_LIMIT=false` | admin@compton.example | Admin1234 | `ADMIN_INITIAL_PASSWORD` | admin | да — Security, Diagnostics, secrets |
- `COOKIE_SECURE=false` | ops@compton.example | OpsAdmin1234 | `DEMO_OPS_PASSWORD` | admin | нет — Users, Content, Activity |
- Default DB credentials (`user:pass`) or placeholder JWT secrets | user@compton.example | User1234 | `DEMO_USER_PASSWORD` | user | — |
- `DATABASE_URL` without `sslmode=require`
## Related Documentation CMS после seed: `/pages/about`, `/pages/privacy`, `/pages/terms`.
- [docs/secrets-recovery.md](docs/secrets-recovery.md) — recover from lost install secrets В production/staging demo-users **выключены** (`SEED_DEMO_USERS=false`).
- [docs/security-checklist.md](docs/security-checklist.md) — pre-production security checklist
- [docs/TZ.md](docs/TZ.md) — full technical specification
## MVP Status (Phase 1) ## Тесты
| # | Feature | Status | ```bash
| --- | ----------------------------- | ------ | pnpm --filter web test:ci # frontend, cov ≥ 85%
| 1.1 | PostgreSQL + Alembic + seed | done | cd apps/api && python -m pytest --cov=app --cov-fail-under=90
| 1.2 | Auth + SMTP (verify/reset) | done | pnpm --filter web e2e # Playwright, API :8001
| 1.3 | Profile CRUD + avatar (MinIO) | done | ```
| 1.4 | Content pages | done |
| 1.5 | Admin panel (Ant Design + zootech auth UI) | done | ## Документация
| 1.6 | E2E regression §15.7 | done |
Вся нормальная дока — в **`docs/`**, на русском, без водянистого README на 500 строк:
| Файл | О чём |
|------|-------|
| [docs/README.md](docs/README.md) | Оглавление |
| [docs/project.md](docs/project.md) | Структура, архитектура, API, маршруты |
| [docs/security.md](docs/security.md) | Auth, JWT revoke, секреты, чеклист prod |
| [docs/deploy.md](docs/deploy.md) | Dev/staging/prod, бэкапы, troubleshooting |
| [docs/release.md](docs/release.md) | QA gates, k6, ZAP, go-live |
| [docs/TZ.md](docs/TZ.md) | Техническое задание |
## Работа с Git
**Репозиторий:** https://git.groupkomton.ru/Matvey/site.git
**Ветка:** `main` (одна ветка, без сюрпризов)
```bash
git pull origin main # перед работой — всегда
git status && git diff # перед коммитом — тоже всегда
git add .
git commit -m "Сделал то-то на русском"
git push origin main
```
**Коммиты — только на русском.** Плохо: `fix`, `WIP`, `asdf`.
**Не коммитить:** `.env`, `install.env`, `node_modules/`, логи, кэши.
**Не делать** `git push --force` на `main` без согласования.
| Команда | Зачем |
|---------|-------|
| `git log --oneline -10` | что было |
| `git checkout -- файл` | откатить файл |
| `git stash` / `git stash pop` | спрятать / вернуть правки |
## План по ТЗ (§16)
| Фаза | Задачи | Статус |
|------|--------|--------|
| **0 — Подготовка** | Monorepo, Docker Compose, UI kit, CI/coverage gates, Playwright smoke | Выполнено |
| **1 — MVP** | Landing, Auth+SMTP, Profile, Content, Admin, staging + полный регресс | В процессе |
| **2 — v1.0** | Celery/notifications, Catalog+Orders, Analytics, k6 + monitoring | Когда начнём? |
| **3 — Scale** | Read replica PG, Celery workers, CDN, horizontal API | Когда начнём? |
### Фаза 1 — детализация
| # | Задача | Модули | Статус |
|---|--------|--------|--------|
| 1.1 | Landing (перенос заглушки) | landing | Выполнено |
| 1.2 | Auth + SMTP | auth | Выполнено |
| 1.2b | Security baseline | core, auth | В процессе |
| 1.3 | Profile CRUD + avatar | profile | Выполнено |
| 1.4 | Content pages | content | Выполнено |
| 1.5 | Admin panel | admin | Выполнено |
| 1.6 | Staging deploy + полный регресс (§15.7) | — | В процессе |
Критерии приёмки MVP — §17.1 ТЗ (k6 50 CCU, Lighthouse ≥85, E2E 10/10, coverage gates).
---
*Дальше — [docs/](docs/README.md). Там схемы, таблицы и вещи, которые не помещаются в README без страданий.*
+1
View File
@@ -40,3 +40,4 @@ DEMO_USER_PASSWORD=User1234
DEMO_OPS_PASSWORD=OpsAdmin1234 DEMO_OPS_PASSWORD=OpsAdmin1234
# E2E only, never enable in production. # E2E only, never enable in production.
ENABLE_TEST_ROUTES=false ENABLE_TEST_ROUTES=false
SEED_DEMO_USERS=true
+22
View File
@@ -0,0 +1,22 @@
# Production API environment (non-secret keys). Secrets live in data/secrets/install.env
APP_ENV=production
ENABLE_DOCS=false
ENABLE_TEST_ROUTES=false
COOKIE_SECURE=true
ENABLE_RATE_LIMIT=true
EMAIL_DELIVERY_MODE=smtp
SEED_DEMO_USERS=false
JWT_ACCESS_TTL_MIN=15
JWT_REFRESH_TTL_DAYS=30
STORAGE_MODE=s3
S3_BUCKET=compton
S3_REGION=us-east-1
AVATAR_MAX_BYTES=2097152
MEDIA_URL_TTL_SECONDS=600
LOG_LEVEL=INFO
AUDIT_RETENTION_DAYS=90
COMPTON_SETTINGS_PATH=data/compton_settings.json
ADMIN_AUDIT_LOG_PATH=data/logs/admin-audit.jsonl
SERVER_LOG_PATH=data/logs/server.log
PASSWORD_DENYLIST_PATH=data/security/password-denylist.txt
# Set on server: FRONTEND_URL, PUBLIC_BASE_URL, CORS_ORIGINS, SMTP_*, DATABASE_URL (via install.env)
+1
View File
@@ -23,6 +23,7 @@ class Settings(BaseSettings):
admin_initial_password: str = "Admin1234" admin_initial_password: str = "Admin1234"
demo_user_password: str = "User1234" demo_user_password: str = "User1234"
demo_ops_password: str = "OpsAdmin1234" demo_ops_password: str = "OpsAdmin1234"
seed_demo_users: bool = True
smtp_host: str = "localhost" smtp_host: str = "localhost"
smtp_port: int = 1025 smtp_port: int = 1025
smtp_user: str = "" smtp_user: str = ""
+2
View File
@@ -48,12 +48,14 @@ def verify_password(raw_password: str, password_hash: str) -> bool:
def create_access_token(user_id: str, role: str, is_superuser: bool = False) -> str: def create_access_token(user_id: str, role: str, is_superuser: bool = False) -> str:
from app.core.config import settings from app.core.config import settings
from app.core.jwt_denylist import get_auth_epoch
now = datetime.now(UTC) now = datetime.now(UTC)
payload = { payload = {
"sub": user_id, "sub": user_id,
"role": role, "role": role,
"is_superuser": is_superuser, "is_superuser": is_superuser,
"auth_epoch": get_auth_epoch(user_id),
"iat": int(now.timestamp()), "iat": int(now.timestamp()),
"exp": int((now + timedelta(minutes=settings.jwt_access_ttl_min)).timestamp()), "exp": int((now + timedelta(minutes=settings.jwt_access_ttl_min)).timestamp()),
"jti": generate_secret_token_hex(16), "jti": generate_secret_token_hex(16),
+7
View File
@@ -1,6 +1,7 @@
from fastapi import Depends, HTTPException, status from fastapi import Depends, HTTPException, status
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from app.core.jwt_denylist import validate_access_claims
from app.core.security import decode_access_token from app.core.security import decode_access_token
from app.modules.users.repository import get_user_by_id from app.modules.users.repository import get_user_by_id
@@ -12,6 +13,12 @@ def get_current_user(credentials: HTTPAuthorizationCredentials | None = Depends(
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="UNAUTHORIZED") raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="UNAUTHORIZED")
try: try:
payload = decode_access_token(credentials.credentials) payload = decode_access_token(credentials.credentials)
validate_access_claims(payload)
except ValueError as exc:
detail = str(exc)
if detail == "TOKEN_REVOKED":
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="TOKEN_REVOKED")
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="INVALID_TOKEN") from exc
except Exception as exc: # pragma: no cover - defensive except Exception as exc: # pragma: no cover - defensive
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="INVALID_TOKEN") from exc raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="INVALID_TOKEN") from exc
user = get_user_by_id(payload["sub"]) user = get_user_by_id(payload["sub"])
+2 -1
View File
@@ -9,7 +9,8 @@ from uuid import uuid4
from app.core.crypto import generate_install_bundle from app.core.crypto import generate_install_bundle
INSTALL_SECRETS_DIR = Path("data/secrets") _API_ROOT = Path(__file__).resolve().parents[2]
INSTALL_SECRETS_DIR = _API_ROOT / "data" / "secrets"
INSTALL_SECRETS_FILE = INSTALL_SECRETS_DIR / "install.env" INSTALL_SECRETS_FILE = INSTALL_SECRETS_DIR / "install.env"
INSTALL_SECRETS_META_FILE = INSTALL_SECRETS_DIR / "install.meta.json" INSTALL_SECRETS_META_FILE = INSTALL_SECRETS_DIR / "install.meta.json"
REQUIRED_KEYS = ( REQUIRED_KEYS = (
+108
View File
@@ -0,0 +1,108 @@
"""Redis-backed JWT jti denylist and per-user auth_epoch for instant access revocation."""
from __future__ import annotations
import time
from app.core.config import settings
from app.core.redis import get_redis_client
AUTH_EPOCH_PREFIX = "auth:epoch:"
JWT_DENY_PREFIX = "jwt:deny:"
_memory_epochs: dict[str, int] = {}
_memory_denied_jti: dict[str, float] = {}
def _purge_expired_memory_jtis() -> None:
now = time.time()
expired = [jti for jti, exp in _memory_denied_jti.items() if exp <= now]
for jti in expired:
_memory_denied_jti.pop(jti, None)
def ensure_jwt_revocation_backend() -> None:
if settings.app_env.lower() != "production":
return
if get_redis_client() is None:
raise RuntimeError("Redis is required for JWT revocation in production")
def get_auth_epoch(user_id: str) -> int:
client = get_redis_client()
if client is not None:
try:
value = client.get(f"{AUTH_EPOCH_PREFIX}{user_id}")
return int(value) if value is not None else 0
except Exception:
if settings.app_env.lower() == "production":
raise
return _memory_epochs.get(user_id, 0)
def bump_auth_epoch(user_id: str) -> int:
client = get_redis_client()
if client is not None:
try:
return int(client.incr(f"{AUTH_EPOCH_PREFIX}{user_id}"))
except Exception:
if settings.app_env.lower() == "production":
raise
next_epoch = _memory_epochs.get(user_id, 0) + 1
_memory_epochs[user_id] = next_epoch
return next_epoch
def deny_jti(jti: str, exp: int) -> None:
if not jti:
return
ttl = max(int(exp - time.time()), 1)
client = get_redis_client()
if client is not None:
try:
client.setex(f"{JWT_DENY_PREFIX}{jti}", ttl, "1")
return
except Exception:
if settings.app_env.lower() == "production":
raise
_purge_expired_memory_jtis()
_memory_denied_jti[jti] = time.time() + ttl
def is_jti_denied(jti: str) -> bool:
if not jti:
return False
client = get_redis_client()
if client is not None:
try:
return bool(client.exists(f"{JWT_DENY_PREFIX}{jti}"))
except Exception:
if settings.app_env.lower() == "production":
return True
_purge_expired_memory_jtis()
return jti in _memory_denied_jti
def revoke_access_token(token: str) -> None:
from app.core.security import decode_access_token
try:
payload = decode_access_token(token)
except Exception:
return
jti = payload.get("jti")
exp = payload.get("exp")
if jti and exp:
deny_jti(str(jti), int(exp))
def validate_access_claims(payload: dict) -> None:
user_id = payload.get("sub")
if not user_id:
raise ValueError("INVALID_TOKEN")
jti = payload.get("jti")
if jti and is_jti_denied(str(jti)):
raise ValueError("TOKEN_REVOKED")
token_epoch = int(payload.get("auth_epoch", 0))
if token_epoch != get_auth_epoch(str(user_id)):
raise ValueError("TOKEN_REVOKED")
+1
View File
@@ -50,6 +50,7 @@ def run_seed(include_demo_pages: bool = True) -> None:
is_superuser=True, is_superuser=True,
status="active", status="active",
) )
if settings.seed_demo_users and settings.app_env.lower() != "production":
_ensure_user( _ensure_user(
email="user@compton.example", email="user@compton.example",
password=settings.demo_user_password, password=settings.demo_user_password,
+2
View File
@@ -6,6 +6,7 @@ from fastapi.middleware.cors import CORSMiddleware
from app.core.install_secrets import ensure_install_secrets from app.core.install_secrets import ensure_install_secrets
from app.core.config import settings from app.core.config import settings
from app.core.jwt_denylist import ensure_jwt_revocation_backend
from app.core.app_settings import bootstrap_settings from app.core.app_settings import bootstrap_settings
from app.core.storage import ensure_bucket from app.core.storage import ensure_bucket
from app.core import database as db_module from app.core import database as db_module
@@ -50,6 +51,7 @@ def create_app() -> FastAPI:
async def lifespan(_: FastAPI): async def lifespan(_: FastAPI):
ensure_install_secrets() ensure_install_secrets()
bootstrap_settings() bootstrap_settings()
ensure_jwt_revocation_backend()
_assert_production_guards() _assert_production_guards()
if settings.enable_test_routes: if settings.enable_test_routes:
Base.metadata.create_all(db_module.engine) Base.metadata.create_all(db_module.engine)
+3
View File
@@ -4,6 +4,7 @@ from app.core.app_settings import apply_settings_to_app, get_settings_payload, w
from app.core.audit_log import read_audit_events, write_audit_event from app.core.audit_log import read_audit_events, write_audit_event
from app.core.config import settings from app.core.config import settings
from app.core.install_secrets import install_secrets_payload, reveal_install_secret from app.core.install_secrets import install_secrets_payload, reveal_install_secret
from app.core.jwt_denylist import bump_auth_epoch
from app.core.security import hash_password from app.core.security import hash_password
from app.modules.admin.security_diagnostics import build_security_diagnostics_report from app.modules.admin.security_diagnostics import build_security_diagnostics_report
from app.modules.auth.service import revoke_user_refresh_family from app.modules.auth.service import revoke_user_refresh_family
@@ -63,6 +64,7 @@ def patch_user(
if status: if status:
target.status = status target.status = status
if status == "blocked": if status == "blocked":
bump_auth_epoch(target.id)
revoke_user_refresh_family(target.id) revoke_user_refresh_family(target.id)
repository.update_user(target) repository.update_user(target)
write_audit_event( write_audit_event(
@@ -111,6 +113,7 @@ def reset_user_password(admin_user, target_user_id: str, password: str) -> dict:
raise ValueError("USER_NOT_FOUND") raise ValueError("USER_NOT_FOUND")
target.password_hash = hash_password(password) target.password_hash = hash_password(password)
repository.update_user(target) repository.update_user(target)
bump_auth_epoch(target.id)
revoke_user_refresh_family(target.id) revoke_user_refresh_family(target.id)
write_audit_event( write_audit_event(
action="admin.user.reset_password", action="admin.user.reset_password",
+18 -7
View File
@@ -1,7 +1,8 @@
from app.core.datetime_utils import ensure_utc, utc_now from fastapi import APIRouter, Depends, HTTPException, Request, Response, status
from fastapi import APIRouter, HTTPException, Request, Response, status from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from app.core.config import settings from app.core.config import settings
from app.core.datetime_utils import ensure_utc, utc_now
from app.core.redis import check_rate_limit, client_ip from app.core.redis import check_rate_limit, client_ip
from app.modules.auth.schemas import ( from app.modules.auth.schemas import (
ForgotPasswordIn, ForgotPasswordIn,
@@ -15,16 +16,17 @@ from app.modules.auth.schemas import (
from app.modules.auth.service import ( from app.modules.auth.service import (
forgot_password, forgot_password,
login, login,
logout,
refresh, refresh,
register, register,
resend_verification, resend_verification,
reset_password, reset_password,
revoke_refresh_token,
verify_email_token, verify_email_token,
) )
from app.modules.users import repository from app.modules.users import repository
router = APIRouter() router = APIRouter()
optional_bearer = HTTPBearer(auto_error=False)
def _allowed_origins() -> set[str]: def _allowed_origins() -> set[str]:
@@ -139,7 +141,12 @@ async def refresh_route(request: Request, response: Response):
check_rate_limit(f"refresh:{client_ip(request)}", limit=30, window_seconds=60) check_rate_limit(f"refresh:{client_ip(request)}", limit=30, window_seconds=60)
try: try:
access_token, new_refresh, user = refresh(refresh_token) access_token, new_refresh, user = refresh(refresh_token)
except PermissionError: except PermissionError as exc:
detail = str(exc)
if detail == "EMAIL_NOT_VERIFIED":
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="EMAIL_NOT_VERIFIED")
if detail == "ACCOUNT_BLOCKED":
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="ACCOUNT_BLOCKED")
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="INVALID_REFRESH") raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="INVALID_REFRESH")
_set_refresh_cookie(response, new_refresh) _set_refresh_cookie(response, new_refresh)
return { return {
@@ -156,11 +163,15 @@ async def refresh_route(request: Request, response: Response):
@router.post("/logout") @router.post("/logout")
async def logout_route(request: Request, response: Response): async def logout_route(
request: Request,
response: Response,
credentials: HTTPAuthorizationCredentials | None = Depends(optional_bearer),
):
_enforce_origin(request, require_header=True) _enforce_origin(request, require_header=True)
refresh_token = request.cookies.get("refresh_token") refresh_token = request.cookies.get("refresh_token")
if refresh_token: access_token = credentials.credentials if credentials else None
revoke_refresh_token(refresh_token) logout(refresh_token, access_token)
response.delete_cookie( response.delete_cookie(
"refresh_token", "refresh_token",
path="/api/v1/auth", path="/api/v1/auth",
+19 -7
View File
@@ -6,6 +6,7 @@ from uuid import uuid4
from app.core.config import settings from app.core.config import settings
from app.core.datetime_utils import ensure_utc, utc_now from app.core.datetime_utils import ensure_utc, utc_now
from app.core.email import send_template_email from app.core.email import send_template_email
from app.core.jwt_denylist import bump_auth_epoch, revoke_access_token
from app.core.security import ( from app.core.security import (
create_access_token, create_access_token,
generate_opaque_token, generate_opaque_token,
@@ -111,7 +112,7 @@ def resend_verification(email: str) -> None:
def forgot_password(email: str) -> None: def forgot_password(email: str) -> None:
user = repository.get_user_by_email(email) user = repository.get_user_by_email(email)
if not user: if not user or user.status == "blocked":
return return
token = _issue_password_reset_token(user.id) token = _issue_password_reset_token(user.id)
_send_password_reset_email(user, token) _send_password_reset_email(user, token)
@@ -132,6 +133,7 @@ def reset_password(token: str, new_password: str) -> None:
user.password_hash = hash_password(new_password) user.password_hash = hash_password(new_password)
repository.update_user(user) repository.update_user(user)
auth_repository.mark_password_reset_token_used(token_hash) auth_repository.mark_password_reset_token_used(token_hash)
bump_auth_epoch(user.id)
revoke_user_refresh_family(user.id) revoke_user_refresh_family(user.id)
@@ -199,18 +201,21 @@ def refresh(refresh_token: str) -> tuple[str, str, User]:
token_row = auth_repository.get_refresh_token(token_hash) token_row = auth_repository.get_refresh_token(token_hash)
if not token_row: if not token_row:
raise PermissionError("INVALID_REFRESH") raise PermissionError("INVALID_REFRESH")
user = repository.get_user_by_id(token_row.user_id)
if not user:
raise PermissionError("INVALID_REFRESH")
if user.status == "pending":
raise PermissionError("EMAIL_NOT_VERIFIED")
if user.status == "blocked":
raise PermissionError("ACCOUNT_BLOCKED")
if token_row.revoked_at is not None: if token_row.revoked_at is not None:
auth_repository.revoke_family_tokens(token_row.family_id) auth_repository.revoke_family_tokens(token_row.family_id)
raise PermissionError("INVALID_REFRESH") raise PermissionError("INVALID_REFRESH")
if ensure_utc(token_row.expires_at) < utc_now(): if ensure_utc(token_row.expires_at) < utc_now():
raise PermissionError("EXPIRED_REFRESH") raise PermissionError("EXPIRED_REFRESH")
user = repository.get_user_by_id(token_row.user_id)
if not user:
raise PermissionError("INVALID_REFRESH")
if user.status in {"pending", "blocked"}:
raise PermissionError("INVALID_REFRESH")
auth_repository.revoke_refresh_token(token_hash) auth_repository.revoke_refresh_token(token_hash)
new_refresh = issue_refresh_token(user.id, family_id=token_row.family_id) new_refresh = issue_refresh_token(user.id, family_id=token_row.family_id)
access = create_access_token(user.id, user.role, user.is_superuser) access = create_access_token(user.id, user.role, user.is_superuser)
@@ -224,5 +229,12 @@ def revoke_refresh_token(refresh_token: str) -> None:
auth_repository.revoke_family_tokens(token_row.family_id) auth_repository.revoke_family_tokens(token_row.family_id)
def logout(refresh_token: str | None, access_token: str | None) -> None:
if access_token:
revoke_access_token(access_token)
if refresh_token:
revoke_refresh_token(refresh_token)
def revoke_user_refresh_family(user_id: str) -> None: def revoke_user_refresh_family(user_id: str) -> None:
auth_repository.revoke_user_families(user_id) auth_repository.revoke_user_families(user_id)
+2
View File
@@ -1,3 +1,4 @@
from app.core.jwt_denylist import bump_auth_epoch
from app.core.security import hash_password, verify_password from app.core.security import hash_password, verify_password
from app.modules.auth.service import revoke_user_refresh_family from app.modules.auth.service import revoke_user_refresh_family
from app.modules.media.service import upload_user_avatar from app.modules.media.service import upload_user_avatar
@@ -26,4 +27,5 @@ def change_password(user: User, current_password: str, new_password: str) -> Non
raise ValueError("INVALID_CURRENT_PASSWORD") raise ValueError("INVALID_CURRENT_PASSWORD")
user.password_hash = hash_password(new_password) user.password_hash = hash_password(new_password)
repository.update_user(user) repository.update_user(user)
bump_auth_epoch(user.id)
revoke_user_refresh_family(user.id) revoke_user_refresh_family(user.id)
+2 -1
View File
@@ -19,6 +19,7 @@ from app.core.install_secrets import ensure_install_secrets
from app.core.config import settings from app.core.config import settings
INITIAL_REVISION = "20260711_0001" INITIAL_REVISION = "20260711_0001"
HEAD_REVISION = "20260714_0005"
SCHEMA_TABLES = ( SCHEMA_TABLES = (
"content_pages", "content_pages",
"email_verification_tokens", "email_verification_tokens",
@@ -49,7 +50,7 @@ def wait_for_database(max_attempts: int = 30, delay_seconds: float = 1.0):
"Local dev fix — deletes Docker DB data:\n" "Local dev fix — deletes Docker DB data:\n"
" docker compose --profile docker-web down -v\n" " docker compose --profile docker-web down -v\n"
" docker compose --profile docker-web up -d --build\n" " docker compose --profile docker-web up -d --build\n"
"See docs/secrets-recovery.md" "See docs/deploy.md#восстановление-секретов"
) )
raise RuntimeError(f"Database is unavailable: {detail}{hint}") from last_error raise RuntimeError(f"Database is unavailable: {detail}{hint}") from last_error
+63
View File
@@ -0,0 +1,63 @@
import json
import pytest
from app.core.app_settings import (
_coerce_value,
apply_settings_to_app,
bootstrap_settings,
env_locks,
get_settings_payload,
write_settings,
)
def test_write_and_bootstrap_settings(tmp_path, monkeypatch):
settings_file = tmp_path / "compton_settings.json"
monkeypatch.setattr("app.core.app_settings.settings.compton_settings_path", str(settings_file))
monkeypatch.delenv("ENABLE_DOCS", raising=False)
merged = write_settings({"enable_docs": False, "log_level": "INFO"})
assert merged["enable_docs"] is False
assert settings_file.exists()
bootstrap_settings()
from app.core.config import settings
assert settings.enable_docs is False
def test_env_locks_skip_locked_keys(tmp_path, monkeypatch):
settings_file = tmp_path / "compton_settings.json"
monkeypatch.setattr("app.core.app_settings.settings.compton_settings_path", str(settings_file))
monkeypatch.setenv("ENABLE_DOCS", "true")
monkeypatch.setattr("app.core.app_settings.settings.enable_docs", True)
write_settings({"enable_docs": False})
payload = get_settings_payload()
assert payload["locks"]["enable_docs"] is True
assert payload["values"]["enable_docs"] is True
def test_apply_settings_coerces_list_and_bool(monkeypatch):
apply_settings_to_app({"enable_docs": "false", "cors_origins": "http://a.test,http://b.test"})
from app.core.config import settings
assert settings.enable_docs is False
assert settings.cors_origins == ["http://a.test", "http://b.test"]
assert isinstance(env_locks(), dict)
def test_read_settings_ignores_invalid_payload(tmp_path, monkeypatch):
settings_file = tmp_path / "compton_settings.json"
settings_file.write_text("[]", encoding="utf-8")
monkeypatch.setattr("app.core.app_settings.settings.compton_settings_path", str(settings_file))
from app.core.app_settings import read_settings
assert read_settings() == {}
def test_coerce_value_rejects_invalid_list():
with pytest.raises(ValueError, match="INVALID_LIST_cors_origins"):
_coerce_value("cors_origins", 123)
+19
View File
@@ -0,0 +1,19 @@
from app.core.audit_log import read_audit_events, write_audit_event
def test_audit_log_roundtrip(tmp_path, monkeypatch):
audit_file = tmp_path / "admin-audit.jsonl"
monkeypatch.setattr("app.core.audit_log.settings.admin_audit_log_path", str(audit_file))
write_audit_event("admin.user.patch", "u1", "admin@example.com", {"target": "u2"})
events = read_audit_events()
assert len(events) == 1
assert events[0]["action"] == "admin.user.patch"
assert events[0]["details"]["target"] == "u2"
def test_read_audit_events_ignores_invalid_json(tmp_path, monkeypatch):
audit_file = tmp_path / "admin-audit.jsonl"
audit_file.write_text('{"action":"ok"}\nnot-json\n', encoding="utf-8")
monkeypatch.setattr("app.core.audit_log.settings.admin_audit_log_path", str(audit_file))
events = read_audit_events()
assert len(events) == 1
+68
View File
@@ -0,0 +1,68 @@
from __future__ import annotations
import pytest
from fastapi import HTTPException
from fastapi.security import HTTPAuthorizationCredentials
from app.core.dependencies import get_current_user, require_admin, require_superuser
from app.core.security import create_access_token, hash_password
from app.modules.users.repository import create_user, update_user
def test_get_current_user_rejects_missing_credentials():
with pytest.raises(HTTPException) as exc:
get_current_user(None)
assert exc.value.status_code == 401
def test_get_current_user_rejects_pending_user():
user = create_user("pending-dep@example.com", hash_password("Valid123"), status="pending")
token = create_access_token(user.id, user.role, user.is_superuser)
credentials = HTTPAuthorizationCredentials(scheme="Bearer", credentials=token)
with pytest.raises(HTTPException) as exc:
get_current_user(credentials)
assert exc.value.status_code == 403
assert exc.value.detail == "EMAIL_NOT_VERIFIED"
def test_get_current_user_rejects_blocked_user():
user = create_user("blocked-dep@example.com", hash_password("Valid123"), status="active")
user.status = "blocked"
update_user(user)
token = create_access_token(user.id, user.role, user.is_superuser)
credentials = HTTPAuthorizationCredentials(scheme="Bearer", credentials=token)
with pytest.raises(HTTPException) as exc:
get_current_user(credentials)
assert exc.value.status_code == 403
assert exc.value.detail == "ACCOUNT_BLOCKED"
def test_get_current_user_rejects_invalid_token():
credentials = HTTPAuthorizationCredentials(scheme="Bearer", credentials="not-a-jwt")
with pytest.raises(HTTPException) as exc:
get_current_user(credentials)
assert exc.value.status_code == 401
assert exc.value.detail == "INVALID_TOKEN"
def test_get_current_user_rejects_unknown_user():
token = create_access_token("missing-user-id", "user", False)
credentials = HTTPAuthorizationCredentials(scheme="Bearer", credentials=token)
with pytest.raises(HTTPException) as exc:
get_current_user(credentials)
assert exc.value.status_code == 401
def test_require_admin_rejects_regular_user():
user = create_user("regular-dep@example.com", hash_password("Valid123"), role="user", status="active")
with pytest.raises(HTTPException) as exc:
require_admin(user)
assert exc.value.status_code == 403
def test_require_superuser_rejects_non_super_admin():
user = create_user("ops-dep@example.com", hash_password("Valid123"), role="admin", status="active")
with pytest.raises(HTTPException) as exc:
require_superuser(user)
assert exc.value.status_code == 403
assert exc.value.detail == "SUPERUSER_ONLY"
+29
View File
@@ -0,0 +1,29 @@
from unittest.mock import MagicMock, patch
from app.core.email import SmtpMailer, get_mailer, memory_mailer, send_template_email
def test_memory_mailer_latest_token():
memory_mailer.clear()
send_template_email(
to="user@example.com",
template="verify_email",
subject="Verify",
body="Open link\nTOKEN:abc123\n",
)
assert memory_mailer.latest_token("user@example.com", "verify_email") == "abc123"
def test_smtp_mailer_sends_message(monkeypatch):
monkeypatch.setattr("app.core.email.settings.email_delivery_mode", "smtp")
monkeypatch.setattr("app.core.email.settings.smtp_from", "noreply@example.com")
monkeypatch.setattr("app.core.email.settings.smtp_host", "localhost")
monkeypatch.setattr("app.core.email.settings.smtp_port", 1025)
monkeypatch.setattr("app.core.email.settings.smtp_user", "")
monkeypatch.setattr("app.core.email.settings.smtp_password", "")
smtp_instance = MagicMock()
with patch("app.core.email.smtplib.SMTP") as smtp_cls:
smtp_cls.return_value.__enter__.return_value = smtp_instance
get_mailer().send("user@example.com", "Subject", "Body", "verify_email")
smtp_instance.send_message.assert_called_once()
+115
View File
@@ -0,0 +1,115 @@
from __future__ import annotations
from pathlib import Path
import pytest
from app.core import install_secrets as secrets
@pytest.fixture
def secrets_dir(tmp_path: Path, monkeypatch):
install_dir = tmp_path / "secrets"
install_file = install_dir / "install.env"
meta_file = install_dir / "install.meta.json"
monkeypatch.setattr(secrets, "INSTALL_SECRETS_DIR", install_dir)
monkeypatch.setattr(secrets, "INSTALL_SECRETS_FILE", install_file)
monkeypatch.setattr(secrets, "INSTALL_SECRETS_META_FILE", meta_file)
return install_dir, install_file, meta_file
def test_read_install_secrets_empty_when_missing(secrets_dir):
_, install_file, _ = secrets_dir
assert not install_file.exists()
assert secrets.read_install_secrets() == {}
def test_write_and_read_install_secrets_roundtrip(secrets_dir):
install_dir, install_file, _ = secrets_dir
install_dir.mkdir(parents=True, exist_ok=True)
install_file.write_text(
"JWT_ACCESS_SECRET=abc\n# comment\nPOSTGRES_PASSWORD=secret\n",
encoding="utf-8",
)
values = secrets.read_install_secrets()
assert values["JWT_ACCESS_SECRET"] == "abc"
assert values["POSTGRES_PASSWORD"] == "secret"
def test_sync_minio_s3_secrets_aligns_keys():
values = {
"S3_ACCESS_KEY": "minio",
"MINIO_ROOT_USER": "minio",
"MINIO_ROOT_PASSWORD": "root-secret",
"S3_SECRET_KEY": "old-secret",
}
synced = secrets._sync_minio_s3_secrets(values)
assert synced["S3_SECRET_KEY"] == "root-secret"
def test_masked_database_url_hides_password():
masked = secrets.masked_database_url("postgresql://user:pass@localhost:5432/compton")
assert "pass" not in masked
assert "user:***" in masked
def test_install_secrets_payload_reports_status(secrets_dir):
install_dir, install_file, _ = secrets_dir
install_dir.mkdir(parents=True, exist_ok=True)
install_file.write_text(
"\n".join(
[
"SECRETS_LOCKED=true",
"DATABASE_URL=postgresql://compton_app:secret@postgres:5432/compton",
"JWT_ACCESS_SECRET=abc",
"JWT_REFRESH_PEPPER=def",
"POSTGRES_PASSWORD=secret",
"S3_SECRET_KEY=key",
]
)
+ "\n",
encoding="utf-8",
)
payload = secrets.install_secrets_payload()
assert payload["initialized"] is True
assert payload["locked"] is True
assert payload["database"]["user"] == "compton_app"
assert "***" in payload["connection_string_masked"]
def test_reveal_install_secret_supported_and_unsupported(secrets_dir):
install_dir, install_file, _ = secrets_dir
install_dir.mkdir(parents=True, exist_ok=True)
install_file.write_text("JWT_ACCESS_SECRET=top-secret\n", encoding="utf-8")
assert secrets.reveal_install_secret("jwt_access_secret") == "top-secret"
with pytest.raises(ValueError, match="UNSUPPORTED_SECRET_KEY"):
secrets.reveal_install_secret("unknown")
def test_ensure_install_secrets_creates_locked_bundle(secrets_dir, monkeypatch):
install_dir, install_file, meta_file = secrets_dir
monkeypatch.delenv("DATABASE_URL", raising=False)
status = secrets.ensure_install_secrets()
assert status.created is True
assert status.locked is True
assert install_file.exists()
assert meta_file.exists()
values = secrets.read_install_secrets()
assert values["SECRETS_LOCKED"] == "true"
assert values["JWT_ACCESS_SECRET"]
def test_ensure_install_secrets_adopts_existing_locked_file(secrets_dir):
install_dir, install_file, meta_file = secrets_dir
install_dir.mkdir(parents=True, exist_ok=True)
install_file.write_text(
"SECRETS_LOCKED=true\nJWT_ACCESS_SECRET=existing\nJWT_REFRESH_PEPPER=pepper\n"
"POSTGRES_PASSWORD=pw\nPOSTGRES_USER=u\nPOSTGRES_DB=db\nDATABASE_URL=postgresql://u:pw@localhost/db\n"
"S3_ACCESS_KEY=a\nS3_SECRET_KEY=b\nMINIO_ROOT_USER=a\nMINIO_ROOT_PASSWORD=b\n",
encoding="utf-8",
)
meta_file.write_text('{"install_id":"x","locked_at":"2026-01-01T00:00:00+00:00"}\n', encoding="utf-8")
status = secrets.ensure_install_secrets()
assert status.created is False
assert status.locked is True
assert secrets.read_install_secrets()["JWT_ACCESS_SECRET"] == "existing"
+119
View File
@@ -0,0 +1,119 @@
from __future__ import annotations
from unittest.mock import MagicMock, patch
import pytest
from app.core import jwt_denylist as denylist_module
from app.core.jwt_denylist import (
bump_auth_epoch,
deny_jti,
ensure_jwt_revocation_backend,
get_auth_epoch,
is_jti_denied,
revoke_access_token,
validate_access_claims,
)
from app.core.security import create_access_token, hash_password
from app.modules.users.repository import create_user
def test_auth_epoch_bump_invalidates_token():
user = create_user("epoch-user@example.com", hash_password("Valid123"), status="active")
token = create_access_token(user.id, user.role, user.is_superuser)
from app.core.security import decode_access_token
payload = decode_access_token(token)
validate_access_claims(payload)
bump_auth_epoch(user.id)
with pytest.raises(ValueError, match="TOKEN_REVOKED"):
validate_access_claims(payload)
def test_deny_jti_blocks_specific_token():
user = create_user("jti-user@example.com", hash_password("Valid123"), status="active")
token = create_access_token(user.id, user.role, user.is_superuser)
from app.core.security import decode_access_token
payload = decode_access_token(token)
deny_jti(payload["jti"], payload["exp"])
assert is_jti_denied(payload["jti"])
with pytest.raises(ValueError, match="TOKEN_REVOKED"):
validate_access_claims(payload)
def test_revoke_access_token_helper():
user = create_user("revoke-user@example.com", hash_password("Valid123"), status="active")
token = create_access_token(user.id, user.role, user.is_superuser)
revoke_access_token(token)
from app.core.security import decode_access_token
with pytest.raises(ValueError, match="TOKEN_REVOKED"):
validate_access_claims(decode_access_token(token))
def test_get_auth_epoch_defaults_to_zero():
assert get_auth_epoch("missing-user-id") == 0
def test_validate_access_claims_rejects_missing_sub():
with pytest.raises(ValueError, match="INVALID_TOKEN"):
validate_access_claims({})
def test_production_is_jti_denied_fail_closed(monkeypatch):
monkeypatch.setattr(denylist_module.settings, "app_env", "production")
mock_client = MagicMock()
mock_client.exists.side_effect = RuntimeError("redis down")
with patch("app.core.jwt_denylist.get_redis_client", return_value=mock_client):
assert is_jti_denied("any-jti") is True
def test_revoke_access_token_ignores_invalid_token():
revoke_access_token("not-a-jwt")
def test_bump_auth_epoch_production_raises_when_redis_fails(monkeypatch):
monkeypatch.setattr(denylist_module.settings, "app_env", "production")
mock_client = MagicMock()
mock_client.incr.side_effect = RuntimeError("redis down")
with patch("app.core.jwt_denylist.get_redis_client", return_value=mock_client):
with pytest.raises(RuntimeError):
bump_auth_epoch("user-x")
def test_empty_jti_is_not_denied():
assert is_jti_denied("") is False
deny_jti("", 9999999999)
def test_ensure_jwt_revocation_backend_requires_redis_in_production(monkeypatch):
monkeypatch.setattr(denylist_module.settings, "app_env", "production")
with patch("app.core.jwt_denylist.get_redis_client", return_value=None):
with pytest.raises(RuntimeError, match="Redis is required"):
ensure_jwt_revocation_backend()
def test_get_auth_epoch_production_raises_when_redis_fails(monkeypatch):
monkeypatch.setattr(denylist_module.settings, "app_env", "production")
mock_client = MagicMock()
mock_client.get.side_effect = RuntimeError("redis down")
with patch("app.core.jwt_denylist.get_redis_client", return_value=mock_client):
with pytest.raises(RuntimeError):
get_auth_epoch("user-y")
def test_deny_jti_production_raises_when_redis_fails(monkeypatch):
monkeypatch.setattr(denylist_module.settings, "app_env", "production")
mock_client = MagicMock()
mock_client.setex.side_effect = RuntimeError("redis down")
with patch("app.core.jwt_denylist.get_redis_client", return_value=mock_client):
with pytest.raises(RuntimeError):
deny_jti("jti-123", int(__import__("time").time()) + 3600)
def test_memory_denylist_purges_expired_jti(monkeypatch):
monkeypatch.setattr(denylist_module, "_memory_denied_jti", {"expired-jti": 1.0})
assert is_jti_denied("expired-jti") is False
+9
View File
@@ -0,0 +1,9 @@
from app.main import create_app
from app.core.config import settings
def test_create_app_includes_test_routes_when_enabled(monkeypatch):
monkeypatch.setattr(settings, "enable_test_routes", True)
app = create_app()
paths = set(app.openapi()["paths"])
assert "/api/v1/test/emails/latest-token" in paths
+11 -1
View File
@@ -1,5 +1,15 @@
from app.core.password_denylist import is_denied_password from app.core.password_denylist import is_denied_password, load_denylist
def test_password_denylist_blocks_common_password(): def test_password_denylist_blocks_common_password():
assert is_denied_password("password123") assert is_denied_password("password123")
def test_password_denylist_loads_custom_entries(tmp_path, monkeypatch):
denylist_file = tmp_path / "denylist.txt"
denylist_file.write_text("# comment\nCustomBad1\n", encoding="utf-8")
monkeypatch.setattr("app.core.password_denylist.settings.password_denylist_path", str(denylist_file))
denylist = load_denylist()
assert "custombad1" in denylist
assert is_denied_password("CustomBad1")
@@ -0,0 +1,86 @@
from __future__ import annotations
import pytest
from app.main import _assert_production_guards
from app.core.config import settings
@pytest.fixture(autouse=True)
def reset_app_env(monkeypatch):
monkeypatch.setattr(settings, "app_env", "development")
monkeypatch.setattr(settings, "enable_test_routes", True)
monkeypatch.setattr(settings, "enable_docs", True)
monkeypatch.setattr(settings, "enable_rate_limit", False)
monkeypatch.setattr(settings, "cookie_secure", False)
monkeypatch.setattr(settings, "jwt_access_secret", "dev-access-secret-32bytes-minimum!!")
monkeypatch.setattr(settings, "jwt_refresh_pepper", "dev-refresh-pepper-32bytes-minimum!!")
monkeypatch.setattr(
settings,
"database_url",
"postgresql+psycopg://compton_app:secret@postgres:5432/compton?sslmode=require",
)
def test_production_guards_skip_in_development():
_assert_production_guards()
def test_production_guards_reject_test_routes(monkeypatch):
monkeypatch.setattr(settings, "app_env", "production")
with pytest.raises(RuntimeError, match="ENABLE_TEST_ROUTES"):
_assert_production_guards()
def test_production_guards_reject_insecure_cookie(monkeypatch):
monkeypatch.setattr(settings, "app_env", "production")
monkeypatch.setattr(settings, "enable_test_routes", False)
monkeypatch.setattr(settings, "enable_docs", False)
monkeypatch.setattr(settings, "enable_rate_limit", True)
with pytest.raises(RuntimeError, match="COOKIE_SECURE"):
_assert_production_guards()
def test_production_guards_reject_default_db_credentials(monkeypatch):
monkeypatch.setattr(settings, "app_env", "production")
monkeypatch.setattr(settings, "enable_test_routes", False)
monkeypatch.setattr(settings, "enable_docs", False)
monkeypatch.setattr(settings, "enable_rate_limit", True)
monkeypatch.setattr(settings, "cookie_secure", True)
monkeypatch.setattr(settings, "database_url", "postgresql://user:pass@db/app?sslmode=require")
with pytest.raises(RuntimeError, match="Default database credentials"):
_assert_production_guards()
def test_production_guards_reject_placeholder_jwt(monkeypatch):
monkeypatch.setattr(settings, "app_env", "production")
monkeypatch.setattr(settings, "enable_test_routes", False)
monkeypatch.setattr(settings, "enable_docs", False)
monkeypatch.setattr(settings, "enable_rate_limit", True)
monkeypatch.setattr(settings, "cookie_secure", True)
monkeypatch.setattr(settings, "jwt_access_secret", "change-me-access")
with pytest.raises(RuntimeError, match="JWT_ACCESS_SECRET"):
_assert_production_guards()
def test_production_guards_reject_placeholder_refresh_pepper(monkeypatch):
monkeypatch.setattr(settings, "app_env", "production")
monkeypatch.setattr(settings, "enable_test_routes", False)
monkeypatch.setattr(settings, "enable_docs", False)
monkeypatch.setattr(settings, "enable_rate_limit", True)
monkeypatch.setattr(settings, "cookie_secure", True)
monkeypatch.setattr(settings, "jwt_access_secret", "prod-access-secret-32bytes-minimum!!")
monkeypatch.setattr(settings, "jwt_refresh_pepper", "change-me-pepper")
with pytest.raises(RuntimeError, match="JWT_REFRESH_PEPPER"):
_assert_production_guards()
def test_production_guards_reject_missing_sslmode(monkeypatch):
monkeypatch.setattr(settings, "app_env", "production")
monkeypatch.setattr(settings, "enable_test_routes", False)
monkeypatch.setattr(settings, "enable_docs", False)
monkeypatch.setattr(settings, "enable_rate_limit", True)
monkeypatch.setattr(settings, "cookie_secure", True)
monkeypatch.setattr(settings, "database_url", "postgresql://app:secret@db/app")
with pytest.raises(RuntimeError, match="sslmode=require"):
_assert_production_guards()
+32
View File
@@ -0,0 +1,32 @@
from unittest.mock import MagicMock, patch
import pytest
from fastapi import HTTPException, Request
from app.core.config import settings
from app.core.redis import check_rate_limit, client_ip, _buckets
def test_check_rate_limit_uses_memory_fallback(monkeypatch):
monkeypatch.setattr(settings, "enable_rate_limit", True)
_buckets.clear()
with patch("app.core.redis.get_redis_client", return_value=None):
check_rate_limit("memory-key", limit=1, window_seconds=60)
with pytest.raises(HTTPException) as exc:
check_rate_limit("memory-key", limit=1, window_seconds=60)
assert exc.value.status_code == 429
def test_client_ip_honors_trusted_proxy(monkeypatch):
monkeypatch.setattr(settings, "trusted_proxy_ips", "127.0.0.1")
request = MagicMock(spec=Request)
request.headers = {"X-Forwarded-For": "203.0.113.10, 127.0.0.1"}
request.client.host = "127.0.0.1"
assert client_ip(request) == "203.0.113.10"
def test_client_ip_unknown_without_client():
request = MagicMock(spec=Request)
request.headers = {}
request.client = None
assert client_ip(request) == "unknown"
+10 -1
View File
@@ -1,5 +1,14 @@
from app.core.storage import ensure_bucket from app.core.storage import download_object, ensure_bucket, memory_store, upload_object
def test_ensure_bucket_noop_in_memory(): def test_ensure_bucket_noop_in_memory():
ensure_bucket() ensure_bucket()
def test_memory_storage_roundtrip():
memory_store.clear()
upload_object("avatars/test.png", b"abc", "image/png")
payload = download_object("avatars/test.png")
assert payload == (b"abc", "image/png")
assert download_object("missing") is None
@@ -71,3 +71,39 @@ def test_superuser_can_create_and_delete_user(client):
deleted = client.delete(f"/api/v1/admin/users/{user_id}", headers=_admin_headers(client)) deleted = client.delete(f"/api/v1/admin/users/{user_id}", headers=_admin_headers(client))
assert deleted.status_code == 200 assert deleted.status_code == 200
assert deleted.json()["status"] == "deleted" assert deleted.json()["status"] == "deleted"
def test_admin_summary_and_stats(client):
headers = _admin_headers(client)
summary = client.get("/api/v1/admin/summary", headers=headers)
assert summary.status_code == 200
assert "users_count" in summary.json()
stats = client.get("/api/v1/admin/stats", headers=headers)
assert stats.status_code == 200
def test_superuser_diagnostics_and_server_log(client):
headers = _admin_headers(client)
diagnostics = client.get("/api/v1/admin/diagnostics/report", headers=headers)
assert diagnostics.status_code == 200
assert "checks" in diagnostics.json()
activity = client.get("/api/v1/admin/activity-feed", headers=headers)
assert activity.status_code == 200
assert "events" in activity.json()
server_log = client.get("/api/v1/admin/server-log", headers=headers)
assert server_log.status_code == 200
assert "lines" in server_log.json()
def test_admin_ui_activity(client):
response = client.post(
"/api/v1/admin/ui-activity",
json={"event": "tab_open", "meta": {"tab": "users"}},
headers=_admin_headers(client),
)
assert response.status_code == 200
assert response.json()["status"] == "ok"
@@ -0,0 +1,10 @@
from app.modules.admin.security_diagnostics import build_security_diagnostics_report
def test_build_security_diagnostics_report_returns_checks():
report = build_security_diagnostics_report()
assert "checks" in report
assert len(report["checks"]) >= 10
ids = {check["id"] for check in report["checks"]}
assert "jwt_access_secret" in ids
assert "install_secrets_locked" in ids
+56 -1
View File
@@ -1,6 +1,12 @@
from unittest.mock import patch from unittest.mock import patch
from app.modules.admin.service import patch_user from app.modules.admin.service import (
create_admin_user,
delete_admin_user,
get_server_log_tail,
patch_user,
reset_user_password,
)
from app.modules.users import repository from app.modules.users import repository
from app.modules.users.repository import create_user, get_user_by_email from app.modules.users.repository import create_user, get_user_by_email
from app.core.security import hash_password from app.core.security import hash_password
@@ -31,3 +37,52 @@ def test_last_admin_protected():
assert False, "Expected last-admin protection" assert False, "Expected last-admin protection"
except ValueError as exc: except ValueError as exc:
assert str(exc) == "LAST_ADMIN_PROTECTED" assert str(exc) == "LAST_ADMIN_PROTECTED"
def test_admin_cannot_self_block():
admin = get_user_by_email("admin@compton.example")
try:
patch_user(admin, admin.id, None, "blocked")
assert False, "Expected self-block error"
except ValueError as exc:
assert str(exc) == "SELF_BLOCK_FORBIDDEN"
def test_reset_user_password_revokes_sessions():
admin = get_user_by_email("admin@compton.example")
target = create_user("reset-pw@example.com", hash_password("Valid123"), status="active")
result = reset_user_password(admin, target.id, "NewValid123")
assert result["status"] == "ok"
def test_delete_admin_user_forbidden_for_self():
admin = get_user_by_email("admin@compton.example")
try:
delete_admin_user(admin, admin.id)
assert False, "Expected self-delete error"
except ValueError as exc:
assert str(exc) == "SELF_DELETE_FORBIDDEN"
def test_create_admin_user_rejects_duplicate_email():
admin = get_user_by_email("admin@compton.example")
try:
create_admin_user(
admin,
email="user@compton.example",
password="Valid123",
role="user",
is_superuser=False,
status="active",
)
assert False, "Expected duplicate user error"
except ValueError as exc:
assert str(exc) == "USER_EXISTS"
def test_get_server_log_tail_empty_when_missing():
with patch("app.modules.admin.service.settings") as mock_settings:
mock_settings.server_log_path = "/tmp/compton-missing-log.txt"
payload = get_server_log_tail()
assert payload["lines"] == []
@@ -0,0 +1,35 @@
from app.core.security import create_access_token, hash_password
from tests.helpers import register_and_verify
def _admin_headers(client) -> dict[str, str]:
from app.modules.users.repository import get_user_by_email
admin = get_user_by_email("admin@compton.example")
token = create_access_token(admin.id, admin.role, admin.is_superuser)
return {"Authorization": f"Bearer {token}"}
def test_blocked_user_access_token_revoked_after_admin_block(client):
register_and_verify(client, "blocked-jwt@example.com")
login = client.post(
"/api/v1/auth/login",
json={"email": "blocked-jwt@example.com", "password": "Valid123"},
)
assert login.status_code == 200
access_token = login.json()["access_token"]
me = client.get("/api/v1/users/me", headers={"Authorization": f"Bearer {access_token}"})
assert me.status_code == 200
user_id = me.json()["user"]["id"]
blocked = client.patch(
f"/api/v1/admin/users/{user_id}",
headers=_admin_headers(client),
json={"status": "blocked"},
)
assert blocked.status_code == 200
revoked = client.get("/api/v1/users/me", headers={"Authorization": f"Bearer {access_token}"})
assert revoked.status_code == 401
assert revoked.json()["detail"] == "TOKEN_REVOKED"
@@ -0,0 +1,77 @@
from unittest.mock import MagicMock, patch
import pytest
from app.core import jwt_denylist as denylist_module
from app.core.jwt_denylist import (
bump_auth_epoch,
deny_jti,
ensure_jwt_revocation_backend,
get_auth_epoch,
is_jti_denied,
)
def test_ensure_jwt_revocation_backend_requires_redis_in_production(monkeypatch):
monkeypatch.setattr(denylist_module.settings, "app_env", "production")
with patch("app.core.jwt_denylist.get_redis_client", return_value=None):
with pytest.raises(RuntimeError, match="Redis is required"):
ensure_jwt_revocation_backend()
def test_redis_auth_epoch_roundtrip():
mock_client = MagicMock()
mock_client.get.return_value = "3"
mock_client.incr.return_value = 4
with patch("app.core.jwt_denylist.get_redis_client", return_value=mock_client):
assert get_auth_epoch("user-1") == 3
assert bump_auth_epoch("user-1") == 4
mock_client.incr.assert_called_once()
def test_redis_deny_jti_and_check():
mock_client = MagicMock()
with patch("app.core.jwt_denylist.get_redis_client", return_value=mock_client):
with patch("app.core.jwt_denylist.time.time", return_value=1000):
deny_jti("abc-jti", 1060)
mock_client.setex.assert_called_once_with("jwt:deny:abc-jti", 60, "1")
mock_client.exists.return_value = 1
assert is_jti_denied("abc-jti") is True
def test_forgot_password_skips_blocked_user(client):
from app.modules.users.repository import create_user, get_user_by_email, update_user
from app.core.security import hash_password
create_user("blocked-forgot@example.com", hash_password("Valid123"), status="active")
user = get_user_by_email("blocked-forgot@example.com")
user.status = "blocked"
update_user(user)
response = client.post(
"/api/v1/auth/forgot-password",
json={"email": "blocked-forgot@example.com"},
)
assert response.status_code == 200
from app.core.email import memory_mailer
assert not any(msg.to == "blocked-forgot@example.com" for msg in memory_mailer.sent)
def test_logout_revokes_access_token(client):
from tests.helpers import register_and_verify
register_and_verify(client, "logout-jti@example.com")
login = client.post(
"/api/v1/auth/login",
json={"email": "logout-jti@example.com", "password": "Valid123"},
)
token = login.json()["access_token"]
logout = client.post(
"/api/v1/auth/logout",
headers={"Authorization": f"Bearer {token}", "Origin": "http://localhost:5173"},
)
assert logout.status_code == 200
me = client.get("/api/v1/users/me", headers={"Authorization": f"Bearer {token}"})
assert me.status_code == 401
assert me.json()["detail"] == "TOKEN_REVOKED"
@@ -12,6 +12,31 @@ def _admin_headers(client) -> dict[str, str]:
return {"Authorization": f"Bearer {token}"} return {"Authorization": f"Bearer {token}"}
def test_refresh_fails_for_pending_user(client):
client.post(
"/api/v1/auth/register",
json={"email": "pending-refresh@example.com", "password": "Valid123"},
)
login = client.post(
"/api/v1/auth/login",
json={"email": "pending-refresh@example.com", "password": "Valid123"},
)
assert login.status_code == 403
assert login.json()["detail"] == "EMAIL_NOT_VERIFIED"
# Simulate stale refresh cookie from an earlier active session edge case via direct token issue.
from app.modules.auth.service import issue_refresh_token
from app.modules.users.repository import get_user_by_email
user = get_user_by_email("pending-refresh@example.com")
refresh_token = issue_refresh_token(user.id)
client.cookies.set("refresh_token", refresh_token, path="/api/v1/auth")
refresh = client.post("/api/v1/auth/refresh", headers={"Origin": "http://localhost:5173"})
assert refresh.status_code == 403
assert refresh.json()["detail"] == "EMAIL_NOT_VERIFIED"
def test_refresh_fails_for_blocked_user(client): def test_refresh_fails_for_blocked_user(client):
register_and_verify(client, "blocked-refresh@example.com") register_and_verify(client, "blocked-refresh@example.com")
login = client.post( login = client.post(
@@ -32,7 +57,8 @@ def test_refresh_fails_for_blocked_user(client):
) )
assert blocked.status_code == 200 assert blocked.status_code == 200
refresh = client.post("/api/v1/auth/refresh", headers={"Origin": "http://localhost:5173"}) refresh = client.post("/api/v1/auth/refresh", headers={"Origin": "http://localhost:5173"})
assert refresh.status_code == 401 assert refresh.status_code == 403
assert refresh.json()["detail"] == "ACCOUNT_BLOCKED"
def test_refresh_requires_origin_header_when_cookie_present(client): def test_refresh_requires_origin_header_when_cookie_present(client):
@@ -0,0 +1,40 @@
from __future__ import annotations
from unittest.mock import patch
import pytest
from app.core.security import hash_password
from app.modules.auth import repository as auth_repository
from app.modules.auth.service import issue_refresh_token, refresh
from app.modules.users.repository import create_user, get_user_by_id, update_user
def test_refresh_returns_account_blocked_for_blocked_user():
user = create_user("blocked-svc@example.com", hash_password("Valid123"), status="active")
token = issue_refresh_token(user.id)
user.status = "blocked"
update_user(user)
auth_repository.revoke_user_families(user.id)
with pytest.raises(PermissionError, match="ACCOUNT_BLOCKED"):
refresh(token)
def test_refresh_returns_email_not_verified_for_pending_user():
user = create_user("pending-svc@example.com", hash_password("Valid123"), status="pending")
token = issue_refresh_token(user.id)
with pytest.raises(PermissionError, match="EMAIL_NOT_VERIFIED"):
refresh(token)
def test_refresh_rejects_revoked_token_for_active_user():
user = create_user("active-svc@example.com", hash_password("Valid123"), status="active")
token = issue_refresh_token(user.id)
auth_repository.revoke_user_families(user.id)
with patch.object(auth_repository, "revoke_family_tokens") as revoke_family:
with pytest.raises(PermissionError, match="INVALID_REFRESH"):
refresh(token)
revoke_family.assert_called_once()
@@ -0,0 +1,38 @@
from fastapi.testclient import TestClient
from app.core.config import settings
from app.core.email import memory_mailer
from app.main import create_app
def _test_client(monkeypatch) -> TestClient:
monkeypatch.setattr(settings, "enable_test_routes", True)
monkeypatch.setattr(settings, "email_delivery_mode", "memory")
return TestClient(create_app())
def test_latest_email_token_route(monkeypatch):
client = _test_client(monkeypatch)
memory_mailer.clear()
memory_mailer.send(
to="token-route@example.com",
subject="Verify",
body="TOKEN:route-token\n",
template="verify_email",
)
response = client.get(
"/api/v1/test/emails/latest-token",
params={"to": "token-route@example.com", "template": "verify_email"},
)
assert response.status_code == 200
assert response.json()["token"] == "route-token"
def test_latest_email_token_route_missing_token(monkeypatch):
client = _test_client(monkeypatch)
response = client.get(
"/api/v1/test/emails/latest-token",
params={"to": "missing@example.com", "template": "verify_email"},
)
assert response.status_code == 404
assert response.json()["detail"] == "TOKEN_NOT_FOUND"
@@ -1,6 +1,6 @@
from sqlalchemy import create_engine, inspect from sqlalchemy import create_engine, inspect
from scripts.docker_entrypoint import INITIAL_REVISION, current_revision, run_migrations from scripts.docker_entrypoint import HEAD_REVISION, INITIAL_REVISION, current_revision, run_migrations
def test_run_migrations_on_empty_sqlite(tmp_path, monkeypatch): def test_run_migrations_on_empty_sqlite(tmp_path, monkeypatch):
@@ -17,7 +17,7 @@ def test_run_migrations_on_empty_sqlite(tmp_path, monkeypatch):
tables = set(inspect(engine).get_table_names()) tables = set(inspect(engine).get_table_names())
assert "users" in tables assert "users" in tables
assert current_revision(engine) == "20260714_0004" assert current_revision(engine) == HEAD_REVISION
def test_run_migrations_stamps_existing_schema_without_alembic(tmp_path, monkeypatch): def test_run_migrations_stamps_existing_schema_without_alembic(tmp_path, monkeypatch):
@@ -52,7 +52,7 @@ def test_run_migrations_stamps_existing_schema_without_alembic(tmp_path, monkeyp
tables = set(inspect(engine).get_table_names()) tables = set(inspect(engine).get_table_names())
assert "refresh_tokens" in tables assert "refresh_tokens" in tables
assert current_revision(engine) == "20260714_0004" assert current_revision(engine) == HEAD_REVISION
assert INITIAL_REVISION == "20260711_0001" assert INITIAL_REVISION == "20260711_0001"
@@ -98,4 +98,4 @@ def test_run_migrations_repairs_partial_schema_with_stale_alembic(tmp_path, monk
tables = set(inspect(engine).get_table_names()) tables = set(inspect(engine).get_table_names())
assert "refresh_tokens" in tables assert "refresh_tokens" in tables
assert current_revision(engine) == "20260714_0004" assert current_revision(engine) == HEAD_REVISION
+19
View File
@@ -2,6 +2,25 @@ import { expect, test } from "@playwright/test";
import { API_URL, adminLogin, loginViaUi, registerVerifyLogin, uniqueEmail } from "../helpers/api"; import { API_URL, adminLogin, loginViaUi, registerVerifyLogin, uniqueEmail } from "../helpers/api";
test.describe("§15.7 scenarios 5 & 9: Admin users", () => { test.describe("§15.7 scenarios 5 & 9: Admin users", () => {
test("blocked user access token rejected immediately after block", async ({ request }) => {
const email = uniqueEmail("e2e-block-jwt");
const session = await registerVerifyLogin(request, email);
const admin = await adminLogin(request);
const adminToken = (await admin.json()).access_token;
const patch = await request.patch(`${API_URL}/api/v1/admin/users/${session.user.id}`, {
headers: { Authorization: `Bearer ${adminToken}` },
data: { status: "blocked" }
});
expect(patch.ok()).toBeTruthy();
const me = await request.get(`${API_URL}/api/v1/users/me`, {
headers: { Authorization: `Bearer ${session.accessToken}` }
});
expect(me.status()).toBe(401);
expect((await me.json()).detail).toBe("TOKEN_REVOKED");
});
test("admin blocks user → blocked user cannot login", async ({ page, request }) => { test("admin blocks user → blocked user cannot login", async ({ page, request }) => {
const email = uniqueEmail("e2e-block"); const email = uniqueEmail("e2e-block");
const session = await registerVerifyLogin(request, email); const session = await registerVerifyLogin(request, email);
@@ -3,7 +3,7 @@ import type { PropsWithChildren } from "react";
import { useAuth } from "@modules/auth"; import { useAuth } from "@modules/auth";
import { useAuthStore } from "@modules/auth/store/authStore"; import { useAuthStore } from "@modules/auth/store/authStore";
export function AdminGuard({ children }: PropsWithChildren): JSX.Element { export function AdminGuard({ children }: PropsWithChildren): JSX.Element | null {
const bootstrapped = useAuthStore((state) => state.bootstrapped); const bootstrapped = useAuthStore((state) => state.bootstrapped);
const auth = useAuth(); const auth = useAuth();
+1 -1
View File
@@ -3,7 +3,7 @@ import type { PropsWithChildren } from "react";
import { useAuth } from "@modules/auth"; import { useAuth } from "@modules/auth";
import { useAuthStore } from "@modules/auth/store/authStore"; import { useAuthStore } from "@modules/auth/store/authStore";
export function AuthGuard({ children }: PropsWithChildren): JSX.Element { export function AuthGuard({ children }: PropsWithChildren): JSX.Element | null {
const bootstrapped = useAuthStore((state) => state.bootstrapped); const bootstrapped = useAuthStore((state) => state.bootstrapped);
const auth = useAuth(); const auth = useAuth();
@@ -3,7 +3,7 @@ import type { PropsWithChildren } from "react";
import { useAuth } from "@modules/auth"; import { useAuth } from "@modules/auth";
import { useAuthStore } from "@modules/auth/store/authStore"; import { useAuthStore } from "@modules/auth/store/authStore";
export function GuestGuard({ children }: PropsWithChildren): JSX.Element { export function GuestGuard({ children }: PropsWithChildren): JSX.Element | null {
const bootstrapped = useAuthStore((state) => state.bootstrapped); const bootstrapped = useAuthStore((state) => state.bootstrapped);
const auth = useAuth(); const auth = useAuth();
@@ -43,9 +43,21 @@ vi.mock("@shared/api/client", () => ({
} }
return { data: { data: [], meta: { total: 0, page: 1, limit: 20 } } }; return { data: { data: [], meta: { total: 0, page: 1, limit: 20 } } };
}), }),
patch: vi.fn(async () => ({ patch: vi.fn(async (url: string) => {
if (url === "/api/v1/admin/settings") {
return {
data: {
values: { enable_docs: false },
locks: {},
settings_path: "data/compton_settings.json",
secrets: {}
}
};
}
return {
data: { id: "1", email: "u@example.com", role: "user", is_superuser: false, status: "blocked" } data: { id: "1", email: "u@example.com", role: "user", is_superuser: false, status: "blocked" }
})), };
}),
post: vi.fn(async (url: string) => { post: vi.fn(async (url: string) => {
if (url === "/api/v1/admin/users") { if (url === "/api/v1/admin/users") {
return { data: { id: "1", email: "new@example.com", role: "user", is_superuser: false, status: "active" } }; return { data: { id: "1", email: "new@example.com", role: "user", is_superuser: false, status: "active" } };
@@ -95,7 +107,7 @@ describe("adminApi", () => {
expect((await resetAdminUserPassword("1", "Valid123A")).status).toBe("blocked"); expect((await resetAdminUserPassword("1", "Valid123A")).status).toBe("blocked");
expect((await deleteAdminUser("1")).status).toBe("deleted"); expect((await deleteAdminUser("1")).status).toBe("deleted");
expect((await getAdminSettings()).settings_path).toBe("data/compton_settings.json"); expect((await getAdminSettings()).settings_path).toBe("data/compton_settings.json");
expect((await patchAdminSettings({ enable_docs: false })).id).toBe("1"); expect((await patchAdminSettings({ enable_docs: false })).settings_path).toBe("data/compton_settings.json");
expect((await getAdminDiagnostics()).checks[0].status).toBe("ok"); expect((await getAdminDiagnostics()).checks[0].status).toBe("ok");
expect((await getAdminActivityFeed()).events.length).toBe(1); expect((await getAdminActivityFeed()).events.length).toBe(1);
expect((await postAdminUiActivity("click")).status).toBe("ok"); expect((await postAdminUiActivity("click")).status).toBe("ok");
@@ -1,5 +1,5 @@
import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { render, screen } from "@testing-library/react"; import { render, screen, within } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest"; import { describe, expect, it, vi } from "vitest";
import { AdminStats } from "./AdminStats"; import { AdminStats } from "./AdminStats";
@@ -23,9 +23,14 @@ describe("AdminStats", () => {
expect(await screen.findByText("CPU")).toBeInTheDocument(); expect(await screen.findByText("CPU")).toBeInTheDocument();
expect(await screen.findByText("WESP")).toBeInTheDocument(); expect(await screen.findByText("WESP")).toBeInTheDocument();
expect(await screen.findByText("nx throughput (instant)")).toBeInTheDocument(); expect(await screen.findByText("nx throughput (instant)")).toBeInTheDocument();
expect(await screen.findByText("users")).toBeInTheDocument(); expect(await screen.findByText("registrations today")).toBeInTheDocument();
expect(await screen.findByText("10")).toBeInTheDocument(); expect((await screen.findAllByText("users")).length).toBeGreaterThanOrEqual(1);
expect(await screen.findByText("3")).toBeInTheDocument();
expect(await screen.findByText("2")).toBeInTheDocument(); const statCards = document.querySelectorAll(".wesp-admin-stat-card");
expect(statCards).toHaveLength(4);
expect(within(statCards[0] as HTMLElement).getByText("10")).toBeInTheDocument();
expect(within(statCards[1] as HTMLElement).getByText("3")).toBeInTheDocument();
expect(within(statCards[2] as HTMLElement).getByText("2")).toBeInTheDocument();
expect(within(statCards[3] as HTMLElement).getByText("1")).toBeInTheDocument();
}); });
}); });
+4 -3
View File
@@ -1,4 +1,4 @@
import { authClient } from "@shared/api/client"; import { authClient, applyAuthHeader } from "@shared/api/client";
export interface LoginPayload { export interface LoginPayload {
email: string; email: string;
@@ -38,8 +38,9 @@ export async function register(payload: RegisterPayload) {
return data; return data;
} }
export async function logout() { export async function logout(accessToken?: string | null) {
await authClient.post("/api/v1/auth/logout"); const headers = accessToken ? applyAuthHeader({}) : {};
await authClient.post("/api/v1/auth/logout", undefined, { headers });
} }
export async function refresh() { export async function refresh() {
+1 -1
View File
@@ -19,7 +19,7 @@ export function useAuth() {
return data.user as AuthUser; return data.user as AuthUser;
}, },
async logout() { async logout() {
await apiLogout(); await apiLogout(accessToken);
clearSession(); clearSession();
}, },
async refreshSession() { async refreshSession() {
+4
View File
@@ -72,6 +72,10 @@ apiClient.interceptors.response.use(
useAuthStore.getState().clearSession(); useAuthStore.getState().clearSession();
return Promise.reject(error); return Promise.reject(error);
} }
if (error.response?.status === 401 && detail === "TOKEN_REVOKED") {
useAuthStore.getState().clearSession();
return Promise.reject(error);
}
const originalRequest = error.config as InternalAxiosRequestConfig & { _retry?: boolean }; const originalRequest = error.config as InternalAxiosRequestConfig & { _retry?: boolean };
if (error.response?.status !== 401 || !originalRequest || originalRequest._retry) { if (error.response?.status !== 401 || !originalRequest || originalRequest._retry) {
return Promise.reject(error); return Promise.reject(error);
+1
View File
@@ -40,6 +40,7 @@ export default defineConfig({
exclude: ["e2e/**", "node_modules/**"], exclude: ["e2e/**", "node_modules/**"],
environment: "jsdom", environment: "jsdom",
setupFiles: ["src/__tests__/setup.ts"], setupFiles: ["src/__tests__/setup.ts"],
testTimeout: 10_000,
coverage: { coverage: {
provider: "v8", provider: "v8",
include: ["src/**/*.{ts,tsx}"], include: ["src/**/*.{ts,tsx}"],
+41
View File
@@ -0,0 +1,41 @@
# Документация Compton
README в корне — только «как запустить за 30 секунд». Всё остальное — здесь.
Мы сознательно держим **мало файлов**, но каждый — **плотный**: таблицы, схемы, команды. Можно с лёгким юмором, но без простыней на 500 строк.
## Карта
| Документ | Когда открывать |
|----------|-----------------|
| [project.md](./project.md) | «Где что лежит?», архитектура, API, маршруты, env |
| [deploy.md](./deploy.md) | Запуск, **стандартные логины dev**, staging/prod, troubleshooting |
| [security.md](./security.md) | Auth, JWT revoke, секреты, nginx, чеклист prod |
| [release.md](./release.md) | Перед выкладкой: E2E, k6, ZAP, Lighthouse, smoke |
| [TZ.md](./TZ.md) | Полное ТЗ — источник правды по требованиям |
## Быстрые ссылки
```bash
# dev
python apps/api/scripts/bootstrap_install.py
docker compose --profile docker-web up -d --build
# тесты
pnpm --filter web test:ci
cd apps/api && python -m pytest --cov=app --cov-fail-under=90
# prod smoke (после деплоя)
./infra/scripts/smoke-prod.sh https://your-domain.com
```
## Что куда не кладём
| Не в git | Почему |
|----------|--------|
| `apps/api/data/secrets/install.env` | пароли БД, JWT, MinIO |
| `.env`, `apps/api/.env` | локальные секреты |
| `apps/api/data/logs/` | runtime-логи |
| `node_modules/`, `.venv/` | очевидно |
Если секрет утёк в git — считайте его скомпрометированным. Force-push не спасает совесть.
+196
View File
@@ -0,0 +1,196 @@
# Деплой и эксплуатация
От «запустил на ноуте» до «живёт на VPS и не стыдно показать security.md».
## Среды
```mermaid
flowchart LR
Dev[docker-compose.yml] --> St[staging]
St --> QA[k6 + ZAP + E2E]
QA --> Prod[production]
```
| Среда | Compose | APP_ENV | Docs | Demo users |
|-------|---------|---------|------|------------|
| Dev | `docker-compose.yml` | development | ✅ | ✅ |
| CI/E2E | `docker-compose.test.yml` | test | ✅ | ✅ |
| Staging | `infra/docker/docker-compose.staging.yml` | staging | ❌ | ❌ |
| Production | `infra/docker/docker-compose.prod.yml` | production | ❌ | ❌ |
## Локальная разработка
```bash
# 1. venv + зависимости backend (один раз)
python3 -m venv .venv
source .venv/bin/activate # Windows: .venv\Scripts\activate
pip install -r apps/api/requirements-dev.txt
# 2. Секреты установки (один раз, до первого docker compose up)
python apps/api/scripts/bootstrap_install.py
# 3. Полный стек в Docker (API + БД + web)
docker compose --profile docker-web up -d --build
# 4. Проверка
curl http://localhost:8000/api/v1/health
```
| Сервис | URL |
|--------|-----|
| Web | http://localhost:5173 |
| API | http://localhost:8000 |
| PG/Redis/MinIO на хост | `docker-compose.dev-ports.yml` → 5432, 6379, 9000/9001 |
> Bootstrap и локальные тесты (`pytest`, `mypy`) — через активированный `.venv`. Docker API использует свой образ; venv нужен для скриптов и разработки на хосте.
**Гибрид** (инфра в Docker, frontend локально):
```bash
source .venv/bin/activate
docker compose up -d
pnpm install
pnpm --filter web dev
```
**Install secrets на хост** (DBeaver): Admin → Security → Install Secrets → Reveal DB password.
### Стандартные логины (dev)
Создаются при seed на старте API. Пароли по умолчанию — из `apps/api/.env.example` (или дефолты в `config.py`).
| Email | Пароль | Env | Роль | Superuser | Куда заходит |
|-------|--------|-----|------|:---------:|--------------|
| `admin@compton.example` | `Admin1234` | `ADMIN_INITIAL_PASSWORD` | admin | да | `/admin` — Users, Content, Security, Diagnostics, secrets |
| `ops@compton.example` | `OpsAdmin1234` | `DEMO_OPS_PASSWORD` | admin | нет | `/admin` — Users, Content, Activity (без Security) |
| `user@compton.example` | `User1234` | `DEMO_USER_PASSWORD` | user | — | `/profile` |
**CMS-страницы (seed):** `about`, `privacy`, `terms``/pages/about` и т.д.
> **Staging/production:** `SEED_DEMO_USERS=false` — demo `user@` и `ops@` **не создаются**, только admin + CMS. Пароль admin задаётся через `ADMIN_INITIAL_PASSWORD` **до первого seed**, потом — сменить в UI.
## Staging
**Нужно:** VPS, DNS, TLS certs в `infra/docker/certs/`, SMTP.
```bash
git clone https://git.groupkomton.ru/Matvey/site.git && cd site
python3 apps/api/scripts/bootstrap_install.py
cp infra/docker/.env.staging.example infra/docker/.env.staging
# правим: домен, CORS, SMTP, ADMIN_INITIAL_PASSWORD
./infra/docker/deploy-staging.sh
```
Проверка:
```bash
curl -fsS https://STAGING/api/v1/health
curl -fsS -o /dev/null -w "%{http_code}" https://STAGING/api/v1/docs # 404
./infra/scripts/health-check.sh https://STAGING
```
Nginx: `default.tls.conf` — 80→443, HSTS.
## Production
```bash
python3 apps/api/scripts/bootstrap_install.py
cp infra/docker/.env.production.example infra/docker/.env.production
./infra/docker/deploy-prod.sh infra/docker/.env.production
```
**Сразу после bootstrap:**
1. Бэкап `install.env` off-server (зашифровать)
2. Сменить пароль admin
| Сервис | Host ports |
|--------|------------|
| nginx | 80, 443 |
| api, web, pg, redis, minio | internal only |
```bash
./infra/scripts/smoke-prod.sh https://YOUR_DOMAIN
```
### Rollback
```bash
docker compose -f infra/docker/docker-compose.prod.yml down
git checkout PREVIOUS_TAG
./infra/docker/deploy-prod.sh infra/docker/.env.production
```
## Бэкапы и мониторинг
| Что | Команда / как |
|-----|---------------|
| PostgreSQL | `./infra/scripts/backup-postgres.sh``backups/postgres-*.sql.gz` |
| install.env | `cp …/install.env backups/install.env.$(date +%F).enc` + gpg |
| Uptime | `./infra/scripts/health-check.sh URL` или UptimeRobot на `/api/v1/health` + `/` |
| Логи | logrotate для `server.log`, `admin-audit.jsonl` |
## Восстановление секретов
`install.env` — единственный источник runtime-секретов. Потеряли — не генерируйте новый вслепую.
### Симптомы
- `password authentication failed for user "compton_app"`
- bootstrap после того, как Postgres volume уже создан
### Fix (есть бэкап)
```bash
docker compose down
# восстановить apps/api/data/secrets/install.env
docker compose up -d --build
```
### Fix (нет бэкапа)
| Вариант | Данные |
|---------|--------|
| Reveal из другой среды | сохраняются |
| `down -v` + bootstrap (**только dev**) | **удаляются все** |
```bash
docker compose --profile docker-web down -v
python apps/api/scripts/bootstrap_install.py
docker compose --profile docker-web up -d --build
```
## SMTP
Staging/prod: `EMAIL_DELIVERY_MODE=smtp`. Dev: `memory` (письма в RAM, SMTP не нужен).
| Env | Назначение |
|-----|------------|
| `SMTP_HOST`, `SMTP_PORT`, `SMTP_FROM` | сервер |
| `FRONTEND_URL` | ссылки в письмах |
## Troubleshooting
| Проблема | Решение |
|----------|---------|
| `install.env missing` | `python apps/api/scripts/bootstrap_install.py` |
| `password authentication failed` | [восстановление секретов](#восстановление-секретов) |
| Login failed | `curl …/health`, проверить `.env`, restart web |
| 401 refresh в консоли (гость) | норма на публичных страницах |
| Logout после F5 на `/admin` | rebuild web, перелогиниться |
| «На сайт» ведёт на login | должно быть `href="/"`, rebuild |
| Port 5173 busy | stop Docker web **или** local Vite |
| CORS | `VITE_USE_API_PROXY=true`, не бить напрямую :8000 |
| Нет Security/Diagnostics | логин `admin@`, не `ops@` |
| Docker web: missing modules | `docker compose … up -d --build web` |
## Compose-справочник
| Файл | Назначение |
|------|------------|
| `docker-compose.yml` | dev |
| `docker-compose.dev-ports.yml` | порты на хост |
| `docker-compose.test.yml` | CI/E2E |
| `infra/docker/docker-compose.staging.yml` | staging |
| `infra/docker/docker-compose.prod.yml` | production |
Deploy: `infra/docker/deploy-staging.sh`, `deploy-prod.sh`.
+220
View File
@@ -0,0 +1,220 @@
# Структура и архитектура
Monorepo-lite: статический лендинг + React SPA + FastAPI + PostgreSQL + Redis + MinIO.
Если вы искали микросервисы на Kubernetes — это другой коридор.
## Стек
| Слой | Технологии |
|------|------------|
| Frontend | React 19, TS, Vite, React Router, TanStack Query, Zustand, RHF+Zod, Ant Design |
| Backend | FastAPI, SQLAlchemy 2, Alembic, Pydantic v2 |
| Данные | PostgreSQL 16, Redis 7, MinIO |
| Инфра | Docker Compose, Nginx, GitHub Actions |
| Качество | Vitest, Playwright, pytest (≥90% / ≥85% cov) |
## Дерево репозитория
```
site/
├── apps/
│ ├── api/ # Backend
│ │ ├── app/
│ │ │ ├── core/ # crypto, jwt_denylist, redis, install_secrets…
│ │ │ ├── db/ # models, seed, migrations helpers
│ │ │ └── modules/ # auth, users, content, admin, media, test
│ │ ├── migrations/ # Alembic
│ │ ├── scripts/ # bootstrap_install.py, docker_entrypoint.py
│ │ ├── tests/
│ │ └── data/
│ │ ├── secrets/ # install.env (gitignore!)
│ │ └── logs/ # server.log, admin-audit.jsonl (gitignore)
│ └── web/
│ ├── index.html # лендинг /
│ ├── app.html # SPA entry
│ ├── main/ # статика лендинга (CSS/JS/video)
│ ├── src/
│ │ ├── app/ # router, guards
│ │ ├── modules/ # auth, profile, admin, content, landing
│ │ ├── pages/
│ │ └── shared/ # api client, ui
│ └── e2e/ # Playwright
├── packages/ # eslint-config, shared-types (target)
├── infra/
│ ├── docker/ # staging/prod compose, deploy.sh
│ ├── nginx/ # default.conf, default.tls.conf
│ ├── k6/ # load test §17.2
│ └── scripts/ # backup, health, smoke
├── docs/ # вы здесь
├── docker-compose.yml # dev
└── docker-compose.test.yml # CI / E2E
```
## Runtime
```mermaid
flowchart TB
subgraph browser [Браузер]
L[index.html /]
S[app.html SPA]
end
subgraph edge [Nginx :80/:443]
N[TLS + headers]
end
subgraph internal [Docker internal]
W[web]
A[api]
PG[(PostgreSQL)]
R[(Redis)]
M[(MinIO)]
end
L --> N
S --> N
N --> W
N --> A
A --> PG
A --> R
A --> M
```
**Prod/staging:** наружу только nginx. Postgres, Redis, MinIO — без host-портов.
## Frontend
### Два входа (dual-entry)
| Entry | URL | Содержимое |
|-------|-----|------------|
| `index.html` | `/` | Маркетинговый лендинг (`main/`) |
| `app.html` | `/login`, `/admin`, … | React SPA |
Vite переписывает SPA-пути на `app.html` (`vite.main-static.ts`).
### Маршруты
| Путь | Guard | Кто |
|------|-------|-----|
| `/` | — | все |
| `/login`, `/register`, `/forgot-password`, `/reset-password` | GuestGuard | гости |
| `/verify`, `/pages/:slug` | — | публично |
| `/profile` | AuthGuard | user |
| `/admin` | AdminGuard | admin |
**Auth UX:**
- Access JWT — только в памяти (Zustand), не localStorage
- Refresh — HttpOnly cookie, `Path=/api/v1/auth`
- После login: admin → `/admin`, user → `/profile`
- Кнопка «На сайт» в админке — **полный** переход на `/` (не React Router)
### Админка (WESP-style)
| Раздел | Кому | Что |
|--------|------|-----|
| Users | admin | CRUD пользователей |
| Content | admin | CMS |
| Security | superuser | runtime settings, Install Secrets |
| Diagnostics | superuser | health checks |
| Activity | admin | audit feed, server log |
Тема Light/Dark — `localStorage.wespAdminTheme`. Auth-страницы — zootech-карточки (`#48816d`).
## Backend API
База: `/api/v1`
| Модуль | Эндпоинты (основное) |
|--------|----------------------|
| health | `GET /health` |
| auth | register, login, logout, refresh, verify, forgot/reset password |
| users | `GET/PATCH /me`, password, avatar |
| content | публичные pages + admin CRUD |
| admin | users, stats, settings, diagnostics, secrets, activity |
| media | подписанные URL файлов |
| test | `/test/emails/latest-token`**только** E2E |
### Core (`apps/api/app/core/`)
| Модуль | Зачем |
|--------|-------|
| `crypto.py` | bcrypt, JWT, HMAC, token hash — одна точка |
| `jwt_denylist.py` | мгновенный revoke access JWT |
| `install_secrets.py` | bootstrap + lock |
| `dependencies.py` | `get_current_user` |
| `redis.py` | rate limit + JWT revoke |
| `storage.py` | MinIO / memory |
## База данных
```mermaid
erDiagram
users ||--o| user_profiles : has
users ||--o{ refresh_tokens : owns
users ||--o{ password_reset_tokens : owns
users ||--o{ email_verification_tokens : owns
```
Таблицы: `users`, `user_profiles`, `refresh_tokens`, `password_reset_tokens`, `email_verification_tokens`, `content_pages`.
```bash
cd apps/api && alembic upgrade head
```
CHECK constraints на `role`, `status`; superuser только при `role=admin`. Cleanup expired tokens при старте API.
### Seed: стандартные логины (dev)
| Email | Пароль | Env |
|-------|--------|-----|
| admin@compton.example | Admin1234 | `ADMIN_INITIAL_PASSWORD` |
| ops@compton.example | OpsAdmin1234 | `DEMO_OPS_PASSWORD` |
| user@compton.example | User1234 | `DEMO_USER_PASSWORD` |
CMS: `about`, `privacy`, `terms`. Подробнее — [deploy.md § логины](./deploy.md#стандартные-логины-dev).
## Переменные окружения (ключевые)
| Переменная | Где | Назначение |
|------------|-----|------------|
| `DATABASE_URL` | api | PostgreSQL |
| `APP_ENV` | api | development / staging / production |
| `JWT_ACCESS_SECRET`, `JWT_REFRESH_PEPPER` | api | токены |
| `REDIS_URL` | api | rate limit + JWT revoke (prod обязателен) |
| `SEED_DEMO_USERS` | api | `false` на staging/prod |
| `ADMIN_INITIAL_PASSWORD` | api | пароль admin при seed (default `Admin1234`) |
| `DEMO_USER_PASSWORD`, `DEMO_OPS_PASSWORD` | api | demo user/ops (только dev) |
| `ENABLE_DOCS` | api | `false` на prod |
| `ENABLE_TEST_ROUTES` | api | `true` только E2E |
| `EMAIL_DELIVERY_MODE` | api | `memory` (dev) / `smtp` (prod) |
| `STORAGE_MODE` | api | `s3` / `memory` |
| `VITE_API_URL` | web | `http://api:8000` в Docker |
| `VITE_USE_API_PROXY` | web | `true` в dev |
Полные примеры: `apps/api/.env.example`, `apps/api/.env.production.example`.
### Runtime settings
`apps/api/data/compton_settings.json` — toggles без секретов. Superuser: `GET/PATCH /admin/settings`. Env с тем же ключом = **lock** (нельзя менять из UI).
## Docker Compose
| Файл | Когда |
|------|-------|
| `docker-compose.yml` | локальная разработка |
| `docker-compose.dev-ports.yml` | PG/Redis/MinIO на хост (DBeaver) |
| `docker-compose.test.yml` | CI, Playwright |
| `infra/docker/docker-compose.staging.yml` | staging VPS |
| `infra/docker/docker-compose.prod.yml` | production VPS |
## CI
`.github/workflows/ci.yml`: lint → types → mypy → tests → audit → Bandit → gitleaks → E2E.
## Тесты
```bash
pnpm --filter web test:ci
cd apps/api && python -m pytest --cov=app --cov-fail-under=90
pnpm --filter web e2e
```
E2E поднимает API `:8001` + Vite `:5175`. Против staging: `E2E_BASE_URL=… E2E_START_API=false`.
-14
View File
@@ -1,14 +0,0 @@
# MVP Regression Checklist
This checklist mirrors the required release scenarios.
1. Landing hero/marquee and reduced-motion behavior.
2. Register -> verify -> login -> profile edit -> logout.
3. Forgot password -> reset -> login.
4. Admin publish content -> public slug availability.
5. Admin blocks user -> blocked user login denied.
6. Pending user cannot access `/profile`.
7. Refresh token rotation and old token rejection.
8. IDOR check: user A cannot access user B.
9. Admin cannot demote/block self; last admin protected.
10. Avatar upload rejects invalid MIME/oversize/SVG.
+113
View File
@@ -0,0 +1,113 @@
# Релиз и QA gates
Перед production не «авось прокатит», а чеклист из ТЗ §17. Если что-то красное — сначала staging, потом prod. Живёт один раз.
## Pipeline
```mermaid
flowchart TD
CI[CI green] --> ST[Staging + TLS]
ST --> E2E[E2E 11/11]
ST --> K6[k6 pass]
ST --> ZAP[ZAP 0 High/Crit]
ST --> LH[Lighthouse ≥ 85]
E2E --> PROD[Production deploy]
K6 --> PROD
ZAP --> PROD
LH --> PROD
PROD --> SM[smoke-prod.sh]
SM --> DNS[DNS cutover]
DNS --> MON[24h мониторинг]
```
## Регрессия E2E
**Последний локальный прогон:** 2026-07-14 — backend 137 / 90.43% cov, frontend 46.
### Staging
```bash
E2E_BASE_URL=https://staging.example.com E2E_START_API=false pnpm --filter web e2e
```
| # | Сценарий | Local | Staging |
|---|----------|:-----:|:-------:|
| 1 | Landing hero, reduced-motion | ☐ | ☐ |
| 2 | Register → verify → login → profile → logout | ☐ | ☐ |
| 3 | Forgot → reset → login | ☐ | ☐ |
| 4 | Admin publish → public slug | ☐ | ☐ |
| 5 | Block user → login denied | ☐ | ☐ |
| 6 | Pending → нет `/profile` | ☐ | ☐ |
| 7 | Refresh rotation | ☐ | ☐ |
| 8 | IDOR user A ≠ user B | ☐ | ☐ |
| 9 | Admin не блокирует себя / last admin | ☐ | ☐ |
| 10 | Avatar: bad MIME / size / SVG | ☐ | ☐ |
| 11 | Block → access JWT 401 TOKEN_REVOKED | ☐ | ☐ |
### Локально
```bash
pnpm --filter web e2e # API :8001, Vite :5175
```
## k6 (§17.2)
```bash
k6 run infra/k6/mvp-load-test.js -e BASE_URL=https://staging.example.com
```
| Параметр | Порог |
|----------|-------|
| VU / ramp | 50 / 5 min |
| Mix | 35% list, 25% login, 20% me, 10% refresh, 10% slug |
| p95 | < 300 ms |
| Errors | < 1% |
## OWASP ZAP
```bash
docker run --rm -v "$(pwd):/zap/wrk:rw" -t ghcr.io/zap/zaproxy:stable \
zap-baseline.py -t https://staging.example.com -r zap-report.html
```
| Severity | Pass |
|----------|------|
| High, Critical | **0** |
Medium/Low — review руками. `zap-report.html` — в архив релиза.
## Lighthouse
```bash
npx lighthouse https://staging.example.com \
--preset=mobile --only-categories=performance \
--output=json --output-path=./lighthouse-report.json
```
| Метрика | ≥ |
|---------|---|
| Performance (mobile, `/`) | 85 |
Не прошло — hero video `preload="none"`, font swap, меньше JS на лендинге.
## Release gates (сводка)
- [ ] CI green на `main`
- [ ] E2E 11/11 на staging
- [ ] k6 pass
- [ ] ZAP 0 High/Critical
- [ ] Lighthouse ≥ 85
- [ ] [security.md](./security.md) staging-пункты
- [ ] `./infra/scripts/smoke-prod.sh` на prod
- [ ] DNS cutover + rollback plan (previous tag)
## CI локально
```bash
pnpm lint && pnpm typecheck
cd apps/api && python -m mypy app
pnpm --filter web test:ci
cd apps/api && python -m pytest --cov=app --cov-fail-under=90
```
Полный pipeline: `.github/workflows/ci.yml`.
-30
View File
@@ -1,30 +0,0 @@
# Install Secrets Recovery
This project keeps runtime installation secrets in `apps/api/data/secrets/install.env`.
## Important
- Do not rotate `POSTGRES_PASSWORD`, `JWT_ACCESS_SECRET`, or `JWT_REFRESH_PEPPER` automatically after first bootstrap.
- A mismatch between `install.env` and initialized Postgres volume can break database access.
## Safe recovery steps
1. Stop services:
- `docker compose down`
2. Restore `apps/api/data/secrets/install.env` from backup.
3. Start services:
- `docker compose up -d --build`
If backup is unavailable, you have two options:
- Preferred: recover credentials directly from running database/admin secret reveal in another environment.
- Last resort: reset local volumes and lose local dev data:
- `docker compose down -v`
- `python apps/api/scripts/bootstrap_install.py`
- `docker compose up -d --build`
## Dev access ports
To expose DB/Redis/MinIO to host tools:
- `docker compose -f docker-compose.yml -f docker-compose.dev-ports.yml up -d`
-20
View File
@@ -1,20 +0,0 @@
# Security Checklist (MVP Pre-Production)
- [x] Access JWT is memory-only in frontend state (no sessionStorage/localStorage persistence).
- [x] Refresh token is HttpOnly/Secure/SameSite cookie on `/api/v1/auth` path.
- [x] Auth endpoints implemented with neutral anti-enumeration messaging.
- [x] Origin/Referer validation is enforced for auth endpoints.
- [x] Authenticated/admin route guards and role checks are enforced, including `SUPERUSER_ONLY` checks for critical endpoints.
- [x] Content sanitization is enabled for CMS HTML body.
- [x] Security headers configured in `infra/nginx/default.conf`.
- [x] CI includes dependency audit, Bandit, and gitleaks scans.
- [x] Settings runtime supports `data/compton_settings.json` with env lock behavior.
- [x] Admin audit feed is persisted to `data/logs/admin-audit.jsonl`.
- [x] Install secrets bootstrap is enabled (`apps/api/data/secrets/install.env`) and locked after first run.
- [x] Database, Redis and MinIO are internal by default in base docker compose.
- [x] `refresh` validates user status and rate limit is checked before token rotation.
- [x] CMS sanitization enforces allowed URL protocols (`http`, `https`, `mailto`).
- [x] Admin password create/reset uses shared password policy validators.
- [ ] Production docs endpoint switch (`ENABLE_DOCS=false`) validated in staging/prod env.
- [ ] HSTS behavior validated behind TLS ingress in staging/prod.
- [ ] Recovery runbook for lost `install.env` tested (`docs/secrets-recovery.md`).
+173
View File
@@ -0,0 +1,173 @@
# Безопасность
Compton MVP — не банк, но и не «admin/admin в prod». Ниже — как устроена защита и что проверить перед выкладкой.
## Auth: схема
```mermaid
sequenceDiagram
participant B as Браузер
participant API as FastAPI
participant RD as Redis
participant PG as PostgreSQL
B->>API: POST /auth/login
API->>PG: bcrypt verify
API->>RD: read auth_epoch
API-->>B: access JWT (memory) + refresh cookie
B->>API: GET /users/me + Bearer
API->>RD: jti denied? epoch ok?
alt revoked
API-->>B: 401 TOKEN_REVOKED
else ok
API-->>B: 200
end
B->>API: POST /auth/logout
API->>RD: deny_jti + revoke refresh
API-->>B: cookie cleared
```
## Токены
| Токен | Где живёт | Отзыв |
|-------|-----------|-------|
| Access JWT | память frontend | jti denylist + auth_epoch (Redis) |
| Refresh | HttpOnly cookie | rotation + family reuse detection |
| Email/reset | opaque → hash в БД | one-time, TTL 1ч |
JWT claims: `sub`, `role`, `jti`, `auth_epoch`, `exp`.
### Мгновенный revoke
`apps/api/app/core/jwt_denylist.py`:
| Redis key | Смысл |
|-----------|-------|
| `jwt:deny:{jti}` | конкретный access-токен |
| `auth:epoch:{user_id}` | версия сессий пользователя |
| Событие | Действие |
|---------|----------|
| Logout | deny jti + revoke refresh |
| Block | INCR epoch + revoke refresh |
| Смена/reset пароля | INCR epoch + revoke refresh |
**Production:** без Redis API не стартует. Redis упал — fail-closed (401, не «ну ладно»).
**Dev:** in-memory fallback (не путать с prod).
## Криптография
Всё через `apps/api/app/core/crypto.py`:
| Данные | Метод |
|--------|-------|
| Пароли | bcrypt cost 12 |
| Access JWT | HS256 |
| Refresh/email tokens | SHA-256 + pepper |
| Media URLs | HMAC-SHA256 + TTL |
| Install secrets | `secrets.token_*`, generate-once + lock |
## Install secrets
```bash
python apps/api/scripts/bootstrap_install.py # до первого docker compose up
```
| Файл | Содержимое |
|------|------------|
| `data/secrets/install.env` | PG, JWT, S3, MinIO |
| `install.meta.json` | install ID, lock time |
**Не ротировать** `POSTGRES_PASSWORD` / JWT после bootstrap без плана — иначе Postgres скажет фразу, которую вы уже видели, и будет прав.
Reveal: Admin → Security → Install Secrets (superuser, аудит в `admin-audit.jsonl`).
Восстановление: [deploy.md § восстановление](./deploy.md#восстановление-секретов).
## RBAC
| Правило | Enforcement |
|---------|-------------|
| `/admin` → role=admin | Guard + API |
| Security/Diagnostics/secrets → superuser | API + UI tabs |
| Нельзя block/demote себя | admin service |
| Last admin protected | admin service |
| blocked/pending → 403 refresh | auth service |
| forgot_password skip для blocked | auth service |
| IDOR на профиль | users router |
## HTTP / инфра
### Nginx headers
| Header | Значение |
|--------|----------|
| X-Frame-Options | DENY |
| X-Content-Type-Options | nosniff |
| Referrer-Policy | strict-origin-when-cross-origin |
| HSTS | `default.tls.conf` (staging/prod) |
### Прочее
- Origin/Referer на cookie-auth endpoints
- Rate limit (prod: обязателен)
- CMS: bleach, протоколы http/https/mailto
- Avatar: jpeg/png/webp, re-encode, **SVG — нет**
- CI: Bandit, pip-audit, gitleaks, npm audit
- Postgres/Redis/MinIO — internal network
- Firewall VPS: 22, 80, 443
## Production guards
`APP_ENV=production` → API **не стартует**, если:
| Проблема | Env |
|----------|-----|
| Test routes | `ENABLE_TEST_ROUTES=true` |
| OpenAPI | `ENABLE_DOCS=true` |
| Rate limit off | `ENABLE_RATE_LIMIT=false` |
| Cookie без Secure | `COOKIE_SECURE=false` |
| Placeholder JWT | `change-me-*` |
| Дефолтная БД | user:pass |
| Без SSL mode | нет `sslmode=require` |
| Без Redis | JWT revocation |
## Prod env (минимум)
| Переменная | Значение |
|------------|----------|
| `APP_ENV` | production |
| `ENABLE_DOCS` | false |
| `ENABLE_TEST_ROUTES` | false |
| `COOKIE_SECURE` | true |
| `ENABLE_RATE_LIMIT` | true |
| `EMAIL_DELIVERY_MODE` | smtp |
| `SEED_DEMO_USERS` | false |
| `DATABASE_URL` | …?sslmode=require |
## Чеклист
### Сделано в коде
- [x] JWT в memory, refresh HttpOnly
- [x] Anti-enumeration auth
- [x] Origin/Referer validation
- [x] RBAC + superuser
- [x] CMS sanitization
- [x] Nginx security headers
- [x] CI security scans
- [x] Install secrets bootstrap + lock
- [x] JWT jti denylist + auth_epoch
- [x] Admin audit log
### Проверить на staging
- [ ] `/api/v1/docs` → 404
- [ ] HSTS за TLS
- [ ] Dry-run recovery секретов
- [ ] ZAP: 0 High/Critical → [release.md](./release.md)
- [ ] k6 pass → [release.md](./release.md)
- [ ] Lighthouse ≥ 85 на `/`
+13
View File
@@ -0,0 +1,13 @@
# Copy to infra/docker/.env.production and fill in production values.
FRONTEND_URL=https://compton.example.com
PUBLIC_BASE_URL=https://compton.example.com
CORS_ORIGINS=["https://compton.example.com"]
TRUSTED_PROXY_IPS=172.16.0.0/12,10.0.0.0/8
SMTP_HOST=smtp.example.com
SMTP_PORT=587
SMTP_USER=
SMTP_PASSWORD=
SMTP_FROM=noreply@compton.example.com
TLS_CERT_DIR=./certs
ADMIN_INITIAL_PASSWORD=ChangeMeProductionAdmin1234
SEED_DEMO_USERS=false
+12
View File
@@ -0,0 +1,12 @@
# Copy to infra/docker/.env.staging and fill in domain/SMTP values.
FRONTEND_URL=https://staging.example.com
PUBLIC_BASE_URL=https://staging.example.com
CORS_ORIGINS=["https://staging.example.com"]
TRUSTED_PROXY_IPS=172.16.0.0/12,10.0.0.0/8
SMTP_HOST=smtp.example.com
SMTP_PORT=587
SMTP_USER=
SMTP_PASSWORD=
SMTP_FROM=noreply@example.com
TLS_CERT_DIR=./certs
ADMIN_INITIAL_PASSWORD=ChangeMeStagingAdmin1234
+30 -3
View File
@@ -1,4 +1,31 @@
# Docker notes # Docker deployment
`docker-compose.yml` is for local development. Краткая справка. Подробный runbook: [docs/deploy.md](../../docs/deploy.md).
`docker-compose.test.yml` is for CI-like integration and e2e testing.
| Файл | Среда |
|------|-------|
| [`../../docker-compose.yml`](../../docker-compose.yml) | Локальная разработка |
| [`../../docker-compose.test.yml`](../../docker-compose.test.yml) | CI / E2E |
| [`docker-compose.staging.yml`](docker-compose.staging.yml) | Staging VPS |
| [`docker-compose.prod.yml`](docker-compose.prod.yml) | Production VPS |
## Staging
```bash
cp infra/docker/.env.staging.example infra/docker/.env.staging
# TLS: infra/docker/certs/fullchain.pem, privkey.pem
python3 apps/api/scripts/bootstrap_install.py
./infra/docker/deploy-staging.sh
```
## Production
```bash
cp infra/docker/.env.production.example infra/docker/.env.production
python3 apps/api/scripts/bootstrap_install.py
./infra/docker/deploy-prod.sh infra/docker/.env.production
```
## QA на staging
См. [docs/release.md](../../docs/release.md): E2E, k6, ZAP, Lighthouse.
+23
View File
@@ -0,0 +1,23 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT="$(cd "$(dirname "$0")/../.." && pwd)"
cd "$ROOT"
ENV_FILE="${1:-infra/docker/.env.production}"
if [[ ! -f "$ENV_FILE" ]]; then
echo "Missing $ENV_FILE — copy infra/docker/.env.production.example first."
exit 1
fi
if [[ ! -f apps/api/data/secrets/install.env ]]; then
python3 apps/api/scripts/bootstrap_install.py
fi
docker compose \
-f infra/docker/docker-compose.prod.yml \
--env-file "$ENV_FILE" \
up -d --build
echo "Production deploy complete."
+23
View File
@@ -0,0 +1,23 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT="$(cd "$(dirname "$0")/../.." && pwd)"
cd "$ROOT"
ENV_FILE="${1:-infra/docker/.env.staging}"
if [[ ! -f "$ENV_FILE" ]]; then
echo "Missing $ENV_FILE — copy infra/docker/.env.staging.example first."
exit 1
fi
if [[ ! -f apps/api/data/secrets/install.env ]]; then
python3 apps/api/scripts/bootstrap_install.py
fi
docker compose \
-f infra/docker/docker-compose.staging.yml \
--env-file "$ENV_FILE" \
up -d --build
echo "Staging deploy complete. Verify: curl -k https://\$(grep FRONTEND_URL $ENV_FILE | cut -d= -f2)/api/v1/health"
+99
View File
@@ -0,0 +1,99 @@
# Production stack — VPS deploy.
# Usage:
# python apps/api/scripts/bootstrap_install.py
# cp infra/docker/.env.production.example infra/docker/.env.production
# docker compose -f infra/docker/docker-compose.prod.yml --env-file infra/docker/.env.production up -d --build
services:
postgres:
image: postgres:16
restart: unless-stopped
env_file:
- ../../apps/api/data/secrets/install.env
volumes:
- prod_postgres_data:/var/lib/postgresql/data
networks:
- internal
redis:
image: redis:7-alpine
restart: unless-stopped
networks:
- internal
minio:
image: minio/minio
restart: unless-stopped
command: server /data --console-address ":9001"
env_file:
- ../../apps/api/data/secrets/install.env
volumes:
- prod_minio_data:/data
networks:
- internal
api:
build:
context: ../../apps/api
dockerfile: Dockerfile
restart: unless-stopped
environment:
APP_ENV: production
ENABLE_DOCS: "false"
ENABLE_TEST_ROUTES: "false"
COOKIE_SECURE: "true"
ENABLE_RATE_LIMIT: "true"
EMAIL_DELIVERY_MODE: smtp
STORAGE_MODE: s3
S3_ENDPOINT: http://minio:9000
REDIS_URL: redis://redis:6379/0
SEED_DEMO_USERS: "false"
env_file:
- ../../apps/api/data/secrets/install.env
- .env.production
volumes:
- ../../apps/api/data/secrets:/app/data/secrets
- prod_api_logs:/app/data/logs
depends_on:
- postgres
- redis
- minio
networks:
- internal
web:
build:
context: ../..
dockerfile: apps/web/Dockerfile
restart: unless-stopped
environment:
VITE_USE_API_PROXY: "true"
VITE_API_URL: http://api:8000
depends_on:
- api
networks:
- internal
nginx:
image: nginx:stable-alpine
restart: unless-stopped
ports:
- "80:80"
- "443:443"
volumes:
- ../nginx/default.tls.conf:/etc/nginx/conf.d/default.conf:ro
- ${TLS_CERT_DIR:-./certs}:/etc/nginx/certs:ro
depends_on:
- web
- api
networks:
- internal
networks:
internal:
driver: bridge
volumes:
prod_postgres_data:
prod_minio_data:
prod_api_logs:
+78 -4
View File
@@ -1,9 +1,83 @@
# Staging stack — VPS deploy (no bind mounts, production-like settings).
# Usage:
# cp infra/docker/.env.staging.example infra/docker/.env.staging
# docker compose -f infra/docker/docker-compose.staging.yml --env-file infra/docker/.env.staging up -d --build
services: services:
web: postgres:
image: compton/web:staging image: postgres:16
restart: unless-stopped
env_file:
- ../../apps/api/data/secrets/install.env
volumes:
- staging_postgres_data:/var/lib/postgresql/data
redis:
image: redis:7-alpine
restart: unless-stopped
minio:
image: minio/minio
restart: unless-stopped
command: server /data --console-address ":9001"
env_file:
- ../../apps/api/data/secrets/install.env
volumes:
- staging_minio_data:/data
api: api:
image: compton/api:staging build:
context: ../../apps/api
dockerfile: Dockerfile
restart: unless-stopped
environment:
APP_ENV: staging
ENABLE_DOCS: "false"
ENABLE_TEST_ROUTES: "false"
COOKIE_SECURE: "true"
ENABLE_RATE_LIMIT: "true"
EMAIL_DELIVERY_MODE: smtp
STORAGE_MODE: s3
S3_ENDPOINT: http://minio:9000
REDIS_URL: redis://redis:6379/0
SEED_DEMO_USERS: "false"
env_file:
- ../../apps/api/.env.example
- ../../apps/api/data/secrets/install.env
- .env.staging
volumes:
- ../../apps/api/data/secrets:/app/data/secrets
- staging_api_logs:/app/data/logs
depends_on:
- postgres
- redis
- minio
web:
build:
context: ../..
dockerfile: apps/web/Dockerfile
restart: unless-stopped
environment:
VITE_USE_API_PROXY: "true"
VITE_API_URL: http://api:8000
depends_on:
- api
nginx: nginx:
image: nginx:stable-alpine image: nginx:stable-alpine
restart: unless-stopped
ports:
- "80:80"
- "443:443"
volumes: volumes:
- ../nginx/default.conf:/etc/nginx/conf.d/default.conf:ro - ../nginx/default.tls.conf:/etc/nginx/conf.d/default.conf:ro
- ${TLS_CERT_DIR:-./certs}:/etc/nginx/certs:ro
depends_on:
- web
- api
volumes:
staging_postgres_data:
staging_minio_data:
staging_api_logs:
+40 -2
View File
@@ -1,6 +1,10 @@
import http from "k6/http"; import http from "k6/http";
import { check, sleep } from "k6"; import { check, sleep } from "k6";
const BASE_URL = __ENV.BASE_URL || "http://localhost:8000";
const ADMIN_EMAIL = __ENV.ADMIN_EMAIL || "admin@compton.example";
const ADMIN_PASSWORD = __ENV.ADMIN_PASSWORD || "Admin1234";
export const options = { export const options = {
stages: [ stages: [
{ duration: "1m", target: 10 }, { duration: "1m", target: 10 },
@@ -14,7 +18,41 @@ export const options = {
}; };
export default function () { export default function () {
const contentList = http.get("http://localhost:8000/api/v1/content/pages"); const roll = Math.random();
check(contentList, { "content list is 200": (r) => r.status === 200 });
if (roll < 0.35) {
const res = http.get(`${BASE_URL}/api/v1/content/pages`);
check(res, { "content list 200": (r) => r.status === 200 });
} else if (roll < 0.6) {
const res = http.post(
`${BASE_URL}/api/v1/auth/login`,
JSON.stringify({ email: ADMIN_EMAIL, password: ADMIN_PASSWORD }),
{ headers: { "Content-Type": "application/json" } }
);
check(res, { "login 200": (r) => r.status === 200 });
if (res.status === 200) {
const token = res.json("access_token");
const me = http.get(`${BASE_URL}/api/v1/users/me`, {
headers: { Authorization: `Bearer ${token}` }
});
check(me, { "users me 200": (r) => r.status === 200 });
}
} else if (roll < 0.7) {
const login = http.post(
`${BASE_URL}/api/v1/auth/login`,
JSON.stringify({ email: ADMIN_EMAIL, password: ADMIN_PASSWORD }),
{ headers: { "Content-Type": "application/json" } }
);
if (login.status === 200) {
const refresh = http.post(`${BASE_URL}/api/v1/auth/refresh`, null, {
headers: { Cookie: login.headers["Set-Cookie"] || "" }
});
check(refresh, { "refresh ok": (r) => r.status === 200 || r.status === 401 });
}
} else {
const res = http.get(`${BASE_URL}/api/v1/content/pages/about`);
check(res, { "content slug": (r) => r.status === 200 || r.status === 404 });
}
sleep(1); sleep(1);
} }
+39
View File
@@ -0,0 +1,39 @@
server {
listen 80;
server_name _;
return 301 https://$host$request_uri;
}
server {
listen 443 ssl http2;
server_name _;
ssl_certificate /etc/nginx/certs/fullchain.pem;
ssl_certificate_key /etc/nginx/certs/privkey.pem;
add_header X-Content-Type-Options "nosniff" always;
add_header X-Frame-Options "DENY" always;
add_header Strict-Transport-Security "max-age=63072000; includeSubDomains; preload" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always;
add_header Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src 'self' https://fonts.gstatic.com; img-src 'self' data:; connect-src 'self'; frame-ancestors 'none'; base-uri 'self'; form-action 'self';" always;
location /api/ {
proxy_pass http://api:8000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location / {
proxy_pass http://web:5173;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
}
}
+14
View File
@@ -0,0 +1,14 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT="$(cd "$(dirname "$0")/../.." && pwd)"
BACKUP_DIR="$ROOT/backups"
mkdir -p "$BACKUP_DIR"
STAMP="$(date +%F-%H%M)"
FILE="$BACKUP_DIR/postgres-$STAMP.sql.gz"
docker compose -f "$ROOT/infra/docker/docker-compose.prod.yml" exec -T postgres \
pg_dump -U "${POSTGRES_USER:-compton_app}" "${POSTGRES_DB:-compton}" | gzip > "$FILE"
echo "Backup written: $FILE"
+18
View File
@@ -0,0 +1,18 @@
#!/usr/bin/env bash
set -euo pipefail
BASE="${1:-http://localhost:8000}"
BASE="${BASE%/}"
fail() {
echo "FAIL: $1"
exit 1
}
code="$(curl -fsS -o /dev/null -w "%{http_code}" "$BASE/api/v1/health" || echo 000)"
[[ "$code" == "200" ]] || fail "health returned $code"
root_code="$(curl -fsS -o /dev/null -w "%{http_code}" "${BASE%/api/v1/health}/" 2>/dev/null || curl -fsS -o /dev/null -w "%{http_code}" "$(echo "$BASE" | sed 's|:8000||')/" || echo 000)"
echo "Landing/root HTTP: $root_code"
echo "OK: health check passed"
+17
View File
@@ -0,0 +1,17 @@
#!/usr/bin/env bash
set -euo pipefail
BASE="${1:-http://127.0.0.1:8000}"
BASE="${BASE%/}"
echo "== Smoke: health =="
curl -fsS "$BASE/api/v1/health" | grep -q '"status":"ok"'
echo "== Smoke: docs disabled =="
docs_code="$(curl -s -o /dev/null -w "%{http_code}" "$BASE/api/v1/docs")"
[[ "$docs_code" == "404" || "$docs_code" == "307" ]] || { echo "Expected docs 404, got $docs_code"; exit 1; }
echo "== Smoke: public content =="
curl -fsS "$BASE/api/v1/content/pages" | grep -q '"data"'
echo "All smoke checks passed."