Milestone 3
This commit is contained in:
31
.env.example
Normal file
31
.env.example
Normal file
@@ -0,0 +1,31 @@
|
||||
# Copy to `.env` and fill in real values. Never commit `.env`.
|
||||
|
||||
# === Runtime mode ===
|
||||
# `dev` allows the placeholder secrets below (only on a workstation).
|
||||
# Anything else (`prod`, `staging`) forces strong values — the API refuses to boot otherwise.
|
||||
APP_ENV=dev
|
||||
|
||||
# === Postgres ===
|
||||
POSTGRES_DB=metamorph
|
||||
POSTGRES_USER=metamorph
|
||||
POSTGRES_PASSWORD=change-me-strong
|
||||
POSTGRES_HOST=db
|
||||
POSTGRES_PORT=5432
|
||||
|
||||
# === Backend (Flask API) ===
|
||||
# Generate with: python -c "import secrets; print(secrets.token_urlsafe(64))"
|
||||
JWT_SECRET=change-me-to-a-long-random-string
|
||||
LOG_LEVEL=INFO
|
||||
# Comma-separated list of allowed origins for CORS (no trailing slash)
|
||||
FRONT_ORIGIN=http://localhost:8080
|
||||
# Where uploaded evidence files are stored inside the api container
|
||||
EVIDENCE_DIR=/data/evidence
|
||||
|
||||
# === Frontend (build-time) ===
|
||||
# Base URL the front uses to reach the API. In compose the nginx of the front
|
||||
# proxies /api/* to the api service, so an empty/relative value is fine.
|
||||
VITE_API_BASE_URL=/api/v1
|
||||
|
||||
# === Compose port mappings (host side) ===
|
||||
HOST_API_PORT=8000
|
||||
HOST_FRONT_PORT=8080
|
||||
46
.gitignore
vendored
Normal file
46
.gitignore
vendored
Normal file
@@ -0,0 +1,46 @@
|
||||
# Env / secrets
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
|
||||
# Python
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*$py.class
|
||||
*.egg-info/
|
||||
.pytest_cache/
|
||||
.ruff_cache/
|
||||
.mypy_cache/
|
||||
.venv/
|
||||
venv/
|
||||
|
||||
# Node
|
||||
node_modules/
|
||||
dist/
|
||||
build/
|
||||
.vite/
|
||||
*.tsbuildinfo
|
||||
|
||||
# Build artifacts
|
||||
*.exe
|
||||
*.dll
|
||||
*.bin
|
||||
*.o
|
||||
*.so
|
||||
*.pyd
|
||||
|
||||
# Data & uploads (host-side mounts)
|
||||
data/
|
||||
backend/data/
|
||||
|
||||
# Editor / OS
|
||||
.vscode/
|
||||
.idea/
|
||||
*.swp
|
||||
*.swo
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Logs
|
||||
*.log
|
||||
logs/
|
||||
46
.pre-commit-config.yaml
Normal file
46
.pre-commit-config.yaml
Normal file
@@ -0,0 +1,46 @@
|
||||
# Run `pre-commit install` after cloning. CI should run `pre-commit run --all-files`.
|
||||
repos:
|
||||
- repo: https://github.com/pre-commit/pre-commit-hooks
|
||||
rev: v4.6.0
|
||||
hooks:
|
||||
- id: trailing-whitespace
|
||||
- id: end-of-file-fixer
|
||||
- id: check-yaml
|
||||
- id: check-json
|
||||
- id: check-added-large-files
|
||||
args: ["--maxkb=512"]
|
||||
- id: detect-private-key
|
||||
|
||||
# Backend — ruff (lint + format)
|
||||
- repo: https://github.com/astral-sh/ruff-pre-commit
|
||||
rev: v0.4.10
|
||||
hooks:
|
||||
- id: ruff
|
||||
args: ["--fix"]
|
||||
files: ^backend/.*\.py$
|
||||
- id: ruff-format
|
||||
files: ^backend/.*\.py$
|
||||
|
||||
# Frontend — eslint + tsc via local hooks (must be run from frontend/)
|
||||
- repo: local
|
||||
hooks:
|
||||
- id: eslint
|
||||
name: eslint (frontend)
|
||||
entry: bash -c 'cd frontend && npm run lint'
|
||||
language: system
|
||||
files: ^frontend/.*\.(ts|tsx)$
|
||||
pass_filenames: false
|
||||
|
||||
- id: tsc-noemit
|
||||
name: tsc --noEmit (frontend)
|
||||
entry: bash -c 'cd frontend && npm run typecheck'
|
||||
language: system
|
||||
files: ^frontend/.*\.(ts|tsx)$
|
||||
pass_filenames: false
|
||||
|
||||
- id: prettier
|
||||
name: prettier --check (frontend)
|
||||
entry: bash -c 'cd frontend && npm run format:check'
|
||||
language: system
|
||||
files: ^frontend/.*\.(ts|tsx|css|json|html)$
|
||||
pass_filenames: false
|
||||
150
CHANGELOG.md
Normal file
150
CHANGELOG.md
Normal file
@@ -0,0 +1,150 @@
|
||||
# Changelog
|
||||
|
||||
All notable changes to this project will be documented here. Format: [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) · [Conventional Commits](https://www.conventionalcommits.org/).
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Added — M3 (RBAC: groups, permissions, users)
|
||||
- **Permission catalogue** (`app/services/permissions_seed.py`): 31 atomic codes across 10 families (`user`, `group`, `invitation`, `test_template`, `scenario_template`, `mission`, `detection_level`, `setting`, `mitre.sync`). Seeded at boot **and** after `/setup` to handle a freshly truncated DB. Idempotent + additive on system groups (never removes a perm).
|
||||
- **Default group bindings**: `admin` = all 31 codes; `redteam` = 8 (catalogue read + mission.{read,create,update,archive,write_red_fields} + detection_level.read); `blueteam` = 5 (catalogue read + mission.{read,write_blue_fields} + detection_level.read).
|
||||
- **Users admin service + API** (`app/services/users.py`, `app/api/users.py`): list (q + is_active filter + pagination), get, patch (display_name/locale/is_active), soft-delete, set groups. Last-admin protection on update/delete/group-strip.
|
||||
- **Groups admin service + API** (`app/services/groups.py`, `app/api/groups.py`): full CRUD with system-group protection (no rename, no delete), `PUT /groups/{id}/permissions` for the bindings. Admin system group's perm set is locked to "every perm" (preserves the bypass invariant).
|
||||
- **Permissions read-only API** (`app/api/permissions.py`): `GET /permissions` returns the catalogue (admin or `group.read` holders).
|
||||
- **Frontend admin pages** (`frontend/src/pages/Admin{Users,Groups,Invitations}Page.tsx`): list + edit modals using TanStack Query mutations, multi-select for perms grouped by family, copy-once invitation URL display.
|
||||
- **Frontend chrome** (`Layout.tsx` + `RequireAdmin.tsx`): admin nav links shown only when `is_admin === true`; direct navigation to `/admin/*` by non-admins redirects to `/`. Server remains the arbiter.
|
||||
- **`/diag/reset` now clears the rate-limit counters** so the Playwright suite can iterate without hitting `10/min/IP` budgets across spec files. Gated to non-prod environments only.
|
||||
- **Testing**:
|
||||
- `tests/test_rbac.py` — **15 pytest integration tests** (39 backend total).
|
||||
- `e2e/tests/m3-rbac.spec.ts` — **8 Playwright tests** covering DoD §10 #2/#3 (28 e2e total).
|
||||
- `tasks/testing-m3.md` — manual + automated procedure.
|
||||
- **Frontend api helpers**: `apiPatch`, `apiPut`, `apiDelete` added to `frontend/src/lib/api.ts`.
|
||||
|
||||
### Fixed (post-M3 spec-review pass)
|
||||
- **Rate-limit scope clarified**: `app/core/rate_limit.py` now enables the limiter for `APP_ENV in ("prod", "staging")` instead of `prod` only — a public staging deployment without auth limits would be surprising. Dev/test stay unthrottled for Playwright ergonomics. Spec §6 NF-security applies to operator-facing deployments.
|
||||
- **Admin perm invariant**: `set_group_permissions` refuses to alter the admin system group's perm set to anything other than the full catalogue (`SystemGroupProtected` → 409). The decorator bypass relies on `is_admin = "admin" in group_names`, but a future refactor could move to a perm-based check, so we keep the invariant.
|
||||
- **LogRecord field collision**: `log.info("...", extra={"name": g.name})` raised `KeyError: "Attempt to overwrite 'name' in LogRecord"` because Python's logger reserves `name`. Renamed to `group_name`. Audited all other `extra=` payloads in `app/api/`+`app/services/` for the same trap.
|
||||
|
||||
### Validated end-to-end (M3 DoD)
|
||||
- `make clean && make up && make migrate` → boot logs show `metamorph.permissions.seeded {perms_created: 31, perms_total: 31, bindings: {admin: 31, redteam: 8, blueteam: 5}}`.
|
||||
- `make test-api` → **39 pytest pass** (1 health + 8 schema + 15 auth + 15 RBAC) in ~4 s.
|
||||
- `make e2e` → **28 Playwright pass** (8 M0 + 4 M1 + 8 M2 + 8 M3) in ~16 s.
|
||||
- Spec-reviewer pass: PASS verdict, 2 minor fixes applied (above), 2 anticipations noted for M12/M14 (no current action).
|
||||
|
||||
### Added — M2 (Auth, bootstrap, invitations)
|
||||
- **Crypto plumbing**: `app.core.security` (Argon2id `time_cost=2 memory_cost=64MiB parallelism=2`, opaque-token SHA-256 helpers), `app.core.jwt_tokens` (HS256, claims `iss/sub/type/jti/iat/exp`, access 1h / refresh 30d).
|
||||
- **Auth services** (`app.services.auth`): login, refresh **with token rotation + reuse-detection chain revoke**, logout (idempotent), change_password (forces logout-all).
|
||||
- **Invitation services** (`app.services.invitations`): create, preview, accept, revoke. Token persisted only as SHA-256, default 7-day TTL.
|
||||
- **Bootstrap** (`app.services.bootstrap` + `app.core.install_token`): seeds 3 system groups (`admin`/`redteam`/`blueteam`), mints a one-shot install token at first boot when `users` is empty, logs a banner with the raw token. CLI `flask --app app.cli metamorph print-install-token [--force]`.
|
||||
- **Auth middleware** (`app.core.auth_decorators`): `@require_auth` populates `g.current_user`; `@require_perm("...")` checks atomic permissions; admin group bypasses the check (atomic perms land in M3).
|
||||
- **API endpoints**:
|
||||
- `POST /api/v1/setup` (consume install token, create 1st admin) + `GET /api/v1/setup` (status).
|
||||
- `POST /api/v1/auth/login` + `POST /auth/refresh` + `POST /auth/logout` + `GET /auth/me` + `POST /auth/change-password`.
|
||||
- `POST /api/v1/invitations` (admin) + `GET /invitations` + `GET /invitations/preview/<token>` + `POST /invitations/accept/<token>` + `POST /invitations/<id>/revoke`.
|
||||
- `POST /api/v1/diag/reset` (test-only kill switch — wipes auth tables + mints fresh install token; only available in `dev`/`test`).
|
||||
- **Rate limiting** (`flask-limiter`): 10/min/IP on `/auth/login`, `/auth/refresh`; 5/min on `/auth/change-password` and `/setup`; 10–20/min on invitation endpoints. Globally disabled when `APP_ENV=test`.
|
||||
- **Refresh cookie** `metamorph_refresh`: HttpOnly + Secure + SameSite=Strict + Path=`/api/v1/auth/`.
|
||||
- **Frontend auth state** (`frontend/src/lib/{api,auth}.ts`): access token in module memory, refresh in cookie, automatic 401-retry via `/auth/refresh` with reentrancy guard. `useAuth()` hook + `<RequireAuth>` route guard.
|
||||
- **Frontend pages**: `/login`, `/setup`, `/register?token=…`, `/profile` (with change-password form), all in RTOps design. Protected layout: nav shows email + Logout when authenticated, Login + Setup links when not.
|
||||
- **Frontend deps**: `@tanstack/react-query`, `react-router-dom`. Tanstack provider in `App.tsx` (will carry actual queries from M3+).
|
||||
- **Email validation** (`app.api._validation.Email`): permissive RFC-shape regex that accepts internal TLDs (`.local`, `.corp`) — `pydantic.EmailStr` was too strict for red-team labs.
|
||||
- **Testing**:
|
||||
- `tests/test_auth_flow.py` — **15 pytest integration tests** (24 backend total with M0/M1).
|
||||
- `e2e/tests/m2-auth.spec.ts` — **8 Playwright tests** covering setup → login → me → invitation → register → 2nd login → RBAC 403 → refresh rotation → logout (20 e2e total).
|
||||
- `tasks/testing-m2.md` — manual + automated procedure.
|
||||
|
||||
### Fixed (post-M2 spec-review pass)
|
||||
- **Refresh cookie `Secure=True`** unconditionally (`backend/app/api/auth.py`). Modern browsers treat `localhost` as a secure context, so dev/test still works. Closes the silent-degradation found by the reviewer.
|
||||
- **`/auth/refresh` rate-limit lowered to 10/min/IP** (`backend/app/api/auth.py`) to match spec §M2 ("10 req/min/IP on `/auth/*`").
|
||||
- `/diag/reset` kept allowed in `dev` *and* `test` (a `make e2e` against a `make up` dev stack must be able to reset). Added a WARNING log when triggered in `dev` and a clear docstring; production envs (`prod`/`staging`) remain locked out.
|
||||
|
||||
### Known scope-creep (intentional, not retracted)
|
||||
- Rate-limits on `/setup` (5/min), `/invitations/preview` (20/min), `/invitations/accept` (10/min) and `/auth/change-password` (5/min) were added in M2 even though §M2 only mandated `/auth/*`. Defensible (these are abuse-attractor endpoints), and noted here so M14 doesn't double-spec them.
|
||||
|
||||
### Added — M1 (DB schema & migrations)
|
||||
- **23 tables** + `alembic_version` covering auth/RBAC (8), MITRE (4), templates (4), missions (6), evidence (1), settings/detection-levels (2), notifications (1).
|
||||
- SQLAlchemy 2.x declarative models with Mapped[]/mapped_column(), grouped under `backend/app/models/{auth,mitre,template,mission,evidence,setting,notification}.py`.
|
||||
- Alembic init: `alembic.ini`, `alembic/env.py` reading `app.core.config.settings.database_url`, `alembic/script.py.mako`, naming convention `pk_/fk_/ck_/uq_/ix_` enforced via `MetaData(naming_convention=...)` on `app.db.base.Base`.
|
||||
- Reusable mixins in `app.db.mixins`: `UuidPkMixin` (uuid4 server-side), `TimestampMixin` (created_at/updated_at, server-default + onupdate), `SoftDeleteMixin` (deleted_at, no auto-injected index — declared explicitly per table to avoid mixin-vs-class `__table_args__` clobbering).
|
||||
- Postgres-specific features used: `JSONB` for `settings.value` and `notifications.payload`; native `Uuid` columns; partial indexes (`WHERE deleted_at IS NULL` on 9 tables; `WHERE read_at IS NULL` on `notifications`); CHECK constraints for status/state/opsec_level/mitre_kind enums; `exactly_one_mitre_fk` CHECK on `test_template_mitre_tags`.
|
||||
- **`mission_test_mitre_tags` deliberately denormalised** (no FK to `mitre_*` tables): copies `mitre_external_id`, `mitre_name`, `mitre_url` at tag time so a later MITRE re-sync that drops an entry cannot purge a mission's tags. Companion `test_template_mitre_tags` keeps FKs since templates are editable. (Spec §11 risk addressed.)
|
||||
- Backend `pyproject.toml` deps: SQLAlchemy ≥2, Alembic ≥1.13, psycopg[binary] ≥3.1.
|
||||
- New Makefile targets: `migrate`, `migrate-down`, `migrate-revision MSG=…`, `migrate-status`. The Dockerfile now ships `alembic.ini` + `alembic/` so the api container can run migrations directly.
|
||||
- **Test stage in `backend/Dockerfile`** (`--target test`): runtime image + dev extras + `tests/` dir. New `make test-api` target spins an ephemeral container against the live DB on the compose network. Backend tests no longer require any local Python toolchain.
|
||||
- `tests/test_schema.py` (8 integration tests + the existing M0 health test = 9 total): expected tables, expected timestamp/soft-delete columns, partial-index presence, expected FK pairs, expected CHECK constraints, alembic-at-head, and a negative INSERT proving the `exactly_one_mitre_fk` CHECK fires.
|
||||
- `tasks/testing-m1.md` — manual + automated verification procedure.
|
||||
|
||||
### Fixed (post-M1 spec-review pass)
|
||||
- Soft delete now consistent across snapshot-bearing tables: `mission_scenarios`, `mission_tests`, `mission_categories` gained `SoftDeleteMixin` + their `ix_<table>_active` partial index (M12 trash bin depends on this).
|
||||
- `evidence_files` gained `TimestampMixin` (`created_at`/`updated_at`) on top of the domain `uploaded_at` (audit minimal everywhere, per M1 brief).
|
||||
- `mission_members` gained `TimestampMixin`, replacing the bespoke `added_at` column.
|
||||
- `scenario_template_tests` PK refactored to a UUID + `UNIQUE(scenario_template_id, position)` so the same test can appear at multiple positions in a scenario (chained operations).
|
||||
- `SoftDeleteMixin.__table_args__` removed (silently clobbered by class `__table_args__`); each soft-delete table now declares `ix_<table>_active` explicitly. Documented in the mixin's docstring.
|
||||
- `mission_test_mitre_tags` schema redesigned to denormalise MITRE labels (see "Added" entry above).
|
||||
- Migration 0001 regenerated end-to-end after these fixes — `24765a5014b6` is the new HEAD.
|
||||
|
||||
### Validated end-to-end (M1 DoD)
|
||||
- `make clean && make up && make migrate` from a vide DB → 27 tables, 32 FK, 9 CHECK, 14 UQ, 12 partial indexes.
|
||||
- `make test-api` → **9 pytest pass** (1 health + 8 schema integration) in <1 s.
|
||||
- `make e2e` → **12 Playwright pass** (8 M0 smoke + 4 M1 db visibility) in 3 s.
|
||||
|
||||
### Added (M1 visibility)
|
||||
- New API endpoint `GET /api/v1/diag/db` exposes `alembic_revision` (short-hashable) and the public-schema `table_count`. Returns 503 with `{"reachable": false}` when Postgres is down.
|
||||
- New `Database` card on the SPA home page consumes that endpoint, renders the revision short-hash and the count next to the existing `API` and `Roadmap` cards.
|
||||
- Footer updated to `M0 bootstrap · M1 db schema`. Roadmap card now points to `M2 — Auth + JWT`.
|
||||
- New e2e suite `e2e/tests/m1-db.spec.ts` (4 tests) covers the diag endpoint contract, the Database card rendering, and the footer/roadmap labels.
|
||||
|
||||
### Added — M0 (bootstrap)
|
||||
- Repo scaffolding: `.gitignore`, `.env.example`, `Makefile`, `docker-compose.yml`, `README.md`, `CHANGELOG.md`.
|
||||
- `docker-compose.yml` with three services: `db` (postgres:16-alpine, no host port), `api` (Flask 3, port 8000), `front` (nginx serving the Vite bundle, port 80).
|
||||
- Named volumes `metamorph_db` and `metamorph_evidence` for data persistence.
|
||||
- Backend skeleton: Flask app factory, JSON structured logging on stdout, `GET /api/v1/health` endpoint, multi-stage Dockerfile, `pyproject.toml` driven by `uv`.
|
||||
- Frontend skeleton: Vite + React 18 + TypeScript strict + TailwindCSS, RTOps design tokens (`tasks/design.md`) translated into `tailwind.config.ts`, base UI primitives (`Card`, `Tag`, `SectionHeader`, `FlowNode`, `Button`), home page wired to `/api/v1/health`.
|
||||
- Multi-stage frontend Dockerfile that builds the bundle and serves it via nginx, proxying `/api/*` to the api container.
|
||||
- Pre-commit hook config: `ruff` for backend, `eslint` + `tsc --noEmit` for frontend.
|
||||
|
||||
### Validated
|
||||
- `docker compose config` parses (validated via `pyyaml` since Docker is not installed in the dev shell).
|
||||
- Every env var referenced by the compose file is documented in `.env.example`.
|
||||
- All Python source files parse cleanly (`ast.parse`).
|
||||
- All TS/JSON config files parse cleanly.
|
||||
|
||||
### Notes
|
||||
- TLS termination is delegated to an external reverse proxy (per spec §6 NF-network). The compose stack exposes plain HTTP on `HOST_FRONT_PORT` (8080) and `HOST_API_PORT` (8000).
|
||||
- The first-admin bootstrap token (M2) will be printed to the api container's stdout on first boot when the `users` table is empty.
|
||||
- `tasks/spec.md` and `tasks/todo.md` remain authoritative; update them before changing scope.
|
||||
|
||||
### Fixed (M0 DoD validation pass on real podman)
|
||||
- **FQDN image references** in `docker-compose.yml`, `backend/Dockerfile`, `frontend/Dockerfile`. Podman on Fedora enforces `short-name-mode=enforcing` for pulls (no TTY ⇒ no prompt ⇒ failure). Replaced `postgres:16-alpine` / `python:3.12-slim` / `node:20-alpine` / `nginx:1.27-alpine` with their `docker.io/library/…` qualified equivalents. Docker accepts the same prefix transparently.
|
||||
- **`*.md` removed from `backend/.dockerignore` and `frontend/.dockerignore`**: `pyproject.toml` declared `readme = "README.md"`, but the file was being filtered out of the build context, so `hatchling.build.build_wheel` raised `OSError: Readme file does not exist: README.md`. Also removed the `readme` field itself from `pyproject.toml` to decouple the build from the doc.
|
||||
- **`Card.tsx` type clash**: `CardProps extends HTMLAttributes<HTMLDivElement>` redefined `title` as `ReactNode`, but the native `title` is `string`. `tsc -b` failed with TS2430 during `vite build`. Switched to `Omit<HTMLAttributes<HTMLDivElement>, 'title'>`.
|
||||
- **Explicit healthchecks added to compose `api` and `front`**: podman-compose 1.x doesn't surface healthchecks declared only in the `Dockerfile` via `inspect`. Mirroring them in `docker-compose.yml` makes `make inspect-health` actually see `healthy/unhealthy/starting` on every engine.
|
||||
- **Suppressed `podman compose` external-provider banner** via `PODMAN_COMPOSE_WARNING_LOGS=false` exported from the Makefile.
|
||||
|
||||
### Validated end-to-end on podman 5.x (Fedora 43)
|
||||
- `make up` → 3 containers, all 3 healthy after start_period.
|
||||
- `make health` → `{"status":"ok","version":"0.1.0"}` via the front nginx proxy (port 8080) and direct API (port 8000).
|
||||
- `make logs-api` → JSON-structured lines on stdout (`ts`, `level`, `logger`, `message`, custom fields).
|
||||
- `make e2e` → **8/8 Playwright tests pass** in 2.5 s. Reports: `e2e/playwright-report/index.html` (529 KB, autoportant) + `junit.xml` (`tests=8 failures=0 skipped=0 errors=0`).
|
||||
|
||||
### Added (engine portability)
|
||||
- Makefile auto-detects **docker** or **podman** at runtime and selects the matching compose driver (`docker compose`, `podman compose`, or legacy `podman-compose`). Override via `ENGINE=…` and/or `COMPOSE="…"`.
|
||||
- New targets: `engine` (print detected runtime), `volumes` (list project-named volumes), `inspect-health` (health status of all 3 containers), `logs-api` (tail just the api), `health` (single curl probe). All engine-agnostic.
|
||||
- `make help` now prints the active engine + compose driver in its footer.
|
||||
- `tasks/testing-m0.md` and `README.md` rewritten to be engine-agnostic — raw `docker logs` / `docker volume ls` / `docker inspect` calls replaced with the new make targets.
|
||||
|
||||
### Added (M0 testing)
|
||||
- `e2e/` Playwright project with chromium, HTML + JUnit XML reporters, traces / screenshots / videos kept on retry. Reports land in `e2e/playwright-report/`.
|
||||
- `e2e/tests/m0-smoke.spec.ts` — 8 smoke tests covering the front rendering, the API proxy, the design tokens, the absence of any runtime CDN traffic (spec §7), and the CORS contract.
|
||||
- Makefile targets `e2e-install`, `e2e`, `e2e-report`, `e2e-up`, `wait-healthy`.
|
||||
- `tasks/testing-m0.md` — step-by-step manual + automated verification procedure for M0.
|
||||
- Convention added to `tasks/todo.md`: every milestone N delivers `tasks/testing-m<N>.md` + at least one `e2e/tests/m<N>-*.spec.ts`, and the spec-reviewer subagent runs before marking the milestone done.
|
||||
|
||||
### Fixed (post-M0 spec-review pass)
|
||||
- `.pre-commit-config.yaml` added at repo root: ruff + ruff-format on backend, eslint + tsc --noEmit + prettier --check on frontend, plus baseline whitespace/JSON/private-key checks. Documented `pre-commit install` in `README.md`.
|
||||
- Self-hosted webfonts via `@fontsource/jetbrains-mono` and `@fontsource/ibm-plex-sans` (imported in `frontend/src/index.css`); dropped the Google Fonts `<link>` from `frontend/index.html` to honor spec §7 ("no runtime CDN").
|
||||
- Refuse-to-boot guard in `backend/app/core/config.py`: when `APP_ENV != "dev"`, defaults / placeholders for `JWT_SECRET` and `POSTGRES_PASSWORD` raise at startup. New `APP_ENV` env var documented in `.env.example`, `README.md`, and `docker-compose.yml`.
|
||||
- `make dev` now runs `dev-api` and `dev-front` in parallel via `make -j2` instead of just printing a hint.
|
||||
- Removed dead `database_url` property from `Settings` (will be reintroduced in M1 with the SQLAlchemy/Alembic stack).
|
||||
- Pinned Node engines to `>=20` in `frontend/package.json`.
|
||||
- Reconciled M0 DoD wording in `tasks/todo.md` (HTTP via `HOST_FRONT_PORT`, with explicit note that prod TLS is external).
|
||||
- Documented the `2xs/3xs/4xs` font-size aliases in `frontend/tailwind.config.ts` against the design.md §3 scale.
|
||||
214
Makefile
Normal file
214
Makefile
Normal file
@@ -0,0 +1,214 @@
|
||||
.DEFAULT_GOAL := help
|
||||
SHELL := /bin/bash
|
||||
|
||||
# Load .env if present so targets can use the same variables as compose.
|
||||
ifneq (,$(wildcard ./.env))
|
||||
include .env
|
||||
export
|
||||
endif
|
||||
|
||||
# === Container engine detection (docker OR podman) ============================
|
||||
#
|
||||
# Auto-detects on PATH, with docker preferred when both are installed.
|
||||
# Override either variable from the environment or the command line:
|
||||
# make up ENGINE=podman
|
||||
# make up COMPOSE="podman compose"
|
||||
#
|
||||
ENGINE ?= $(shell \
|
||||
if command -v docker >/dev/null 2>&1; then echo docker; \
|
||||
elif command -v podman >/dev/null 2>&1; then echo podman; \
|
||||
fi)
|
||||
|
||||
ifeq ($(strip $(ENGINE)),)
|
||||
$(error Neither docker nor podman found in PATH. Install one, or set ENGINE=...)
|
||||
endif
|
||||
|
||||
# Pick the right compose driver based on the chosen engine.
|
||||
# - docker → "docker compose" (compose v2 plugin)
|
||||
# - podman 4.0+ → "podman compose"
|
||||
# - older podman → "podman-compose" (legacy Python wrapper)
|
||||
ifndef COMPOSE
|
||||
ifeq ($(ENGINE),docker)
|
||||
COMPOSE := docker compose
|
||||
else
|
||||
COMPOSE := $(shell \
|
||||
if podman compose version >/dev/null 2>&1; then echo "podman compose"; \
|
||||
elif command -v podman-compose >/dev/null 2>&1; then echo "podman-compose"; \
|
||||
else echo "podman compose"; fi)
|
||||
endif
|
||||
endif
|
||||
|
||||
# Project name is mostly used to look up volumes / containers via raw engine calls.
|
||||
PROJECT ?= metamorph
|
||||
|
||||
# Suppress the noisy `>>>> Executing external compose provider …` banner that
|
||||
# `podman compose` emits on every invocation (harmless, but spammy in logs).
|
||||
export PODMAN_COMPOSE_WARNING_LOGS = false
|
||||
|
||||
.PHONY: help env engine up down build rebuild logs logs-api ps health shell-api shell-db psql \
|
||||
dev dev-api dev-front lint lint-api lint-front fmt test test-api test-front \
|
||||
e2e e2e-install e2e-report e2e-up wait-healthy \
|
||||
migrate migrate-down migrate-revision migrate-status \
|
||||
seed-mitre print-install-token print-install-token-force \
|
||||
volumes inspect-health clean
|
||||
|
||||
help: ## Show this help
|
||||
@awk 'BEGIN {FS = ":.*##"} /^[a-zA-Z_0-9-]+:.*##/ {printf " \033[36m%-22s\033[0m %s\n", $$1, $$2}' $(MAKEFILE_LIST)
|
||||
@printf "\n Container engine in use: \033[33m%s\033[0m | compose: \033[33m%s\033[0m\n" "$(ENGINE)" "$(COMPOSE)"
|
||||
|
||||
engine: ## Print the detected container engine and compose driver
|
||||
@echo "ENGINE=$(ENGINE)"
|
||||
@echo "COMPOSE=$(COMPOSE)"
|
||||
|
||||
env: ## Bootstrap a local .env from .env.example if missing
|
||||
@test -f .env || (cp .env.example .env && echo "Created .env — edit it before 'make up'")
|
||||
|
||||
# === Compose lifecycle ========================================================
|
||||
|
||||
up: env ## Build (if needed) and start all services
|
||||
$(COMPOSE) up -d --build
|
||||
|
||||
down: ## Stop and remove containers (keep volumes)
|
||||
$(COMPOSE) down
|
||||
|
||||
build: ## Build images without starting
|
||||
$(COMPOSE) build
|
||||
|
||||
rebuild: ## Force rebuild without cache
|
||||
$(COMPOSE) build --no-cache
|
||||
|
||||
logs: ## Tail logs from all services
|
||||
$(COMPOSE) logs -f --tail=200
|
||||
|
||||
logs-api: ## Tail only the api container logs (useful to inspect JSON log lines)
|
||||
$(COMPOSE) logs -f --tail=200 api
|
||||
|
||||
ps: ## List running services
|
||||
$(COMPOSE) ps
|
||||
|
||||
shell-api: ## Shell into the api container
|
||||
$(COMPOSE) exec api bash
|
||||
|
||||
shell-db: ## Shell into the db container
|
||||
$(COMPOSE) exec db sh
|
||||
|
||||
psql: ## Open psql in the db container
|
||||
$(COMPOSE) exec db psql -U $(POSTGRES_USER) -d $(POSTGRES_DB)
|
||||
|
||||
# === Container introspection (engine-agnostic) ================================
|
||||
|
||||
volumes: ## List the named volumes created by this project
|
||||
@$(ENGINE) volume ls --filter "name=$(PROJECT)_"
|
||||
|
||||
inspect-health: ## Print the health status of every container in the project
|
||||
@for c in $(PROJECT)-db $(PROJECT)-api $(PROJECT)-front; do \
|
||||
printf "%-30s " "$$c"; \
|
||||
$(ENGINE) inspect --format '{{.State.Health.Status}}' "$$c" 2>/dev/null || echo "(no-healthcheck or stopped)"; \
|
||||
done
|
||||
|
||||
# === Local dev (no container) =================================================
|
||||
|
||||
dev: ## Run api + front locally in parallel (Ctrl-C stops both)
|
||||
@$(MAKE) -j2 --no-print-directory dev-api dev-front
|
||||
|
||||
dev-api: ## Run Flask in dev mode
|
||||
cd backend && APP_ENV=dev uv run flask --app app.main run --debug --host 0.0.0.0 --port 8000
|
||||
|
||||
dev-front: ## Run Vite dev server
|
||||
cd frontend && npm run dev
|
||||
|
||||
# === Quality ==================================================================
|
||||
|
||||
lint: lint-api lint-front ## Lint everything
|
||||
|
||||
lint-api:
|
||||
cd backend && uv run ruff check . && uv run ruff format --check .
|
||||
|
||||
lint-front:
|
||||
cd frontend && npm run lint && npm run typecheck
|
||||
|
||||
fmt: ## Auto-format
|
||||
cd backend && uv run ruff format .
|
||||
cd frontend && npm run format
|
||||
|
||||
test: test-api test-front ## Run all tests
|
||||
|
||||
test-api: ## Run backend pytest in an ephemeral container against the live DB
|
||||
@echo "Building backend test image (target: test)…"
|
||||
@$(ENGINE) build -q --target test -t metamorph-api-test ./backend > /dev/null
|
||||
@echo "Running pytest…"
|
||||
$(ENGINE) run --rm \
|
||||
--network $(PROJECT)_metamorph \
|
||||
-e APP_ENV=test \
|
||||
-e POSTGRES_DB=$(POSTGRES_DB) \
|
||||
-e POSTGRES_USER=$(POSTGRES_USER) \
|
||||
-e POSTGRES_PASSWORD=$(POSTGRES_PASSWORD) \
|
||||
-e POSTGRES_HOST=db \
|
||||
-e POSTGRES_PORT=5432 \
|
||||
-e JWT_SECRET=test-only-secret-not-checked-in-this-mode \
|
||||
-e LOG_LEVEL=WARNING \
|
||||
-e FRONT_ORIGIN=http://localhost:8080 \
|
||||
metamorph-api-test
|
||||
|
||||
test-front:
|
||||
cd frontend && npm test --if-present
|
||||
|
||||
# === End-to-end tests (Playwright) ============================================
|
||||
|
||||
e2e-install: ## Install Playwright deps + chromium browser (use sudo on Debian/Ubuntu)
|
||||
cd e2e && npm install && npx playwright install --with-deps chromium
|
||||
|
||||
e2e: wait-healthy ## Run the e2e suite against the running stack
|
||||
cd e2e && BASE_URL=http://localhost:$(or $(HOST_FRONT_PORT),8080) npm test
|
||||
|
||||
e2e-report: ## Open the latest Playwright HTML report
|
||||
cd e2e && npx playwright show-report
|
||||
|
||||
e2e-up: ## Bring the stack up, wait healthy, then run e2e
|
||||
$(MAKE) up
|
||||
$(MAKE) e2e
|
||||
|
||||
health: ## Curl the health endpoint via the front nginx (one shot)
|
||||
@curl -sSf "http://localhost:$(or $(HOST_FRONT_PORT),8080)/api/v1/health" \
|
||||
&& echo "" \
|
||||
|| (echo " unreachable — is 'make up' done?"; exit 1)
|
||||
|
||||
wait-healthy: ## Wait until the front+api are reachable (60s timeout)
|
||||
@port=$(or $(HOST_FRONT_PORT),8080); \
|
||||
echo "Waiting for http://localhost:$$port/api/v1/health …"; \
|
||||
for i in $$(seq 1 30); do \
|
||||
if curl -sf "http://localhost:$$port/api/v1/health" > /dev/null; then \
|
||||
echo " ready after $$((i*2))s"; exit 0; \
|
||||
fi; \
|
||||
sleep 2; \
|
||||
done; \
|
||||
echo " timeout after 60s — check 'make ps' and 'make logs'"; exit 1
|
||||
|
||||
# === App-specific commands (placeholders for later milestones) ================
|
||||
|
||||
migrate: ## Apply DB migrations (alembic upgrade head, runs inside the api container)
|
||||
$(COMPOSE) exec api alembic upgrade head
|
||||
|
||||
migrate-down: ## Roll back the latest migration
|
||||
$(COMPOSE) exec api alembic downgrade -1
|
||||
|
||||
migrate-revision: ## Generate a new autogenerated migration: make migrate-revision MSG="my message"
|
||||
@test -n "$(MSG)" || (echo "Usage: make migrate-revision MSG=\"short description\""; exit 1)
|
||||
$(COMPOSE) exec api alembic revision --autogenerate -m "$(MSG)"
|
||||
|
||||
migrate-status: ## Show current revision and any pending migrations
|
||||
$(COMPOSE) exec api alembic current
|
||||
@echo "---"
|
||||
$(COMPOSE) exec api alembic heads
|
||||
|
||||
seed-mitre: ## Seed MITRE ATT&CK Enterprise dataset (M4)
|
||||
$(COMPOSE) exec api flask --app app.cli metamorph seed-mitre
|
||||
|
||||
print-install-token: ## Print the bootstrap install token (M2)
|
||||
$(COMPOSE) exec api flask --app app.cli metamorph print-install-token
|
||||
|
||||
print-install-token-force: ## Force-mint a fresh install token (M2, --force)
|
||||
$(COMPOSE) exec api flask --app app.cli metamorph print-install-token --force
|
||||
|
||||
clean: ## Remove containers, networks AND volumes (DESTRUCTIVE)
|
||||
$(COMPOSE) down -v
|
||||
129
README.md
Normal file
129
README.md
Normal file
@@ -0,0 +1,129 @@
|
||||
# Metamorph
|
||||
|
||||
Collaborative purple-team platform. Red team logs the tests they execute (procedure, command, timestamp); blue team annotates each test with detection evidence (alerts, logs, files). At the end of an engagement, Metamorph generates a standalone reveal.js slide deck classified by MITRE ATT&CK tactic.
|
||||
|
||||
> **Status**: M0 (bootstrap). See `tasks/spec.md` for the full specification and `tasks/todo.md` for the milestone-by-milestone plan.
|
||||
|
||||
## Stack
|
||||
|
||||
- **Backend**: Python 3.12, Flask 3, SQLAlchemy 2 + Alembic (M1+), PostgreSQL 16.
|
||||
- **Frontend**: React 18 + TypeScript + Vite + TailwindCSS (RTOps design tokens, see `tasks/design.md`).
|
||||
- **Auth (M2+)**: JWT access (1h) + refresh (30d), Argon2id, invite-link enrollment.
|
||||
- **Delivery**: docker-compose. TLS termination is expected to be handled by an external reverse proxy in production.
|
||||
|
||||
## Quickstart
|
||||
|
||||
Works with **Docker** *or* **Podman**. The Makefile auto-detects the available engine and picks the matching compose driver (`docker compose`, `podman compose`, or `podman-compose`).
|
||||
|
||||
Requires one of:
|
||||
|
||||
- Docker Engine 24+ with the Compose v2 plugin, **or**
|
||||
- Podman 4.0+ with `podman compose` (or the legacy `podman-compose` ≥ 1.0.6)
|
||||
|
||||
```bash
|
||||
git clone <this repo>
|
||||
cd Metamorph
|
||||
make engine # confirm which engine the Makefile picked up
|
||||
make env # creates .env from .env.example
|
||||
$EDITOR .env # set strong values for POSTGRES_PASSWORD and JWT_SECRET
|
||||
make up # builds and starts api + db + front
|
||||
make logs # tail logs
|
||||
```
|
||||
|
||||
Override the auto-detection if you have both engines installed:
|
||||
|
||||
```bash
|
||||
make up ENGINE=podman # force podman + auto-pick its compose driver
|
||||
make up ENGINE=docker COMPOSE="docker compose"
|
||||
COMPOSE=podman-compose make up # force the legacy wrapper specifically
|
||||
```
|
||||
|
||||
Then:
|
||||
|
||||
- Front: <http://localhost:8080>
|
||||
- API health: <http://localhost:8080/api/v1/health> (proxied) or <http://localhost:8000/api/v1/health>
|
||||
|
||||
To stop:
|
||||
|
||||
```bash
|
||||
make down # keep volumes
|
||||
make clean # also drop volumes (DESTRUCTIVE)
|
||||
```
|
||||
|
||||
## Local dev (no Docker)
|
||||
|
||||
Requires:
|
||||
|
||||
- [uv](https://github.com/astral-sh/uv) for Python deps
|
||||
- Node.js 20+ and `npm`
|
||||
- A reachable Postgres (or `make up db` to run only the db container)
|
||||
|
||||
```bash
|
||||
make dev-api # in one terminal
|
||||
make dev-front # in another
|
||||
```
|
||||
|
||||
## Environment variables
|
||||
|
||||
See `.env.example`. The most important ones:
|
||||
|
||||
| Variable | Purpose |
|
||||
|--------------------|------------------------------------------------------|
|
||||
| `APP_ENV` | `dev` allows placeholder secrets; anything else (prod/staging) refuses to boot with defaults |
|
||||
| `POSTGRES_*` | DB credentials (used by `db` and `api`) |
|
||||
| `JWT_SECRET` | HS256 signing key — generate 64+ random bytes (`python -c "import secrets; print(secrets.token_urlsafe(64))"`) |
|
||||
| `LOG_LEVEL` | `DEBUG` / `INFO` / `WARNING` / `ERROR` |
|
||||
| `FRONT_ORIGIN` | Allowed CORS origin for the SPA |
|
||||
| `EVIDENCE_DIR` | Path inside the api container where uploads land |
|
||||
| `HOST_API_PORT` | Host port mapped to the api (default 8000) |
|
||||
| `HOST_FRONT_PORT` | Host port mapped to the front nginx (default 8080) |
|
||||
|
||||
## Testing
|
||||
|
||||
- **Manual + automated checklist for the current milestone**: see [`tasks/testing-m<N>.md`](tasks/testing-m0.md) (currently `testing-m0.md`).
|
||||
- **Backend unit tests**: `make test-api`
|
||||
- **End-to-end (Playwright)**: `make e2e-install` (once), then `make up && make e2e`. Reports land in `e2e/playwright-report/` (HTML + JUnit XML); open with `make e2e-report`.
|
||||
|
||||
## Pre-commit hooks
|
||||
|
||||
After cloning, install hooks once:
|
||||
|
||||
```bash
|
||||
pipx install pre-commit # or: pip install --user pre-commit
|
||||
pre-commit install
|
||||
pre-commit run --all-files # initial sweep
|
||||
```
|
||||
|
||||
The hooks run `ruff` + `ruff-format` on the backend and `eslint` / `tsc --noEmit` / `prettier --check` on the frontend (see `.pre-commit-config.yaml`).
|
||||
|
||||
## Project layout
|
||||
|
||||
```
|
||||
.
|
||||
├── backend/ # Flask API
|
||||
│ └── app/
|
||||
│ ├── api/ # HTTP layer (blueprints)
|
||||
│ ├── core/ # config, logging, errors
|
||||
│ ├── db/ # SQLAlchemy session, migrations (M1+)
|
||||
│ ├── models/ # ORM models (M1+)
|
||||
│ ├── services/ # domain logic (M2+)
|
||||
│ └── i18n/ # message catalogs (M13)
|
||||
├── frontend/ # Vite + React + TS + Tailwind
|
||||
│ └── src/components/ui/ # RTOps design system primitives
|
||||
├── tasks/
|
||||
│ ├── spec.md # source of truth for requirements
|
||||
│ ├── design.md # RTOps design system
|
||||
│ ├── todo.md # milestone plan
|
||||
│ └── lessons.md # session retrospectives
|
||||
├── docker-compose.yml
|
||||
├── Makefile
|
||||
└── CHANGELOG.md
|
||||
```
|
||||
|
||||
## Roadmap
|
||||
|
||||
See `tasks/todo.md`. Current milestone: **M0 — bootstrap**.
|
||||
|
||||
## License
|
||||
|
||||
TBD.
|
||||
8
backend/.dockerignore
Normal file
8
backend/.dockerignore
Normal file
@@ -0,0 +1,8 @@
|
||||
__pycache__/
|
||||
*.pyc
|
||||
.pytest_cache/
|
||||
.ruff_cache/
|
||||
.venv/
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
85
backend/Dockerfile
Normal file
85
backend/Dockerfile
Normal file
@@ -0,0 +1,85 @@
|
||||
# syntax=docker/dockerfile:1.7
|
||||
|
||||
# === Stage 1: install deps with uv ===
|
||||
FROM docker.io/library/python:3.12-slim AS deps
|
||||
|
||||
ENV PYTHONDONTWRITEBYTECODE=1 \
|
||||
PYTHONUNBUFFERED=1 \
|
||||
PIP_DISABLE_PIP_VERSION_CHECK=1
|
||||
|
||||
# Install uv (fast, reproducible Python package manager)
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends curl ca-certificates \
|
||||
&& rm -rf /var/lib/apt/lists/* \
|
||||
&& curl -LsSf https://astral.sh/uv/install.sh | sh \
|
||||
&& ln -s /root/.local/bin/uv /usr/local/bin/uv
|
||||
|
||||
WORKDIR /app
|
||||
COPY pyproject.toml ./
|
||||
# Resolve & install deps into a dedicated venv. After running `uv lock` locally,
|
||||
# switch this to `uv sync --frozen --no-dev` for fully reproducible builds.
|
||||
RUN uv venv /opt/venv \
|
||||
&& uv pip install --python /opt/venv/bin/python --no-cache .
|
||||
|
||||
# === Stage 2: runtime ===
|
||||
FROM docker.io/library/python:3.12-slim AS runtime
|
||||
|
||||
ENV PYTHONDONTWRITEBYTECODE=1 \
|
||||
PYTHONUNBUFFERED=1 \
|
||||
PATH="/opt/venv/bin:$PATH"
|
||||
|
||||
# Non-root user
|
||||
RUN groupadd --gid 10001 metamorph \
|
||||
&& useradd --uid 10001 --gid metamorph --shell /usr/sbin/nologin --create-home metamorph \
|
||||
&& mkdir -p /data/evidence \
|
||||
&& chown -R metamorph:metamorph /data
|
||||
|
||||
COPY --from=deps /opt/venv /opt/venv
|
||||
|
||||
WORKDIR /app
|
||||
COPY --chown=metamorph:metamorph app ./app
|
||||
COPY --chown=metamorph:metamorph alembic ./alembic
|
||||
COPY --chown=metamorph:metamorph alembic.ini pyproject.toml ./
|
||||
|
||||
USER metamorph
|
||||
|
||||
EXPOSE 8000
|
||||
|
||||
# Healthcheck hits the local API.
|
||||
HEALTHCHECK --interval=30s --timeout=3s --start-period=10s --retries=3 \
|
||||
CMD python -c "import urllib.request,sys; \
|
||||
sys.exit(0 if urllib.request.urlopen('http://127.0.0.1:8000/api/v1/health',timeout=2).status==200 else 1)"
|
||||
|
||||
CMD ["gunicorn", "app.main:app", \
|
||||
"--bind", "0.0.0.0:8000", \
|
||||
"--workers", "2", \
|
||||
"--threads", "4", \
|
||||
"--access-logfile", "-", \
|
||||
"--error-logfile", "-", \
|
||||
"--log-level", "info"]
|
||||
|
||||
# === Stage 3: test image — runtime deps + dev extras + tests dir ===
|
||||
# Built only when explicitly targeted (`build --target test`). Not used in prod.
|
||||
FROM docker.io/library/python:3.12-slim AS test
|
||||
|
||||
ENV PYTHONDONTWRITEBYTECODE=1 \
|
||||
PYTHONUNBUFFERED=1 \
|
||||
PATH="/opt/venv/bin:$PATH"
|
||||
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends curl ca-certificates \
|
||||
&& rm -rf /var/lib/apt/lists/* \
|
||||
&& curl -LsSf https://astral.sh/uv/install.sh | sh \
|
||||
&& ln -s /root/.local/bin/uv /usr/local/bin/uv
|
||||
|
||||
COPY --from=deps /opt/venv /opt/venv
|
||||
|
||||
WORKDIR /app
|
||||
COPY pyproject.toml ./
|
||||
# Install the dev extras (pytest, ruff, httpx) on top of the runtime venv.
|
||||
RUN uv pip install --python /opt/venv/bin/python --no-cache ".[dev]"
|
||||
|
||||
COPY app ./app
|
||||
COPY alembic ./alembic
|
||||
COPY alembic.ini ./
|
||||
COPY tests ./tests
|
||||
|
||||
CMD ["python", "-m", "pytest", "tests", "-v"]
|
||||
39
backend/README.md
Normal file
39
backend/README.md
Normal file
@@ -0,0 +1,39 @@
|
||||
# Metamorph backend
|
||||
|
||||
Flask 3 API. See repo root `README.md` for the big picture.
|
||||
|
||||
## Layout
|
||||
|
||||
```
|
||||
app/
|
||||
├── api/ # HTTP layer (blueprints), versioned under /api/v1
|
||||
├── core/ # config (env-driven), structured logging
|
||||
├── db/ # SQLAlchemy session + Alembic (M1+)
|
||||
├── models/ # ORM models (M1+)
|
||||
├── services/ # domain logic (M2+)
|
||||
└── i18n/ # message catalogs (M13)
|
||||
tests/ # pytest
|
||||
```
|
||||
|
||||
## Local dev
|
||||
|
||||
Requires [uv](https://github.com/astral-sh/uv) and a reachable Postgres (M1+; not needed yet for `/health`).
|
||||
|
||||
```bash
|
||||
uv sync # install deps from pyproject.toml
|
||||
uv run flask --app app.main run --debug --port 8000
|
||||
curl http://localhost:8000/api/v1/health
|
||||
```
|
||||
|
||||
## Tests
|
||||
|
||||
```bash
|
||||
uv run pytest
|
||||
```
|
||||
|
||||
## Lint
|
||||
|
||||
```bash
|
||||
uv run ruff check .
|
||||
uv run ruff format .
|
||||
```
|
||||
49
backend/alembic.ini
Normal file
49
backend/alembic.ini
Normal file
@@ -0,0 +1,49 @@
|
||||
# Alembic configuration. The actual DB URL is injected at runtime by `alembic/env.py`
|
||||
# from `app.core.config.settings.database_url`, so we leave `sqlalchemy.url` empty.
|
||||
[alembic]
|
||||
script_location = alembic
|
||||
prepend_sys_path = .
|
||||
sqlalchemy.url =
|
||||
|
||||
# We use a YYYYMMDD_HHMM prefix on revision files for chronological readability.
|
||||
file_template = %%(year)d%%(month).2d%%(day).2d_%%(hour).2d%%(minute).2d_%%(rev)s_%%(slug)s
|
||||
|
||||
[post_write_hooks]
|
||||
# We deliberately disable post-write hooks: ruff is a dev-only dep, not installed
|
||||
# in the runtime image where `alembic revision --autogenerate` runs in podman.
|
||||
# Run `make fmt` on the host after generating a migration to format it.
|
||||
hooks =
|
||||
|
||||
[loggers]
|
||||
keys = root,sqlalchemy,alembic
|
||||
|
||||
[handlers]
|
||||
keys = console
|
||||
|
||||
[formatters]
|
||||
keys = generic
|
||||
|
||||
[logger_root]
|
||||
level = WARNING
|
||||
handlers = console
|
||||
qualname =
|
||||
|
||||
[logger_sqlalchemy]
|
||||
level = WARNING
|
||||
handlers =
|
||||
qualname = sqlalchemy.engine
|
||||
|
||||
[logger_alembic]
|
||||
level = INFO
|
||||
handlers =
|
||||
qualname = alembic
|
||||
|
||||
[handler_console]
|
||||
class = StreamHandler
|
||||
args = (sys.stderr,)
|
||||
level = NOTSET
|
||||
formatter = generic
|
||||
|
||||
[formatter_generic]
|
||||
format = %(levelname)-5.5s [%(name)s] %(message)s
|
||||
datefmt = %Y-%m-%d %H:%M:%S
|
||||
76
backend/alembic/env.py
Normal file
76
backend/alembic/env.py
Normal file
@@ -0,0 +1,76 @@
|
||||
"""Alembic environment.
|
||||
|
||||
We bypass `alembic.ini`'s `sqlalchemy.url` and pull the URL from the project's
|
||||
Pydantic settings so a single .env governs both runtime and migrations.
|
||||
|
||||
Importing `app.models` registers every model on `Base.metadata`.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from logging.config import fileConfig
|
||||
|
||||
from alembic import context
|
||||
from sqlalchemy import engine_from_config, pool
|
||||
|
||||
# noqa: F401 — registers models on Base.metadata
|
||||
from app import models as _models # noqa: F401
|
||||
from app.core.config import settings
|
||||
from app.db.base import Base
|
||||
|
||||
config = context.config
|
||||
|
||||
if config.config_file_name is not None:
|
||||
fileConfig(config.config_file_name)
|
||||
|
||||
# Inject the DB URL at runtime.
|
||||
config.set_main_option("sqlalchemy.url", settings.database_url)
|
||||
|
||||
target_metadata = Base.metadata
|
||||
|
||||
|
||||
def include_object(_object, _name, type_, _reflected, _compare_to): # type: ignore[no-untyped-def]
|
||||
"""Skip alembic's internal version table from autogenerate diffs."""
|
||||
if type_ == "table" and _name == "alembic_version":
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def run_migrations_offline() -> None:
|
||||
"""Generate SQL without an engine — useful for review."""
|
||||
context.configure(
|
||||
url=config.get_main_option("sqlalchemy.url"),
|
||||
target_metadata=target_metadata,
|
||||
literal_binds=True,
|
||||
dialect_opts={"paramstyle": "named"},
|
||||
include_object=include_object,
|
||||
compare_type=True,
|
||||
compare_server_default=True,
|
||||
)
|
||||
with context.begin_transaction():
|
||||
context.run_migrations()
|
||||
|
||||
|
||||
def run_migrations_online() -> None:
|
||||
"""Apply migrations against a live DB."""
|
||||
connectable = engine_from_config(
|
||||
config.get_section(config.config_ini_section, {}),
|
||||
prefix="sqlalchemy.",
|
||||
poolclass=pool.NullPool,
|
||||
)
|
||||
with connectable.connect() as connection:
|
||||
context.configure(
|
||||
connection=connection,
|
||||
target_metadata=target_metadata,
|
||||
include_object=include_object,
|
||||
compare_type=True,
|
||||
compare_server_default=True,
|
||||
)
|
||||
with context.begin_transaction():
|
||||
context.run_migrations()
|
||||
|
||||
|
||||
if context.is_offline_mode():
|
||||
run_migrations_offline()
|
||||
else:
|
||||
run_migrations_online()
|
||||
28
backend/alembic/script.py.mako
Normal file
28
backend/alembic/script.py.mako
Normal file
@@ -0,0 +1,28 @@
|
||||
"""${message}
|
||||
|
||||
Revision ID: ${up_revision}
|
||||
Revises: ${down_revision | comma,n}
|
||||
Create Date: ${create_date}
|
||||
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Sequence
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
${imports if imports else ""}
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = ${repr(up_revision)}
|
||||
down_revision: str | None = ${repr(down_revision)}
|
||||
branch_labels: str | Sequence[str] | None = ${repr(branch_labels)}
|
||||
depends_on: str | Sequence[str] | None = ${repr(depends_on)}
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
${upgrades if upgrades else "pass"}
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
${downgrades if downgrades else "pass"}
|
||||
0
backend/alembic/versions/.gitkeep
Normal file
0
backend/alembic/versions/.gitkeep
Normal file
@@ -0,0 +1,446 @@
|
||||
"""initial schema
|
||||
|
||||
Revision ID: 24765a5014b6
|
||||
Revises:
|
||||
Create Date: 2026-05-10 10:40:31.816149
|
||||
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Sequence
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.dialects import postgresql
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = '24765a5014b6'
|
||||
down_revision: str | None = None
|
||||
branch_labels: str | Sequence[str] | None = None
|
||||
depends_on: str | Sequence[str] | None = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.create_table('detection_levels',
|
||||
sa.Column('key', sa.String(length=40), nullable=False),
|
||||
sa.Column('label_fr', sa.String(length=80), nullable=False),
|
||||
sa.Column('label_en', sa.String(length=80), nullable=False),
|
||||
sa.Column('color_token', sa.String(length=16), nullable=False),
|
||||
sa.Column('position', sa.Integer(), nullable=False),
|
||||
sa.Column('is_default', sa.Boolean(), nullable=False),
|
||||
sa.Column('is_system', sa.Boolean(), nullable=False),
|
||||
sa.Column('id', sa.Uuid(), nullable=False),
|
||||
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
|
||||
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
|
||||
sa.PrimaryKeyConstraint('id', name=op.f('pk_detection_levels')),
|
||||
sa.UniqueConstraint('key', name=op.f('uq_detection_levels_key'))
|
||||
)
|
||||
op.create_table('groups',
|
||||
sa.Column('name', sa.String(length=80), nullable=False),
|
||||
sa.Column('description', sa.Text(), nullable=True),
|
||||
sa.Column('is_system', sa.Boolean(), nullable=False),
|
||||
sa.Column('id', sa.Uuid(), nullable=False),
|
||||
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
|
||||
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
|
||||
sa.Column('deleted_at', sa.DateTime(timezone=True), nullable=True),
|
||||
sa.PrimaryKeyConstraint('id', name=op.f('pk_groups'))
|
||||
)
|
||||
op.create_index('ix_groups_active', 'groups', ['deleted_at'], unique=False, postgresql_where='deleted_at IS NULL')
|
||||
op.create_index('uq_groups_name_active', 'groups', ['name'], unique=True, postgresql_where='deleted_at IS NULL')
|
||||
op.create_table('missions',
|
||||
sa.Column('name', sa.String(length=255), nullable=False),
|
||||
sa.Column('client_target', sa.String(length=255), nullable=True),
|
||||
sa.Column('date_start', sa.Date(), nullable=True),
|
||||
sa.Column('date_end', sa.Date(), nullable=True),
|
||||
sa.Column('status', sa.String(length=16), nullable=False),
|
||||
sa.Column('description_md', sa.Text(), nullable=True),
|
||||
sa.Column('visibility_mode', sa.String(length=16), nullable=False),
|
||||
sa.Column('id', sa.Uuid(), nullable=False),
|
||||
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
|
||||
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
|
||||
sa.Column('deleted_at', sa.DateTime(timezone=True), nullable=True),
|
||||
sa.CheckConstraint("status IN ('draft', 'in_progress', 'completed', 'archived')", name=op.f('ck_missions_status_valid')),
|
||||
sa.CheckConstraint("visibility_mode IN ('whitebox', 'titles_only', 'executed_only')", name=op.f('ck_missions_visibility_mode_valid')),
|
||||
sa.PrimaryKeyConstraint('id', name=op.f('pk_missions'))
|
||||
)
|
||||
op.create_index('ix_missions_active', 'missions', ['deleted_at'], unique=False, postgresql_where='deleted_at IS NULL')
|
||||
op.create_index('ix_missions_status', 'missions', ['status'], unique=False)
|
||||
op.create_table('mitre_tactics',
|
||||
sa.Column('external_id', sa.String(length=16), nullable=False),
|
||||
sa.Column('short_name', sa.String(length=80), nullable=False),
|
||||
sa.Column('name', sa.String(length=120), nullable=False),
|
||||
sa.Column('description', sa.Text(), nullable=True),
|
||||
sa.Column('url', sa.String(length=512), nullable=True),
|
||||
sa.Column('id', sa.Uuid(), nullable=False),
|
||||
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
|
||||
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
|
||||
sa.PrimaryKeyConstraint('id', name=op.f('pk_mitre_tactics')),
|
||||
sa.UniqueConstraint('external_id', name=op.f('uq_mitre_tactics_external_id'))
|
||||
)
|
||||
op.create_table('mitre_techniques',
|
||||
sa.Column('external_id', sa.String(length=16), nullable=False),
|
||||
sa.Column('name', sa.String(length=255), nullable=False),
|
||||
sa.Column('description', sa.Text(), nullable=True),
|
||||
sa.Column('url', sa.String(length=512), nullable=True),
|
||||
sa.Column('id', sa.Uuid(), nullable=False),
|
||||
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
|
||||
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
|
||||
sa.PrimaryKeyConstraint('id', name=op.f('pk_mitre_techniques')),
|
||||
sa.UniqueConstraint('external_id', name=op.f('uq_mitre_techniques_external_id'))
|
||||
)
|
||||
op.create_index('ix_mitre_techniques_name', 'mitre_techniques', ['name'], unique=False)
|
||||
op.create_table('permissions',
|
||||
sa.Column('code', sa.String(length=80), nullable=False),
|
||||
sa.Column('description', sa.Text(), nullable=True),
|
||||
sa.Column('id', sa.Uuid(), nullable=False),
|
||||
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
|
||||
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
|
||||
sa.PrimaryKeyConstraint('id', name=op.f('pk_permissions')),
|
||||
sa.UniqueConstraint('code', name=op.f('uq_permissions_code'))
|
||||
)
|
||||
op.create_table('scenario_templates',
|
||||
sa.Column('name', sa.String(length=255), nullable=False),
|
||||
sa.Column('description', sa.Text(), nullable=True),
|
||||
sa.Column('id', sa.Uuid(), nullable=False),
|
||||
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
|
||||
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
|
||||
sa.Column('deleted_at', sa.DateTime(timezone=True), nullable=True),
|
||||
sa.PrimaryKeyConstraint('id', name=op.f('pk_scenario_templates'))
|
||||
)
|
||||
op.create_index('ix_scenario_templates_active', 'scenario_templates', ['deleted_at'], unique=False, postgresql_where='deleted_at IS NULL')
|
||||
op.create_index('ix_scenario_templates_name', 'scenario_templates', ['name'], unique=False)
|
||||
op.create_table('settings',
|
||||
sa.Column('key', sa.String(length=80), nullable=False),
|
||||
sa.Column('value', postgresql.JSONB(astext_type=sa.Text()), nullable=False),
|
||||
sa.Column('description', sa.Text(), nullable=True),
|
||||
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
|
||||
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
|
||||
sa.PrimaryKeyConstraint('key', name=op.f('pk_settings'))
|
||||
)
|
||||
op.create_table('test_templates',
|
||||
sa.Column('name', sa.String(length=255), nullable=False),
|
||||
sa.Column('description', sa.Text(), nullable=True),
|
||||
sa.Column('objective', sa.Text(), nullable=True),
|
||||
sa.Column('procedure_md', sa.Text(), nullable=True),
|
||||
sa.Column('prerequisites_md', sa.Text(), nullable=True),
|
||||
sa.Column('expected_result_red_md', sa.Text(), nullable=True),
|
||||
sa.Column('expected_detection_blue_md', sa.Text(), nullable=True),
|
||||
sa.Column('opsec_level', sa.String(length=8), nullable=False),
|
||||
sa.Column('tags', sa.ARRAY(sa.String(length=64)), server_default='{}', nullable=False),
|
||||
sa.Column('expected_iocs', sa.ARRAY(sa.String(length=255)), server_default='{}', nullable=False),
|
||||
sa.Column('id', sa.Uuid(), nullable=False),
|
||||
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
|
||||
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
|
||||
sa.Column('deleted_at', sa.DateTime(timezone=True), nullable=True),
|
||||
sa.CheckConstraint("opsec_level IN ('low', 'medium', 'high')", name=op.f('ck_test_templates_opsec_level_valid')),
|
||||
sa.PrimaryKeyConstraint('id', name=op.f('pk_test_templates'))
|
||||
)
|
||||
op.create_index('ix_test_templates_active', 'test_templates', ['deleted_at'], unique=False, postgresql_where='deleted_at IS NULL')
|
||||
op.create_index('ix_test_templates_name', 'test_templates', ['name'], unique=False)
|
||||
op.create_table('users',
|
||||
sa.Column('email', sa.String(length=254), nullable=False),
|
||||
sa.Column('password_hash', sa.String(length=255), nullable=False),
|
||||
sa.Column('display_name', sa.String(length=120), nullable=True),
|
||||
sa.Column('locale', sa.String(length=8), nullable=False),
|
||||
sa.Column('is_active', sa.Boolean(), nullable=False),
|
||||
sa.Column('id', sa.Uuid(), nullable=False),
|
||||
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
|
||||
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
|
||||
sa.Column('deleted_at', sa.DateTime(timezone=True), nullable=True),
|
||||
sa.PrimaryKeyConstraint('id', name=op.f('pk_users'))
|
||||
)
|
||||
op.create_index('ix_users_active', 'users', ['deleted_at'], unique=False, postgresql_where='deleted_at IS NULL')
|
||||
op.create_index('uq_users_email_active', 'users', ['email'], unique=True, postgresql_where='deleted_at IS NULL')
|
||||
op.create_table('group_permissions',
|
||||
sa.Column('group_id', sa.Uuid(), nullable=False),
|
||||
sa.Column('permission_id', sa.Uuid(), nullable=False),
|
||||
sa.ForeignKeyConstraint(['group_id'], ['groups.id'], name=op.f('fk_group_permissions_group_id_groups'), ondelete='CASCADE'),
|
||||
sa.ForeignKeyConstraint(['permission_id'], ['permissions.id'], name=op.f('fk_group_permissions_permission_id_permissions'), ondelete='CASCADE'),
|
||||
sa.PrimaryKeyConstraint('group_id', 'permission_id', name=op.f('pk_group_permissions'))
|
||||
)
|
||||
op.create_table('invitations',
|
||||
sa.Column('token_hash', sa.String(length=128), nullable=False),
|
||||
sa.Column('email_hint', sa.String(length=254), nullable=True),
|
||||
sa.Column('created_by_user_id', sa.Uuid(), nullable=False),
|
||||
sa.Column('expires_at', sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column('consumed_at', sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column('revoked_at', sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column('consumed_by_user_id', sa.Uuid(), nullable=True),
|
||||
sa.Column('id', sa.Uuid(), nullable=False),
|
||||
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
|
||||
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
|
||||
sa.ForeignKeyConstraint(['consumed_by_user_id'], ['users.id'], name=op.f('fk_invitations_consumed_by_user_id_users'), ondelete='SET NULL'),
|
||||
sa.ForeignKeyConstraint(['created_by_user_id'], ['users.id'], name=op.f('fk_invitations_created_by_user_id_users'), ondelete='RESTRICT'),
|
||||
sa.PrimaryKeyConstraint('id', name=op.f('pk_invitations')),
|
||||
sa.UniqueConstraint('token_hash', name=op.f('uq_invitations_token_hash'))
|
||||
)
|
||||
op.create_index('ix_invitations_expires_at', 'invitations', ['expires_at'], unique=False)
|
||||
op.create_table('mission_categories',
|
||||
sa.Column('mission_id', sa.Uuid(), nullable=False),
|
||||
sa.Column('name', sa.String(length=120), nullable=False),
|
||||
sa.Column('color_token', sa.String(length=16), nullable=True),
|
||||
sa.Column('position', sa.Integer(), nullable=False),
|
||||
sa.Column('id', sa.Uuid(), nullable=False),
|
||||
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
|
||||
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
|
||||
sa.Column('deleted_at', sa.DateTime(timezone=True), nullable=True),
|
||||
sa.ForeignKeyConstraint(['mission_id'], ['missions.id'], name=op.f('fk_mission_categories_mission_id_missions'), ondelete='CASCADE'),
|
||||
sa.PrimaryKeyConstraint('id', name=op.f('pk_mission_categories')),
|
||||
sa.UniqueConstraint('mission_id', 'name', name='uq_mission_categories_name'),
|
||||
sa.UniqueConstraint('mission_id', 'position', name='uq_mission_categories_position')
|
||||
)
|
||||
op.create_index('ix_mission_categories_active', 'mission_categories', ['deleted_at'], unique=False, postgresql_where='deleted_at IS NULL')
|
||||
op.create_table('mission_members',
|
||||
sa.Column('mission_id', sa.Uuid(), nullable=False),
|
||||
sa.Column('user_id', sa.Uuid(), nullable=False),
|
||||
sa.Column('role_hint', sa.String(length=8), nullable=False),
|
||||
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
|
||||
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
|
||||
sa.CheckConstraint("role_hint IN ('red', 'blue')", name=op.f('ck_mission_members_role_hint_valid')),
|
||||
sa.ForeignKeyConstraint(['mission_id'], ['missions.id'], name=op.f('fk_mission_members_mission_id_missions'), ondelete='CASCADE'),
|
||||
sa.ForeignKeyConstraint(['user_id'], ['users.id'], name=op.f('fk_mission_members_user_id_users'), ondelete='CASCADE'),
|
||||
sa.PrimaryKeyConstraint('mission_id', 'user_id', name=op.f('pk_mission_members'))
|
||||
)
|
||||
op.create_index('ix_mission_members_user', 'mission_members', ['user_id'], unique=False)
|
||||
op.create_table('mission_scenarios',
|
||||
sa.Column('mission_id', sa.Uuid(), nullable=False),
|
||||
sa.Column('source_scenario_template_id', sa.Uuid(), nullable=True),
|
||||
sa.Column('snapshot_name', sa.String(length=255), nullable=False),
|
||||
sa.Column('snapshot_description', sa.Text(), nullable=True),
|
||||
sa.Column('position', sa.Integer(), nullable=False),
|
||||
sa.Column('id', sa.Uuid(), nullable=False),
|
||||
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
|
||||
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
|
||||
sa.Column('deleted_at', sa.DateTime(timezone=True), nullable=True),
|
||||
sa.ForeignKeyConstraint(['mission_id'], ['missions.id'], name=op.f('fk_mission_scenarios_mission_id_missions'), ondelete='CASCADE'),
|
||||
sa.ForeignKeyConstraint(['source_scenario_template_id'], ['scenario_templates.id'], name=op.f('fk_mission_scenarios_source_scenario_template_id_scenario_templates'), ondelete='SET NULL'),
|
||||
sa.PrimaryKeyConstraint('id', name=op.f('pk_mission_scenarios')),
|
||||
sa.UniqueConstraint('mission_id', 'position', name='uq_mission_scenarios_position')
|
||||
)
|
||||
op.create_index('ix_mission_scenarios_active', 'mission_scenarios', ['deleted_at'], unique=False, postgresql_where='deleted_at IS NULL')
|
||||
op.create_index('ix_mission_scenarios_mission', 'mission_scenarios', ['mission_id'], unique=False)
|
||||
op.create_table('mitre_subtechniques',
|
||||
sa.Column('external_id', sa.String(length=16), nullable=False),
|
||||
sa.Column('name', sa.String(length=255), nullable=False),
|
||||
sa.Column('description', sa.Text(), nullable=True),
|
||||
sa.Column('url', sa.String(length=512), nullable=True),
|
||||
sa.Column('technique_id', sa.Uuid(), nullable=False),
|
||||
sa.Column('id', sa.Uuid(), nullable=False),
|
||||
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
|
||||
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
|
||||
sa.ForeignKeyConstraint(['technique_id'], ['mitre_techniques.id'], name=op.f('fk_mitre_subtechniques_technique_id_mitre_techniques'), ondelete='CASCADE'),
|
||||
sa.PrimaryKeyConstraint('id', name=op.f('pk_mitre_subtechniques')),
|
||||
sa.UniqueConstraint('external_id', name=op.f('uq_mitre_subtechniques_external_id'))
|
||||
)
|
||||
op.create_index('ix_mitre_subtechniques_technique_id', 'mitre_subtechniques', ['technique_id'], unique=False)
|
||||
op.create_table('mitre_technique_tactics',
|
||||
sa.Column('technique_id', sa.Uuid(), nullable=False),
|
||||
sa.Column('tactic_id', sa.Uuid(), nullable=False),
|
||||
sa.ForeignKeyConstraint(['tactic_id'], ['mitre_tactics.id'], name=op.f('fk_mitre_technique_tactics_tactic_id_mitre_tactics'), ondelete='CASCADE'),
|
||||
sa.ForeignKeyConstraint(['technique_id'], ['mitre_techniques.id'], name=op.f('fk_mitre_technique_tactics_technique_id_mitre_techniques'), ondelete='CASCADE'),
|
||||
sa.PrimaryKeyConstraint('technique_id', 'tactic_id', name=op.f('pk_mitre_technique_tactics'))
|
||||
)
|
||||
op.create_table('notifications',
|
||||
sa.Column('user_id', sa.Uuid(), nullable=False),
|
||||
sa.Column('type', sa.String(length=64), nullable=False),
|
||||
sa.Column('payload', postgresql.JSONB(astext_type=sa.Text()), server_default='{}', nullable=False),
|
||||
sa.Column('read_at', sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column('id', sa.Uuid(), nullable=False),
|
||||
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
|
||||
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
|
||||
sa.ForeignKeyConstraint(['user_id'], ['users.id'], name=op.f('fk_notifications_user_id_users'), ondelete='CASCADE'),
|
||||
sa.PrimaryKeyConstraint('id', name=op.f('pk_notifications'))
|
||||
)
|
||||
op.create_index('ix_notifications_user_unread', 'notifications', ['user_id', 'created_at'], unique=False, postgresql_where='read_at IS NULL')
|
||||
op.create_table('refresh_tokens',
|
||||
sa.Column('user_id', sa.Uuid(), nullable=False),
|
||||
sa.Column('jti', sa.String(length=64), nullable=False),
|
||||
sa.Column('token_hash', sa.String(length=128), nullable=False),
|
||||
sa.Column('issued_at', sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column('expires_at', sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column('revoked_at', sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column('replaced_by_id', sa.Uuid(), nullable=True),
|
||||
sa.Column('id', sa.Uuid(), nullable=False),
|
||||
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
|
||||
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
|
||||
sa.ForeignKeyConstraint(['replaced_by_id'], ['refresh_tokens.id'], name=op.f('fk_refresh_tokens_replaced_by_id_refresh_tokens'), ondelete='SET NULL'),
|
||||
sa.ForeignKeyConstraint(['user_id'], ['users.id'], name=op.f('fk_refresh_tokens_user_id_users'), ondelete='CASCADE'),
|
||||
sa.PrimaryKeyConstraint('id', name=op.f('pk_refresh_tokens')),
|
||||
sa.UniqueConstraint('jti', name='uq_refresh_tokens_jti')
|
||||
)
|
||||
op.create_index('ix_refresh_tokens_user_id_expires_at', 'refresh_tokens', ['user_id', 'expires_at'], unique=False)
|
||||
op.create_table('scenario_template_tests',
|
||||
sa.Column('scenario_template_id', sa.Uuid(), nullable=False),
|
||||
sa.Column('test_template_id', sa.Uuid(), nullable=False),
|
||||
sa.Column('position', sa.Integer(), nullable=False),
|
||||
sa.Column('id', sa.Uuid(), nullable=False),
|
||||
sa.ForeignKeyConstraint(['scenario_template_id'], ['scenario_templates.id'], name=op.f('fk_scenario_template_tests_scenario_template_id_scenario_templates'), ondelete='CASCADE'),
|
||||
sa.ForeignKeyConstraint(['test_template_id'], ['test_templates.id'], name=op.f('fk_scenario_template_tests_test_template_id_test_templates'), ondelete='RESTRICT'),
|
||||
sa.PrimaryKeyConstraint('id', name=op.f('pk_scenario_template_tests')),
|
||||
sa.UniqueConstraint('scenario_template_id', 'position', name='uq_scenario_template_tests_position')
|
||||
)
|
||||
op.create_index('ix_scenario_template_tests_scenario', 'scenario_template_tests', ['scenario_template_id'], unique=False)
|
||||
op.create_index('ix_scenario_template_tests_test', 'scenario_template_tests', ['test_template_id'], unique=False)
|
||||
op.create_table('user_groups',
|
||||
sa.Column('user_id', sa.Uuid(), nullable=False),
|
||||
sa.Column('group_id', sa.Uuid(), nullable=False),
|
||||
sa.ForeignKeyConstraint(['group_id'], ['groups.id'], name=op.f('fk_user_groups_group_id_groups'), ondelete='CASCADE'),
|
||||
sa.ForeignKeyConstraint(['user_id'], ['users.id'], name=op.f('fk_user_groups_user_id_users'), ondelete='CASCADE'),
|
||||
sa.PrimaryKeyConstraint('user_id', 'group_id', name=op.f('pk_user_groups'))
|
||||
)
|
||||
op.create_table('invitation_groups',
|
||||
sa.Column('invitation_id', sa.Uuid(), nullable=False),
|
||||
sa.Column('group_id', sa.Uuid(), nullable=False),
|
||||
sa.ForeignKeyConstraint(['group_id'], ['groups.id'], name=op.f('fk_invitation_groups_group_id_groups'), ondelete='CASCADE'),
|
||||
sa.ForeignKeyConstraint(['invitation_id'], ['invitations.id'], name=op.f('fk_invitation_groups_invitation_id_invitations'), ondelete='CASCADE'),
|
||||
sa.PrimaryKeyConstraint('invitation_id', 'group_id', name=op.f('pk_invitation_groups'))
|
||||
)
|
||||
op.create_table('mission_tests',
|
||||
sa.Column('scenario_id', sa.Uuid(), nullable=False),
|
||||
sa.Column('source_test_template_id', sa.Uuid(), nullable=True),
|
||||
sa.Column('position', sa.Integer(), nullable=False),
|
||||
sa.Column('snapshot_name', sa.String(length=255), nullable=False),
|
||||
sa.Column('snapshot_description', sa.Text(), nullable=True),
|
||||
sa.Column('snapshot_objective', sa.Text(), nullable=True),
|
||||
sa.Column('snapshot_procedure_md', sa.Text(), nullable=True),
|
||||
sa.Column('snapshot_prerequisites_md', sa.Text(), nullable=True),
|
||||
sa.Column('snapshot_expected_red_md', sa.Text(), nullable=True),
|
||||
sa.Column('snapshot_expected_blue_md', sa.Text(), nullable=True),
|
||||
sa.Column('snapshot_opsec_level', sa.String(length=8), nullable=False),
|
||||
sa.Column('snapshot_tags', sa.ARRAY(sa.String(length=64)), server_default='{}', nullable=False),
|
||||
sa.Column('snapshot_expected_iocs', sa.ARRAY(sa.String(length=255)), server_default='{}', nullable=False),
|
||||
sa.Column('state', sa.String(length=24), nullable=False),
|
||||
sa.Column('executed_at', sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column('executed_at_overridden', sa.Boolean(), nullable=False),
|
||||
sa.Column('red_command', sa.Text(), nullable=True),
|
||||
sa.Column('red_output', sa.Text(), nullable=True),
|
||||
sa.Column('red_comment_md', sa.Text(), nullable=True),
|
||||
sa.Column('blue_comment_md', sa.Text(), nullable=True),
|
||||
sa.Column('detection_level_id', sa.Uuid(), nullable=True),
|
||||
sa.Column('category_id', sa.Uuid(), nullable=True),
|
||||
sa.Column('id', sa.Uuid(), nullable=False),
|
||||
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
|
||||
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
|
||||
sa.Column('deleted_at', sa.DateTime(timezone=True), nullable=True),
|
||||
sa.CheckConstraint("snapshot_opsec_level IN ('low', 'medium', 'high')", name=op.f('ck_mission_tests_snapshot_opsec_level_valid')),
|
||||
sa.CheckConstraint("state IN ('pending', 'executed', 'reviewed_by_blue', 'skipped', 'blocked')", name=op.f('ck_mission_tests_state_valid')),
|
||||
sa.ForeignKeyConstraint(['category_id'], ['mission_categories.id'], name=op.f('fk_mission_tests_category_id_mission_categories'), ondelete='SET NULL'),
|
||||
sa.ForeignKeyConstraint(['detection_level_id'], ['detection_levels.id'], name=op.f('fk_mission_tests_detection_level_id_detection_levels'), ondelete='SET NULL'),
|
||||
sa.ForeignKeyConstraint(['scenario_id'], ['mission_scenarios.id'], name=op.f('fk_mission_tests_scenario_id_mission_scenarios'), ondelete='CASCADE'),
|
||||
sa.ForeignKeyConstraint(['source_test_template_id'], ['test_templates.id'], name=op.f('fk_mission_tests_source_test_template_id_test_templates'), ondelete='SET NULL'),
|
||||
sa.PrimaryKeyConstraint('id', name=op.f('pk_mission_tests')),
|
||||
sa.UniqueConstraint('scenario_id', 'position', name='uq_mission_tests_position')
|
||||
)
|
||||
op.create_index('ix_mission_tests_active', 'mission_tests', ['deleted_at'], unique=False, postgresql_where='deleted_at IS NULL')
|
||||
op.create_index('ix_mission_tests_state', 'mission_tests', ['state'], unique=False)
|
||||
op.create_table('test_template_mitre_tags',
|
||||
sa.Column('id', sa.Uuid(), nullable=False),
|
||||
sa.Column('test_template_id', sa.Uuid(), nullable=False),
|
||||
sa.Column('mitre_kind', sa.String(length=16), nullable=False),
|
||||
sa.Column('tactic_id', sa.Uuid(), nullable=True),
|
||||
sa.Column('technique_id', sa.Uuid(), nullable=True),
|
||||
sa.Column('subtechnique_id', sa.Uuid(), nullable=True),
|
||||
sa.CheckConstraint("mitre_kind IN ('tactic', 'technique', 'subtechnique')", name=op.f('ck_test_template_mitre_tags_mitre_kind_valid')),
|
||||
sa.CheckConstraint('(CASE WHEN tactic_id IS NOT NULL THEN 1 ELSE 0 END) + (CASE WHEN technique_id IS NOT NULL THEN 1 ELSE 0 END) + (CASE WHEN subtechnique_id IS NOT NULL THEN 1 ELSE 0 END) = 1', name=op.f('ck_test_template_mitre_tags_exactly_one_mitre_fk')),
|
||||
sa.ForeignKeyConstraint(['subtechnique_id'], ['mitre_subtechniques.id'], name=op.f('fk_test_template_mitre_tags_subtechnique_id_mitre_subtechniques'), ondelete='CASCADE'),
|
||||
sa.ForeignKeyConstraint(['tactic_id'], ['mitre_tactics.id'], name=op.f('fk_test_template_mitre_tags_tactic_id_mitre_tactics'), ondelete='CASCADE'),
|
||||
sa.ForeignKeyConstraint(['technique_id'], ['mitre_techniques.id'], name=op.f('fk_test_template_mitre_tags_technique_id_mitre_techniques'), ondelete='CASCADE'),
|
||||
sa.ForeignKeyConstraint(['test_template_id'], ['test_templates.id'], name=op.f('fk_test_template_mitre_tags_test_template_id_test_templates'), ondelete='CASCADE'),
|
||||
sa.PrimaryKeyConstraint('id', name=op.f('pk_test_template_mitre_tags')),
|
||||
sa.UniqueConstraint('test_template_id', 'tactic_id', 'technique_id', 'subtechnique_id', name='uq_test_template_mitre_tag')
|
||||
)
|
||||
op.create_index('ix_test_template_mitre_tags_template', 'test_template_mitre_tags', ['test_template_id'], unique=False)
|
||||
op.create_table('evidence_files',
|
||||
sa.Column('mission_test_id', sa.Uuid(), nullable=False),
|
||||
sa.Column('sha256', sa.String(length=64), nullable=False),
|
||||
sa.Column('mime', sa.String(length=127), nullable=False),
|
||||
sa.Column('size_bytes', sa.BigInteger(), nullable=False),
|
||||
sa.Column('storage_path', sa.Text(), nullable=False),
|
||||
sa.Column('original_filename', sa.String(length=255), nullable=False),
|
||||
sa.Column('uploaded_by_user_id', sa.Uuid(), nullable=True),
|
||||
sa.Column('uploaded_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
|
||||
sa.Column('id', sa.Uuid(), nullable=False),
|
||||
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
|
||||
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
|
||||
sa.Column('deleted_at', sa.DateTime(timezone=True), nullable=True),
|
||||
sa.ForeignKeyConstraint(['mission_test_id'], ['mission_tests.id'], name=op.f('fk_evidence_files_mission_test_id_mission_tests'), ondelete='CASCADE'),
|
||||
sa.ForeignKeyConstraint(['uploaded_by_user_id'], ['users.id'], name=op.f('fk_evidence_files_uploaded_by_user_id_users'), ondelete='SET NULL'),
|
||||
sa.PrimaryKeyConstraint('id', name=op.f('pk_evidence_files'))
|
||||
)
|
||||
op.create_index('ix_evidence_files_active', 'evidence_files', ['deleted_at'], unique=False, postgresql_where='deleted_at IS NULL')
|
||||
op.create_index('ix_evidence_files_mission_test', 'evidence_files', ['mission_test_id'], unique=False)
|
||||
op.create_index('ix_evidence_files_sha256', 'evidence_files', ['sha256'], unique=False)
|
||||
op.create_table('mission_test_mitre_tags',
|
||||
sa.Column('id', sa.Uuid(), nullable=False),
|
||||
sa.Column('mission_test_id', sa.Uuid(), nullable=False),
|
||||
sa.Column('mitre_kind', sa.String(length=16), nullable=False),
|
||||
sa.Column('mitre_external_id', sa.String(length=16), nullable=False),
|
||||
sa.Column('mitre_name', sa.String(length=255), nullable=False),
|
||||
sa.Column('mitre_url', sa.String(length=512), nullable=True),
|
||||
sa.CheckConstraint("mitre_kind IN ('tactic', 'technique', 'subtechnique')", name=op.f('ck_mission_test_mitre_tags_mitre_kind_valid')),
|
||||
sa.ForeignKeyConstraint(['mission_test_id'], ['mission_tests.id'], name=op.f('fk_mission_test_mitre_tags_mission_test_id_mission_tests'), ondelete='CASCADE'),
|
||||
sa.PrimaryKeyConstraint('id', name=op.f('pk_mission_test_mitre_tags')),
|
||||
sa.UniqueConstraint('mission_test_id', 'mitre_external_id', name='uq_mission_test_mitre_tag')
|
||||
)
|
||||
op.create_index('ix_mission_test_mitre_tags_test', 'mission_test_mitre_tags', ['mission_test_id'], unique=False)
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.drop_index('ix_mission_test_mitre_tags_test', table_name='mission_test_mitre_tags')
|
||||
op.drop_table('mission_test_mitre_tags')
|
||||
op.drop_index('ix_evidence_files_sha256', table_name='evidence_files')
|
||||
op.drop_index('ix_evidence_files_mission_test', table_name='evidence_files')
|
||||
op.drop_index('ix_evidence_files_active', table_name='evidence_files', postgresql_where='deleted_at IS NULL')
|
||||
op.drop_table('evidence_files')
|
||||
op.drop_index('ix_test_template_mitre_tags_template', table_name='test_template_mitre_tags')
|
||||
op.drop_table('test_template_mitre_tags')
|
||||
op.drop_index('ix_mission_tests_state', table_name='mission_tests')
|
||||
op.drop_index('ix_mission_tests_active', table_name='mission_tests', postgresql_where='deleted_at IS NULL')
|
||||
op.drop_table('mission_tests')
|
||||
op.drop_table('invitation_groups')
|
||||
op.drop_table('user_groups')
|
||||
op.drop_index('ix_scenario_template_tests_test', table_name='scenario_template_tests')
|
||||
op.drop_index('ix_scenario_template_tests_scenario', table_name='scenario_template_tests')
|
||||
op.drop_table('scenario_template_tests')
|
||||
op.drop_index('ix_refresh_tokens_user_id_expires_at', table_name='refresh_tokens')
|
||||
op.drop_table('refresh_tokens')
|
||||
op.drop_index('ix_notifications_user_unread', table_name='notifications', postgresql_where='read_at IS NULL')
|
||||
op.drop_table('notifications')
|
||||
op.drop_table('mitre_technique_tactics')
|
||||
op.drop_index('ix_mitre_subtechniques_technique_id', table_name='mitre_subtechniques')
|
||||
op.drop_table('mitre_subtechniques')
|
||||
op.drop_index('ix_mission_scenarios_mission', table_name='mission_scenarios')
|
||||
op.drop_index('ix_mission_scenarios_active', table_name='mission_scenarios', postgresql_where='deleted_at IS NULL')
|
||||
op.drop_table('mission_scenarios')
|
||||
op.drop_index('ix_mission_members_user', table_name='mission_members')
|
||||
op.drop_table('mission_members')
|
||||
op.drop_index('ix_mission_categories_active', table_name='mission_categories', postgresql_where='deleted_at IS NULL')
|
||||
op.drop_table('mission_categories')
|
||||
op.drop_index('ix_invitations_expires_at', table_name='invitations')
|
||||
op.drop_table('invitations')
|
||||
op.drop_table('group_permissions')
|
||||
op.drop_index('uq_users_email_active', table_name='users', postgresql_where='deleted_at IS NULL')
|
||||
op.drop_index('ix_users_active', table_name='users', postgresql_where='deleted_at IS NULL')
|
||||
op.drop_table('users')
|
||||
op.drop_index('ix_test_templates_name', table_name='test_templates')
|
||||
op.drop_index('ix_test_templates_active', table_name='test_templates', postgresql_where='deleted_at IS NULL')
|
||||
op.drop_table('test_templates')
|
||||
op.drop_table('settings')
|
||||
op.drop_index('ix_scenario_templates_name', table_name='scenario_templates')
|
||||
op.drop_index('ix_scenario_templates_active', table_name='scenario_templates', postgresql_where='deleted_at IS NULL')
|
||||
op.drop_table('scenario_templates')
|
||||
op.drop_table('permissions')
|
||||
op.drop_index('ix_mitre_techniques_name', table_name='mitre_techniques')
|
||||
op.drop_table('mitre_techniques')
|
||||
op.drop_table('mitre_tactics')
|
||||
op.drop_index('ix_missions_status', table_name='missions')
|
||||
op.drop_index('ix_missions_active', table_name='missions', postgresql_where='deleted_at IS NULL')
|
||||
op.drop_table('missions')
|
||||
op.drop_index('uq_groups_name_active', table_name='groups', postgresql_where='deleted_at IS NULL')
|
||||
op.drop_index('ix_groups_active', table_name='groups', postgresql_where='deleted_at IS NULL')
|
||||
op.drop_table('groups')
|
||||
op.drop_table('detection_levels')
|
||||
# ### end Alembic commands ###
|
||||
3
backend/app/__init__.py
Normal file
3
backend/app/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
||||
"""Metamorph backend API package."""
|
||||
|
||||
__version__ = "0.1.0"
|
||||
0
backend/app/api/__init__.py
Normal file
0
backend/app/api/__init__.py
Normal file
30
backend/app/api/_validation.py
Normal file
30
backend/app/api/_validation.py
Normal file
@@ -0,0 +1,30 @@
|
||||
"""Lightweight email validator that tolerates internal/lab TLDs (.local, .corp, …).
|
||||
|
||||
`pydantic.EmailStr` relies on `email-validator` with `globally_deliverable=True`,
|
||||
which rejects RFC 6761 special-use domains. Red-team and corporate intranet
|
||||
deployments routinely use such suffixes — we accept any RFC-shape email and
|
||||
defer deliverability checks to the operator.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from typing import Annotated
|
||||
|
||||
from pydantic import AfterValidator
|
||||
|
||||
# Permissive RFC-shape pattern: local-part 1..64 chars, domain has at least one
|
||||
# dot, each label is 1..63 chars of letters/digits/hyphens, total ≤ 254.
|
||||
_EMAIL_RE = re.compile(
|
||||
r"^(?=.{1,254}$)[A-Za-z0-9._%+\-]{1,64}@[A-Za-z0-9](?:[A-Za-z0-9\-]{0,61}[A-Za-z0-9])?(?:\.[A-Za-z0-9](?:[A-Za-z0-9\-]{0,61}[A-Za-z0-9])?)+$"
|
||||
)
|
||||
|
||||
|
||||
def _validate_email(value: str) -> str:
|
||||
v = value.strip()
|
||||
if not _EMAIL_RE.match(v):
|
||||
raise ValueError("not a valid email address")
|
||||
return v.lower()
|
||||
|
||||
|
||||
Email = Annotated[str, AfterValidator(_validate_email)]
|
||||
157
backend/app/api/auth.py
Normal file
157
backend/app/api/auth.py
Normal file
@@ -0,0 +1,157 @@
|
||||
"""Authentication endpoints.
|
||||
|
||||
`POST /auth/login` returns the access token in the body and sets the refresh
|
||||
token in an HTTPOnly cookie scoped to `/api/v1/auth/`. The cookie is
|
||||
`Secure; SameSite=Strict` and only the matching paths can read it.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from flask import Blueprint, g, jsonify, make_response, request
|
||||
from pydantic import BaseModel, Field, ValidationError
|
||||
|
||||
from app.api._validation import Email
|
||||
from app.core.auth_decorators import require_auth
|
||||
from app.core.config import settings
|
||||
from app.core.rate_limit import limiter
|
||||
from app.services import auth as auth_svc
|
||||
|
||||
bp = Blueprint("auth", __name__, url_prefix="/auth")
|
||||
log = logging.getLogger("metamorph.api.auth")
|
||||
|
||||
REFRESH_COOKIE_NAME = "metamorph_refresh"
|
||||
REFRESH_COOKIE_PATH = "/api/v1/auth/"
|
||||
|
||||
|
||||
class LoginPayload(BaseModel):
|
||||
email: Email
|
||||
password: str = Field(min_length=1)
|
||||
|
||||
|
||||
class ChangePasswordPayload(BaseModel):
|
||||
current_password: str = Field(min_length=1)
|
||||
new_password: str = Field(min_length=8)
|
||||
|
||||
|
||||
def _set_refresh_cookie(resp, token: str, expires_at) -> None:
|
||||
resp.set_cookie(
|
||||
REFRESH_COOKIE_NAME,
|
||||
token,
|
||||
expires=expires_at,
|
||||
httponly=True,
|
||||
secure=True, # spec §M2; localhost is a secure context for modern browsers
|
||||
samesite="Strict",
|
||||
path=REFRESH_COOKIE_PATH,
|
||||
)
|
||||
|
||||
|
||||
def _clear_refresh_cookie(resp) -> None:
|
||||
resp.set_cookie(
|
||||
REFRESH_COOKIE_NAME,
|
||||
"",
|
||||
expires=0,
|
||||
httponly=True,
|
||||
secure=True, # spec §M2; localhost is a secure context for modern browsers
|
||||
samesite="Strict",
|
||||
path=REFRESH_COOKIE_PATH,
|
||||
)
|
||||
|
||||
|
||||
def _read_refresh_cookie() -> str | None:
|
||||
return request.cookies.get(REFRESH_COOKIE_NAME)
|
||||
|
||||
|
||||
def _serialize_pair(pair: auth_svc.TokenPair) -> dict:
|
||||
return {
|
||||
"access_token": pair.access_token,
|
||||
"token_type": "Bearer",
|
||||
"user_id": str(pair.user_id),
|
||||
}
|
||||
|
||||
|
||||
@bp.post("/login")
|
||||
@limiter.limit("10 per minute")
|
||||
def login():
|
||||
try:
|
||||
payload = LoginPayload.model_validate(request.get_json(silent=True) or {})
|
||||
except ValidationError as e:
|
||||
return jsonify({"error": "invalid_request", "details": e.errors()}), 400
|
||||
try:
|
||||
pair = auth_svc.login(payload.email, payload.password)
|
||||
except auth_svc.InvalidCredentials:
|
||||
return jsonify({"error": "invalid_credentials"}), 401
|
||||
|
||||
resp = make_response(jsonify(_serialize_pair(pair)))
|
||||
_set_refresh_cookie(resp, pair.refresh_token, pair.refresh_expires_at)
|
||||
return resp
|
||||
|
||||
|
||||
@bp.post("/refresh")
|
||||
@limiter.limit("10 per minute")
|
||||
def refresh_endpoint():
|
||||
raw = _read_refresh_cookie()
|
||||
if not raw:
|
||||
return jsonify({"error": "no_refresh_cookie"}), 401
|
||||
try:
|
||||
pair = auth_svc.refresh(raw)
|
||||
except auth_svc.TokenRevoked:
|
||||
resp = make_response(jsonify({"error": "token_revoked"}), 401)
|
||||
_clear_refresh_cookie(resp)
|
||||
return resp
|
||||
except auth_svc.InvalidCredentials:
|
||||
resp = make_response(jsonify({"error": "invalid_refresh"}), 401)
|
||||
_clear_refresh_cookie(resp)
|
||||
return resp
|
||||
|
||||
resp = make_response(jsonify(_serialize_pair(pair)))
|
||||
_set_refresh_cookie(resp, pair.refresh_token, pair.refresh_expires_at)
|
||||
return resp
|
||||
|
||||
|
||||
@bp.post("/logout")
|
||||
def logout():
|
||||
raw = _read_refresh_cookie()
|
||||
if raw:
|
||||
auth_svc.logout(raw)
|
||||
resp = make_response(jsonify({"ok": True}))
|
||||
_clear_refresh_cookie(resp)
|
||||
return resp
|
||||
|
||||
|
||||
@bp.get("/me")
|
||||
@require_auth
|
||||
def me():
|
||||
u = g.current_user
|
||||
return jsonify(
|
||||
{
|
||||
"id": str(u.id),
|
||||
"email": u.email,
|
||||
"display_name": u.display_name,
|
||||
"locale": u.locale,
|
||||
"is_admin": u.is_admin,
|
||||
"groups": sorted(u.group_names),
|
||||
"permissions": sorted(u.permissions),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@bp.post("/change-password")
|
||||
@require_auth
|
||||
@limiter.limit("5 per minute")
|
||||
def change_password():
|
||||
try:
|
||||
payload = ChangePasswordPayload.model_validate(request.get_json(silent=True) or {})
|
||||
except ValidationError as e:
|
||||
return jsonify({"error": "invalid_request", "details": e.errors()}), 400
|
||||
try:
|
||||
auth_svc.change_password(g.current_user.id, payload.current_password, payload.new_password)
|
||||
except auth_svc.InvalidCredentials:
|
||||
return jsonify({"error": "current_password_incorrect"}), 400
|
||||
except ValueError as e:
|
||||
return jsonify({"error": "weak_password", "message": str(e)}), 400
|
||||
|
||||
resp = make_response(jsonify({"ok": True}))
|
||||
_clear_refresh_cookie(resp)
|
||||
return resp
|
||||
93
backend/app/api/diag.py
Normal file
93
backend/app/api/diag.py
Normal file
@@ -0,0 +1,93 @@
|
||||
"""Operational diagnostics. No auth in v1 (M0/M1 only expose non-sensitive
|
||||
counts and the current Alembic revision).
|
||||
|
||||
The `/diag/reset` endpoint is **test-only** — it requires `APP_ENV=test` and
|
||||
is the bedrock of the e2e suite (clean DB + freshly minted install token).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from flask import Blueprint, abort, jsonify
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy.exc import SQLAlchemyError
|
||||
|
||||
from app.core.config import settings
|
||||
from app.core.install_token import regenerate_install_token
|
||||
from app.db.session import get_engine
|
||||
|
||||
bp = Blueprint("diag", __name__, url_prefix="/diag")
|
||||
log = logging.getLogger("metamorph.diag")
|
||||
|
||||
|
||||
@bp.get("/db")
|
||||
def db_diag():
|
||||
"""Return the Alembic revision and the count of public-schema tables."""
|
||||
try:
|
||||
with get_engine().connect() as conn:
|
||||
revision = conn.execute(
|
||||
text("SELECT version_num FROM alembic_version")
|
||||
).scalar()
|
||||
table_count = conn.execute(
|
||||
text(
|
||||
"SELECT count(*) FROM information_schema.tables "
|
||||
"WHERE table_schema='public' AND table_type='BASE TABLE'"
|
||||
)
|
||||
).scalar_one()
|
||||
except SQLAlchemyError as e:
|
||||
log.warning("metamorph.diag.db_unreachable", extra={"error": str(e)})
|
||||
return jsonify({"reachable": False, "error": "database_unreachable"}), 503
|
||||
|
||||
return jsonify(
|
||||
{
|
||||
"reachable": True,
|
||||
"alembic_revision": revision,
|
||||
"table_count": int(table_count),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@bp.post("/reset")
|
||||
def reset_test_state():
|
||||
"""TEST-ONLY: wipe users/auth tables and mint a fresh install token.
|
||||
|
||||
Refuses unless `APP_ENV=test`. Used by the Playwright suite to start each
|
||||
auth scenario from a deterministic state.
|
||||
"""
|
||||
# NOTE: this endpoint is the test-suite reset hook. Allowed in `dev` too so
|
||||
# the e2e suite can run against a normal `make up` stack, but in dev it is
|
||||
# destructive — equivalent to `make clean` for the auth tables. Production
|
||||
# (APP_ENV=prod/staging) is locked out.
|
||||
if settings.APP_ENV not in ("dev", "test"):
|
||||
abort(403, description="diag/reset is only available in dev/test")
|
||||
if settings.APP_ENV == "dev":
|
||||
log.warning("metamorph.diag.reset_in_dev_environment")
|
||||
|
||||
try:
|
||||
with get_engine().begin() as conn:
|
||||
conn.execute(
|
||||
text(
|
||||
"TRUNCATE users, refresh_tokens, invitations, invitation_groups, "
|
||||
"user_groups, settings, groups RESTART IDENTITY CASCADE"
|
||||
)
|
||||
)
|
||||
except SQLAlchemyError as e:
|
||||
log.error("metamorph.diag.reset_failed", extra={"error": str(e)})
|
||||
return jsonify({"reset": False, "error": "database_error"}), 500
|
||||
|
||||
token = regenerate_install_token()
|
||||
|
||||
# Clear the in-memory rate-limit counters so the e2e suite that follows can
|
||||
# log in repeatedly without hitting `/auth/login`/`/auth/refresh` limits.
|
||||
# The limiter uses `memory://` in dev (cf. `app/core/rate_limit.py`).
|
||||
try:
|
||||
from app.core.rate_limit import limiter # noqa: PLC0415 — avoid import cycle
|
||||
|
||||
if limiter.enabled:
|
||||
limiter.reset()
|
||||
except Exception as e: # noqa: BLE001
|
||||
log.warning("metamorph.diag.rate_limit_reset_failed", extra={"error": str(e)})
|
||||
|
||||
log.warning("metamorph.diag.reset_completed")
|
||||
return jsonify({"reset": True, "install_token": token})
|
||||
169
backend/app/api/groups.py
Normal file
169
backend/app/api/groups.py
Normal file
@@ -0,0 +1,169 @@
|
||||
"""Admin endpoints for groups + their permission bindings."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import uuid
|
||||
|
||||
from flask import Blueprint, jsonify, request
|
||||
from pydantic import BaseModel, Field, ValidationError
|
||||
|
||||
from app.core.auth_decorators import require_auth, require_perm
|
||||
from app.services import groups as groups_svc
|
||||
|
||||
bp = Blueprint("groups", __name__, url_prefix="/groups")
|
||||
log = logging.getLogger("metamorph.api.groups")
|
||||
|
||||
|
||||
def _serialize(g: groups_svc.GroupView) -> dict:
|
||||
return {
|
||||
"id": str(g.id),
|
||||
"name": g.name,
|
||||
"description": g.description,
|
||||
"is_system": g.is_system,
|
||||
"members_count": g.members_count,
|
||||
"permissions": g.permissions,
|
||||
"created_at": g.created_at.isoformat(),
|
||||
"updated_at": g.updated_at.isoformat(),
|
||||
}
|
||||
|
||||
|
||||
class CreateGroupPayload(BaseModel):
|
||||
name: str = Field(min_length=1, max_length=80)
|
||||
description: str | None = Field(default=None, max_length=2000)
|
||||
|
||||
model_config = {"extra": "forbid"}
|
||||
|
||||
|
||||
class UpdateGroupPayload(BaseModel):
|
||||
name: str | None = Field(default=None, min_length=1, max_length=80)
|
||||
description: str | None = Field(default=None, max_length=2000)
|
||||
|
||||
model_config = {"extra": "forbid"}
|
||||
|
||||
|
||||
class SetPermissionsPayload(BaseModel):
|
||||
codes: list[str] = Field(default_factory=list)
|
||||
|
||||
model_config = {"extra": "forbid"}
|
||||
|
||||
|
||||
def _parse_uuid_or_400(raw: str):
|
||||
try:
|
||||
return uuid.UUID(raw)
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
|
||||
@bp.get("")
|
||||
@require_auth
|
||||
@require_perm("group.read")
|
||||
def list_groups():
|
||||
rows = groups_svc.list_groups()
|
||||
return jsonify({"items": [_serialize(g) for g in rows], "total": len(rows)})
|
||||
|
||||
|
||||
@bp.get("/<group_id>")
|
||||
@require_auth
|
||||
@require_perm("group.read")
|
||||
def get_group(group_id: str):
|
||||
gid = _parse_uuid_or_400(group_id)
|
||||
if gid is None:
|
||||
return jsonify({"error": "invalid_id"}), 400
|
||||
try:
|
||||
g = groups_svc.get_group(gid)
|
||||
except groups_svc.GroupNotFound:
|
||||
return jsonify({"error": "not_found"}), 404
|
||||
return jsonify(_serialize(g))
|
||||
|
||||
|
||||
@bp.post("")
|
||||
@require_auth
|
||||
@require_perm("group.create")
|
||||
def create_group():
|
||||
try:
|
||||
payload = CreateGroupPayload.model_validate(request.get_json(silent=True) or {})
|
||||
except ValidationError as e:
|
||||
return jsonify({"error": "invalid_request", "details": e.errors()}), 400
|
||||
try:
|
||||
g = groups_svc.create_group(name=payload.name, description=payload.description)
|
||||
except groups_svc.GroupNameConflict as e:
|
||||
return jsonify({"error": "name_conflict", "message": str(e)}), 409
|
||||
except ValueError as e:
|
||||
return jsonify({"error": "invalid_request", "message": str(e)}), 400
|
||||
log.info("metamorph.group.created", extra={"group_id": str(g.id), "group_name": g.name})
|
||||
return jsonify(_serialize(g)), 201
|
||||
|
||||
|
||||
@bp.patch("/<group_id>")
|
||||
@require_auth
|
||||
@require_perm("group.update")
|
||||
def update_group(group_id: str):
|
||||
gid = _parse_uuid_or_400(group_id)
|
||||
if gid is None:
|
||||
return jsonify({"error": "invalid_id"}), 400
|
||||
raw = request.get_json(silent=True) or {}
|
||||
try:
|
||||
payload = UpdateGroupPayload.model_validate(raw)
|
||||
except ValidationError as e:
|
||||
return jsonify({"error": "invalid_request", "details": e.errors()}), 400
|
||||
description_unset = "description" not in raw
|
||||
try:
|
||||
g = groups_svc.update_group(
|
||||
gid,
|
||||
name=payload.name,
|
||||
description=... if description_unset else payload.description,
|
||||
)
|
||||
except groups_svc.GroupNotFound:
|
||||
return jsonify({"error": "not_found"}), 404
|
||||
except groups_svc.SystemGroupProtected as e:
|
||||
return jsonify({"error": "system_group_protected", "message": str(e)}), 409
|
||||
except groups_svc.GroupNameConflict as e:
|
||||
return jsonify({"error": "name_conflict", "message": str(e)}), 409
|
||||
except ValueError as e:
|
||||
return jsonify({"error": "invalid_request", "message": str(e)}), 400
|
||||
log.info("metamorph.group.updated", extra={"group_id": str(gid), "fields": sorted(raw.keys())})
|
||||
return jsonify(_serialize(g))
|
||||
|
||||
|
||||
@bp.delete("/<group_id>")
|
||||
@require_auth
|
||||
@require_perm("group.delete")
|
||||
def soft_delete(group_id: str):
|
||||
gid = _parse_uuid_or_400(group_id)
|
||||
if gid is None:
|
||||
return jsonify({"error": "invalid_id"}), 400
|
||||
try:
|
||||
groups_svc.soft_delete_group(gid)
|
||||
except groups_svc.GroupNotFound:
|
||||
return jsonify({"error": "not_found"}), 404
|
||||
except groups_svc.SystemGroupProtected as e:
|
||||
return jsonify({"error": "system_group_protected", "message": str(e)}), 409
|
||||
log.info("metamorph.group.soft_deleted", extra={"group_id": str(gid)})
|
||||
return jsonify({"ok": True})
|
||||
|
||||
|
||||
@bp.put("/<group_id>/permissions")
|
||||
@require_auth
|
||||
@require_perm("group.update")
|
||||
def set_permissions(group_id: str):
|
||||
gid = _parse_uuid_or_400(group_id)
|
||||
if gid is None:
|
||||
return jsonify({"error": "invalid_id"}), 400
|
||||
try:
|
||||
payload = SetPermissionsPayload.model_validate(request.get_json(silent=True) or {})
|
||||
except ValidationError as e:
|
||||
return jsonify({"error": "invalid_request", "details": e.errors()}), 400
|
||||
try:
|
||||
g = groups_svc.set_group_permissions(gid, payload.codes)
|
||||
except groups_svc.GroupNotFound:
|
||||
return jsonify({"error": "not_found"}), 404
|
||||
except groups_svc.SystemGroupProtected as e:
|
||||
return jsonify({"error": "system_group_protected", "message": str(e)}), 409
|
||||
except ValueError as e:
|
||||
return jsonify({"error": "invalid_request", "message": str(e)}), 400
|
||||
log.info(
|
||||
"metamorph.group.permissions_set",
|
||||
extra={"group_id": str(gid), "count": len(payload.codes)},
|
||||
)
|
||||
return jsonify(_serialize(g))
|
||||
14
backend/app/api/health.py
Normal file
14
backend/app/api/health.py
Normal file
@@ -0,0 +1,14 @@
|
||||
"""Health endpoint — no DB dependency, used by orchestrators and the SPA."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from flask import Blueprint, jsonify
|
||||
|
||||
from app import __version__
|
||||
|
||||
bp = Blueprint("health", __name__)
|
||||
|
||||
|
||||
@bp.get("/health")
|
||||
def health():
|
||||
return jsonify({"status": "ok", "version": __version__})
|
||||
146
backend/app/api/invitations.py
Normal file
146
backend/app/api/invitations.py
Normal file
@@ -0,0 +1,146 @@
|
||||
"""Invitation endpoints — admin issues, invitee previews + accepts."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import uuid
|
||||
|
||||
from flask import Blueprint, g, jsonify, make_response, request
|
||||
from pydantic import BaseModel, Field, ValidationError
|
||||
|
||||
from app.api._validation import Email
|
||||
from app.core.auth_decorators import require_auth, require_perm
|
||||
from app.core.rate_limit import limiter
|
||||
from app.services import invitations as inv_svc
|
||||
|
||||
bp = Blueprint("invitations", __name__, url_prefix="/invitations")
|
||||
log = logging.getLogger("metamorph.api.invitations")
|
||||
|
||||
|
||||
class CreateInvitationPayload(BaseModel):
|
||||
email_hint: Email | None = None
|
||||
group_ids: list[uuid.UUID] = Field(default_factory=list)
|
||||
ttl_days: int | None = Field(default=None, ge=1, le=30)
|
||||
|
||||
|
||||
class AcceptInvitationPayload(BaseModel):
|
||||
email: Email
|
||||
password: str = Field(min_length=8)
|
||||
display_name: str | None = None
|
||||
|
||||
|
||||
@bp.post("")
|
||||
@require_auth
|
||||
@require_perm("invitation.create")
|
||||
def create():
|
||||
try:
|
||||
payload = CreateInvitationPayload.model_validate(request.get_json(silent=True) or {})
|
||||
except ValidationError as e:
|
||||
return jsonify({"error": "invalid_request", "details": e.errors()}), 400
|
||||
|
||||
from datetime import timedelta
|
||||
|
||||
ttl = (
|
||||
timedelta(days=payload.ttl_days)
|
||||
if payload.ttl_days is not None
|
||||
else inv_svc.INVITATION_TTL
|
||||
)
|
||||
result = inv_svc.create_invitation(
|
||||
created_by_user_id=g.current_user.id,
|
||||
email_hint=payload.email_hint,
|
||||
group_ids=payload.group_ids,
|
||||
ttl=ttl,
|
||||
)
|
||||
log.info(
|
||||
"metamorph.invitation.created",
|
||||
extra={
|
||||
"invitation_id": str(result.invitation_id),
|
||||
"by_user_id": str(g.current_user.id),
|
||||
"expires_at": result.expires_at.isoformat(),
|
||||
},
|
||||
)
|
||||
return make_response(
|
||||
jsonify(
|
||||
{
|
||||
"id": str(result.invitation_id),
|
||||
"token": result.raw_token, # shown ONCE
|
||||
"expires_at": result.expires_at.isoformat(),
|
||||
}
|
||||
),
|
||||
201,
|
||||
)
|
||||
|
||||
|
||||
@bp.get("")
|
||||
@require_auth
|
||||
@require_perm("invitation.read")
|
||||
def list_active():
|
||||
rows = inv_svc.list_active()
|
||||
return jsonify(
|
||||
[
|
||||
{
|
||||
"id": str(r.id),
|
||||
"email_hint": r.email_hint,
|
||||
"expires_at": r.expires_at.isoformat(),
|
||||
"groups": [g.name for g in r.pre_assigned_groups],
|
||||
}
|
||||
for r in rows
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
@bp.post("/<invitation_id>/revoke")
|
||||
@require_auth
|
||||
@require_perm("invitation.revoke")
|
||||
def revoke(invitation_id: str):
|
||||
try:
|
||||
iid = uuid.UUID(invitation_id)
|
||||
except ValueError:
|
||||
return jsonify({"error": "invalid_id"}), 400
|
||||
ok = inv_svc.revoke(iid)
|
||||
if not ok:
|
||||
return jsonify({"error": "not_revocable"}), 404
|
||||
return jsonify({"ok": True})
|
||||
|
||||
|
||||
@bp.get("/preview/<token>")
|
||||
@limiter.limit("20 per minute")
|
||||
def preview(token: str):
|
||||
p = inv_svc.preview(token)
|
||||
return jsonify(
|
||||
{
|
||||
"is_valid": p.is_valid,
|
||||
"reason": p.reason,
|
||||
"email_hint": p.email_hint,
|
||||
"expires_at": p.expires_at.isoformat(),
|
||||
"groups": p.groups,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@bp.post("/accept/<token>")
|
||||
@limiter.limit("10 per minute")
|
||||
def accept(token: str):
|
||||
try:
|
||||
payload = AcceptInvitationPayload.model_validate(request.get_json(silent=True) or {})
|
||||
except ValidationError as e:
|
||||
return jsonify({"error": "invalid_request", "details": e.errors()}), 400
|
||||
try:
|
||||
user_id = inv_svc.accept(
|
||||
token,
|
||||
email=payload.email,
|
||||
password=payload.password,
|
||||
display_name=payload.display_name,
|
||||
)
|
||||
except inv_svc.InvitationExpired:
|
||||
return jsonify({"error": "invitation_expired"}), 410
|
||||
except inv_svc.InvitationConsumed:
|
||||
return jsonify({"error": "invitation_consumed"}), 410
|
||||
except inv_svc.InvitationRevoked:
|
||||
return jsonify({"error": "invitation_revoked"}), 410
|
||||
except inv_svc.InvitationError as e:
|
||||
return jsonify({"error": "invitation_invalid", "message": str(e)}), 400
|
||||
except ValueError as e:
|
||||
return jsonify({"error": "invalid_request", "message": str(e)}), 400
|
||||
|
||||
return make_response(jsonify({"ok": True, "user_id": str(user_id)}), 201)
|
||||
17
backend/app/api/permissions.py
Normal file
17
backend/app/api/permissions.py
Normal file
@@ -0,0 +1,17 @@
|
||||
"""Read-only catalogue of platform permission codes."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from flask import Blueprint, jsonify
|
||||
|
||||
from app.core.auth_decorators import require_auth, require_perm
|
||||
from app.services import groups as groups_svc
|
||||
|
||||
bp = Blueprint("permissions", __name__, url_prefix="/permissions")
|
||||
|
||||
|
||||
@bp.get("")
|
||||
@require_auth
|
||||
@require_perm("group.read")
|
||||
def list_permissions():
|
||||
return jsonify({"items": groups_svc.list_permissions()})
|
||||
79
backend/app/api/setup.py
Normal file
79
backend/app/api/setup.py
Normal file
@@ -0,0 +1,79 @@
|
||||
"""Bootstrap endpoint — consumes the install token to create the first admin."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from flask import Blueprint, jsonify, make_response, request
|
||||
from pydantic import BaseModel, Field, ValidationError
|
||||
|
||||
from app.api._validation import Email
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.core.rate_limit import limiter
|
||||
from app.db.session import session_scope
|
||||
from app.models.auth import User
|
||||
from app.services.bootstrap import (
|
||||
BootstrapError,
|
||||
bootstrap_admin,
|
||||
ensure_system_groups,
|
||||
)
|
||||
|
||||
bp = Blueprint("setup", __name__, url_prefix="/setup")
|
||||
log = logging.getLogger("metamorph.api.setup")
|
||||
|
||||
|
||||
class SetupPayload(BaseModel):
|
||||
install_token: str = Field(min_length=20)
|
||||
email: Email
|
||||
password: str = Field(min_length=8)
|
||||
display_name: str | None = None
|
||||
|
||||
|
||||
@bp.get("")
|
||||
def setup_status():
|
||||
"""Tell the SPA whether the bootstrap has already been done.
|
||||
|
||||
Used by the front to redirect to /setup vs /login on first paint.
|
||||
"""
|
||||
with session_scope() as s:
|
||||
any_user = s.scalar(select(User.id).limit(1)) is not None
|
||||
return jsonify({"completed": any_user})
|
||||
|
||||
|
||||
@bp.post("")
|
||||
@limiter.limit("5 per minute")
|
||||
def setup():
|
||||
try:
|
||||
payload = SetupPayload.model_validate(request.get_json(silent=True) or {})
|
||||
except ValidationError as e:
|
||||
return jsonify({"error": "invalid_request", "details": e.errors()}), 400
|
||||
|
||||
try:
|
||||
result = bootstrap_admin(
|
||||
install_token=payload.install_token,
|
||||
email=payload.email,
|
||||
password=payload.password,
|
||||
display_name=payload.display_name,
|
||||
)
|
||||
except BootstrapError as e:
|
||||
return jsonify({"error": "bootstrap_failed", "message": str(e)}), 409
|
||||
except ValueError as e:
|
||||
return jsonify({"error": "invalid_request", "message": str(e)}), 400
|
||||
|
||||
log.warning(
|
||||
"metamorph.bootstrap.completed",
|
||||
extra={"user_id": str(result.user_id), "admin_group_id": str(result.admin_group_id)},
|
||||
)
|
||||
# Make sure the redteam/blueteam groups exist too (idempotent).
|
||||
ensure_system_groups()
|
||||
|
||||
return make_response(
|
||||
jsonify(
|
||||
{
|
||||
"ok": True,
|
||||
"user_id": str(result.user_id),
|
||||
}
|
||||
),
|
||||
201,
|
||||
)
|
||||
185
backend/app/api/users.py
Normal file
185
backend/app/api/users.py
Normal file
@@ -0,0 +1,185 @@
|
||||
"""Admin endpoints for user management.
|
||||
|
||||
Note: self-service updates (own display name, locale, password) belong to
|
||||
`/auth/*`; this blueprint is admin-only.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import uuid
|
||||
|
||||
from flask import Blueprint, jsonify, request
|
||||
from pydantic import BaseModel, Field, ValidationError
|
||||
|
||||
from app.core.auth_decorators import require_auth, require_perm
|
||||
from app.services import users as users_svc
|
||||
|
||||
bp = Blueprint("users", __name__, url_prefix="/users")
|
||||
log = logging.getLogger("metamorph.api.users")
|
||||
|
||||
|
||||
def _serialize(u: users_svc.UserView) -> dict:
|
||||
return {
|
||||
"id": str(u.id),
|
||||
"email": u.email,
|
||||
"display_name": u.display_name,
|
||||
"locale": u.locale,
|
||||
"is_active": u.is_active,
|
||||
"deleted_at": u.deleted_at.isoformat() if u.deleted_at else None,
|
||||
"created_at": u.created_at.isoformat(),
|
||||
"updated_at": u.updated_at.isoformat(),
|
||||
"groups": [{"id": str(gid), "name": name} for gid, name in u.groups],
|
||||
}
|
||||
|
||||
|
||||
class UpdateUserPayload(BaseModel):
|
||||
# display_name: omitted = no change, null = clear, str = set.
|
||||
# Tri-state encoded with a `default-unset` sentinel via model_extra.
|
||||
display_name: str | None = None
|
||||
locale: str | None = Field(default=None, pattern=r"^[a-z]{2}$")
|
||||
is_active: bool | None = None
|
||||
|
||||
model_config = {"extra": "forbid"}
|
||||
|
||||
|
||||
class SetGroupsPayload(BaseModel):
|
||||
group_ids: list[uuid.UUID]
|
||||
|
||||
model_config = {"extra": "forbid"}
|
||||
|
||||
|
||||
def _parse_uuid_or_400(raw: str):
|
||||
try:
|
||||
return uuid.UUID(raw)
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
|
||||
@bp.get("")
|
||||
@require_auth
|
||||
@require_perm("user.read")
|
||||
def list_users():
|
||||
q = request.args.get("q") or None
|
||||
is_active_raw = request.args.get("is_active")
|
||||
is_active: bool | None
|
||||
if is_active_raw is None:
|
||||
is_active = None
|
||||
elif is_active_raw.lower() in ("true", "1", "yes"):
|
||||
is_active = True
|
||||
elif is_active_raw.lower() in ("false", "0", "no"):
|
||||
is_active = False
|
||||
else:
|
||||
return jsonify({"error": "invalid_is_active"}), 400
|
||||
|
||||
try:
|
||||
limit = int(request.args.get("limit", "50"))
|
||||
offset = int(request.args.get("offset", "0"))
|
||||
except ValueError:
|
||||
return jsonify({"error": "invalid_pagination"}), 400
|
||||
limit = max(1, min(limit, 200))
|
||||
offset = max(0, offset)
|
||||
|
||||
rows, total = users_svc.list_users(q=q, is_active=is_active, limit=limit, offset=offset)
|
||||
return jsonify(
|
||||
{
|
||||
"items": [_serialize(u) for u in rows],
|
||||
"total": total,
|
||||
"limit": limit,
|
||||
"offset": offset,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@bp.get("/<user_id>")
|
||||
@require_auth
|
||||
@require_perm("user.read")
|
||||
def get_user(user_id: str):
|
||||
uid = _parse_uuid_or_400(user_id)
|
||||
if uid is None:
|
||||
return jsonify({"error": "invalid_id"}), 400
|
||||
try:
|
||||
u = users_svc.get_user(uid)
|
||||
except users_svc.UserNotFound:
|
||||
return jsonify({"error": "not_found"}), 404
|
||||
return jsonify(_serialize(u))
|
||||
|
||||
|
||||
@bp.patch("/<user_id>")
|
||||
@require_auth
|
||||
@require_perm("user.update")
|
||||
def update_user(user_id: str):
|
||||
uid = _parse_uuid_or_400(user_id)
|
||||
if uid is None:
|
||||
return jsonify({"error": "invalid_id"}), 400
|
||||
raw = request.get_json(silent=True) or {}
|
||||
try:
|
||||
payload = UpdateUserPayload.model_validate(raw)
|
||||
except ValidationError as e:
|
||||
return jsonify({"error": "invalid_request", "details": e.errors()}), 400
|
||||
|
||||
# Distinguish "key absent" (no change) from "key=null" (clear) for display_name.
|
||||
display_name_unset = "display_name" not in raw
|
||||
|
||||
try:
|
||||
u = users_svc.update_user(
|
||||
uid,
|
||||
display_name=... if display_name_unset else payload.display_name,
|
||||
locale=payload.locale,
|
||||
is_active=payload.is_active,
|
||||
)
|
||||
except users_svc.UserNotFound:
|
||||
return jsonify({"error": "not_found"}), 404
|
||||
except users_svc.LastAdminProtected as e:
|
||||
return jsonify({"error": "last_admin_protected", "message": str(e)}), 409
|
||||
log.info(
|
||||
"metamorph.user.updated",
|
||||
extra={
|
||||
"user_id": str(uid),
|
||||
"fields": sorted(raw.keys()),
|
||||
},
|
||||
)
|
||||
return jsonify(_serialize(u))
|
||||
|
||||
|
||||
@bp.delete("/<user_id>")
|
||||
@require_auth
|
||||
@require_perm("user.delete")
|
||||
def soft_delete(user_id: str):
|
||||
uid = _parse_uuid_or_400(user_id)
|
||||
if uid is None:
|
||||
return jsonify({"error": "invalid_id"}), 400
|
||||
try:
|
||||
users_svc.soft_delete_user(uid)
|
||||
except users_svc.UserNotFound:
|
||||
return jsonify({"error": "not_found"}), 404
|
||||
except users_svc.LastAdminProtected as e:
|
||||
return jsonify({"error": "last_admin_protected", "message": str(e)}), 409
|
||||
log.info("metamorph.user.soft_deleted", extra={"user_id": str(uid)})
|
||||
return jsonify({"ok": True})
|
||||
|
||||
|
||||
@bp.put("/<user_id>/groups")
|
||||
@require_auth
|
||||
@require_perm("user.update")
|
||||
def set_groups(user_id: str):
|
||||
uid = _parse_uuid_or_400(user_id)
|
||||
if uid is None:
|
||||
return jsonify({"error": "invalid_id"}), 400
|
||||
try:
|
||||
payload = SetGroupsPayload.model_validate(request.get_json(silent=True) or {})
|
||||
except ValidationError as e:
|
||||
return jsonify({"error": "invalid_request", "details": e.errors()}), 400
|
||||
try:
|
||||
u = users_svc.set_user_groups(uid, payload.group_ids)
|
||||
except users_svc.UserNotFound:
|
||||
return jsonify({"error": "not_found"}), 404
|
||||
except users_svc.LastAdminProtected as e:
|
||||
return jsonify({"error": "last_admin_protected", "message": str(e)}), 409
|
||||
except ValueError as e:
|
||||
return jsonify({"error": "invalid_request", "message": str(e)}), 400
|
||||
log.info(
|
||||
"metamorph.user.groups_set",
|
||||
extra={"user_id": str(uid), "groups": [str(g) for g in payload.group_ids]},
|
||||
)
|
||||
return jsonify(_serialize(u))
|
||||
24
backend/app/api/v1.py
Normal file
24
backend/app/api/v1.py
Normal file
@@ -0,0 +1,24 @@
|
||||
"""Aggregate v1 blueprint. Future blueprints (missions, ...) register here."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from flask import Blueprint
|
||||
|
||||
from app.api.auth import bp as auth_bp
|
||||
from app.api.diag import bp as diag_bp
|
||||
from app.api.groups import bp as groups_bp
|
||||
from app.api.health import bp as health_bp
|
||||
from app.api.invitations import bp as invitations_bp
|
||||
from app.api.permissions import bp as permissions_bp
|
||||
from app.api.setup import bp as setup_bp
|
||||
from app.api.users import bp as users_bp
|
||||
|
||||
bp = Blueprint("v1", __name__, url_prefix="/api/v1")
|
||||
bp.register_blueprint(health_bp)
|
||||
bp.register_blueprint(diag_bp)
|
||||
bp.register_blueprint(setup_bp)
|
||||
bp.register_blueprint(auth_bp)
|
||||
bp.register_blueprint(invitations_bp)
|
||||
bp.register_blueprint(users_bp)
|
||||
bp.register_blueprint(groups_bp)
|
||||
bp.register_blueprint(permissions_bp)
|
||||
65
backend/app/cli.py
Normal file
65
backend/app/cli.py
Normal file
@@ -0,0 +1,65 @@
|
||||
"""Flask CLI entry point.
|
||||
|
||||
Used as `flask --app app.cli metamorph <subcommand>` (or via the make targets).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
|
||||
import click
|
||||
from flask import Flask
|
||||
from flask.cli import AppGroup
|
||||
|
||||
from app.core.install_token import (
|
||||
ensure_install_token,
|
||||
log_install_token_banner,
|
||||
regenerate_install_token,
|
||||
)
|
||||
from app.core.logging import configure_logging
|
||||
from app.services.bootstrap import ensure_system_groups
|
||||
from app.core.config import settings
|
||||
|
||||
|
||||
def _create_cli_app() -> Flask:
|
||||
configure_logging(settings.LOG_LEVEL)
|
||||
return Flask("metamorph-cli")
|
||||
|
||||
|
||||
app = _create_cli_app()
|
||||
metamorph = AppGroup("metamorph", help="Metamorph admin commands.")
|
||||
|
||||
|
||||
@metamorph.command("print-install-token")
|
||||
@click.option(
|
||||
"--force",
|
||||
is_flag=True,
|
||||
help="Always mint a fresh token even if one is already pending.",
|
||||
)
|
||||
def print_install_token(force: bool):
|
||||
"""Mint and print the bootstrap install token (idempotent unless --force)."""
|
||||
ensure_system_groups()
|
||||
if force:
|
||||
token = regenerate_install_token()
|
||||
else:
|
||||
token = ensure_install_token()
|
||||
|
||||
if token is None:
|
||||
click.echo(
|
||||
"No install token minted: either at least one user already exists, "
|
||||
"or a token is already pending (use --force to mint a fresh one).",
|
||||
err=True,
|
||||
)
|
||||
sys.exit(1)
|
||||
|
||||
log_install_token_banner(token)
|
||||
|
||||
|
||||
@metamorph.command("seed-mitre")
|
||||
def seed_mitre():
|
||||
"""Placeholder for M4 — left so `make seed-mitre` doesn't crash."""
|
||||
click.echo("MITRE seeding will land in M4. (no-op for now)", err=True)
|
||||
sys.exit(0)
|
||||
|
||||
|
||||
app.cli.add_command(metamorph)
|
||||
0
backend/app/core/__init__.py
Normal file
0
backend/app/core/__init__.py
Normal file
139
backend/app/core/auth_decorators.py
Normal file
139
backend/app/core/auth_decorators.py
Normal file
@@ -0,0 +1,139 @@
|
||||
"""Flask decorators for authentication + authorization.
|
||||
|
||||
Usage:
|
||||
@bp.get("/whatever")
|
||||
@require_auth # populates g.current_user
|
||||
def whatever():
|
||||
return jsonify(...)
|
||||
|
||||
@bp.post("/admin/users")
|
||||
@require_auth
|
||||
@require_perm("user.create") # checks the user's effective perms
|
||||
def create_user():
|
||||
...
|
||||
|
||||
`g.current_user` is a small `AuthenticatedUser` snapshot — no live ORM session.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import uuid
|
||||
from dataclasses import dataclass, field
|
||||
from functools import wraps
|
||||
from typing import Callable
|
||||
|
||||
import jwt
|
||||
from flask import abort, g, request
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.core.jwt_tokens import decode_token
|
||||
from app.db.session import session_scope
|
||||
from app.models.auth import Permission, User
|
||||
from app.services.bootstrap import ADMIN_GROUP_NAME
|
||||
|
||||
log = logging.getLogger("metamorph.auth")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class AuthenticatedUser:
|
||||
id: uuid.UUID
|
||||
email: str
|
||||
locale: str
|
||||
display_name: str | None
|
||||
is_admin: bool
|
||||
permissions: frozenset[str] = field(default_factory=frozenset)
|
||||
group_names: frozenset[str] = field(default_factory=frozenset)
|
||||
|
||||
|
||||
def _load_authenticated_user(user_id: uuid.UUID) -> AuthenticatedUser | None:
|
||||
with session_scope() as s:
|
||||
user = s.get(User, user_id)
|
||||
if user is None or user.deleted_at is not None or not user.is_active:
|
||||
return None
|
||||
group_names: set[str] = set()
|
||||
permissions: set[str] = set()
|
||||
for grp in user.groups:
|
||||
if grp.deleted_at is not None:
|
||||
continue
|
||||
group_names.add(grp.name)
|
||||
for perm in grp.permissions:
|
||||
permissions.add(perm.code)
|
||||
return AuthenticatedUser(
|
||||
id=user.id,
|
||||
email=user.email,
|
||||
locale=user.locale,
|
||||
display_name=user.display_name,
|
||||
is_admin=ADMIN_GROUP_NAME in group_names,
|
||||
permissions=frozenset(permissions),
|
||||
group_names=frozenset(group_names),
|
||||
)
|
||||
|
||||
|
||||
def _extract_bearer() -> str | None:
|
||||
raw = request.headers.get("Authorization", "")
|
||||
if not raw.lower().startswith("bearer "):
|
||||
return None
|
||||
return raw[7:].strip() or None
|
||||
|
||||
|
||||
def require_auth(fn: Callable):
|
||||
@wraps(fn)
|
||||
def wrapper(*args, **kwargs):
|
||||
token = _extract_bearer()
|
||||
if token is None:
|
||||
abort(401, description="missing bearer token")
|
||||
try:
|
||||
claims = decode_token(token, expected_type="access")
|
||||
except jwt.ExpiredSignatureError:
|
||||
abort(401, description="access token expired")
|
||||
except jwt.PyJWTError:
|
||||
abort(401, description="invalid access token")
|
||||
try:
|
||||
user_id = uuid.UUID(claims.sub)
|
||||
except ValueError:
|
||||
abort(401, description="malformed subject")
|
||||
snapshot = _load_authenticated_user(user_id)
|
||||
if snapshot is None:
|
||||
abort(401, description="user no longer active")
|
||||
g.current_user = snapshot
|
||||
return fn(*args, **kwargs)
|
||||
|
||||
return wrapper
|
||||
|
||||
|
||||
def require_perm(*codes: str):
|
||||
"""Require any one of the listed permission codes.
|
||||
|
||||
Members of the system `admin` group bypass the check.
|
||||
"""
|
||||
|
||||
def decorator(fn: Callable):
|
||||
@wraps(fn)
|
||||
def wrapper(*args, **kwargs):
|
||||
user: AuthenticatedUser | None = getattr(g, "current_user", None)
|
||||
if user is None:
|
||||
abort(401, description="not authenticated")
|
||||
if user.is_admin:
|
||||
return fn(*args, **kwargs)
|
||||
if not any(code in user.permissions for code in codes):
|
||||
log.info(
|
||||
"metamorph.auth.permission_denied",
|
||||
extra={
|
||||
"user_id": str(user.id),
|
||||
"required": list(codes),
|
||||
"had": sorted(user.permissions),
|
||||
},
|
||||
)
|
||||
abort(403, description="insufficient permissions")
|
||||
return fn(*args, **kwargs)
|
||||
|
||||
return wrapper
|
||||
|
||||
return decorator
|
||||
|
||||
|
||||
def fetch_all_permissions() -> list[str]:
|
||||
"""Utility for debugging / admin UI: list every known permission code."""
|
||||
with session_scope() as s:
|
||||
return list(s.scalars(select(Permission.code).order_by(Permission.code)).all())
|
||||
76
backend/app/core/config.py
Normal file
76
backend/app/core/config.py
Normal file
@@ -0,0 +1,76 @@
|
||||
"""Runtime configuration loaded from environment variables."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Literal
|
||||
|
||||
from pydantic import Field, model_validator
|
||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||
|
||||
# Sentinel values that .env.example ships with. If the runtime is configured
|
||||
# in a non-dev environment with one of these still in place, we refuse to boot.
|
||||
_DEV_JWT_SECRET = "change-me-to-a-long-random-string"
|
||||
_DEV_DB_PASSWORD = "change-me-strong"
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
model_config = SettingsConfigDict(
|
||||
env_file=".env",
|
||||
env_file_encoding="utf-8",
|
||||
case_sensitive=True,
|
||||
extra="ignore",
|
||||
)
|
||||
|
||||
# === Runtime mode ===
|
||||
# Set to "dev" to allow the default placeholder secrets. Anything else
|
||||
# (e.g. "prod", "staging") forces strong values.
|
||||
APP_ENV: Literal["dev", "prod", "staging", "test"] = "prod"
|
||||
|
||||
# === Postgres ===
|
||||
POSTGRES_DB: str = "metamorph"
|
||||
POSTGRES_USER: str = "metamorph"
|
||||
POSTGRES_PASSWORD: str = ""
|
||||
POSTGRES_HOST: str = "db"
|
||||
POSTGRES_PORT: int = 5432
|
||||
|
||||
# === API ===
|
||||
JWT_SECRET: str = Field(default="", min_length=0)
|
||||
LOG_LEVEL: str = "INFO"
|
||||
FRONT_ORIGIN: str = "http://localhost:8080"
|
||||
EVIDENCE_DIR: str = "/data/evidence"
|
||||
|
||||
@property
|
||||
def cors_origins(self) -> list[str]:
|
||||
return [o.strip() for o in self.FRONT_ORIGIN.split(",") if o.strip()]
|
||||
|
||||
@property
|
||||
def database_url(self) -> str:
|
||||
"""SQLAlchemy URL using the psycopg3 driver."""
|
||||
return (
|
||||
f"postgresql+psycopg://{self.POSTGRES_USER}:{self.POSTGRES_PASSWORD}"
|
||||
f"@{self.POSTGRES_HOST}:{self.POSTGRES_PORT}/{self.POSTGRES_DB}"
|
||||
)
|
||||
|
||||
@model_validator(mode="after")
|
||||
def _enforce_secret_strength(self) -> "Settings":
|
||||
"""Refuse to boot in prod/staging if secrets are missing or default.
|
||||
|
||||
`dev` and `test` are explicitly exempted so workstations and the
|
||||
ephemeral test container don't need real secrets.
|
||||
"""
|
||||
if self.APP_ENV in ("dev", "test"):
|
||||
return self
|
||||
if not self.JWT_SECRET or self.JWT_SECRET == _DEV_JWT_SECRET or len(self.JWT_SECRET) < 32:
|
||||
raise ValueError(
|
||||
"JWT_SECRET is missing, default, or shorter than 32 chars. "
|
||||
"Set APP_ENV=dev to bypass for local development."
|
||||
)
|
||||
if not self.POSTGRES_PASSWORD or self.POSTGRES_PASSWORD == _DEV_DB_PASSWORD:
|
||||
raise ValueError(
|
||||
"POSTGRES_PASSWORD is missing or default. "
|
||||
"Set APP_ENV=dev to bypass for local development."
|
||||
)
|
||||
return self
|
||||
|
||||
|
||||
settings = Settings()
|
||||
147
backend/app/core/install_token.py
Normal file
147
backend/app/core/install_token.py
Normal file
@@ -0,0 +1,147 @@
|
||||
"""First-admin install token.
|
||||
|
||||
When the `users` table is empty at boot, we mint a one-shot opaque token,
|
||||
store its SHA-256 in `settings(key='install_token_hash')`, and log the raw
|
||||
token to stdout. The operator copies it from the logs and posts it to
|
||||
`/api/v1/setup` with the desired admin credentials.
|
||||
|
||||
Idempotency: as long as the token row exists and no admin has consumed it,
|
||||
subsequent boots reuse the same hash and re-emit the same token only if
|
||||
explicitly invoked via `flask metamorph print-install-token`.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.core.security import generate_opaque_token, hash_opaque_token
|
||||
from app.db.session import session_scope
|
||||
from app.models.auth import User
|
||||
from app.models.setting import Setting
|
||||
|
||||
INSTALL_TOKEN_KEY = "install_token"
|
||||
log = logging.getLogger("metamorph.bootstrap")
|
||||
|
||||
# Setting JSONB shape: {"hash": "<sha256>", "issued_at": ISO, "expires_at": ISO|null, "consumed_at": ISO|null}
|
||||
|
||||
|
||||
def _users_exist() -> bool:
|
||||
with session_scope() as s:
|
||||
return s.execute(select(User.id).limit(1)).first() is not None
|
||||
|
||||
|
||||
def _read_setting() -> Setting | None:
|
||||
with session_scope() as s:
|
||||
return s.get(Setting, INSTALL_TOKEN_KEY)
|
||||
|
||||
|
||||
def _write_setting(payload: dict) -> None:
|
||||
with session_scope() as s:
|
||||
existing = s.get(Setting, INSTALL_TOKEN_KEY)
|
||||
if existing is None:
|
||||
s.add(
|
||||
Setting(
|
||||
key=INSTALL_TOKEN_KEY,
|
||||
value=payload,
|
||||
description="One-shot bootstrap token for the first admin (M2).",
|
||||
)
|
||||
)
|
||||
else:
|
||||
existing.value = payload
|
||||
|
||||
|
||||
def ensure_install_token(*, force: bool = False) -> str | None:
|
||||
"""Mint a token if no users exist and no live token is on file.
|
||||
|
||||
Returns the raw token if newly minted (caller is responsible for logging it),
|
||||
or None if the bootstrap is already consumed / not applicable.
|
||||
"""
|
||||
if _users_exist() and not force:
|
||||
return None
|
||||
|
||||
setting = _read_setting()
|
||||
if setting is not None and not force:
|
||||
value = setting.value or {}
|
||||
if value.get("consumed_at"):
|
||||
return None # consumed, do not mint again
|
||||
# A pending token exists; we don't know its raw value any more.
|
||||
# Caller must `force=True` to mint a new one (CLI command will do that).
|
||||
return None
|
||||
|
||||
token = generate_opaque_token()
|
||||
_write_setting(
|
||||
{
|
||||
"hash": hash_opaque_token(token),
|
||||
"issued_at": datetime.now(tz=timezone.utc).isoformat(),
|
||||
"expires_at": None, # never expires until consumed
|
||||
"consumed_at": None,
|
||||
}
|
||||
)
|
||||
return token
|
||||
|
||||
|
||||
def regenerate_install_token() -> str:
|
||||
"""CLI helper: always mint and persist a fresh token (overwrites any pending one)."""
|
||||
return ensure_install_token(force=True) or _force_mint()
|
||||
|
||||
|
||||
def _force_mint() -> str:
|
||||
token = generate_opaque_token()
|
||||
_write_setting(
|
||||
{
|
||||
"hash": hash_opaque_token(token),
|
||||
"issued_at": datetime.now(tz=timezone.utc).isoformat(),
|
||||
"expires_at": None,
|
||||
"consumed_at": None,
|
||||
}
|
||||
)
|
||||
return token
|
||||
|
||||
|
||||
def verify_install_token(token: str) -> bool:
|
||||
"""Constant-time comparison against the stored hash."""
|
||||
setting = _read_setting()
|
||||
if setting is None or not setting.value:
|
||||
return False
|
||||
payload = setting.value
|
||||
if payload.get("consumed_at"):
|
||||
return False
|
||||
expected = payload.get("hash")
|
||||
if not expected:
|
||||
return False
|
||||
import hmac
|
||||
|
||||
return hmac.compare_digest(hash_opaque_token(token), expected)
|
||||
|
||||
|
||||
def mark_install_token_consumed() -> None:
|
||||
setting = _read_setting()
|
||||
if setting is None:
|
||||
return
|
||||
payload = dict(setting.value or {})
|
||||
payload["consumed_at"] = datetime.now(tz=timezone.utc).isoformat()
|
||||
_write_setting(payload)
|
||||
|
||||
|
||||
def log_install_token_banner(raw_token: str) -> None:
|
||||
"""Pretty banner so the token is unmissable in container logs."""
|
||||
sep = "=" * 72
|
||||
log.warning(
|
||||
"metamorph.install_token.minted",
|
||||
extra={
|
||||
"banner": sep,
|
||||
"message_template": (
|
||||
"BOOTSTRAP — copy the token below and POST it to /api/v1/setup "
|
||||
"with your desired admin email + password. Save it: it is logged once."
|
||||
),
|
||||
"install_token": raw_token,
|
||||
},
|
||||
)
|
||||
# Also dump a plain banner so the token is grep-friendly even if the JSON
|
||||
# consumer hides `extra` fields.
|
||||
print(sep, flush=True) # noqa: T201
|
||||
print(f"INSTALL TOKEN: {raw_token}", flush=True) # noqa: T201
|
||||
print(sep, flush=True) # noqa: T201
|
||||
97
backend/app/core/jwt_tokens.py
Normal file
97
backend/app/core/jwt_tokens.py
Normal file
@@ -0,0 +1,97 @@
|
||||
"""JWT encoding / decoding.
|
||||
|
||||
Two token types:
|
||||
- `access` — short-lived (1 h), in `Authorization: Bearer ...` headers, kept
|
||||
client-side **in memory** only (cf. spec §M2).
|
||||
- `refresh` — long-lived (30 d), in an HTTPOnly Secure SameSite=Strict cookie
|
||||
scoped to `/api/v1/auth/`. Rotated on every successful refresh,
|
||||
old `jti` revoked.
|
||||
|
||||
We sign HS256 with `settings.JWT_SECRET`. The `jti` claim links each token to
|
||||
its DB row in `refresh_tokens` for revocation; access tokens are stateless.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import secrets
|
||||
import uuid
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Literal
|
||||
|
||||
import jwt
|
||||
|
||||
from app.core.config import settings
|
||||
|
||||
ACCESS_TOKEN_TTL = timedelta(hours=1)
|
||||
REFRESH_TOKEN_TTL = timedelta(days=30)
|
||||
ALGORITHM = "HS256"
|
||||
ISSUER = "metamorph"
|
||||
|
||||
|
||||
TokenType = Literal["access", "refresh"]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class TokenClaims:
|
||||
sub: str # user id (UUID as string)
|
||||
type: TokenType
|
||||
jti: str
|
||||
iat: datetime
|
||||
exp: datetime
|
||||
|
||||
|
||||
def _now() -> datetime:
|
||||
return datetime.now(tz=timezone.utc)
|
||||
|
||||
|
||||
def generate_jti() -> str:
|
||||
"""Compact, URL-safe random identifier (≈22 chars)."""
|
||||
return secrets.token_urlsafe(16)
|
||||
|
||||
|
||||
def encode_token(
|
||||
user_id: uuid.UUID | str,
|
||||
token_type: TokenType,
|
||||
*,
|
||||
jti: str | None = None,
|
||||
) -> tuple[str, TokenClaims]:
|
||||
"""Return `(jwt_string, claims)`. `jti` is generated if not provided."""
|
||||
now = _now()
|
||||
ttl = ACCESS_TOKEN_TTL if token_type == "access" else REFRESH_TOKEN_TTL
|
||||
claims = TokenClaims(
|
||||
sub=str(user_id),
|
||||
type=token_type,
|
||||
jti=jti or generate_jti(),
|
||||
iat=now,
|
||||
exp=now + ttl,
|
||||
)
|
||||
payload = {
|
||||
"iss": ISSUER,
|
||||
"sub": claims.sub,
|
||||
"type": claims.type,
|
||||
"jti": claims.jti,
|
||||
"iat": int(claims.iat.timestamp()),
|
||||
"exp": int(claims.exp.timestamp()),
|
||||
}
|
||||
return jwt.encode(payload, settings.JWT_SECRET, algorithm=ALGORITHM), claims
|
||||
|
||||
|
||||
def decode_token(token: str, *, expected_type: TokenType) -> TokenClaims:
|
||||
"""Decode and validate a JWT. Raises `jwt.PyJWTError` on any failure."""
|
||||
payload = jwt.decode(
|
||||
token,
|
||||
settings.JWT_SECRET,
|
||||
algorithms=[ALGORITHM],
|
||||
issuer=ISSUER,
|
||||
options={"require": ["sub", "type", "jti", "iat", "exp"]},
|
||||
)
|
||||
if payload["type"] != expected_type:
|
||||
raise jwt.InvalidTokenError(f"expected {expected_type} token, got {payload['type']}")
|
||||
return TokenClaims(
|
||||
sub=payload["sub"],
|
||||
type=payload["type"],
|
||||
jti=payload["jti"],
|
||||
iat=datetime.fromtimestamp(payload["iat"], tz=timezone.utc),
|
||||
exp=datetime.fromtimestamp(payload["exp"], tz=timezone.utc),
|
||||
)
|
||||
34
backend/app/core/logging.py
Normal file
34
backend/app/core/logging.py
Normal file
@@ -0,0 +1,34 @@
|
||||
"""JSON structured logging on stdout."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import sys
|
||||
|
||||
from pythonjsonlogger import jsonlogger
|
||||
|
||||
|
||||
def configure_logging(level: str = "INFO") -> None:
|
||||
"""Replace the root handler with a single JSON stdout handler.
|
||||
|
||||
Fields emitted: ts, level, name, msg, plus any extras passed via `logger.X(..., extra={...})`.
|
||||
"""
|
||||
root = logging.getLogger()
|
||||
root.setLevel(level.upper())
|
||||
|
||||
# Drop any pre-existing handlers (uvicorn/gunicorn add their own).
|
||||
for h in list(root.handlers):
|
||||
root.removeHandler(h)
|
||||
|
||||
handler = logging.StreamHandler(sys.stdout)
|
||||
formatter = jsonlogger.JsonFormatter(
|
||||
fmt="%(asctime)s %(levelname)s %(name)s %(message)s",
|
||||
rename_fields={"asctime": "ts", "levelname": "level", "name": "logger"},
|
||||
json_ensure_ascii=False,
|
||||
)
|
||||
handler.setFormatter(formatter)
|
||||
root.addHandler(handler)
|
||||
|
||||
# Tame the noisy third parties unless explicitly debugging.
|
||||
if level.upper() != "DEBUG":
|
||||
logging.getLogger("werkzeug").setLevel(logging.WARNING)
|
||||
29
backend/app/core/rate_limit.py
Normal file
29
backend/app/core/rate_limit.py
Normal file
@@ -0,0 +1,29 @@
|
||||
"""Shared flask-limiter instance.
|
||||
|
||||
Anchored on remote address. In-memory backend for v1 (single-process gunicorn
|
||||
worker pool can drift; that's acceptable at this scale). M14 will switch to
|
||||
Redis if it becomes a real concern.
|
||||
|
||||
The limiter is enforced in `APP_ENV in ("prod", "staging")` — dev and test
|
||||
deployments share an in-memory backend that's noisy across hot-reloads and
|
||||
would gate the Playwright e2e suite at 10 req/min/IP. The spec NF-security
|
||||
requirement is explicitly a *production* one (cf. tasks/spec.md §6
|
||||
NF-security); a staging deployment is exposed to humans so the same limits
|
||||
apply there.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from flask_limiter import Limiter
|
||||
from flask_limiter.util import get_remote_address
|
||||
|
||||
from app.core.config import settings
|
||||
|
||||
limiter = Limiter(
|
||||
key_func=get_remote_address,
|
||||
default_limits=[],
|
||||
storage_uri="memory://",
|
||||
headers_enabled=True,
|
||||
strategy="fixed-window",
|
||||
enabled=settings.APP_ENV in ("prod", "staging"),
|
||||
)
|
||||
62
backend/app/core/security.py
Normal file
62
backend/app/core/security.py
Normal file
@@ -0,0 +1,62 @@
|
||||
"""Password hashing and constant-time secret hashing."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import hmac
|
||||
import secrets
|
||||
|
||||
from argon2 import PasswordHasher
|
||||
from argon2.exceptions import VerifyMismatchError
|
||||
|
||||
# Argon2id with moderate cost. `time_cost=2`, `memory_cost=64MiB`, `parallelism=2`
|
||||
# is well above OWASP minimums while staying snappy on a Debian small VM.
|
||||
_hasher = PasswordHasher(
|
||||
time_cost=2,
|
||||
memory_cost=64 * 1024,
|
||||
parallelism=2,
|
||||
hash_len=32,
|
||||
salt_len=16,
|
||||
)
|
||||
|
||||
|
||||
def hash_password(plaintext: str) -> str:
|
||||
return _hasher.hash(plaintext)
|
||||
|
||||
|
||||
def verify_password(stored_hash: str, plaintext: str) -> bool:
|
||||
"""Constant-time verification. Returns False on mismatch, never raises."""
|
||||
try:
|
||||
return _hasher.verify(stored_hash, plaintext)
|
||||
except VerifyMismatchError:
|
||||
return False
|
||||
except Exception: # corrupted hash or unsupported parameters
|
||||
return False
|
||||
|
||||
|
||||
def needs_rehash(stored_hash: str) -> bool:
|
||||
"""True when Argon2 parameters have evolved since the hash was created."""
|
||||
try:
|
||||
return _hasher.check_needs_rehash(stored_hash)
|
||||
except Exception:
|
||||
return True
|
||||
|
||||
|
||||
# === Opaque-token helpers (refresh tokens, invitation tokens) ===
|
||||
#
|
||||
# We never store the raw token in DB — only its SHA-256. Comparison uses
|
||||
# `hmac.compare_digest` to dodge timing attacks. Tokens are URL-safe base64.
|
||||
|
||||
TOKEN_BYTES = 48 # 384 bits of entropy → 64 chars b64url
|
||||
|
||||
|
||||
def generate_opaque_token() -> str:
|
||||
return secrets.token_urlsafe(TOKEN_BYTES)
|
||||
|
||||
|
||||
def hash_opaque_token(token: str) -> str:
|
||||
return hashlib.sha256(token.encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
def verify_opaque_token(token: str, stored_hash: str) -> bool:
|
||||
return hmac.compare_digest(hash_opaque_token(token), stored_hash)
|
||||
15
backend/app/db/__init__.py
Normal file
15
backend/app/db/__init__.py
Normal file
@@ -0,0 +1,15 @@
|
||||
"""DB layer — base, session, mixins, shared enums."""
|
||||
|
||||
from app.db.base import Base
|
||||
from app.db.mixins import SoftDeleteMixin, TimestampMixin, UuidPkMixin
|
||||
from app.db.session import get_engine, get_sessionmaker, session_scope
|
||||
|
||||
__all__ = [
|
||||
"Base",
|
||||
"SoftDeleteMixin",
|
||||
"TimestampMixin",
|
||||
"UuidPkMixin",
|
||||
"get_engine",
|
||||
"get_sessionmaker",
|
||||
"session_scope",
|
||||
]
|
||||
23
backend/app/db/base.py
Normal file
23
backend/app/db/base.py
Normal file
@@ -0,0 +1,23 @@
|
||||
"""Declarative base for all ORM models.
|
||||
|
||||
Naming convention is set explicitly so Alembic generates stable, reviewable
|
||||
constraint names across migrations and Postgres versions.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from sqlalchemy import MetaData
|
||||
from sqlalchemy.orm import DeclarativeBase
|
||||
|
||||
# https://alembic.sqlalchemy.org/en/latest/naming.html#integration-of-naming-conventions-into-operations-autogenerate
|
||||
NAMING_CONVENTION = {
|
||||
"ix": "ix_%(table_name)s_%(column_0_N_name)s",
|
||||
"uq": "uq_%(table_name)s_%(column_0_N_name)s",
|
||||
"ck": "ck_%(table_name)s_%(constraint_name)s",
|
||||
"fk": "fk_%(table_name)s_%(column_0_N_name)s_%(referred_table_name)s",
|
||||
"pk": "pk_%(table_name)s",
|
||||
}
|
||||
|
||||
|
||||
class Base(DeclarativeBase):
|
||||
metadata = MetaData(naming_convention=NAMING_CONVENTION)
|
||||
56
backend/app/db/mixins.py
Normal file
56
backend/app/db/mixins.py
Normal file
@@ -0,0 +1,56 @@
|
||||
"""Reusable column mixins.
|
||||
|
||||
Pattern: subclass `Base, TimestampMixin, SoftDeleteMixin` to get the columns.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import DateTime, Uuid, func
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
|
||||
class UuidPkMixin:
|
||||
"""Native UUID primary key, generated Python-side."""
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(
|
||||
Uuid(as_uuid=True),
|
||||
primary_key=True,
|
||||
default=uuid.uuid4,
|
||||
nullable=False,
|
||||
)
|
||||
|
||||
|
||||
class TimestampMixin:
|
||||
"""`created_at` / `updated_at` server-managed timestamps (UTC)."""
|
||||
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
server_default=func.now(),
|
||||
nullable=False,
|
||||
)
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
server_default=func.now(),
|
||||
onupdate=func.now(),
|
||||
nullable=False,
|
||||
)
|
||||
|
||||
|
||||
class SoftDeleteMixin:
|
||||
"""Soft delete via a nullable `deleted_at` column.
|
||||
|
||||
NOTE: each soft-deletable model must declare its own `ix_<table>_active`
|
||||
partial index in `__table_args__`. We deliberately don't auto-inject one
|
||||
here because SQLAlchemy's `__table_args__` from a mixin gets clobbered as
|
||||
soon as the model class declares its own — silently dropping the index.
|
||||
Declaring it explicitly keeps the contract visible at the model site.
|
||||
"""
|
||||
|
||||
deleted_at: Mapped[datetime | None] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
nullable=True,
|
||||
default=None,
|
||||
)
|
||||
47
backend/app/db/session.py
Normal file
47
backend/app/db/session.py
Normal file
@@ -0,0 +1,47 @@
|
||||
"""Engine + sessionmaker. Lazily initialised so test code can swap the URL."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Iterator
|
||||
from contextlib import contextmanager
|
||||
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.engine import Engine
|
||||
from sqlalchemy.orm import Session, sessionmaker
|
||||
|
||||
from app.core.config import settings
|
||||
|
||||
_engine: Engine | None = None
|
||||
_SessionLocal: sessionmaker[Session] | None = None
|
||||
|
||||
|
||||
def get_engine() -> Engine:
|
||||
global _engine
|
||||
if _engine is None:
|
||||
_engine = create_engine(
|
||||
settings.database_url,
|
||||
pool_pre_ping=True,
|
||||
future=True,
|
||||
)
|
||||
return _engine
|
||||
|
||||
|
||||
def get_sessionmaker() -> sessionmaker[Session]:
|
||||
global _SessionLocal
|
||||
if _SessionLocal is None:
|
||||
_SessionLocal = sessionmaker(bind=get_engine(), expire_on_commit=False, future=True)
|
||||
return _SessionLocal
|
||||
|
||||
|
||||
@contextmanager
|
||||
def session_scope() -> Iterator[Session]:
|
||||
"""Context manager that commits on success, rolls back on error."""
|
||||
s = get_sessionmaker()()
|
||||
try:
|
||||
yield s
|
||||
s.commit()
|
||||
except Exception:
|
||||
s.rollback()
|
||||
raise
|
||||
finally:
|
||||
s.close()
|
||||
27
backend/app/db/types.py
Normal file
27
backend/app/db/types.py
Normal file
@@ -0,0 +1,27 @@
|
||||
"""Shared enum-like string sets used across models.
|
||||
|
||||
Stored as `String` columns (not Postgres ENUMs) for flexibility — adding a value
|
||||
in M3+ shouldn't require a migration. CHECK constraints validate the value set
|
||||
at the DB level.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
# Roles a user is hinted with on a mission. Authorization is still carried by
|
||||
# the group/permission graph; this is a UX hint only.
|
||||
MISSION_ROLE_HINTS = ("red", "blue")
|
||||
|
||||
# Mission lifecycle.
|
||||
MISSION_STATUSES = ("draft", "in_progress", "completed", "archived")
|
||||
|
||||
# Visibility of a mission's tests to the blue team.
|
||||
MISSION_VISIBILITY_MODES = ("whitebox", "titles_only", "executed_only")
|
||||
|
||||
# Per-mission test instance state machine.
|
||||
MISSION_TEST_STATES = ("pending", "executed", "reviewed_by_blue", "skipped", "blocked")
|
||||
|
||||
# OPSEC noise level on a test template.
|
||||
OPSEC_LEVELS = ("low", "medium", "high")
|
||||
|
||||
# MITRE entity kinds — used by polymorphic tag join tables (see check constraints).
|
||||
MITRE_KINDS = ("tactic", "technique", "subtechnique")
|
||||
0
backend/app/i18n/__init__.py
Normal file
0
backend/app/i18n/__init__.py
Normal file
72
backend/app/main.py
Normal file
72
backend/app/main.py
Normal file
@@ -0,0 +1,72 @@
|
||||
"""Flask application factory and WSGI entry point."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from flask import Flask
|
||||
from flask_cors import CORS
|
||||
|
||||
from app.api.v1 import bp as v1_bp
|
||||
from app.core.config import settings
|
||||
from app.core.install_token import (
|
||||
ensure_install_token,
|
||||
log_install_token_banner,
|
||||
)
|
||||
from app.core.logging import configure_logging
|
||||
from app.core.rate_limit import limiter
|
||||
from app.services.bootstrap import ensure_system_groups
|
||||
from app.services.permissions_seed import seed_all as seed_permissions_and_bindings
|
||||
|
||||
|
||||
def _try_bootstrap_at_boot(log: logging.Logger) -> None:
|
||||
"""Best-effort: seed system groups + mint an install token if needed.
|
||||
|
||||
Wrapped in try/except because the DB may not be ready (or schema not
|
||||
migrated yet) at the very first boot — gunicorn must still come up so the
|
||||
operator can run `make migrate` and curl /setup afterwards.
|
||||
"""
|
||||
try:
|
||||
ensure_system_groups()
|
||||
seed_permissions_and_bindings()
|
||||
token = ensure_install_token()
|
||||
if token is not None:
|
||||
log_install_token_banner(token)
|
||||
else:
|
||||
log.info("metamorph.bootstrap.skipped")
|
||||
except Exception as e:
|
||||
log.warning("metamorph.bootstrap.deferred", extra={"error": str(e)})
|
||||
|
||||
|
||||
def create_app() -> Flask:
|
||||
configure_logging(settings.LOG_LEVEL)
|
||||
log = logging.getLogger("metamorph.boot")
|
||||
|
||||
app = Flask(__name__)
|
||||
app.config["MAX_CONTENT_LENGTH"] = 64 * 1024 * 1024 # 64 MB hard cap; per-file limit is 25 MB.
|
||||
|
||||
CORS(
|
||||
app,
|
||||
origins=settings.cors_origins,
|
||||
supports_credentials=True,
|
||||
max_age=600,
|
||||
)
|
||||
|
||||
limiter.init_app(app)
|
||||
app.register_blueprint(v1_bp)
|
||||
|
||||
log.info(
|
||||
"metamorph.api.boot",
|
||||
extra={
|
||||
"cors_origins": settings.cors_origins,
|
||||
"log_level": settings.LOG_LEVEL,
|
||||
"evidence_dir": settings.EVIDENCE_DIR,
|
||||
},
|
||||
)
|
||||
|
||||
_try_bootstrap_at_boot(log)
|
||||
return app
|
||||
|
||||
|
||||
# WSGI entry point used by gunicorn (`gunicorn app.main:app`).
|
||||
app = create_app()
|
||||
73
backend/app/models/__init__.py
Normal file
73
backend/app/models/__init__.py
Normal file
@@ -0,0 +1,73 @@
|
||||
"""ORM models — every module must be imported here so Alembic's autogenerate
|
||||
can see them via `Base.metadata`.
|
||||
"""
|
||||
|
||||
from app.models.auth import (
|
||||
Group,
|
||||
GroupPermission,
|
||||
Invitation,
|
||||
InvitationGroup,
|
||||
Permission,
|
||||
RefreshToken,
|
||||
User,
|
||||
UserGroup,
|
||||
)
|
||||
from app.models.evidence import EvidenceFile
|
||||
from app.models.mission import (
|
||||
Mission,
|
||||
MissionCategory,
|
||||
MissionMember,
|
||||
MissionScenario,
|
||||
MissionTest,
|
||||
MissionTestMitreTag,
|
||||
)
|
||||
from app.models.mitre import (
|
||||
MitreSubtechnique,
|
||||
MitreTactic,
|
||||
MitreTechnique,
|
||||
MitreTechniqueTactic,
|
||||
)
|
||||
from app.models.notification import Notification
|
||||
from app.models.setting import DetectionLevel, Setting
|
||||
from app.models.template import (
|
||||
ScenarioTemplate,
|
||||
ScenarioTemplateTest,
|
||||
TestTemplate,
|
||||
TestTemplateMitreTag,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
# auth
|
||||
"Group",
|
||||
"GroupPermission",
|
||||
"Invitation",
|
||||
"InvitationGroup",
|
||||
"Permission",
|
||||
"RefreshToken",
|
||||
"User",
|
||||
"UserGroup",
|
||||
# evidence
|
||||
"EvidenceFile",
|
||||
# mission
|
||||
"Mission",
|
||||
"MissionCategory",
|
||||
"MissionMember",
|
||||
"MissionScenario",
|
||||
"MissionTest",
|
||||
"MissionTestMitreTag",
|
||||
# mitre
|
||||
"MitreSubtechnique",
|
||||
"MitreTactic",
|
||||
"MitreTechnique",
|
||||
"MitreTechniqueTactic",
|
||||
# notification
|
||||
"Notification",
|
||||
# setting
|
||||
"DetectionLevel",
|
||||
"Setting",
|
||||
# template
|
||||
"ScenarioTemplate",
|
||||
"ScenarioTemplateTest",
|
||||
"TestTemplate",
|
||||
"TestTemplateMitreTag",
|
||||
]
|
||||
188
backend/app/models/auth.py
Normal file
188
backend/app/models/auth.py
Normal file
@@ -0,0 +1,188 @@
|
||||
"""Auth + RBAC: users, groups, permissions, invitations, refresh tokens."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from sqlalchemy import ForeignKey, Index, String, Text, UniqueConstraint, Uuid
|
||||
from sqlalchemy import DateTime as SADateTime
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from app.db.base import Base
|
||||
from app.db.mixins import SoftDeleteMixin, TimestampMixin, UuidPkMixin
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from app.models.evidence import EvidenceFile
|
||||
from app.models.notification import Notification
|
||||
|
||||
|
||||
class User(Base, UuidPkMixin, TimestampMixin, SoftDeleteMixin):
|
||||
__tablename__ = "users"
|
||||
|
||||
email: Mapped[str] = mapped_column(String(254), nullable=False)
|
||||
password_hash: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
display_name: Mapped[str | None] = mapped_column(String(120), nullable=True)
|
||||
locale: Mapped[str] = mapped_column(String(8), default="fr", nullable=False)
|
||||
is_active: Mapped[bool] = mapped_column(default=True, nullable=False)
|
||||
|
||||
groups: Mapped[list["Group"]] = relationship(
|
||||
secondary="user_groups",
|
||||
back_populates="users",
|
||||
lazy="selectin",
|
||||
)
|
||||
refresh_tokens: Mapped[list["RefreshToken"]] = relationship(
|
||||
back_populates="user",
|
||||
cascade="all, delete-orphan",
|
||||
)
|
||||
notifications: Mapped[list["Notification"]] = relationship(
|
||||
back_populates="user",
|
||||
cascade="all, delete-orphan",
|
||||
)
|
||||
uploaded_evidence: Mapped[list["EvidenceFile"]] = relationship(
|
||||
back_populates="uploaded_by",
|
||||
)
|
||||
|
||||
__table_args__ = (
|
||||
# Email uniqueness scoped to non-deleted rows so an admin can re-invite
|
||||
# a previously-soft-deleted user.
|
||||
Index(
|
||||
"uq_users_email_active",
|
||||
"email",
|
||||
unique=True,
|
||||
postgresql_where="deleted_at IS NULL",
|
||||
),
|
||||
Index("ix_users_active", "deleted_at", postgresql_where="deleted_at IS NULL"),
|
||||
)
|
||||
|
||||
|
||||
class Group(Base, UuidPkMixin, TimestampMixin, SoftDeleteMixin):
|
||||
__tablename__ = "groups"
|
||||
|
||||
name: Mapped[str] = mapped_column(String(80), nullable=False)
|
||||
description: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
# Built-in groups (admin/redteam/blueteam) are protected from deletion.
|
||||
is_system: Mapped[bool] = mapped_column(default=False, nullable=False)
|
||||
|
||||
users: Mapped[list[User]] = relationship(
|
||||
secondary="user_groups",
|
||||
back_populates="groups",
|
||||
)
|
||||
permissions: Mapped[list["Permission"]] = relationship(
|
||||
secondary="group_permissions",
|
||||
back_populates="groups",
|
||||
lazy="selectin",
|
||||
)
|
||||
|
||||
__table_args__ = (
|
||||
Index(
|
||||
"uq_groups_name_active",
|
||||
"name",
|
||||
unique=True,
|
||||
postgresql_where="deleted_at IS NULL",
|
||||
),
|
||||
Index("ix_groups_active", "deleted_at", postgresql_where="deleted_at IS NULL"),
|
||||
)
|
||||
|
||||
|
||||
class Permission(Base, UuidPkMixin, TimestampMixin):
|
||||
"""Atomic permission. Code follows the `<entity>.<action>` convention."""
|
||||
|
||||
__tablename__ = "permissions"
|
||||
|
||||
code: Mapped[str] = mapped_column(String(80), unique=True, nullable=False)
|
||||
description: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
|
||||
groups: Mapped[list[Group]] = relationship(
|
||||
secondary="group_permissions",
|
||||
back_populates="permissions",
|
||||
)
|
||||
|
||||
|
||||
class UserGroup(Base):
|
||||
"""User ↔ Group join — no soft delete, just attach/detach."""
|
||||
|
||||
__tablename__ = "user_groups"
|
||||
|
||||
user_id: Mapped[uuid.UUID] = mapped_column(
|
||||
Uuid(as_uuid=True), ForeignKey("users.id", ondelete="CASCADE"), primary_key=True
|
||||
)
|
||||
group_id: Mapped[uuid.UUID] = mapped_column(
|
||||
Uuid(as_uuid=True), ForeignKey("groups.id", ondelete="CASCADE"), primary_key=True
|
||||
)
|
||||
|
||||
|
||||
class GroupPermission(Base):
|
||||
"""Group ↔ Permission join."""
|
||||
|
||||
__tablename__ = "group_permissions"
|
||||
|
||||
group_id: Mapped[uuid.UUID] = mapped_column(
|
||||
Uuid(as_uuid=True), ForeignKey("groups.id", ondelete="CASCADE"), primary_key=True
|
||||
)
|
||||
permission_id: Mapped[uuid.UUID] = mapped_column(
|
||||
Uuid(as_uuid=True), ForeignKey("permissions.id", ondelete="CASCADE"), primary_key=True
|
||||
)
|
||||
|
||||
|
||||
class Invitation(Base, UuidPkMixin, TimestampMixin):
|
||||
__tablename__ = "invitations"
|
||||
|
||||
# Hash of the URL token, never the token itself.
|
||||
token_hash: Mapped[str] = mapped_column(String(128), unique=True, nullable=False)
|
||||
email_hint: Mapped[str | None] = mapped_column(String(254), nullable=True)
|
||||
created_by_user_id: Mapped[uuid.UUID] = mapped_column(
|
||||
Uuid(as_uuid=True), ForeignKey("users.id", ondelete="RESTRICT"), nullable=False
|
||||
)
|
||||
expires_at: Mapped[datetime] = mapped_column(SADateTime(timezone=True), nullable=False)
|
||||
consumed_at: Mapped[datetime | None] = mapped_column(SADateTime(timezone=True), nullable=True)
|
||||
revoked_at: Mapped[datetime | None] = mapped_column(SADateTime(timezone=True), nullable=True)
|
||||
consumed_by_user_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||
Uuid(as_uuid=True), ForeignKey("users.id", ondelete="SET NULL"), nullable=True
|
||||
)
|
||||
|
||||
pre_assigned_groups: Mapped[list[Group]] = relationship(
|
||||
secondary="invitation_groups",
|
||||
lazy="selectin",
|
||||
)
|
||||
|
||||
__table_args__ = (Index("ix_invitations_expires_at", "expires_at"),)
|
||||
|
||||
|
||||
class InvitationGroup(Base):
|
||||
"""Pre-assigned groups attached to an invitation; applied at acceptance."""
|
||||
|
||||
__tablename__ = "invitation_groups"
|
||||
|
||||
invitation_id: Mapped[uuid.UUID] = mapped_column(
|
||||
Uuid(as_uuid=True), ForeignKey("invitations.id", ondelete="CASCADE"), primary_key=True
|
||||
)
|
||||
group_id: Mapped[uuid.UUID] = mapped_column(
|
||||
Uuid(as_uuid=True), ForeignKey("groups.id", ondelete="CASCADE"), primary_key=True
|
||||
)
|
||||
|
||||
|
||||
class RefreshToken(Base, UuidPkMixin, TimestampMixin):
|
||||
"""Long-lived refresh tokens. The hash, never the token, is stored."""
|
||||
|
||||
__tablename__ = "refresh_tokens"
|
||||
|
||||
user_id: Mapped[uuid.UUID] = mapped_column(
|
||||
Uuid(as_uuid=True), ForeignKey("users.id", ondelete="CASCADE"), nullable=False
|
||||
)
|
||||
jti: Mapped[str] = mapped_column(String(64), nullable=False)
|
||||
token_hash: Mapped[str] = mapped_column(String(128), nullable=False)
|
||||
issued_at: Mapped[datetime] = mapped_column(SADateTime(timezone=True), nullable=False)
|
||||
expires_at: Mapped[datetime] = mapped_column(SADateTime(timezone=True), nullable=False)
|
||||
revoked_at: Mapped[datetime | None] = mapped_column(SADateTime(timezone=True), nullable=True)
|
||||
replaced_by_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||
Uuid(as_uuid=True), ForeignKey("refresh_tokens.id", ondelete="SET NULL"), nullable=True
|
||||
)
|
||||
|
||||
user: Mapped[User] = relationship(back_populates="refresh_tokens")
|
||||
|
||||
__table_args__ = (
|
||||
UniqueConstraint("jti", name="uq_refresh_tokens_jti"),
|
||||
Index("ix_refresh_tokens_user_id_expires_at", "user_id", "expires_at"),
|
||||
)
|
||||
51
backend/app/models/evidence.py
Normal file
51
backend/app/models/evidence.py
Normal file
@@ -0,0 +1,51 @@
|
||||
"""Blue-team evidence files attached to a `mission_test`."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from sqlalchemy import BigInteger, DateTime, ForeignKey, Index, String, Text, Uuid, func
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from app.db.base import Base
|
||||
from app.db.mixins import SoftDeleteMixin, TimestampMixin, UuidPkMixin
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from app.models.auth import User
|
||||
|
||||
|
||||
class EvidenceFile(Base, UuidPkMixin, TimestampMixin, SoftDeleteMixin):
|
||||
__tablename__ = "evidence_files"
|
||||
|
||||
mission_test_id: Mapped[uuid.UUID] = mapped_column(
|
||||
Uuid(as_uuid=True),
|
||||
ForeignKey("mission_tests.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
)
|
||||
sha256: Mapped[str] = mapped_column(String(64), nullable=False)
|
||||
mime: Mapped[str] = mapped_column(String(127), nullable=False)
|
||||
size_bytes: Mapped[int] = mapped_column(BigInteger, nullable=False)
|
||||
storage_path: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
original_filename: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
uploaded_by_user_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||
Uuid(as_uuid=True),
|
||||
ForeignKey("users.id", ondelete="SET NULL"),
|
||||
nullable=True,
|
||||
)
|
||||
uploaded_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), server_default=func.now(), nullable=False
|
||||
)
|
||||
|
||||
uploaded_by: Mapped["User | None"] = relationship(back_populates="uploaded_evidence")
|
||||
|
||||
__table_args__ = (
|
||||
Index("ix_evidence_files_mission_test", "mission_test_id"),
|
||||
Index("ix_evidence_files_sha256", "sha256"),
|
||||
Index(
|
||||
"ix_evidence_files_active",
|
||||
"deleted_at",
|
||||
postgresql_where="deleted_at IS NULL",
|
||||
),
|
||||
)
|
||||
316
backend/app/models/mission.py
Normal file
316
backend/app/models/mission.py
Normal file
@@ -0,0 +1,316 @@
|
||||
"""Missions and snapshots.
|
||||
|
||||
A `Mission` references members and a tree of snapshot rows:
|
||||
mission ─< mission_scenarios ─< mission_tests ─< (red/blue annotations)
|
||||
|
||||
Snapshots copy template fields verbatim so editing a template doesn't drift
|
||||
already-running missions. `source_*_template_id` keep a soft pointer for
|
||||
analytics, but the source rows can be soft-deleted without breaking the mission.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from datetime import date, datetime
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import (
|
||||
ARRAY,
|
||||
CheckConstraint,
|
||||
Date,
|
||||
DateTime,
|
||||
ForeignKey,
|
||||
Index,
|
||||
Integer,
|
||||
String,
|
||||
Text,
|
||||
UniqueConstraint,
|
||||
Uuid,
|
||||
)
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from app.db.base import Base
|
||||
from app.db.mixins import SoftDeleteMixin, TimestampMixin, UuidPkMixin
|
||||
|
||||
# DateTime is no longer needed since MissionMember now uses TimestampMixin.
|
||||
# The remaining DateTime usages in MissionTest (executed_at) keep the import below.
|
||||
from app.db.types import (
|
||||
MISSION_ROLE_HINTS,
|
||||
MISSION_STATUSES,
|
||||
MISSION_TEST_STATES,
|
||||
MISSION_VISIBILITY_MODES,
|
||||
MITRE_KINDS,
|
||||
OPSEC_LEVELS,
|
||||
)
|
||||
|
||||
# `mission_test_mitre_tags` deliberately denormalises the MITRE labels so a
|
||||
# mission's tags survive a MITRE re-sync that drops the original entry. The
|
||||
# FK columns were removed in favour of frozen `mitre_external_id` + `mitre_name`
|
||||
# snapshots — see spec §11 ("snapshot vs reference" risk).
|
||||
|
||||
|
||||
class Mission(Base, UuidPkMixin, TimestampMixin, SoftDeleteMixin):
|
||||
__tablename__ = "missions"
|
||||
|
||||
name: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
client_target: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||
date_start: Mapped[date | None] = mapped_column(Date, nullable=True)
|
||||
date_end: Mapped[date | None] = mapped_column(Date, nullable=True)
|
||||
status: Mapped[str] = mapped_column(String(16), default="draft", nullable=False)
|
||||
description_md: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
visibility_mode: Mapped[str] = mapped_column(
|
||||
String(16), default="whitebox", nullable=False
|
||||
)
|
||||
|
||||
members: Mapped[list["MissionMember"]] = relationship(
|
||||
back_populates="mission",
|
||||
cascade="all, delete-orphan",
|
||||
)
|
||||
scenarios: Mapped[list["MissionScenario"]] = relationship(
|
||||
back_populates="mission",
|
||||
cascade="all, delete-orphan",
|
||||
order_by="MissionScenario.position",
|
||||
)
|
||||
categories: Mapped[list["MissionCategory"]] = relationship(
|
||||
back_populates="mission",
|
||||
cascade="all, delete-orphan",
|
||||
order_by="MissionCategory.position",
|
||||
)
|
||||
|
||||
__table_args__ = (
|
||||
CheckConstraint(
|
||||
f"status IN ({', '.join(repr(v) for v in MISSION_STATUSES)})",
|
||||
name="status_valid",
|
||||
),
|
||||
CheckConstraint(
|
||||
f"visibility_mode IN ({', '.join(repr(v) for v in MISSION_VISIBILITY_MODES)})",
|
||||
name="visibility_mode_valid",
|
||||
),
|
||||
Index("ix_missions_active", "deleted_at", postgresql_where="deleted_at IS NULL"),
|
||||
Index("ix_missions_status", "status"),
|
||||
)
|
||||
|
||||
|
||||
class MissionMember(Base, TimestampMixin):
|
||||
"""A user's membership in a mission with a hint about their team side."""
|
||||
|
||||
__tablename__ = "mission_members"
|
||||
|
||||
mission_id: Mapped[uuid.UUID] = mapped_column(
|
||||
Uuid(as_uuid=True),
|
||||
ForeignKey("missions.id", ondelete="CASCADE"),
|
||||
primary_key=True,
|
||||
)
|
||||
user_id: Mapped[uuid.UUID] = mapped_column(
|
||||
Uuid(as_uuid=True),
|
||||
ForeignKey("users.id", ondelete="CASCADE"),
|
||||
primary_key=True,
|
||||
)
|
||||
role_hint: Mapped[str] = mapped_column(String(8), nullable=False)
|
||||
|
||||
mission: Mapped[Mission] = relationship(back_populates="members")
|
||||
|
||||
__table_args__ = (
|
||||
CheckConstraint(
|
||||
f"role_hint IN ({', '.join(repr(v) for v in MISSION_ROLE_HINTS)})",
|
||||
name="role_hint_valid",
|
||||
),
|
||||
Index("ix_mission_members_user", "user_id"),
|
||||
)
|
||||
|
||||
|
||||
class MissionScenario(Base, UuidPkMixin, TimestampMixin, SoftDeleteMixin):
|
||||
"""Snapshot of a `scenario_template` instantiated within a mission."""
|
||||
|
||||
__tablename__ = "mission_scenarios"
|
||||
|
||||
mission_id: Mapped[uuid.UUID] = mapped_column(
|
||||
Uuid(as_uuid=True),
|
||||
ForeignKey("missions.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
)
|
||||
source_scenario_template_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||
Uuid(as_uuid=True),
|
||||
ForeignKey("scenario_templates.id", ondelete="SET NULL"),
|
||||
nullable=True,
|
||||
)
|
||||
snapshot_name: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
snapshot_description: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
position: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||
|
||||
mission: Mapped[Mission] = relationship(back_populates="scenarios")
|
||||
tests: Mapped[list["MissionTest"]] = relationship(
|
||||
back_populates="scenario",
|
||||
cascade="all, delete-orphan",
|
||||
order_by="MissionTest.position",
|
||||
)
|
||||
|
||||
__table_args__ = (
|
||||
UniqueConstraint(
|
||||
"mission_id", "position", name="uq_mission_scenarios_position"
|
||||
),
|
||||
Index("ix_mission_scenarios_mission", "mission_id"),
|
||||
Index(
|
||||
"ix_mission_scenarios_active",
|
||||
"deleted_at",
|
||||
postgresql_where="deleted_at IS NULL",
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
class MissionTest(Base, UuidPkMixin, TimestampMixin, SoftDeleteMixin):
|
||||
"""Snapshot of a `test_template` + execution state + red/blue annotations."""
|
||||
|
||||
__tablename__ = "mission_tests"
|
||||
|
||||
scenario_id: Mapped[uuid.UUID] = mapped_column(
|
||||
Uuid(as_uuid=True),
|
||||
ForeignKey("mission_scenarios.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
)
|
||||
source_test_template_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||
Uuid(as_uuid=True),
|
||||
ForeignKey("test_templates.id", ondelete="SET NULL"),
|
||||
nullable=True,
|
||||
)
|
||||
position: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||
|
||||
# --- Snapshot of the template (immutable after creation) ---
|
||||
snapshot_name: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
snapshot_description: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
snapshot_objective: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
snapshot_procedure_md: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
snapshot_prerequisites_md: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
snapshot_expected_red_md: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
snapshot_expected_blue_md: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
snapshot_opsec_level: Mapped[str] = mapped_column(
|
||||
String(8), default="medium", nullable=False
|
||||
)
|
||||
snapshot_tags: Mapped[list[str]] = mapped_column(
|
||||
ARRAY(String(64)), nullable=False, server_default="{}"
|
||||
)
|
||||
snapshot_expected_iocs: Mapped[list[str]] = mapped_column(
|
||||
ARRAY(String(255)), nullable=False, server_default="{}"
|
||||
)
|
||||
|
||||
# --- Execution state ---
|
||||
state: Mapped[str] = mapped_column(String(24), default="pending", nullable=False)
|
||||
executed_at: Mapped[datetime | None] = mapped_column(
|
||||
DateTime(timezone=True), nullable=True
|
||||
)
|
||||
executed_at_overridden: Mapped[bool] = mapped_column(default=False, nullable=False)
|
||||
|
||||
# --- Red side (text-only per spec §4) ---
|
||||
red_command: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
red_output: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
red_comment_md: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
|
||||
# --- Blue side ---
|
||||
blue_comment_md: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
detection_level_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||
Uuid(as_uuid=True),
|
||||
ForeignKey("detection_levels.id", ondelete="SET NULL"),
|
||||
nullable=True,
|
||||
)
|
||||
category_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||
Uuid(as_uuid=True),
|
||||
ForeignKey("mission_categories.id", ondelete="SET NULL"),
|
||||
nullable=True,
|
||||
)
|
||||
|
||||
scenario: Mapped[MissionScenario] = relationship(back_populates="tests")
|
||||
mitre_tags: Mapped[list["MissionTestMitreTag"]] = relationship(
|
||||
back_populates="mission_test",
|
||||
cascade="all, delete-orphan",
|
||||
lazy="selectin",
|
||||
)
|
||||
|
||||
__table_args__ = (
|
||||
CheckConstraint(
|
||||
f"snapshot_opsec_level IN ({', '.join(repr(v) for v in OPSEC_LEVELS)})",
|
||||
name="snapshot_opsec_level_valid",
|
||||
),
|
||||
CheckConstraint(
|
||||
f"state IN ({', '.join(repr(v) for v in MISSION_TEST_STATES)})",
|
||||
name="state_valid",
|
||||
),
|
||||
UniqueConstraint("scenario_id", "position", name="uq_mission_tests_position"),
|
||||
Index("ix_mission_tests_state", "state"),
|
||||
Index(
|
||||
"ix_mission_tests_active",
|
||||
"deleted_at",
|
||||
postgresql_where="deleted_at IS NULL",
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
class MissionCategory(Base, UuidPkMixin, TimestampMixin, SoftDeleteMixin):
|
||||
"""Optional custom grouping override for the slide synthesis."""
|
||||
|
||||
__tablename__ = "mission_categories"
|
||||
|
||||
mission_id: Mapped[uuid.UUID] = mapped_column(
|
||||
Uuid(as_uuid=True),
|
||||
ForeignKey("missions.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
)
|
||||
name: Mapped[str] = mapped_column(String(120), nullable=False)
|
||||
color_token: Mapped[str | None] = mapped_column(String(16), nullable=True)
|
||||
position: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||
|
||||
mission: Mapped[Mission] = relationship(back_populates="categories")
|
||||
|
||||
__table_args__ = (
|
||||
UniqueConstraint(
|
||||
"mission_id", "position", name="uq_mission_categories_position"
|
||||
),
|
||||
UniqueConstraint("mission_id", "name", name="uq_mission_categories_name"),
|
||||
Index(
|
||||
"ix_mission_categories_active",
|
||||
"deleted_at",
|
||||
postgresql_where="deleted_at IS NULL",
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
class MissionTestMitreTag(Base):
|
||||
"""Frozen MITRE tag attached to a mission test.
|
||||
|
||||
DELIBERATELY DENORMALISED — no FK to mitre_* tables. The MITRE
|
||||
`external_id` and human label are copied at tag-creation time so that a
|
||||
later MITRE re-sync that drops the original entry cannot purge or alter
|
||||
a mission's tags. See spec §11 (snapshot vs reference).
|
||||
|
||||
The companion `test_template_mitre_tags` table keeps the FK relationship
|
||||
because templates are editable and admins can re-tag them after a sync.
|
||||
"""
|
||||
|
||||
__tablename__ = "mission_test_mitre_tags"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(
|
||||
Uuid(as_uuid=True), primary_key=True, default=uuid.uuid4, nullable=False
|
||||
)
|
||||
mission_test_id: Mapped[uuid.UUID] = mapped_column(
|
||||
Uuid(as_uuid=True),
|
||||
ForeignKey("mission_tests.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
)
|
||||
mitre_kind: Mapped[str] = mapped_column(String(16), nullable=False)
|
||||
mitre_external_id: Mapped[str] = mapped_column(String(16), nullable=False)
|
||||
mitre_name: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
mitre_url: Mapped[str | None] = mapped_column(String(512), nullable=True)
|
||||
|
||||
mission_test: Mapped[MissionTest] = relationship(back_populates="mitre_tags")
|
||||
|
||||
__table_args__: Any = (
|
||||
CheckConstraint(
|
||||
f"mitre_kind IN ({', '.join(repr(v) for v in MITRE_KINDS)})",
|
||||
name="mitre_kind_valid",
|
||||
),
|
||||
UniqueConstraint(
|
||||
"mission_test_id",
|
||||
"mitre_external_id",
|
||||
name="uq_mission_test_mitre_tag",
|
||||
),
|
||||
Index("ix_mission_test_mitre_tags_test", "mission_test_id"),
|
||||
)
|
||||
86
backend/app/models/mitre.py
Normal file
86
backend/app/models/mitre.py
Normal file
@@ -0,0 +1,86 @@
|
||||
"""MITRE ATT&CK reference tables.
|
||||
|
||||
Read-mostly. Hard delete (no soft-delete) — replaced by the periodic sync job.
|
||||
A technique can map to multiple tactics (kill_chain_phases in STIX) hence the
|
||||
M2M `technique_tactics` join. Sub-techniques inherit their parent's tactics
|
||||
through the parent technique.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
|
||||
from sqlalchemy import ForeignKey, Index, String, Text, Uuid
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from app.db.base import Base
|
||||
from app.db.mixins import TimestampMixin, UuidPkMixin
|
||||
|
||||
|
||||
class MitreTactic(Base, UuidPkMixin, TimestampMixin):
|
||||
__tablename__ = "mitre_tactics"
|
||||
|
||||
external_id: Mapped[str] = mapped_column(String(16), unique=True, nullable=False)
|
||||
short_name: Mapped[str] = mapped_column(String(80), nullable=False)
|
||||
name: Mapped[str] = mapped_column(String(120), nullable=False)
|
||||
description: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
url: Mapped[str | None] = mapped_column(String(512), nullable=True)
|
||||
|
||||
techniques: Mapped[list["MitreTechnique"]] = relationship(
|
||||
secondary="mitre_technique_tactics",
|
||||
back_populates="tactics",
|
||||
)
|
||||
|
||||
|
||||
class MitreTechnique(Base, UuidPkMixin, TimestampMixin):
|
||||
__tablename__ = "mitre_techniques"
|
||||
|
||||
external_id: Mapped[str] = mapped_column(String(16), unique=True, nullable=False)
|
||||
name: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
description: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
url: Mapped[str | None] = mapped_column(String(512), nullable=True)
|
||||
|
||||
tactics: Mapped[list[MitreTactic]] = relationship(
|
||||
secondary="mitre_technique_tactics",
|
||||
back_populates="techniques",
|
||||
lazy="selectin",
|
||||
)
|
||||
subtechniques: Mapped[list["MitreSubtechnique"]] = relationship(
|
||||
back_populates="technique",
|
||||
cascade="all, delete-orphan",
|
||||
)
|
||||
|
||||
__table_args__ = (Index("ix_mitre_techniques_name", "name"),)
|
||||
|
||||
|
||||
class MitreSubtechnique(Base, UuidPkMixin, TimestampMixin):
|
||||
__tablename__ = "mitre_subtechniques"
|
||||
|
||||
external_id: Mapped[str] = mapped_column(String(16), unique=True, nullable=False)
|
||||
name: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
description: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
url: Mapped[str | None] = mapped_column(String(512), nullable=True)
|
||||
technique_id: Mapped[uuid.UUID] = mapped_column(
|
||||
Uuid(as_uuid=True), ForeignKey("mitre_techniques.id", ondelete="CASCADE"), nullable=False
|
||||
)
|
||||
|
||||
technique: Mapped[MitreTechnique] = relationship(back_populates="subtechniques")
|
||||
|
||||
__table_args__ = (Index("ix_mitre_subtechniques_technique_id", "technique_id"),)
|
||||
|
||||
|
||||
class MitreTechniqueTactic(Base):
|
||||
"""Many-to-many: a technique can serve several tactics (STIX kill_chain_phases)."""
|
||||
|
||||
__tablename__ = "mitre_technique_tactics"
|
||||
|
||||
technique_id: Mapped[uuid.UUID] = mapped_column(
|
||||
Uuid(as_uuid=True),
|
||||
ForeignKey("mitre_techniques.id", ondelete="CASCADE"),
|
||||
primary_key=True,
|
||||
)
|
||||
tactic_id: Mapped[uuid.UUID] = mapped_column(
|
||||
Uuid(as_uuid=True),
|
||||
ForeignKey("mitre_tactics.id", ondelete="CASCADE"),
|
||||
primary_key=True,
|
||||
)
|
||||
41
backend/app/models/notification.py
Normal file
41
backend/app/models/notification.py
Normal file
@@ -0,0 +1,41 @@
|
||||
"""In-app notifications. Mail is out-of-scope for v1 (spec §4)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from typing import Any, TYPE_CHECKING
|
||||
|
||||
from sqlalchemy import DateTime, ForeignKey, Index, String, Uuid
|
||||
from sqlalchemy.dialects.postgresql import JSONB
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from app.db.base import Base
|
||||
from app.db.mixins import TimestampMixin, UuidPkMixin
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from app.models.auth import User
|
||||
|
||||
|
||||
class Notification(Base, UuidPkMixin, TimestampMixin):
|
||||
__tablename__ = "notifications"
|
||||
|
||||
user_id: Mapped[uuid.UUID] = mapped_column(
|
||||
Uuid(as_uuid=True),
|
||||
ForeignKey("users.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
)
|
||||
type: Mapped[str] = mapped_column(String(64), nullable=False)
|
||||
payload: Mapped[Any] = mapped_column(JSONB, nullable=False, server_default="{}")
|
||||
read_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
|
||||
user: Mapped["User"] = relationship(back_populates="notifications")
|
||||
|
||||
__table_args__ = (
|
||||
Index(
|
||||
"ix_notifications_user_unread",
|
||||
"user_id",
|
||||
"created_at",
|
||||
postgresql_where="read_at IS NULL",
|
||||
),
|
||||
)
|
||||
37
backend/app/models/setting.py
Normal file
37
backend/app/models/setting.py
Normal file
@@ -0,0 +1,37 @@
|
||||
"""Platform settings (key/value JSONB) and admin-defined detection levels."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import Integer, String, Text
|
||||
from sqlalchemy.dialects.postgresql import JSONB
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.db.base import Base
|
||||
from app.db.mixins import TimestampMixin, UuidPkMixin
|
||||
|
||||
|
||||
class Setting(Base, TimestampMixin):
|
||||
__tablename__ = "settings"
|
||||
|
||||
key: Mapped[str] = mapped_column(String(80), primary_key=True)
|
||||
value: Mapped[Any] = mapped_column(JSONB, nullable=False)
|
||||
description: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
|
||||
|
||||
class DetectionLevel(Base, UuidPkMixin, TimestampMixin):
|
||||
"""Custom taxonomy admin can edit (cf. spec §F6).
|
||||
|
||||
Pre-seeded with detected_blocked / detected_alert / logged_only / not_detected.
|
||||
"""
|
||||
|
||||
__tablename__ = "detection_levels"
|
||||
|
||||
key: Mapped[str] = mapped_column(String(40), unique=True, nullable=False)
|
||||
label_fr: Mapped[str] = mapped_column(String(80), nullable=False)
|
||||
label_en: Mapped[str] = mapped_column(String(80), nullable=False)
|
||||
color_token: Mapped[str] = mapped_column(String(16), nullable=False)
|
||||
position: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||
is_default: Mapped[bool] = mapped_column(default=False, nullable=False)
|
||||
is_system: Mapped[bool] = mapped_column(default=False, nullable=False)
|
||||
174
backend/app/models/template.py
Normal file
174
backend/app/models/template.py
Normal file
@@ -0,0 +1,174 @@
|
||||
"""Reusable templates: test_templates and scenario_templates.
|
||||
|
||||
A `mission_scenarios` row is a snapshot copy of a `scenario_templates` row at
|
||||
mission-creation time. Templates can therefore be edited freely without
|
||||
disturbing already-running missions.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import (
|
||||
ARRAY,
|
||||
CheckConstraint,
|
||||
ForeignKey,
|
||||
Index,
|
||||
Integer,
|
||||
String,
|
||||
Text,
|
||||
UniqueConstraint,
|
||||
Uuid,
|
||||
)
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from app.db.base import Base
|
||||
from app.db.mixins import SoftDeleteMixin, TimestampMixin, UuidPkMixin
|
||||
from app.db.types import MITRE_KINDS, OPSEC_LEVELS
|
||||
|
||||
|
||||
class TestTemplate(Base, UuidPkMixin, TimestampMixin, SoftDeleteMixin):
|
||||
__tablename__ = "test_templates"
|
||||
|
||||
name: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
description: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
objective: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
procedure_md: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
prerequisites_md: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
expected_result_red_md: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
expected_detection_blue_md: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
opsec_level: Mapped[str] = mapped_column(String(8), default="medium", nullable=False)
|
||||
tags: Mapped[list[str]] = mapped_column(
|
||||
ARRAY(String(64)), nullable=False, server_default="{}"
|
||||
)
|
||||
expected_iocs: Mapped[list[str]] = mapped_column(
|
||||
ARRAY(String(255)), nullable=False, server_default="{}"
|
||||
)
|
||||
|
||||
mitre_tags: Mapped[list["TestTemplateMitreTag"]] = relationship(
|
||||
back_populates="test_template",
|
||||
cascade="all, delete-orphan",
|
||||
lazy="selectin",
|
||||
)
|
||||
|
||||
__table_args__ = (
|
||||
CheckConstraint(
|
||||
f"opsec_level IN ({', '.join(repr(v) for v in OPSEC_LEVELS)})",
|
||||
name="opsec_level_valid",
|
||||
),
|
||||
Index("ix_test_templates_active", "deleted_at", postgresql_where="deleted_at IS NULL"),
|
||||
Index("ix_test_templates_name", "name"),
|
||||
)
|
||||
|
||||
|
||||
class TestTemplateMitreTag(Base):
|
||||
"""Polymorphic MITRE tag on a test template.
|
||||
|
||||
Exactly one of `tactic_id`, `technique_id`, `subtechnique_id` is set —
|
||||
enforced by the CHECK constraint. This keeps FK integrity per MITRE level
|
||||
while letting a single conceptual table answer "what's tagged on this test".
|
||||
"""
|
||||
|
||||
__tablename__ = "test_template_mitre_tags"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(
|
||||
Uuid(as_uuid=True), primary_key=True, default=uuid.uuid4, nullable=False
|
||||
)
|
||||
test_template_id: Mapped[uuid.UUID] = mapped_column(
|
||||
Uuid(as_uuid=True),
|
||||
ForeignKey("test_templates.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
)
|
||||
mitre_kind: Mapped[str] = mapped_column(String(16), nullable=False)
|
||||
tactic_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||
Uuid(as_uuid=True), ForeignKey("mitre_tactics.id", ondelete="CASCADE"), nullable=True
|
||||
)
|
||||
technique_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||
Uuid(as_uuid=True), ForeignKey("mitre_techniques.id", ondelete="CASCADE"), nullable=True
|
||||
)
|
||||
subtechnique_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||
Uuid(as_uuid=True),
|
||||
ForeignKey("mitre_subtechniques.id", ondelete="CASCADE"),
|
||||
nullable=True,
|
||||
)
|
||||
|
||||
test_template: Mapped[TestTemplate] = relationship(back_populates="mitre_tags")
|
||||
|
||||
__table_args__: Any = (
|
||||
CheckConstraint(
|
||||
f"mitre_kind IN ({', '.join(repr(v) for v in MITRE_KINDS)})",
|
||||
name="mitre_kind_valid",
|
||||
),
|
||||
CheckConstraint(
|
||||
"(CASE WHEN tactic_id IS NOT NULL THEN 1 ELSE 0 END) "
|
||||
"+ (CASE WHEN technique_id IS NOT NULL THEN 1 ELSE 0 END) "
|
||||
"+ (CASE WHEN subtechnique_id IS NOT NULL THEN 1 ELSE 0 END) = 1",
|
||||
name="exactly_one_mitre_fk",
|
||||
),
|
||||
UniqueConstraint(
|
||||
"test_template_id",
|
||||
"tactic_id",
|
||||
"technique_id",
|
||||
"subtechnique_id",
|
||||
name="uq_test_template_mitre_tag",
|
||||
),
|
||||
Index("ix_test_template_mitre_tags_template", "test_template_id"),
|
||||
)
|
||||
|
||||
|
||||
class ScenarioTemplate(Base, UuidPkMixin, TimestampMixin, SoftDeleteMixin):
|
||||
__tablename__ = "scenario_templates"
|
||||
|
||||
name: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
description: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
|
||||
tests: Mapped[list["ScenarioTemplateTest"]] = relationship(
|
||||
back_populates="scenario_template",
|
||||
cascade="all, delete-orphan",
|
||||
order_by="ScenarioTemplateTest.position",
|
||||
)
|
||||
|
||||
__table_args__ = (
|
||||
Index(
|
||||
"ix_scenario_templates_active",
|
||||
"deleted_at",
|
||||
postgresql_where="deleted_at IS NULL",
|
||||
),
|
||||
Index("ix_scenario_templates_name", "name"),
|
||||
)
|
||||
|
||||
|
||||
class ScenarioTemplateTest(Base, UuidPkMixin):
|
||||
"""Ordered membership of a test template inside a scenario template.
|
||||
|
||||
UUID PK + UNIQUE(scenario_template_id, position) lets the same test appear
|
||||
multiple times at different positions (chained operations are common in
|
||||
purple-team scenarios).
|
||||
"""
|
||||
|
||||
__tablename__ = "scenario_template_tests"
|
||||
|
||||
scenario_template_id: Mapped[uuid.UUID] = mapped_column(
|
||||
Uuid(as_uuid=True),
|
||||
ForeignKey("scenario_templates.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
)
|
||||
test_template_id: Mapped[uuid.UUID] = mapped_column(
|
||||
Uuid(as_uuid=True),
|
||||
ForeignKey("test_templates.id", ondelete="RESTRICT"),
|
||||
nullable=False,
|
||||
)
|
||||
position: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||
|
||||
scenario_template: Mapped[ScenarioTemplate] = relationship(back_populates="tests")
|
||||
|
||||
__table_args__ = (
|
||||
UniqueConstraint(
|
||||
"scenario_template_id",
|
||||
"position",
|
||||
name="uq_scenario_template_tests_position",
|
||||
),
|
||||
Index("ix_scenario_template_tests_scenario", "scenario_template_id"),
|
||||
Index("ix_scenario_template_tests_test", "test_template_id"),
|
||||
)
|
||||
0
backend/app/services/__init__.py
Normal file
0
backend/app/services/__init__.py
Normal file
224
backend/app/services/auth.py
Normal file
224
backend/app/services/auth.py
Normal file
@@ -0,0 +1,224 @@
|
||||
"""Auth domain logic: login, refresh rotation, logout, change_password.
|
||||
|
||||
Returns lightweight DTOs (dicts) — the API layer is responsible for HTTP shape.
|
||||
Raises plain `ValueError` / `LookupError` / `PermissionError` and lets the API
|
||||
layer translate them into HTTP statuses.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.core.jwt_tokens import (
|
||||
REFRESH_TOKEN_TTL,
|
||||
decode_token,
|
||||
encode_token,
|
||||
generate_jti,
|
||||
)
|
||||
from app.core.security import (
|
||||
hash_opaque_token,
|
||||
hash_password,
|
||||
needs_rehash,
|
||||
verify_opaque_token,
|
||||
verify_password,
|
||||
)
|
||||
from app.db.session import session_scope
|
||||
from app.models.auth import RefreshToken, User
|
||||
|
||||
|
||||
class AuthError(Exception):
|
||||
"""Base for auth-flow exceptions; HTTP layer maps to 401/403."""
|
||||
|
||||
|
||||
class InvalidCredentials(AuthError):
|
||||
pass
|
||||
|
||||
|
||||
class TokenRevoked(AuthError):
|
||||
pass
|
||||
|
||||
|
||||
@dataclass
|
||||
class TokenPair:
|
||||
access_token: str
|
||||
refresh_token: str
|
||||
refresh_expires_at: datetime
|
||||
user_id: uuid.UUID
|
||||
|
||||
|
||||
def _now() -> datetime:
|
||||
return datetime.now(tz=timezone.utc)
|
||||
|
||||
|
||||
# === Login ===================================================================
|
||||
|
||||
|
||||
def login(email: str, password: str) -> TokenPair:
|
||||
email_norm = email.strip().lower()
|
||||
with session_scope() as s:
|
||||
user = s.scalar(
|
||||
select(User).where(
|
||||
User.email == email_norm,
|
||||
User.deleted_at.is_(None),
|
||||
User.is_active.is_(True),
|
||||
)
|
||||
)
|
||||
if user is None or not verify_password(user.password_hash, password):
|
||||
# Same error for "no such user" and "wrong password" — no account enumeration.
|
||||
raise InvalidCredentials("invalid credentials")
|
||||
|
||||
if needs_rehash(user.password_hash):
|
||||
user.password_hash = hash_password(password)
|
||||
|
||||
return _issue_token_pair(s, user.id)
|
||||
|
||||
|
||||
# === Refresh rotation ========================================================
|
||||
|
||||
|
||||
def refresh(raw_refresh_token: str) -> TokenPair:
|
||||
"""Validate the refresh token, revoke the old one, mint a new pair.
|
||||
|
||||
Detects token reuse: if a refresh token that has already been rotated is
|
||||
presented again, we revoke the entire chain (treat as compromise).
|
||||
"""
|
||||
try:
|
||||
claims = decode_token(raw_refresh_token, expected_type="refresh")
|
||||
except Exception as e:
|
||||
raise InvalidCredentials("invalid refresh token") from e
|
||||
|
||||
token_hash = hash_opaque_token(raw_refresh_token)
|
||||
|
||||
with session_scope() as s:
|
||||
rt = s.scalar(
|
||||
select(RefreshToken).where(
|
||||
RefreshToken.jti == claims.jti,
|
||||
RefreshToken.token_hash == token_hash,
|
||||
)
|
||||
)
|
||||
if rt is None:
|
||||
raise InvalidCredentials("refresh token not recognised")
|
||||
|
||||
if rt.revoked_at is not None:
|
||||
# Reuse of a revoked token → likely compromise. Cascade-revoke chain.
|
||||
_revoke_chain(s, rt)
|
||||
raise TokenRevoked("refresh token has been revoked")
|
||||
|
||||
if rt.expires_at <= _now():
|
||||
raise InvalidCredentials("refresh token expired")
|
||||
|
||||
# Rotate: mark old as revoked + replaced_by, mint new.
|
||||
new_pair = _issue_token_pair(s, rt.user_id)
|
||||
new_jti = decode_token(new_pair.refresh_token, expected_type="refresh").jti
|
||||
new_rt = s.scalar(select(RefreshToken).where(RefreshToken.jti == new_jti))
|
||||
rt.revoked_at = _now()
|
||||
rt.replaced_by_id = new_rt.id if new_rt else None
|
||||
return new_pair
|
||||
|
||||
|
||||
# === Logout ==================================================================
|
||||
|
||||
|
||||
def logout(raw_refresh_token: str) -> None:
|
||||
"""Revoke the refresh token. Idempotent — silently no-ops on bad tokens."""
|
||||
try:
|
||||
claims = decode_token(raw_refresh_token, expected_type="refresh")
|
||||
except Exception:
|
||||
return
|
||||
with session_scope() as s:
|
||||
rt = s.scalar(select(RefreshToken).where(RefreshToken.jti == claims.jti))
|
||||
if rt is not None and rt.revoked_at is None:
|
||||
rt.revoked_at = _now()
|
||||
|
||||
|
||||
def logout_all_for_user(user_id: uuid.UUID) -> int:
|
||||
"""Revoke every active refresh token for a user. Returns count revoked."""
|
||||
now = _now()
|
||||
with session_scope() as s:
|
||||
active = s.scalars(
|
||||
select(RefreshToken).where(
|
||||
RefreshToken.user_id == user_id,
|
||||
RefreshToken.revoked_at.is_(None),
|
||||
)
|
||||
).all()
|
||||
for rt in active:
|
||||
rt.revoked_at = now
|
||||
return len(active)
|
||||
|
||||
|
||||
# === Password change ========================================================
|
||||
|
||||
|
||||
def change_password(user_id: uuid.UUID, current: str, new: str) -> None:
|
||||
if len(new) < 8:
|
||||
raise ValueError("new password must be at least 8 characters")
|
||||
with session_scope() as s:
|
||||
user = s.get(User, user_id)
|
||||
if user is None or user.deleted_at is not None or not user.is_active:
|
||||
raise LookupError("user not found")
|
||||
if not verify_password(user.password_hash, current):
|
||||
raise InvalidCredentials("current password is incorrect")
|
||||
user.password_hash = hash_password(new)
|
||||
# Force re-login on every other device.
|
||||
logout_all_for_user(user_id)
|
||||
|
||||
|
||||
# === Helpers =================================================================
|
||||
|
||||
|
||||
def _issue_token_pair(s, user_id: uuid.UUID) -> TokenPair:
|
||||
"""Issue a fresh access + refresh pair. The refresh row is persisted."""
|
||||
access_jti = generate_jti()
|
||||
refresh_jti = generate_jti()
|
||||
access_token, _ = encode_token(user_id, "access", jti=access_jti)
|
||||
refresh_token, refresh_claims = encode_token(user_id, "refresh", jti=refresh_jti)
|
||||
|
||||
s.add(
|
||||
RefreshToken(
|
||||
user_id=user_id,
|
||||
jti=refresh_jti,
|
||||
token_hash=hash_opaque_token(refresh_token),
|
||||
issued_at=refresh_claims.iat,
|
||||
expires_at=refresh_claims.exp,
|
||||
)
|
||||
)
|
||||
s.flush() # ensure the row gets an id before we return
|
||||
|
||||
return TokenPair(
|
||||
access_token=access_token,
|
||||
refresh_token=refresh_token,
|
||||
refresh_expires_at=refresh_claims.exp,
|
||||
user_id=user_id,
|
||||
)
|
||||
|
||||
|
||||
def _revoke_chain(s, rt: RefreshToken) -> None:
|
||||
"""When reuse is detected, revoke this token and its replacement chain."""
|
||||
seen: set[uuid.UUID] = set()
|
||||
cur: RefreshToken | None = rt
|
||||
while cur is not None and cur.id not in seen:
|
||||
seen.add(cur.id)
|
||||
if cur.revoked_at is None:
|
||||
cur.revoked_at = _now()
|
||||
if cur.replaced_by_id:
|
||||
cur = s.get(RefreshToken, cur.replaced_by_id)
|
||||
else:
|
||||
cur = None
|
||||
|
||||
|
||||
__all__ = [
|
||||
"AuthError",
|
||||
"InvalidCredentials",
|
||||
"TokenRevoked",
|
||||
"TokenPair",
|
||||
"REFRESH_TOKEN_TTL",
|
||||
"login",
|
||||
"refresh",
|
||||
"logout",
|
||||
"logout_all_for_user",
|
||||
"change_password",
|
||||
]
|
||||
98
backend/app/services/bootstrap.py
Normal file
98
backend/app/services/bootstrap.py
Normal file
@@ -0,0 +1,98 @@
|
||||
"""Initial bootstrap : seed `admin` / `redteam` / `blueteam` system groups + first admin.
|
||||
|
||||
The detailed permission seeding lives in M3 (`mitre.sync` etc.); for M2 we only
|
||||
need an `admin` group that effectively grants full access. We model that as an
|
||||
absent permission set + a special `is_system` flag on the group, plus the
|
||||
`@require_perm` decorator that bypasses checks for any user belonging to a
|
||||
system `admin` group. M3 will fill in the atomic permissions.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from dataclasses import dataclass
|
||||
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.core.install_token import (
|
||||
mark_install_token_consumed,
|
||||
verify_install_token,
|
||||
)
|
||||
from app.core.security import hash_password
|
||||
from app.db.session import session_scope
|
||||
from app.models.auth import Group, User, UserGroup
|
||||
|
||||
ADMIN_GROUP_NAME = "admin"
|
||||
REDTEAM_GROUP_NAME = "redteam"
|
||||
BLUETEAM_GROUP_NAME = "blueteam"
|
||||
|
||||
|
||||
@dataclass
|
||||
class BootstrapResult:
|
||||
user_id: uuid.UUID
|
||||
admin_group_id: uuid.UUID
|
||||
|
||||
|
||||
class BootstrapError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
def ensure_system_groups() -> dict[str, uuid.UUID]:
|
||||
"""Create the three system groups if missing. Idempotent."""
|
||||
out: dict[str, uuid.UUID] = {}
|
||||
with session_scope() as s:
|
||||
for name, desc in (
|
||||
(ADMIN_GROUP_NAME, "Platform administrators — full access."),
|
||||
(REDTEAM_GROUP_NAME, "Red team operators."),
|
||||
(BLUETEAM_GROUP_NAME, "Blue team operators."),
|
||||
):
|
||||
grp = s.scalar(select(Group).where(Group.name == name, Group.is_system.is_(True)))
|
||||
if grp is None:
|
||||
grp = Group(name=name, description=desc, is_system=True)
|
||||
s.add(grp)
|
||||
s.flush()
|
||||
out[name] = grp.id
|
||||
return out
|
||||
|
||||
|
||||
def bootstrap_admin(
|
||||
*, install_token: str, email: str, password: str, display_name: str | None = None
|
||||
) -> BootstrapResult:
|
||||
"""Consume the install token, create the first admin user, attach to admin group."""
|
||||
if not verify_install_token(install_token):
|
||||
raise BootstrapError("invalid or already-consumed install token")
|
||||
if len(password) < 8:
|
||||
raise ValueError("password must be at least 8 characters")
|
||||
|
||||
email_norm = email.strip().lower()
|
||||
|
||||
# Re-check users count under transaction to avoid races.
|
||||
with session_scope() as s:
|
||||
if s.scalar(select(User.id).limit(1)) is not None:
|
||||
raise BootstrapError("setup already done — at least one user exists")
|
||||
|
||||
groups = ensure_system_groups()
|
||||
|
||||
with session_scope() as s:
|
||||
user = User(
|
||||
email=email_norm,
|
||||
display_name=(display_name or "").strip() or None,
|
||||
password_hash=hash_password(password),
|
||||
)
|
||||
s.add(user)
|
||||
s.flush()
|
||||
s.add(UserGroup(user_id=user.id, group_id=groups[ADMIN_GROUP_NAME]))
|
||||
admin_id = groups[ADMIN_GROUP_NAME]
|
||||
user_id = user.id
|
||||
|
||||
mark_install_token_consumed()
|
||||
|
||||
# Re-seed the permission catalogue + system-group bindings. This is called
|
||||
# at boot too, but on a fresh DB after `/diag/reset` the groups were just
|
||||
# recreated above and have no permissions yet — seeding here keeps the
|
||||
# bootstrap path self-contained.
|
||||
from app.services.permissions_seed import seed_all # noqa: PLC0415 — avoid import cycle
|
||||
|
||||
seed_all()
|
||||
|
||||
return BootstrapResult(user_id=user_id, admin_group_id=admin_id)
|
||||
210
backend/app/services/groups.py
Normal file
210
backend/app/services/groups.py
Normal file
@@ -0,0 +1,210 @@
|
||||
"""Admin-side group management: CRUD + permission bindings.
|
||||
|
||||
System groups (`is_system=True`: admin, redteam, blueteam) cannot be renamed
|
||||
or deleted, but their permission bindings are seeded on boot and editable
|
||||
afterwards (e.g. an admin can broaden `redteam`).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from sqlalchemy import func, select
|
||||
|
||||
from app.db.session import session_scope
|
||||
from app.models.auth import Group, GroupPermission, Permission, UserGroup
|
||||
from app.services.bootstrap import ADMIN_GROUP_NAME
|
||||
|
||||
|
||||
class GroupNotFound(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class GroupNameConflict(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class SystemGroupProtected(Exception):
|
||||
"""Refusing to delete or rename a built-in system group."""
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class GroupView:
|
||||
id: uuid.UUID
|
||||
name: str
|
||||
description: str | None
|
||||
is_system: bool
|
||||
deleted_at: datetime | None
|
||||
members_count: int
|
||||
permissions: list[str]
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
|
||||
def _to_view(g: Group, members_count: int) -> GroupView:
|
||||
return GroupView(
|
||||
id=g.id,
|
||||
name=g.name,
|
||||
description=g.description,
|
||||
is_system=g.is_system,
|
||||
deleted_at=g.deleted_at,
|
||||
members_count=members_count,
|
||||
permissions=sorted(p.code for p in g.permissions),
|
||||
created_at=g.created_at,
|
||||
updated_at=g.updated_at,
|
||||
)
|
||||
|
||||
|
||||
def _members_counts(s, group_ids: list[uuid.UUID]) -> dict[uuid.UUID, int]:
|
||||
if not group_ids:
|
||||
return {}
|
||||
from app.models.auth import User as _U # local to avoid model cycles
|
||||
|
||||
rows = s.execute(
|
||||
select(UserGroup.group_id, func.count(UserGroup.user_id))
|
||||
.join(_U, _U.id == UserGroup.user_id)
|
||||
.where(UserGroup.group_id.in_(group_ids), _U.deleted_at.is_(None))
|
||||
.group_by(UserGroup.group_id)
|
||||
).all()
|
||||
return {gid: int(cnt) for gid, cnt in rows}
|
||||
|
||||
|
||||
def list_groups(*, include_deleted: bool = False) -> list[GroupView]:
|
||||
with session_scope() as s:
|
||||
stmt = select(Group).order_by(Group.is_system.desc(), Group.name.asc())
|
||||
if not include_deleted:
|
||||
stmt = stmt.where(Group.deleted_at.is_(None))
|
||||
rows = s.scalars(stmt).all()
|
||||
counts = _members_counts(s, [g.id for g in rows])
|
||||
return [_to_view(g, counts.get(g.id, 0)) for g in rows]
|
||||
|
||||
|
||||
def get_group(group_id: uuid.UUID) -> GroupView:
|
||||
with session_scope() as s:
|
||||
g = s.get(Group, group_id)
|
||||
if g is None or g.deleted_at is not None:
|
||||
raise GroupNotFound()
|
||||
counts = _members_counts(s, [g.id])
|
||||
return _to_view(g, counts.get(g.id, 0))
|
||||
|
||||
|
||||
def create_group(*, name: str, description: str | None) -> GroupView:
|
||||
name_norm = name.strip()
|
||||
if not name_norm:
|
||||
raise ValueError("name is required")
|
||||
with session_scope() as s:
|
||||
existing = s.scalar(
|
||||
select(Group).where(Group.name == name_norm, Group.deleted_at.is_(None))
|
||||
)
|
||||
if existing is not None:
|
||||
raise GroupNameConflict(f"group name {name_norm!r} already in use")
|
||||
g = Group(name=name_norm, description=(description or "").strip() or None, is_system=False)
|
||||
s.add(g)
|
||||
s.flush()
|
||||
return _to_view(g, 0)
|
||||
|
||||
|
||||
def update_group(
|
||||
group_id: uuid.UUID,
|
||||
*,
|
||||
name: str | None = None,
|
||||
description: str | None | object = ...,
|
||||
) -> GroupView:
|
||||
with session_scope() as s:
|
||||
g = s.get(Group, group_id)
|
||||
if g is None or g.deleted_at is not None:
|
||||
raise GroupNotFound()
|
||||
if name is not None:
|
||||
name_norm = name.strip()
|
||||
if not name_norm:
|
||||
raise ValueError("name cannot be empty")
|
||||
if g.is_system and name_norm != g.name:
|
||||
raise SystemGroupProtected("system groups cannot be renamed")
|
||||
if name_norm != g.name:
|
||||
clash = s.scalar(
|
||||
select(Group).where(
|
||||
Group.name == name_norm,
|
||||
Group.deleted_at.is_(None),
|
||||
Group.id != g.id,
|
||||
)
|
||||
)
|
||||
if clash is not None:
|
||||
raise GroupNameConflict(f"group name {name_norm!r} already in use")
|
||||
g.name = name_norm
|
||||
if description is not ...:
|
||||
if description in (None, ""):
|
||||
g.description = None
|
||||
else:
|
||||
g.description = description.strip() or None
|
||||
|
||||
counts = _members_counts(s, [g.id])
|
||||
return _to_view(g, counts.get(g.id, 0))
|
||||
|
||||
|
||||
def soft_delete_group(group_id: uuid.UUID) -> None:
|
||||
with session_scope() as s:
|
||||
g = s.get(Group, group_id)
|
||||
if g is None or g.deleted_at is not None:
|
||||
raise GroupNotFound()
|
||||
if g.is_system:
|
||||
raise SystemGroupProtected("system groups cannot be deleted")
|
||||
g.deleted_at = datetime.now(tz=timezone.utc)
|
||||
|
||||
|
||||
def set_group_permissions(group_id: uuid.UUID, codes: list[str]) -> GroupView:
|
||||
"""Replace the group's permission set with the given codes (validated)."""
|
||||
desired_codes = set(codes)
|
||||
with session_scope() as s:
|
||||
g = s.get(Group, group_id)
|
||||
if g is None or g.deleted_at is not None:
|
||||
raise GroupNotFound()
|
||||
|
||||
# Preserve the invariant "the system `admin` group has every perm." The
|
||||
# decorator's admin bypass relies on `is_admin` (group membership), not
|
||||
# on the perm set, so a stripped admin group would still grant access —
|
||||
# but the listing would look misleading and a future refactor could
|
||||
# reasonably switch the bypass to a perm-based check.
|
||||
if g.is_system and g.name == ADMIN_GROUP_NAME:
|
||||
all_codes = {p.code for p in s.scalars(select(Permission)).all()}
|
||||
if desired_codes != all_codes:
|
||||
raise SystemGroupProtected(
|
||||
"the admin group must keep every permission"
|
||||
)
|
||||
|
||||
if desired_codes:
|
||||
perms = s.scalars(select(Permission).where(Permission.code.in_(desired_codes))).all()
|
||||
known = {p.code for p in perms}
|
||||
unknown = desired_codes - known
|
||||
if unknown:
|
||||
raise ValueError(f"unknown permission codes: {sorted(unknown)}")
|
||||
else:
|
||||
perms = []
|
||||
|
||||
current = {p.code: p for p in g.permissions}
|
||||
to_remove = set(current) - desired_codes
|
||||
to_add = desired_codes - set(current)
|
||||
|
||||
for code in to_remove:
|
||||
row = s.get(GroupPermission, (g.id, current[code].id))
|
||||
if row is not None:
|
||||
s.delete(row)
|
||||
for p in perms:
|
||||
if p.code in to_add:
|
||||
s.add(GroupPermission(group_id=g.id, permission_id=p.id))
|
||||
|
||||
s.flush()
|
||||
s.refresh(g)
|
||||
counts = _members_counts(s, [g.id])
|
||||
return _to_view(g, counts.get(g.id, 0))
|
||||
|
||||
|
||||
def list_permissions() -> list[dict]:
|
||||
"""Return the catalogue of all permissions known to the platform."""
|
||||
with session_scope() as s:
|
||||
rows = s.scalars(select(Permission).order_by(Permission.code.asc())).all()
|
||||
return [
|
||||
{"id": str(p.id), "code": p.code, "description": p.description}
|
||||
for p in rows
|
||||
]
|
||||
188
backend/app/services/invitations.py
Normal file
188
backend/app/services/invitations.py
Normal file
@@ -0,0 +1,188 @@
|
||||
"""Invitation flow: admin issues a one-shot URL token, invitee accepts.
|
||||
|
||||
The raw token is shown to the admin once (returned by `create_invitation`)
|
||||
and never persisted — only its SHA-256 lives in the DB. Pre-assigned groups
|
||||
are attached at creation and applied at acceptance.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Iterable
|
||||
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.core.security import (
|
||||
generate_opaque_token,
|
||||
hash_opaque_token,
|
||||
hash_password,
|
||||
)
|
||||
from app.db.session import session_scope
|
||||
from app.models.auth import Group, Invitation, InvitationGroup, User, UserGroup
|
||||
|
||||
INVITATION_TTL = timedelta(days=7)
|
||||
|
||||
|
||||
class InvitationError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class InvitationExpired(InvitationError):
|
||||
pass
|
||||
|
||||
|
||||
class InvitationConsumed(InvitationError):
|
||||
pass
|
||||
|
||||
|
||||
class InvitationRevoked(InvitationError):
|
||||
pass
|
||||
|
||||
|
||||
@dataclass
|
||||
class InvitationCreated:
|
||||
invitation_id: uuid.UUID
|
||||
raw_token: str
|
||||
expires_at: datetime
|
||||
|
||||
|
||||
@dataclass
|
||||
class InvitationPreview:
|
||||
email_hint: str | None
|
||||
expires_at: datetime
|
||||
groups: list[str]
|
||||
is_valid: bool
|
||||
reason: str | None
|
||||
|
||||
|
||||
def _now() -> datetime:
|
||||
return datetime.now(tz=timezone.utc)
|
||||
|
||||
|
||||
def create_invitation(
|
||||
*,
|
||||
created_by_user_id: uuid.UUID,
|
||||
email_hint: str | None,
|
||||
group_ids: Iterable[uuid.UUID] = (),
|
||||
ttl: timedelta = INVITATION_TTL,
|
||||
) -> InvitationCreated:
|
||||
raw = generate_opaque_token()
|
||||
expires_at = _now() + ttl
|
||||
with session_scope() as s:
|
||||
inv = Invitation(
|
||||
token_hash=hash_opaque_token(raw),
|
||||
email_hint=email_hint.strip().lower() if email_hint else None,
|
||||
created_by_user_id=created_by_user_id,
|
||||
expires_at=expires_at,
|
||||
)
|
||||
s.add(inv)
|
||||
s.flush()
|
||||
for gid in group_ids:
|
||||
s.add(InvitationGroup(invitation_id=inv.id, group_id=gid))
|
||||
return InvitationCreated(
|
||||
invitation_id=inv.id,
|
||||
raw_token=raw,
|
||||
expires_at=expires_at,
|
||||
)
|
||||
|
||||
|
||||
def _load_by_token(s, raw_token: str) -> Invitation | None:
|
||||
return s.scalar(
|
||||
select(Invitation).where(Invitation.token_hash == hash_opaque_token(raw_token))
|
||||
)
|
||||
|
||||
|
||||
def preview(raw_token: str) -> InvitationPreview:
|
||||
with session_scope() as s:
|
||||
inv = _load_by_token(s, raw_token)
|
||||
if inv is None:
|
||||
return InvitationPreview(None, _now(), [], False, "not_found")
|
||||
groups = [g.name for g in inv.pre_assigned_groups]
|
||||
if inv.revoked_at is not None:
|
||||
return InvitationPreview(inv.email_hint, inv.expires_at, groups, False, "revoked")
|
||||
if inv.consumed_at is not None:
|
||||
return InvitationPreview(inv.email_hint, inv.expires_at, groups, False, "consumed")
|
||||
if inv.expires_at <= _now():
|
||||
return InvitationPreview(inv.email_hint, inv.expires_at, groups, False, "expired")
|
||||
return InvitationPreview(inv.email_hint, inv.expires_at, groups, True, None)
|
||||
|
||||
|
||||
def accept(raw_token: str, *, email: str, password: str, display_name: str | None) -> uuid.UUID:
|
||||
"""Create the user, attach pre-assigned groups, mark invitation consumed."""
|
||||
if len(password) < 8:
|
||||
raise ValueError("password must be at least 8 characters")
|
||||
|
||||
email_norm = email.strip().lower()
|
||||
with session_scope() as s:
|
||||
inv = _load_by_token(s, raw_token)
|
||||
if inv is None:
|
||||
raise InvitationError("invitation not found")
|
||||
if inv.revoked_at is not None:
|
||||
raise InvitationRevoked("invitation revoked")
|
||||
if inv.consumed_at is not None:
|
||||
raise InvitationConsumed("invitation already consumed")
|
||||
if inv.expires_at <= _now():
|
||||
raise InvitationExpired("invitation expired")
|
||||
|
||||
# Email must not be already in use among active users.
|
||||
existing = s.scalar(
|
||||
select(User).where(User.email == email_norm, User.deleted_at.is_(None))
|
||||
)
|
||||
if existing is not None:
|
||||
raise ValueError("email already in use")
|
||||
|
||||
user = User(
|
||||
email=email_norm,
|
||||
display_name=(display_name or "").strip() or None,
|
||||
password_hash=hash_password(password),
|
||||
)
|
||||
s.add(user)
|
||||
s.flush()
|
||||
|
||||
for grp in inv.pre_assigned_groups:
|
||||
s.add(UserGroup(user_id=user.id, group_id=grp.id))
|
||||
|
||||
inv.consumed_at = _now()
|
||||
inv.consumed_by_user_id = user.id
|
||||
return user.id
|
||||
|
||||
|
||||
def revoke(invitation_id: uuid.UUID) -> bool:
|
||||
with session_scope() as s:
|
||||
inv = s.get(Invitation, invitation_id)
|
||||
if inv is None:
|
||||
return False
|
||||
if inv.revoked_at is not None or inv.consumed_at is not None:
|
||||
return False
|
||||
inv.revoked_at = _now()
|
||||
return True
|
||||
|
||||
|
||||
def list_active(*, limit: int = 100) -> list[Invitation]:
|
||||
with session_scope() as s:
|
||||
rows = s.scalars(
|
||||
select(Invitation)
|
||||
.where(
|
||||
Invitation.consumed_at.is_(None),
|
||||
Invitation.revoked_at.is_(None),
|
||||
Invitation.expires_at > _now(),
|
||||
)
|
||||
.order_by(Invitation.created_at.desc())
|
||||
.limit(limit)
|
||||
).all()
|
||||
# detach so caller can read after session closes
|
||||
for r in rows:
|
||||
s.expunge(r)
|
||||
for g in r.pre_assigned_groups:
|
||||
s.expunge(g)
|
||||
return list(rows)
|
||||
|
||||
|
||||
def find_group_id_by_name(name: str) -> uuid.UUID | None:
|
||||
with session_scope() as s:
|
||||
gid = s.scalar(
|
||||
select(Group.id).where(Group.name == name, Group.deleted_at.is_(None))
|
||||
)
|
||||
return gid
|
||||
179
backend/app/services/permissions_seed.py
Normal file
179
backend/app/services/permissions_seed.py
Normal file
@@ -0,0 +1,179 @@
|
||||
"""Atomic permission catalogue + seed for the 3 default system groups.
|
||||
|
||||
Permissions follow the `<entity>.<action>` convention. They are the ground truth
|
||||
checked by `@require_perm`; admins bypass everything (cf. `auth_decorators.py`).
|
||||
|
||||
This module is the single place that lists every permission code shipped with
|
||||
the platform. To add a new perm in a future milestone:
|
||||
|
||||
1. Add an entry to `PERMISSION_CATALOGUE`.
|
||||
2. Decide which system group(s) should get it by default — edit
|
||||
`_default_redteam_perms()` / `_default_blueteam_perms()` if relevant
|
||||
(admin always gets everything, so no edit needed there).
|
||||
3. The next boot picks it up; existing groups are *upgraded* (perms added),
|
||||
never downgraded (we never remove perms from a system group, even if you
|
||||
trim the catalogue — that would be a destructive op disguised as a seed).
|
||||
|
||||
The seed is idempotent and safe to call on every boot.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.db.session import session_scope
|
||||
from app.models.auth import Group, GroupPermission, Permission
|
||||
from app.services.bootstrap import (
|
||||
ADMIN_GROUP_NAME,
|
||||
BLUETEAM_GROUP_NAME,
|
||||
REDTEAM_GROUP_NAME,
|
||||
)
|
||||
|
||||
log = logging.getLogger("metamorph.permissions")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PermissionDef:
|
||||
code: str
|
||||
description: str
|
||||
|
||||
|
||||
# === Catalogue ================================================================
|
||||
#
|
||||
# Order is presentation-only; the seed is idempotent. Grouped by family to keep
|
||||
# diffs reviewable and to mirror the admin UI grouping in M3.6.
|
||||
#
|
||||
PERMISSION_CATALOGUE: tuple[PermissionDef, ...] = (
|
||||
# users
|
||||
PermissionDef("user.read", "View users."),
|
||||
PermissionDef("user.create", "Create users (typically via invitation)."),
|
||||
PermissionDef("user.update", "Update user metadata (display name, locale, active flag)."),
|
||||
PermissionDef("user.delete", "Soft-delete a user."),
|
||||
# groups
|
||||
PermissionDef("group.read", "View groups and their permissions."),
|
||||
PermissionDef("group.create", "Create a custom group."),
|
||||
PermissionDef("group.update", "Edit a custom group (name, description, permissions, members)."),
|
||||
PermissionDef("group.delete", "Soft-delete a custom group."),
|
||||
# invitations
|
||||
PermissionDef("invitation.read", "View pending invitations."),
|
||||
PermissionDef("invitation.create", "Issue a new invitation URL."),
|
||||
PermissionDef("invitation.revoke", "Revoke an unconsumed invitation."),
|
||||
# test templates
|
||||
PermissionDef("test_template.read", "View the test-template catalogue."),
|
||||
PermissionDef("test_template.create", "Create a test template."),
|
||||
PermissionDef("test_template.update", "Edit a test template."),
|
||||
PermissionDef("test_template.delete", "Soft-delete a test template."),
|
||||
# scenario templates
|
||||
PermissionDef("scenario_template.read", "View the scenario-template catalogue."),
|
||||
PermissionDef("scenario_template.create", "Create a scenario template."),
|
||||
PermissionDef("scenario_template.update", "Edit a scenario template (and its ordered tests)."),
|
||||
PermissionDef("scenario_template.delete", "Soft-delete a scenario template."),
|
||||
# missions
|
||||
PermissionDef("mission.read", "View missions (server still filters by membership for non-admin)."),
|
||||
PermissionDef("mission.create", "Create a mission."),
|
||||
PermissionDef("mission.update", "Edit mission metadata, scenarios, members."),
|
||||
PermissionDef("mission.archive", "Move a mission to status=archived."),
|
||||
PermissionDef("mission.delete", "Soft-delete a mission."),
|
||||
PermissionDef("mission.write_red_fields", "Write red-side fields on a mission test."),
|
||||
PermissionDef("mission.write_blue_fields", "Write blue-side fields and upload evidence."),
|
||||
# detection levels + platform settings + MITRE sync
|
||||
PermissionDef("detection_level.read", "View the detection-level taxonomy."),
|
||||
PermissionDef("detection_level.update", "Edit the detection-level taxonomy."),
|
||||
PermissionDef("setting.read", "Read platform settings."),
|
||||
PermissionDef("setting.update", "Update platform settings."),
|
||||
PermissionDef("mitre.sync", "Trigger a MITRE ATT&CK Enterprise re-sync."),
|
||||
)
|
||||
|
||||
|
||||
def _default_redteam_perms() -> frozenset[str]:
|
||||
return frozenset(
|
||||
{
|
||||
# catalogue read-only
|
||||
"test_template.read",
|
||||
"scenario_template.read",
|
||||
# MITRE/detection refs
|
||||
"detection_level.read",
|
||||
# missions: full lifecycle on red side
|
||||
"mission.read",
|
||||
"mission.create",
|
||||
"mission.update",
|
||||
"mission.archive",
|
||||
"mission.write_red_fields",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def _default_blueteam_perms() -> frozenset[str]:
|
||||
return frozenset(
|
||||
{
|
||||
"test_template.read",
|
||||
"scenario_template.read",
|
||||
"detection_level.read",
|
||||
"mission.read",
|
||||
"mission.write_blue_fields",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def _all_perm_codes() -> frozenset[str]:
|
||||
return frozenset(p.code for p in PERMISSION_CATALOGUE)
|
||||
|
||||
|
||||
def seed_permissions() -> dict[str, int]:
|
||||
"""Insert any missing permissions. Returns counts: `created`, `total`."""
|
||||
created = 0
|
||||
with session_scope() as s:
|
||||
existing_codes = set(s.scalars(select(Permission.code)).all())
|
||||
for p in PERMISSION_CATALOGUE:
|
||||
if p.code in existing_codes:
|
||||
continue
|
||||
s.add(Permission(code=p.code, description=p.description))
|
||||
created += 1
|
||||
return {"created": created, "total": len(PERMISSION_CATALOGUE)}
|
||||
|
||||
|
||||
def _assign_perms_to_group(group_name: str, codes: frozenset[str]) -> int:
|
||||
"""Attach the named perms to the given system group. Returns added count.
|
||||
|
||||
We never *remove* perms from a system group here — the seed is additive.
|
||||
Admins/operators who want to revoke must do so explicitly via the UI/API.
|
||||
"""
|
||||
if not codes:
|
||||
return 0
|
||||
added = 0
|
||||
with session_scope() as s:
|
||||
group = s.scalar(select(Group).where(Group.name == group_name, Group.is_system.is_(True)))
|
||||
if group is None:
|
||||
raise RuntimeError(f"system group {group_name!r} missing — call ensure_system_groups() first")
|
||||
|
||||
existing_codes = {p.code for p in group.permissions}
|
||||
perms = s.scalars(select(Permission).where(Permission.code.in_(codes))).all()
|
||||
for p in perms:
|
||||
if p.code in existing_codes:
|
||||
continue
|
||||
s.add(GroupPermission(group_id=group.id, permission_id=p.id))
|
||||
added += 1
|
||||
return added
|
||||
|
||||
|
||||
def seed_default_group_permissions() -> dict[str, int]:
|
||||
"""Bind the catalogue to the 3 default groups. Idempotent + additive."""
|
||||
counts: dict[str, int] = {}
|
||||
counts[ADMIN_GROUP_NAME] = _assign_perms_to_group(ADMIN_GROUP_NAME, _all_perm_codes())
|
||||
counts[REDTEAM_GROUP_NAME] = _assign_perms_to_group(REDTEAM_GROUP_NAME, _default_redteam_perms())
|
||||
counts[BLUETEAM_GROUP_NAME] = _assign_perms_to_group(BLUETEAM_GROUP_NAME, _default_blueteam_perms())
|
||||
return counts
|
||||
|
||||
|
||||
def seed_all() -> dict[str, dict[str, int]]:
|
||||
"""One-shot helper: catalogue + default group bindings."""
|
||||
perms = seed_permissions()
|
||||
bindings = seed_default_group_permissions()
|
||||
log.info(
|
||||
"metamorph.permissions.seeded",
|
||||
extra={"perms_created": perms["created"], "perms_total": perms["total"], "bindings": bindings},
|
||||
)
|
||||
return {"permissions": perms, "bindings": bindings}
|
||||
204
backend/app/services/users.py
Normal file
204
backend/app/services/users.py
Normal file
@@ -0,0 +1,204 @@
|
||||
"""Admin-side user management: list, get, update, soft-delete, assign groups.
|
||||
|
||||
Self-service updates (locale, password, display_name) live in
|
||||
`services.auth` — this module is for admin operations on other users.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timezone
|
||||
from typing import Iterable
|
||||
|
||||
from sqlalchemy import func, or_, select
|
||||
|
||||
from app.db.session import session_scope
|
||||
from app.models.auth import Group, User, UserGroup
|
||||
from app.services.bootstrap import ADMIN_GROUP_NAME
|
||||
|
||||
|
||||
class UserNotFound(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class LastAdminProtected(Exception):
|
||||
"""Refusing to strip admin from the last active admin."""
|
||||
|
||||
|
||||
class SystemGroupProtected(Exception):
|
||||
"""Refusing to delete or rename a built-in system group."""
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class UserView:
|
||||
id: uuid.UUID
|
||||
email: str
|
||||
display_name: str | None
|
||||
locale: str
|
||||
is_active: bool
|
||||
deleted_at: datetime | None
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
groups: list[tuple[uuid.UUID, str]]
|
||||
|
||||
|
||||
def _to_view(u: User) -> UserView:
|
||||
return UserView(
|
||||
id=u.id,
|
||||
email=u.email,
|
||||
display_name=u.display_name,
|
||||
locale=u.locale,
|
||||
is_active=u.is_active,
|
||||
deleted_at=u.deleted_at,
|
||||
created_at=u.created_at,
|
||||
updated_at=u.updated_at,
|
||||
groups=[(g.id, g.name) for g in u.groups if g.deleted_at is None],
|
||||
)
|
||||
|
||||
|
||||
def list_users(
|
||||
*,
|
||||
q: str | None = None,
|
||||
is_active: bool | None = None,
|
||||
include_deleted: bool = False,
|
||||
limit: int = 50,
|
||||
offset: int = 0,
|
||||
) -> tuple[list[UserView], int]:
|
||||
"""Return (rows, total_count) with case-insensitive search on email + display_name."""
|
||||
with session_scope() as s:
|
||||
stmt = select(User)
|
||||
count_stmt = select(func.count()).select_from(User)
|
||||
if not include_deleted:
|
||||
stmt = stmt.where(User.deleted_at.is_(None))
|
||||
count_stmt = count_stmt.where(User.deleted_at.is_(None))
|
||||
if is_active is not None:
|
||||
stmt = stmt.where(User.is_active.is_(is_active))
|
||||
count_stmt = count_stmt.where(User.is_active.is_(is_active))
|
||||
if q:
|
||||
like = f"%{q.lower()}%"
|
||||
stmt = stmt.where(
|
||||
or_(func.lower(User.email).like(like), func.lower(User.display_name).like(like))
|
||||
)
|
||||
count_stmt = count_stmt.where(
|
||||
or_(func.lower(User.email).like(like), func.lower(User.display_name).like(like))
|
||||
)
|
||||
stmt = stmt.order_by(User.email.asc()).limit(limit).offset(offset)
|
||||
rows = s.scalars(stmt).all()
|
||||
total = int(s.scalar(count_stmt) or 0)
|
||||
views = [_to_view(u) for u in rows]
|
||||
return views, total
|
||||
|
||||
|
||||
def get_user(user_id: uuid.UUID, *, include_deleted: bool = False) -> UserView:
|
||||
with session_scope() as s:
|
||||
u = s.get(User, user_id)
|
||||
if u is None or (u.deleted_at is not None and not include_deleted):
|
||||
raise UserNotFound()
|
||||
return _to_view(u)
|
||||
|
||||
|
||||
def update_user(
|
||||
user_id: uuid.UUID,
|
||||
*,
|
||||
display_name: str | None | object = ...,
|
||||
locale: str | None = None,
|
||||
is_active: bool | None = None,
|
||||
) -> UserView:
|
||||
"""Partial update. Pass display_name=None to clear; omit to leave unchanged."""
|
||||
with session_scope() as s:
|
||||
u = s.get(User, user_id)
|
||||
if u is None or u.deleted_at is not None:
|
||||
raise UserNotFound()
|
||||
if display_name is not ...:
|
||||
if display_name in (None, ""):
|
||||
u.display_name = None
|
||||
else:
|
||||
u.display_name = display_name.strip() or None
|
||||
if locale is not None:
|
||||
u.locale = locale
|
||||
if is_active is not None:
|
||||
# If deactivating the last active admin, refuse.
|
||||
if not is_active and _is_last_active_admin(s, u):
|
||||
raise LastAdminProtected("cannot deactivate the last active admin")
|
||||
u.is_active = is_active
|
||||
return _to_view(u)
|
||||
|
||||
|
||||
def soft_delete_user(user_id: uuid.UUID) -> None:
|
||||
with session_scope() as s:
|
||||
u = s.get(User, user_id)
|
||||
if u is None or u.deleted_at is not None:
|
||||
raise UserNotFound()
|
||||
if _is_last_active_admin(s, u):
|
||||
raise LastAdminProtected("cannot delete the last active admin")
|
||||
u.deleted_at = datetime.now(tz=timezone.utc)
|
||||
u.is_active = False
|
||||
|
||||
|
||||
def set_user_groups(user_id: uuid.UUID, group_ids: Iterable[uuid.UUID]) -> UserView:
|
||||
"""Replace the user's group memberships with the given set."""
|
||||
desired = set(group_ids)
|
||||
with session_scope() as s:
|
||||
u = s.get(User, user_id)
|
||||
if u is None or u.deleted_at is not None:
|
||||
raise UserNotFound()
|
||||
|
||||
# Resolve admin group id once.
|
||||
admin_group_id = s.scalar(
|
||||
select(Group.id).where(Group.name == ADMIN_GROUP_NAME, Group.is_system.is_(True))
|
||||
)
|
||||
is_currently_admin = admin_group_id in {g.id for g in u.groups}
|
||||
will_be_admin = admin_group_id in desired
|
||||
if is_currently_admin and not will_be_admin and _is_last_active_admin(s, u):
|
||||
raise LastAdminProtected("cannot remove admin from the last active admin")
|
||||
|
||||
# Refuse silently for unknown groups: validate first.
|
||||
if desired:
|
||||
known = set(
|
||||
s.scalars(
|
||||
select(Group.id).where(Group.id.in_(desired), Group.deleted_at.is_(None))
|
||||
).all()
|
||||
)
|
||||
unknown = desired - known
|
||||
if unknown:
|
||||
raise ValueError(f"unknown groups: {sorted(map(str, unknown))}")
|
||||
|
||||
current = {g.id for g in u.groups}
|
||||
to_add = desired - current
|
||||
to_remove = current - desired
|
||||
|
||||
for gid in to_remove:
|
||||
row = s.get(UserGroup, (u.id, gid))
|
||||
if row is not None:
|
||||
s.delete(row)
|
||||
for gid in to_add:
|
||||
s.add(UserGroup(user_id=u.id, group_id=gid))
|
||||
|
||||
s.flush()
|
||||
s.refresh(u)
|
||||
return _to_view(u)
|
||||
|
||||
|
||||
def _is_last_active_admin(s, user: User) -> bool:
|
||||
"""True when `user` is currently in the admin system group and removing/blocking
|
||||
them would leave the platform with zero active admins."""
|
||||
admin_group_id = s.scalar(
|
||||
select(Group.id).where(Group.name == ADMIN_GROUP_NAME, Group.is_system.is_(True))
|
||||
)
|
||||
if admin_group_id is None:
|
||||
return False
|
||||
if admin_group_id not in {g.id for g in user.groups}:
|
||||
return False
|
||||
other_admins = s.scalar(
|
||||
select(func.count())
|
||||
.select_from(User)
|
||||
.join(UserGroup, UserGroup.user_id == User.id)
|
||||
.where(
|
||||
UserGroup.group_id == admin_group_id,
|
||||
User.id != user.id,
|
||||
User.deleted_at.is_(None),
|
||||
User.is_active.is_(True),
|
||||
)
|
||||
)
|
||||
return int(other_admins or 0) == 0
|
||||
60
backend/pyproject.toml
Normal file
60
backend/pyproject.toml
Normal file
@@ -0,0 +1,60 @@
|
||||
[project]
|
||||
name = "metamorph-api"
|
||||
version = "0.1.0"
|
||||
description = "Metamorph backend API — collaborative purple-team platform."
|
||||
requires-python = ">=3.12"
|
||||
license = { text = "Proprietary" }
|
||||
|
||||
dependencies = [
|
||||
"flask>=3.0,<4.0",
|
||||
"flask-cors>=4.0,<5.0",
|
||||
"flask-limiter>=3.7,<4.0",
|
||||
"pydantic[email]>=2.6,<3.0",
|
||||
"pydantic-settings>=2.2,<3.0",
|
||||
"python-json-logger>=2.0,<3.0",
|
||||
"gunicorn>=21.2,<22.0",
|
||||
"sqlalchemy>=2.0,<3.0",
|
||||
"alembic>=1.13,<2.0",
|
||||
"psycopg[binary]>=3.1,<4.0",
|
||||
"argon2-cffi>=23.1,<25.0",
|
||||
"pyjwt>=2.8,<3.0",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
dev = [
|
||||
"pytest>=8.0,<9.0",
|
||||
"pytest-cov>=5.0,<6.0",
|
||||
"ruff>=0.4,<1.0",
|
||||
"httpx>=0.27,<1.0",
|
||||
]
|
||||
|
||||
[build-system]
|
||||
requires = ["hatchling"]
|
||||
build-backend = "hatchling.build"
|
||||
|
||||
[tool.hatch.build.targets.wheel]
|
||||
packages = ["app"]
|
||||
|
||||
[tool.ruff]
|
||||
line-length = 100
|
||||
target-version = "py312"
|
||||
src = ["app", "tests"]
|
||||
|
||||
[tool.ruff.lint]
|
||||
select = [
|
||||
"E", # pycodestyle
|
||||
"F", # pyflakes
|
||||
"I", # isort
|
||||
"B", # flake8-bugbear
|
||||
"UP", # pyupgrade
|
||||
"SIM", # flake8-simplify
|
||||
"RUF", # ruff-specific
|
||||
]
|
||||
ignore = ["E501"] # line length handled by formatter
|
||||
|
||||
[tool.ruff.format]
|
||||
quote-style = "double"
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
testpaths = ["tests"]
|
||||
addopts = "-q --strict-markers"
|
||||
0
backend/tests/__init__.py
Normal file
0
backend/tests/__init__.py
Normal file
25
backend/tests/conftest.py
Normal file
25
backend/tests/conftest.py
Normal file
@@ -0,0 +1,25 @@
|
||||
"""Shared pytest fixtures.
|
||||
|
||||
The DB integration tests need a reachable Postgres on the URL configured in
|
||||
`app.core.config.settings`. They are skipped automatically when the DB isn't up,
|
||||
so unit tests still pass on a developer's bare laptop.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
from sqlalchemy.exc import OperationalError
|
||||
|
||||
from app.db.session import get_engine
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def db_engine_or_skip():
|
||||
"""Yield the SQLAlchemy engine, skipping the test if the DB is unreachable."""
|
||||
engine = get_engine()
|
||||
try:
|
||||
with engine.connect() as conn:
|
||||
conn.execute.__self__ # touch the connection
|
||||
except OperationalError as e:
|
||||
pytest.skip(f"Postgres unreachable: {e}", allow_module_level=False)
|
||||
return engine
|
||||
299
backend/tests/test_auth_flow.py
Normal file
299
backend/tests/test_auth_flow.py
Normal file
@@ -0,0 +1,299 @@
|
||||
"""End-to-end auth flow integration test (live DB).
|
||||
|
||||
Hits the Flask test client to exercise:
|
||||
- /setup with the install token
|
||||
- /auth/login + /auth/me
|
||||
- /auth/refresh (rotation)
|
||||
- /auth/logout (revocation, idempotency)
|
||||
- /auth/change-password (forces logout-all)
|
||||
- /invitations create + preview + accept
|
||||
- RBAC: non-admin gets 403 on admin endpoint
|
||||
|
||||
The DB schema is left in place between tests; we use unique emails to avoid
|
||||
collisions across runs. The install token is force-minted at the start.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import secrets
|
||||
import uuid
|
||||
|
||||
import pytest
|
||||
from sqlalchemy import text
|
||||
|
||||
from app.core.install_token import regenerate_install_token
|
||||
from app.db.session import get_engine
|
||||
from app.main import create_app
|
||||
|
||||
|
||||
def _truncate_users(engine):
|
||||
"""Wipe data so /setup has work to do. CASCADE handles dependent rows."""
|
||||
with engine.begin() as conn:
|
||||
conn.execute(
|
||||
text(
|
||||
"TRUNCATE users, refresh_tokens, invitations, invitation_groups, "
|
||||
"user_groups, settings, groups RESTART IDENTITY CASCADE"
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def app(db_engine_or_skip):
|
||||
_truncate_users(db_engine_or_skip)
|
||||
flask_app = create_app()
|
||||
flask_app.config.update(TESTING=True)
|
||||
return flask_app
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def client(app):
|
||||
return app.test_client()
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def install_token(db_engine_or_skip):
|
||||
return regenerate_install_token()
|
||||
|
||||
|
||||
def _unique_email(prefix: str) -> str:
|
||||
return f"{prefix}-{secrets.token_hex(4)}@metamorph.local"
|
||||
|
||||
|
||||
# -- /setup -------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_setup_status_starts_uncompleted(client):
|
||||
r = client.get("/api/v1/setup")
|
||||
assert r.status_code == 200
|
||||
assert r.get_json()["completed"] is False
|
||||
|
||||
|
||||
def test_setup_creates_first_admin(client, install_token):
|
||||
email = _unique_email("admin")
|
||||
r = client.post(
|
||||
"/api/v1/setup",
|
||||
json={
|
||||
"install_token": install_token,
|
||||
"email": email,
|
||||
"password": "AdminPass1234!",
|
||||
"display_name": "Init Admin",
|
||||
},
|
||||
)
|
||||
assert r.status_code == 201, r.get_data(as_text=True)
|
||||
body = r.get_json()
|
||||
assert "user_id" in body
|
||||
pytest.shared_admin = {"email": email, "password": "AdminPass1234!", "id": body["user_id"]} # type: ignore[attr-defined]
|
||||
|
||||
|
||||
def test_setup_status_now_completed(client):
|
||||
assert client.get("/api/v1/setup").get_json()["completed"] is True
|
||||
|
||||
|
||||
def test_setup_replay_is_blocked(client, install_token):
|
||||
# Token already consumed — a second call must be refused.
|
||||
r = client.post(
|
||||
"/api/v1/setup",
|
||||
json={
|
||||
"install_token": install_token,
|
||||
"email": _unique_email("replay"),
|
||||
"password": "AdminPass1234!",
|
||||
},
|
||||
)
|
||||
assert r.status_code == 409
|
||||
|
||||
|
||||
# -- /auth/login + /auth/me ---------------------------------------------------
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def admin_credentials():
|
||||
return getattr(pytest, "shared_admin") # populated by test_setup_creates_first_admin
|
||||
|
||||
|
||||
def _login(client, email: str, password: str) -> tuple[str, dict]:
|
||||
r = client.post("/api/v1/auth/login", json={"email": email, "password": password})
|
||||
assert r.status_code == 200, r.get_data(as_text=True)
|
||||
body = r.get_json()
|
||||
return body["access_token"], dict(r.headers)
|
||||
|
||||
|
||||
def test_login_and_me(client, admin_credentials):
|
||||
access, _ = _login(client, admin_credentials["email"], admin_credentials["password"])
|
||||
r = client.get("/api/v1/auth/me", headers={"Authorization": f"Bearer {access}"})
|
||||
assert r.status_code == 200
|
||||
me = r.get_json()
|
||||
assert me["email"] == admin_credentials["email"]
|
||||
assert me["is_admin"] is True
|
||||
assert "admin" in me["groups"]
|
||||
|
||||
|
||||
def test_login_with_wrong_password_returns_401(client, admin_credentials):
|
||||
r = client.post(
|
||||
"/api/v1/auth/login",
|
||||
json={"email": admin_credentials["email"], "password": "wrong"},
|
||||
)
|
||||
assert r.status_code == 401
|
||||
assert r.get_json()["error"] == "invalid_credentials"
|
||||
|
||||
|
||||
def test_me_without_token_returns_401(client):
|
||||
r = client.get("/api/v1/auth/me")
|
||||
assert r.status_code == 401
|
||||
|
||||
|
||||
# -- /auth/refresh rotation ---------------------------------------------------
|
||||
|
||||
|
||||
def test_refresh_rotates_and_old_token_is_revoked(client, admin_credentials):
|
||||
# Login fresh to get a refresh cookie on the test client.
|
||||
client.post(
|
||||
"/api/v1/auth/login",
|
||||
json={"email": admin_credentials["email"], "password": admin_credentials["password"]},
|
||||
)
|
||||
# First refresh — should succeed.
|
||||
r1 = client.post("/api/v1/auth/refresh")
|
||||
assert r1.status_code == 200, r1.get_data(as_text=True)
|
||||
new_access1 = r1.get_json()["access_token"]
|
||||
assert new_access1
|
||||
|
||||
# Second refresh — uses the rotated cookie automatically (test client persists cookies).
|
||||
r2 = client.post("/api/v1/auth/refresh")
|
||||
assert r2.status_code == 200
|
||||
assert r2.get_json()["access_token"] != new_access1
|
||||
|
||||
|
||||
def test_refresh_with_no_cookie_returns_401(client):
|
||||
fresh = client.application.test_client() # blank cookie jar
|
||||
r = fresh.post("/api/v1/auth/refresh")
|
||||
assert r.status_code == 401
|
||||
|
||||
|
||||
# -- /auth/logout -------------------------------------------------------------
|
||||
|
||||
|
||||
def test_logout_clears_cookie_and_is_idempotent(client, admin_credentials):
|
||||
client.post(
|
||||
"/api/v1/auth/login",
|
||||
json={"email": admin_credentials["email"], "password": admin_credentials["password"]},
|
||||
)
|
||||
r1 = client.post("/api/v1/auth/logout")
|
||||
assert r1.status_code == 200
|
||||
# Second logout — no token, still 200.
|
||||
r2 = client.post("/api/v1/auth/logout")
|
||||
assert r2.status_code == 200
|
||||
|
||||
|
||||
# -- /invitations -------------------------------------------------------------
|
||||
|
||||
|
||||
def test_admin_creates_invitation_and_invitee_accepts(client, admin_credentials):
|
||||
access, _ = _login(client, admin_credentials["email"], admin_credentials["password"])
|
||||
inv_email = _unique_email("alice")
|
||||
|
||||
create = client.post(
|
||||
"/api/v1/invitations",
|
||||
headers={"Authorization": f"Bearer {access}"},
|
||||
json={"email_hint": inv_email},
|
||||
)
|
||||
assert create.status_code == 201, create.get_data(as_text=True)
|
||||
token = create.get_json()["token"]
|
||||
|
||||
preview = client.get(f"/api/v1/invitations/preview/{token}")
|
||||
assert preview.status_code == 200
|
||||
assert preview.get_json()["is_valid"] is True
|
||||
|
||||
accept = client.post(
|
||||
f"/api/v1/invitations/accept/{token}",
|
||||
json={"email": inv_email, "password": "AlicePass1234!", "display_name": "Alice"},
|
||||
)
|
||||
assert accept.status_code == 201, accept.get_data(as_text=True)
|
||||
|
||||
# Alice can now log in.
|
||||
a_access, _ = _login(client, inv_email, "AlicePass1234!")
|
||||
me = client.get("/api/v1/auth/me", headers={"Authorization": f"Bearer {a_access}"}).get_json()
|
||||
assert me["is_admin"] is False
|
||||
assert me["email"] == inv_email
|
||||
|
||||
|
||||
def test_unauthenticated_cannot_create_invitation(client):
|
||||
r = client.post("/api/v1/invitations", json={})
|
||||
assert r.status_code == 401
|
||||
|
||||
|
||||
def test_non_admin_cannot_create_invitation(client, admin_credentials):
|
||||
# Create a non-admin user via invitation, then try as them.
|
||||
access, _ = _login(client, admin_credentials["email"], admin_credentials["password"])
|
||||
inv_email = _unique_email("bob")
|
||||
create = client.post(
|
||||
"/api/v1/invitations",
|
||||
headers={"Authorization": f"Bearer {access}"},
|
||||
json={"email_hint": inv_email},
|
||||
)
|
||||
token = create.get_json()["token"]
|
||||
client.post(
|
||||
f"/api/v1/invitations/accept/{token}",
|
||||
json={"email": inv_email, "password": "BobPass1234!"},
|
||||
)
|
||||
bob_access, _ = _login(client, inv_email, "BobPass1234!")
|
||||
|
||||
r = client.post(
|
||||
"/api/v1/invitations",
|
||||
headers={"Authorization": f"Bearer {bob_access}"},
|
||||
json={},
|
||||
)
|
||||
assert r.status_code == 403
|
||||
|
||||
|
||||
def test_used_invitation_cannot_be_accepted_twice(client, admin_credentials):
|
||||
access, _ = _login(client, admin_credentials["email"], admin_credentials["password"])
|
||||
inv_email = _unique_email("carol")
|
||||
token = client.post(
|
||||
"/api/v1/invitations",
|
||||
headers={"Authorization": f"Bearer {access}"},
|
||||
json={"email_hint": inv_email},
|
||||
).get_json()["token"]
|
||||
|
||||
first = client.post(
|
||||
f"/api/v1/invitations/accept/{token}",
|
||||
json={"email": inv_email, "password": "CarolPass1234!"},
|
||||
)
|
||||
assert first.status_code == 201
|
||||
|
||||
second = client.post(
|
||||
f"/api/v1/invitations/accept/{token}",
|
||||
json={"email": _unique_email("carol2"), "password": "OtherPass1234!"},
|
||||
)
|
||||
assert second.status_code == 410
|
||||
assert second.get_json()["error"] == "invitation_consumed"
|
||||
|
||||
|
||||
# -- change-password forces logout-all ----------------------------------------
|
||||
|
||||
|
||||
def test_change_password_revokes_all_refresh_tokens(client, admin_credentials):
|
||||
access, _ = _login(client, admin_credentials["email"], admin_credentials["password"])
|
||||
|
||||
# Trigger a couple of refreshes so we have multiple chains in DB.
|
||||
client.post("/api/v1/auth/refresh")
|
||||
client.post("/api/v1/auth/refresh")
|
||||
|
||||
# Change password.
|
||||
new_pw = "AdminPass5678!"
|
||||
r = client.post(
|
||||
"/api/v1/auth/change-password",
|
||||
headers={"Authorization": f"Bearer {access}"},
|
||||
json={
|
||||
"current_password": admin_credentials["password"],
|
||||
"new_password": new_pw,
|
||||
},
|
||||
)
|
||||
assert r.status_code == 200
|
||||
admin_credentials["password"] = new_pw
|
||||
|
||||
# Existing refresh cookie must now be rejected.
|
||||
r2 = client.post("/api/v1/auth/refresh")
|
||||
assert r2.status_code == 401
|
||||
|
||||
# New login still works.
|
||||
_login(client, admin_credentials["email"], admin_credentials["password"])
|
||||
14
backend/tests/test_health.py
Normal file
14
backend/tests/test_health.py
Normal file
@@ -0,0 +1,14 @@
|
||||
"""M0 smoke test: the /api/v1/health endpoint returns 200 and the expected payload."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from app.main import app
|
||||
|
||||
|
||||
def test_health_returns_ok():
|
||||
client = app.test_client()
|
||||
resp = client.get("/api/v1/health")
|
||||
assert resp.status_code == 200
|
||||
body = resp.get_json()
|
||||
assert body["status"] == "ok"
|
||||
assert "version" in body
|
||||
344
backend/tests/test_rbac.py
Normal file
344
backend/tests/test_rbac.py
Normal file
@@ -0,0 +1,344 @@
|
||||
"""Integration tests for M3: permission seed + users/groups/permissions APIs.
|
||||
|
||||
Exercises the Flask test client against a live Postgres. The DB is wiped at
|
||||
module load so test ordering inside the module matters (see `pytest.shared_*`).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import secrets
|
||||
|
||||
import pytest
|
||||
from sqlalchemy import text
|
||||
|
||||
from app.core.install_token import regenerate_install_token
|
||||
from app.main import create_app
|
||||
from app.services.permissions_seed import PERMISSION_CATALOGUE
|
||||
|
||||
|
||||
def _truncate_all(engine):
|
||||
"""Wipe data plus permissions table. CASCADE handles dependent rows."""
|
||||
with engine.begin() as conn:
|
||||
conn.execute(
|
||||
text(
|
||||
"TRUNCATE users, refresh_tokens, invitations, invitation_groups, "
|
||||
"user_groups, group_permissions, permissions, settings, groups "
|
||||
"RESTART IDENTITY CASCADE"
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def app(db_engine_or_skip):
|
||||
_truncate_all(db_engine_or_skip)
|
||||
flask_app = create_app() # triggers bootstrap → seed_all()
|
||||
flask_app.config.update(TESTING=True)
|
||||
return flask_app
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def client(app):
|
||||
return app.test_client()
|
||||
|
||||
|
||||
def _unique_email(prefix: str) -> str:
|
||||
return f"{prefix}-{secrets.token_hex(4)}@metamorph.local"
|
||||
|
||||
|
||||
def _login(client, email: str, password: str) -> str:
|
||||
r = client.post("/api/v1/auth/login", json={"email": email, "password": password})
|
||||
assert r.status_code == 200, r.get_data(as_text=True)
|
||||
return r.get_json()["access_token"]
|
||||
|
||||
|
||||
# -- M3.1 — Permissions seeded at boot -----------------------------------------
|
||||
|
||||
|
||||
def test_permissions_catalogue_seeded(client):
|
||||
"""Catalogue table has every code from PERMISSION_CATALOGUE."""
|
||||
# We need an admin to call /permissions — bootstrap one via /setup.
|
||||
token = regenerate_install_token()
|
||||
email = _unique_email("admin")
|
||||
r = client.post(
|
||||
"/api/v1/setup",
|
||||
json={
|
||||
"install_token": token,
|
||||
"email": email,
|
||||
"password": "AdminPass1234!",
|
||||
"display_name": "Admin",
|
||||
},
|
||||
)
|
||||
assert r.status_code == 201, r.get_data(as_text=True)
|
||||
pytest.shared_admin = {"email": email, "password": "AdminPass1234!", "user_id": r.get_json()["user_id"]} # type: ignore[attr-defined]
|
||||
|
||||
access = _login(client, email, "AdminPass1234!")
|
||||
perms = client.get(
|
||||
"/api/v1/permissions", headers={"Authorization": f"Bearer {access}"}
|
||||
).get_json()
|
||||
codes = {p["code"] for p in perms["items"]}
|
||||
expected = {p.code for p in PERMISSION_CATALOGUE}
|
||||
assert expected.issubset(codes)
|
||||
|
||||
|
||||
def test_admin_group_has_every_permission(client):
|
||||
admin = pytest.shared_admin
|
||||
access = _login(client, admin["email"], admin["password"])
|
||||
groups = client.get(
|
||||
"/api/v1/groups", headers={"Authorization": f"Bearer {access}"}
|
||||
).get_json()
|
||||
admin_group = next(g for g in groups["items"] if g["name"] == "admin")
|
||||
assert set(admin_group["permissions"]) == {p.code for p in PERMISSION_CATALOGUE}
|
||||
assert admin_group["is_system"] is True
|
||||
|
||||
|
||||
def test_redteam_group_has_red_perms_but_not_blue_write(client):
|
||||
admin = pytest.shared_admin
|
||||
access = _login(client, admin["email"], admin["password"])
|
||||
groups = client.get(
|
||||
"/api/v1/groups", headers={"Authorization": f"Bearer {access}"}
|
||||
).get_json()
|
||||
redteam = next(g for g in groups["items"] if g["name"] == "redteam")
|
||||
assert "mission.write_red_fields" in redteam["permissions"]
|
||||
assert "mission.write_blue_fields" not in redteam["permissions"]
|
||||
assert "mission.create" in redteam["permissions"]
|
||||
|
||||
|
||||
def test_blueteam_group_has_blue_perm_but_not_red_write(client):
|
||||
admin = pytest.shared_admin
|
||||
access = _login(client, admin["email"], admin["password"])
|
||||
groups = client.get(
|
||||
"/api/v1/groups", headers={"Authorization": f"Bearer {access}"}
|
||||
).get_json()
|
||||
blueteam = next(g for g in groups["items"] if g["name"] == "blueteam")
|
||||
assert "mission.write_blue_fields" in blueteam["permissions"]
|
||||
assert "mission.write_red_fields" not in blueteam["permissions"]
|
||||
assert "mission.create" not in blueteam["permissions"]
|
||||
|
||||
|
||||
# -- M3.2 — Users CRUD ---------------------------------------------------------
|
||||
|
||||
|
||||
def _invite_user(client, admin_access: str, email: str, password: str, group_ids: list[str] | None = None) -> str:
|
||||
"""Create + accept an invitation, return the new user's id."""
|
||||
create = client.post(
|
||||
"/api/v1/invitations",
|
||||
headers={"Authorization": f"Bearer {admin_access}"},
|
||||
json={"email_hint": email, "group_ids": group_ids or []},
|
||||
)
|
||||
assert create.status_code == 201, create.get_data(as_text=True)
|
||||
token = create.get_json()["token"]
|
||||
accept = client.post(
|
||||
f"/api/v1/invitations/accept/{token}",
|
||||
json={"email": email, "password": password, "display_name": email.split("@")[0]},
|
||||
)
|
||||
assert accept.status_code == 201, accept.get_data(as_text=True)
|
||||
return accept.get_json()["user_id"]
|
||||
|
||||
|
||||
def test_admin_lists_users(client):
|
||||
admin = pytest.shared_admin
|
||||
access = _login(client, admin["email"], admin["password"])
|
||||
r = client.get("/api/v1/users", headers={"Authorization": f"Bearer {access}"})
|
||||
assert r.status_code == 200
|
||||
body = r.get_json()
|
||||
assert body["total"] >= 1
|
||||
emails = [u["email"] for u in body["items"]]
|
||||
assert admin["email"] in emails
|
||||
|
||||
|
||||
def test_admin_updates_a_user(client):
|
||||
admin = pytest.shared_admin
|
||||
access = _login(client, admin["email"], admin["password"])
|
||||
bob_email = _unique_email("bob")
|
||||
bob_id = _invite_user(client, access, bob_email, "BobPass1234!")
|
||||
|
||||
r = client.patch(
|
||||
f"/api/v1/users/{bob_id}",
|
||||
headers={"Authorization": f"Bearer {access}"},
|
||||
json={"display_name": "Robert", "locale": "en"},
|
||||
)
|
||||
assert r.status_code == 200, r.get_data(as_text=True)
|
||||
body = r.get_json()
|
||||
assert body["display_name"] == "Robert"
|
||||
assert body["locale"] == "en"
|
||||
|
||||
|
||||
def test_admin_soft_deletes_a_user(client):
|
||||
admin = pytest.shared_admin
|
||||
access = _login(client, admin["email"], admin["password"])
|
||||
target_email = _unique_email("ghost")
|
||||
target_id = _invite_user(client, access, target_email, "GhostPass1234!")
|
||||
|
||||
r = client.delete(
|
||||
f"/api/v1/users/{target_id}",
|
||||
headers={"Authorization": f"Bearer {access}"},
|
||||
)
|
||||
assert r.status_code == 200, r.get_data(as_text=True)
|
||||
|
||||
# Listing must not return the deleted user by default.
|
||||
listing = client.get(
|
||||
"/api/v1/users", headers={"Authorization": f"Bearer {access}"}
|
||||
).get_json()
|
||||
assert target_email not in [u["email"] for u in listing["items"]]
|
||||
|
||||
|
||||
def test_last_admin_cannot_be_deleted(client):
|
||||
admin = pytest.shared_admin
|
||||
access = _login(client, admin["email"], admin["password"])
|
||||
r = client.delete(
|
||||
f"/api/v1/users/{admin['user_id']}",
|
||||
headers={"Authorization": f"Bearer {access}"},
|
||||
)
|
||||
assert r.status_code == 409
|
||||
assert r.get_json()["error"] == "last_admin_protected"
|
||||
|
||||
|
||||
def test_last_admin_cannot_be_deactivated(client):
|
||||
admin = pytest.shared_admin
|
||||
access = _login(client, admin["email"], admin["password"])
|
||||
r = client.patch(
|
||||
f"/api/v1/users/{admin['user_id']}",
|
||||
headers={"Authorization": f"Bearer {access}"},
|
||||
json={"is_active": False},
|
||||
)
|
||||
assert r.status_code == 409
|
||||
assert r.get_json()["error"] == "last_admin_protected"
|
||||
|
||||
|
||||
# -- M3.3 — Groups CRUD --------------------------------------------------------
|
||||
|
||||
|
||||
def test_admin_creates_custom_group_and_assigns_perms(client):
|
||||
admin = pytest.shared_admin
|
||||
access = _login(client, admin["email"], admin["password"])
|
||||
|
||||
# Create the group
|
||||
create = client.post(
|
||||
"/api/v1/groups",
|
||||
headers={"Authorization": f"Bearer {access}"},
|
||||
json={"name": f"pentest-{secrets.token_hex(3)}", "description": "Test group"},
|
||||
)
|
||||
assert create.status_code == 201, create.get_data(as_text=True)
|
||||
gid = create.get_json()["id"]
|
||||
|
||||
# Attach mission.read + mission.write_red_fields only
|
||||
r = client.put(
|
||||
f"/api/v1/groups/{gid}/permissions",
|
||||
headers={"Authorization": f"Bearer {access}"},
|
||||
json={"codes": ["mission.read", "mission.write_red_fields"]},
|
||||
)
|
||||
assert r.status_code == 200, r.get_data(as_text=True)
|
||||
assert set(r.get_json()["permissions"]) == {"mission.read", "mission.write_red_fields"}
|
||||
|
||||
|
||||
def test_system_group_cannot_be_renamed_or_deleted(client):
|
||||
admin = pytest.shared_admin
|
||||
access = _login(client, admin["email"], admin["password"])
|
||||
groups = client.get(
|
||||
"/api/v1/groups", headers={"Authorization": f"Bearer {access}"}
|
||||
).get_json()
|
||||
admin_group = next(g for g in groups["items"] if g["name"] == "admin")
|
||||
|
||||
rename = client.patch(
|
||||
f"/api/v1/groups/{admin_group['id']}",
|
||||
headers={"Authorization": f"Bearer {access}"},
|
||||
json={"name": "superadmin"},
|
||||
)
|
||||
assert rename.status_code == 409
|
||||
assert rename.get_json()["error"] == "system_group_protected"
|
||||
|
||||
delete = client.delete(
|
||||
f"/api/v1/groups/{admin_group['id']}",
|
||||
headers={"Authorization": f"Bearer {access}"},
|
||||
)
|
||||
assert delete.status_code == 409
|
||||
assert delete.get_json()["error"] == "system_group_protected"
|
||||
|
||||
|
||||
def test_setting_unknown_permission_code_returns_400(client):
|
||||
admin = pytest.shared_admin
|
||||
access = _login(client, admin["email"], admin["password"])
|
||||
create = client.post(
|
||||
"/api/v1/groups",
|
||||
headers={"Authorization": f"Bearer {access}"},
|
||||
json={"name": f"bad-perms-{secrets.token_hex(3)}", "description": None},
|
||||
)
|
||||
gid = create.get_json()["id"]
|
||||
r = client.put(
|
||||
f"/api/v1/groups/{gid}/permissions",
|
||||
headers={"Authorization": f"Bearer {access}"},
|
||||
json={"codes": ["bogus.permission"]},
|
||||
)
|
||||
assert r.status_code == 400
|
||||
|
||||
|
||||
# -- M3 user ↔ group assignment ------------------------------------------------
|
||||
|
||||
|
||||
def test_admin_assigns_user_to_custom_group_and_perms_apply(client):
|
||||
admin = pytest.shared_admin
|
||||
access = _login(client, admin["email"], admin["password"])
|
||||
|
||||
# Create a custom group that *only* grants user.read.
|
||||
gname = f"readers-{secrets.token_hex(3)}"
|
||||
group = client.post(
|
||||
"/api/v1/groups",
|
||||
headers={"Authorization": f"Bearer {access}"},
|
||||
json={"name": gname, "description": None},
|
||||
).get_json()
|
||||
client.put(
|
||||
f"/api/v1/groups/{group['id']}/permissions",
|
||||
headers={"Authorization": f"Bearer {access}"},
|
||||
json={"codes": ["user.read"]},
|
||||
)
|
||||
|
||||
# Invite Dave, attach the new group via /users/{id}/groups.
|
||||
dave_email = _unique_email("dave")
|
||||
dave_id = _invite_user(client, access, dave_email, "DavePass1234!")
|
||||
r = client.put(
|
||||
f"/api/v1/users/{dave_id}/groups",
|
||||
headers={"Authorization": f"Bearer {access}"},
|
||||
json={"group_ids": [group["id"]]},
|
||||
)
|
||||
assert r.status_code == 200, r.get_data(as_text=True)
|
||||
|
||||
# Dave can now list users (user.read) but cannot create a group (group.create).
|
||||
dave_access = _login(client, dave_email, "DavePass1234!")
|
||||
can_read = client.get(
|
||||
"/api/v1/users", headers={"Authorization": f"Bearer {dave_access}"}
|
||||
)
|
||||
assert can_read.status_code == 200
|
||||
|
||||
cannot_create_group = client.post(
|
||||
"/api/v1/groups",
|
||||
headers={"Authorization": f"Bearer {dave_access}"},
|
||||
json={"name": "wont-happen", "description": None},
|
||||
)
|
||||
assert cannot_create_group.status_code == 403
|
||||
|
||||
|
||||
def test_last_admin_cannot_lose_admin_membership(client):
|
||||
admin = pytest.shared_admin
|
||||
access = _login(client, admin["email"], admin["password"])
|
||||
r = client.put(
|
||||
f"/api/v1/users/{admin['user_id']}/groups",
|
||||
headers={"Authorization": f"Bearer {access}"},
|
||||
json={"group_ids": []},
|
||||
)
|
||||
assert r.status_code == 409
|
||||
assert r.get_json()["error"] == "last_admin_protected"
|
||||
|
||||
|
||||
# -- Permission enforcement ----------------------------------------------------
|
||||
|
||||
|
||||
def test_non_admin_without_user_read_gets_403_on_users_list(client):
|
||||
admin = pytest.shared_admin
|
||||
access = _login(client, admin["email"], admin["password"])
|
||||
# Invite Eve with no groups → no perms.
|
||||
eve_email = _unique_email("eve")
|
||||
_invite_user(client, access, eve_email, "EvePass1234!", group_ids=[])
|
||||
eve_access = _login(client, eve_email, "EvePass1234!")
|
||||
|
||||
r = client.get("/api/v1/users", headers={"Authorization": f"Bearer {eve_access}"})
|
||||
assert r.status_code == 403
|
||||
234
backend/tests/test_schema.py
Normal file
234
backend/tests/test_schema.py
Normal file
@@ -0,0 +1,234 @@
|
||||
"""M1 schema integration test.
|
||||
|
||||
Asserts that the migration has produced the expected tables, FK relations,
|
||||
CHECK constraints, partial indexes, and that the alembic_version row is at head.
|
||||
Skips automatically when Postgres is unreachable.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
from sqlalchemy import inspect, text
|
||||
|
||||
EXPECTED_TABLES = {
|
||||
# Auth / RBAC
|
||||
"users",
|
||||
"groups",
|
||||
"permissions",
|
||||
"user_groups",
|
||||
"group_permissions",
|
||||
"invitations",
|
||||
"invitation_groups",
|
||||
"refresh_tokens",
|
||||
# MITRE
|
||||
"mitre_tactics",
|
||||
"mitre_techniques",
|
||||
"mitre_subtechniques",
|
||||
"mitre_technique_tactics",
|
||||
# Templates
|
||||
"test_templates",
|
||||
"test_template_mitre_tags",
|
||||
"scenario_templates",
|
||||
"scenario_template_tests",
|
||||
# Missions
|
||||
"missions",
|
||||
"mission_members",
|
||||
"mission_scenarios",
|
||||
"mission_tests",
|
||||
"mission_test_mitre_tags",
|
||||
"mission_categories",
|
||||
# Evidence / settings / notifications
|
||||
"evidence_files",
|
||||
"settings",
|
||||
"detection_levels",
|
||||
"notifications",
|
||||
# Alembic bookkeeping
|
||||
"alembic_version",
|
||||
}
|
||||
|
||||
# Tables that MUST carry a `deleted_at` column (soft delete).
|
||||
SOFT_DELETE_TABLES = {
|
||||
"users",
|
||||
"groups",
|
||||
"test_templates",
|
||||
"scenario_templates",
|
||||
"missions",
|
||||
"mission_scenarios",
|
||||
"mission_tests",
|
||||
"mission_categories",
|
||||
"evidence_files",
|
||||
}
|
||||
|
||||
# Tables that MUST carry the standard `created_at` + `updated_at` pair.
|
||||
TIMESTAMP_TABLES = {
|
||||
"users",
|
||||
"groups",
|
||||
"test_templates",
|
||||
"scenario_templates",
|
||||
"missions",
|
||||
"mission_scenarios",
|
||||
"mission_tests",
|
||||
"mission_categories",
|
||||
"evidence_files",
|
||||
}
|
||||
|
||||
# Spot-checked FK pairs (child_table, child_col, parent_table).
|
||||
EXPECTED_FKS = {
|
||||
("evidence_files", "mission_test_id", "mission_tests"),
|
||||
("evidence_files", "uploaded_by_user_id", "users"),
|
||||
("mission_members", "mission_id", "missions"),
|
||||
("mission_members", "user_id", "users"),
|
||||
("mission_scenarios", "mission_id", "missions"),
|
||||
("mission_tests", "scenario_id", "mission_scenarios"),
|
||||
("mission_tests", "detection_level_id", "detection_levels"),
|
||||
("group_permissions", "group_id", "groups"),
|
||||
("group_permissions", "permission_id", "permissions"),
|
||||
("user_groups", "user_id", "users"),
|
||||
("user_groups", "group_id", "groups"),
|
||||
("refresh_tokens", "user_id", "users"),
|
||||
("notifications", "user_id", "users"),
|
||||
("mitre_subtechniques", "technique_id", "mitre_techniques"),
|
||||
}
|
||||
|
||||
# CHECK constraint names we expect to see (namespace 'public' only).
|
||||
# `mission_test_mitre_tags` deliberately lacks the exactly_one_mitre_fk check
|
||||
# because it is denormalised — see app/models/mission.py docstring.
|
||||
EXPECTED_CHECKS = {
|
||||
"ck_missions_status_valid",
|
||||
"ck_missions_visibility_mode_valid",
|
||||
"ck_mission_tests_state_valid",
|
||||
"ck_mission_tests_snapshot_opsec_level_valid",
|
||||
"ck_test_templates_opsec_level_valid",
|
||||
"ck_mission_members_role_hint_valid",
|
||||
"ck_test_template_mitre_tags_mitre_kind_valid",
|
||||
"ck_test_template_mitre_tags_exactly_one_mitre_fk",
|
||||
"ck_mission_test_mitre_tags_mitre_kind_valid",
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def insp(db_engine_or_skip):
|
||||
return inspect(db_engine_or_skip)
|
||||
|
||||
|
||||
def test_all_expected_tables_exist(insp):
|
||||
actual = set(insp.get_table_names(schema="public"))
|
||||
missing = EXPECTED_TABLES - actual
|
||||
assert not missing, f"missing tables: {sorted(missing)}"
|
||||
|
||||
|
||||
def test_soft_delete_columns_present(insp):
|
||||
for tbl in sorted(SOFT_DELETE_TABLES):
|
||||
cols = {c["name"] for c in insp.get_columns(tbl)}
|
||||
assert "deleted_at" in cols, f"{tbl} missing deleted_at"
|
||||
|
||||
|
||||
def test_standard_timestamp_columns_present(insp):
|
||||
for tbl in sorted(TIMESTAMP_TABLES):
|
||||
cols = {c["name"] for c in insp.get_columns(tbl)}
|
||||
assert "created_at" in cols, f"{tbl} missing created_at"
|
||||
assert "updated_at" in cols, f"{tbl} missing updated_at"
|
||||
|
||||
|
||||
def test_partial_index_for_soft_delete(db_engine_or_skip):
|
||||
"""Each soft-delete table must carry an `ix_<table>_active` partial index."""
|
||||
with db_engine_or_skip.connect() as conn:
|
||||
rows = conn.execute(
|
||||
text(
|
||||
"SELECT indexname FROM pg_indexes "
|
||||
"WHERE schemaname='public' AND indexdef ILIKE '%deleted_at IS NULL%'"
|
||||
)
|
||||
).all()
|
||||
names = {r[0] for r in rows}
|
||||
for tbl in SOFT_DELETE_TABLES:
|
||||
assert f"ix_{tbl}_active" in names, f"{tbl}: partial index missing — got {names}"
|
||||
|
||||
|
||||
def test_expected_foreign_keys(insp):
|
||||
all_fks = set()
|
||||
for tbl in EXPECTED_TABLES:
|
||||
if tbl == "alembic_version":
|
||||
continue
|
||||
for fk in insp.get_foreign_keys(tbl):
|
||||
for col in fk["constrained_columns"]:
|
||||
all_fks.add((tbl, col, fk["referred_table"]))
|
||||
for triple in EXPECTED_FKS:
|
||||
assert triple in all_fks, f"missing FK: {triple}"
|
||||
|
||||
|
||||
def test_expected_check_constraints(db_engine_or_skip):
|
||||
with db_engine_or_skip.connect() as conn:
|
||||
rows = conn.execute(
|
||||
text(
|
||||
"SELECT conname FROM pg_constraint "
|
||||
"WHERE contype='c' AND connamespace = 'public'::regnamespace"
|
||||
)
|
||||
).all()
|
||||
names = {r[0] for r in rows}
|
||||
missing = EXPECTED_CHECKS - names
|
||||
assert not missing, f"missing CHECK constraints: {sorted(missing)}"
|
||||
|
||||
|
||||
def test_alembic_at_head(db_engine_or_skip):
|
||||
"""The DB must be at the latest migration after `make migrate`."""
|
||||
with db_engine_or_skip.connect() as conn:
|
||||
rev = conn.execute(text("SELECT version_num FROM alembic_version")).scalar_one()
|
||||
assert rev, "alembic_version is empty — migrate didn't run"
|
||||
assert len(rev) >= 8, f"unexpected alembic version: {rev}"
|
||||
|
||||
|
||||
def test_exactly_one_mitre_fk_check_enforced(db_engine_or_skip):
|
||||
"""Inserting a tag with two non-null FKs must raise (CHECK constraint)."""
|
||||
import uuid
|
||||
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
|
||||
with db_engine_or_skip.begin() as conn:
|
||||
# Seed a minimal test_template + tactic + technique to reference.
|
||||
tmpl_id = uuid.uuid4()
|
||||
tactic_id = uuid.uuid4()
|
||||
technique_id = uuid.uuid4()
|
||||
conn.execute(
|
||||
text(
|
||||
"INSERT INTO test_templates (id, name, opsec_level) "
|
||||
"VALUES (:id, 'tmp', 'low')"
|
||||
),
|
||||
{"id": tmpl_id},
|
||||
)
|
||||
conn.execute(
|
||||
text(
|
||||
"INSERT INTO mitre_tactics (id, external_id, short_name, name) "
|
||||
"VALUES (:id, 'TA0099', 'tmp', 'tmp')"
|
||||
),
|
||||
{"id": tactic_id},
|
||||
)
|
||||
conn.execute(
|
||||
text(
|
||||
"INSERT INTO mitre_techniques (id, external_id, name) "
|
||||
"VALUES (:id, 'T9999', 'tmp')"
|
||||
),
|
||||
{"id": technique_id},
|
||||
)
|
||||
|
||||
# Now try to insert a violating row — must fail.
|
||||
with pytest.raises(IntegrityError):
|
||||
with db_engine_or_skip.begin() as conn:
|
||||
conn.execute(
|
||||
text(
|
||||
"INSERT INTO test_template_mitre_tags "
|
||||
"(id, test_template_id, mitre_kind, tactic_id, technique_id) "
|
||||
"VALUES (:id, :tmpl, 'tactic', :tac, :tech)"
|
||||
),
|
||||
{
|
||||
"id": uuid.uuid4(),
|
||||
"tmpl": tmpl_id,
|
||||
"tac": tactic_id,
|
||||
"tech": technique_id,
|
||||
},
|
||||
)
|
||||
|
||||
# Cleanup so the test is rerunnable.
|
||||
with db_engine_or_skip.begin() as conn:
|
||||
conn.execute(text("DELETE FROM mitre_techniques WHERE id = :id"), {"id": technique_id})
|
||||
conn.execute(text("DELETE FROM mitre_tactics WHERE id = :id"), {"id": tactic_id})
|
||||
conn.execute(text("DELETE FROM test_templates WHERE id = :id"), {"id": tmpl_id})
|
||||
82
docker-compose.yml
Normal file
82
docker-compose.yml
Normal file
@@ -0,0 +1,82 @@
|
||||
services:
|
||||
db:
|
||||
image: docker.io/library/postgres:16-alpine
|
||||
container_name: metamorph-db
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
POSTGRES_DB: ${POSTGRES_DB}
|
||||
POSTGRES_USER: ${POSTGRES_USER}
|
||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
|
||||
volumes:
|
||||
- metamorph_db:/var/lib/postgresql/data
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U $${POSTGRES_USER} -d $${POSTGRES_DB}"]
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 10
|
||||
networks:
|
||||
- metamorph
|
||||
# No ports exposed on the host: the api reaches it on the internal network.
|
||||
|
||||
api:
|
||||
build:
|
||||
context: ./backend
|
||||
dockerfile: Dockerfile
|
||||
target: runtime
|
||||
container_name: metamorph-api
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
APP_ENV: ${APP_ENV}
|
||||
POSTGRES_DB: ${POSTGRES_DB}
|
||||
POSTGRES_USER: ${POSTGRES_USER}
|
||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
|
||||
POSTGRES_HOST: ${POSTGRES_HOST}
|
||||
POSTGRES_PORT: ${POSTGRES_PORT}
|
||||
JWT_SECRET: ${JWT_SECRET}
|
||||
LOG_LEVEL: ${LOG_LEVEL}
|
||||
FRONT_ORIGIN: ${FRONT_ORIGIN}
|
||||
EVIDENCE_DIR: ${EVIDENCE_DIR}
|
||||
volumes:
|
||||
- metamorph_evidence:/data/evidence
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
ports:
|
||||
- "${HOST_API_PORT:-8000}:8000"
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "python -c \"import urllib.request,sys; sys.exit(0 if urllib.request.urlopen('http://127.0.0.1:8000/api/v1/health',timeout=2).status==200 else 1)\""]
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
start_period: 10s
|
||||
networks:
|
||||
- metamorph
|
||||
|
||||
front:
|
||||
build:
|
||||
context: ./frontend
|
||||
dockerfile: Dockerfile
|
||||
args:
|
||||
VITE_API_BASE_URL: ${VITE_API_BASE_URL}
|
||||
container_name: metamorph-front
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
- api
|
||||
ports:
|
||||
- "${HOST_FRONT_PORT:-8080}:80"
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "wget -qO- http://127.0.0.1/healthz | grep -q ok"]
|
||||
interval: 30s
|
||||
timeout: 3s
|
||||
retries: 3
|
||||
start_period: 5s
|
||||
networks:
|
||||
- metamorph
|
||||
|
||||
volumes:
|
||||
metamorph_db:
|
||||
metamorph_evidence:
|
||||
|
||||
networks:
|
||||
metamorph:
|
||||
driver: bridge
|
||||
12
e2e/.eslintrc.cjs
Normal file
12
e2e/.eslintrc.cjs
Normal file
@@ -0,0 +1,12 @@
|
||||
/* eslint-env node */
|
||||
module.exports = {
|
||||
root: true,
|
||||
env: { node: true, es2022: true },
|
||||
extends: ['eslint:recommended', 'plugin:@typescript-eslint/recommended'],
|
||||
parser: '@typescript-eslint/parser',
|
||||
parserOptions: { ecmaVersion: 'latest', sourceType: 'module' },
|
||||
ignorePatterns: ['playwright-report', 'test-results', 'node_modules', '.eslintrc.cjs'],
|
||||
rules: {
|
||||
'@typescript-eslint/no-unused-vars': ['error', { argsIgnorePattern: '^_' }],
|
||||
},
|
||||
};
|
||||
5
e2e/.gitignore
vendored
Normal file
5
e2e/.gitignore
vendored
Normal file
@@ -0,0 +1,5 @@
|
||||
node_modules
|
||||
playwright-report
|
||||
test-results
|
||||
playwright/.cache
|
||||
*.log
|
||||
66
e2e/README.md
Normal file
66
e2e/README.md
Normal file
@@ -0,0 +1,66 @@
|
||||
# Metamorph e2e
|
||||
|
||||
End-to-end tests powered by [Playwright](https://playwright.dev/). Each milestone in `tasks/todo.md` should add at least one spec file (`tests/m<N>-*.spec.ts`).
|
||||
|
||||
## One-time setup
|
||||
|
||||
```bash
|
||||
cd e2e
|
||||
npm install
|
||||
npm run install-browsers # downloads chromium (uses sudo for system deps)
|
||||
```
|
||||
|
||||
## Running against a live stack
|
||||
|
||||
```bash
|
||||
# 1. Bring the stack up from the repo root:
|
||||
cd .. && make up
|
||||
|
||||
# 2. Run the tests:
|
||||
cd e2e && npm test
|
||||
|
||||
# 3. Open the HTML report:
|
||||
npm run report # opens playwright-report/index.html in your browser
|
||||
```
|
||||
|
||||
Or from the repo root:
|
||||
|
||||
```bash
|
||||
make e2e # runs against the already-up stack
|
||||
make e2e-report # opens the HTML report
|
||||
make e2e-up # one-shot: make up + wait healthy + run tests
|
||||
```
|
||||
|
||||
## Auto-spawn mode
|
||||
|
||||
Set `PW_AUTOSTART=1` to let Playwright spawn `make up` itself before the run:
|
||||
|
||||
```bash
|
||||
PW_AUTOSTART=1 npm test
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
| Env var | Default | Purpose |
|
||||
|--------------|--------------------------|-----------------------------------------------|
|
||||
| `BASE_URL` | `http://localhost:8080` | The front nginx URL (which proxies `/api/*`) |
|
||||
| `PW_AUTOSTART` | `0` | If `1`, spawn `make up` before the tests |
|
||||
| `CI` | unset | When set, retries=2 and parallel workers=2 |
|
||||
|
||||
## Reports
|
||||
|
||||
- **HTML** : `e2e/playwright-report/index.html`
|
||||
- **JUnit** : `e2e/playwright-report/junit.xml` (CI ingestion)
|
||||
- **Trace** : kept on first retry, opened with `npx playwright show-trace …`
|
||||
|
||||
## Layout
|
||||
|
||||
```
|
||||
e2e/
|
||||
├── tests/
|
||||
│ └── m0-smoke.spec.ts # bootstrap milestone (current)
|
||||
│ └── m<N>-*.spec.ts # one spec per milestone, added as features land
|
||||
├── playwright.config.ts
|
||||
├── tsconfig.json
|
||||
└── package.json
|
||||
```
|
||||
1841
e2e/package-lock.json
generated
Normal file
1841
e2e/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
26
e2e/package.json
Normal file
26
e2e/package.json
Normal file
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"name": "metamorph-e2e",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"test": "playwright test",
|
||||
"test:headed": "playwright test --headed",
|
||||
"test:debug": "PWDEBUG=1 playwright test",
|
||||
"report": "playwright show-report",
|
||||
"install-browsers": "playwright install --with-deps chromium",
|
||||
"lint": "eslint .",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.45.0",
|
||||
"@types/node": "^20.12.7",
|
||||
"@typescript-eslint/eslint-plugin": "^7.13.0",
|
||||
"@typescript-eslint/parser": "^7.13.0",
|
||||
"eslint": "^8.57.0",
|
||||
"typescript": "^5.4.5"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
}
|
||||
}
|
||||
60
e2e/playwright.config.ts
Normal file
60
e2e/playwright.config.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
import { defineConfig, devices } from '@playwright/test';
|
||||
|
||||
/**
|
||||
* Playwright configuration for Metamorph end-to-end tests.
|
||||
*
|
||||
* Run modes:
|
||||
* 1. Against an already-running stack (default in CI/local):
|
||||
* cd e2e && BASE_URL=http://localhost:8080 npm test
|
||||
* 2. With auto-spawned dev servers — set PW_AUTOSTART=1 (see `webServer` block).
|
||||
*
|
||||
* Reports:
|
||||
* - HTML report → `e2e/playwright-report/` (open with `npm run report`)
|
||||
* - JUnit XML → `e2e/playwright-report/junit.xml` (CI ingestion)
|
||||
* - Traces and screenshots are kept on retry for forensics.
|
||||
*/
|
||||
const BASE_URL = process.env.BASE_URL ?? 'http://localhost:8080';
|
||||
const AUTOSTART = process.env.PW_AUTOSTART === '1';
|
||||
|
||||
export default defineConfig({
|
||||
testDir: './tests',
|
||||
timeout: 30_000,
|
||||
expect: { timeout: 5_000 },
|
||||
// The stack uses a shared Postgres. Each spec that calls /diag/reset wipes
|
||||
// global state, so we must serialise execution to avoid spec-vs-spec races
|
||||
// (notably the install-token reset and the per-spec admin bootstrap).
|
||||
fullyParallel: false,
|
||||
retries: process.env.CI ? 2 : 0,
|
||||
workers: 1,
|
||||
reporter: [
|
||||
['list'],
|
||||
['html', { outputFolder: 'playwright-report', open: 'never' }],
|
||||
['junit', { outputFile: 'playwright-report/junit.xml' }],
|
||||
],
|
||||
use: {
|
||||
baseURL: BASE_URL,
|
||||
trace: 'on-first-retry',
|
||||
screenshot: 'only-on-failure',
|
||||
video: 'retain-on-failure',
|
||||
},
|
||||
projects: [
|
||||
{
|
||||
name: 'chromium',
|
||||
use: { ...devices['Desktop Chrome'] },
|
||||
},
|
||||
],
|
||||
// Optional: spawn the compose stack via `make up` before the tests run.
|
||||
// Disabled by default — rely on the operator to bring the stack up.
|
||||
...(AUTOSTART
|
||||
? {
|
||||
webServer: {
|
||||
command: 'cd .. && make up',
|
||||
url: `${BASE_URL}/api/v1/health`,
|
||||
reuseExistingServer: true,
|
||||
timeout: 120_000,
|
||||
stdout: 'pipe',
|
||||
stderr: 'pipe',
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
});
|
||||
127
e2e/tests/m0-smoke.spec.ts
Normal file
127
e2e/tests/m0-smoke.spec.ts
Normal file
@@ -0,0 +1,127 @@
|
||||
import { test, expect, type Request } from '@playwright/test';
|
||||
|
||||
/**
|
||||
* M0 — Bootstrap smoke checks.
|
||||
* Validates what M0 actually delivers:
|
||||
* 1. The 3-container stack is reachable (front + api proxy).
|
||||
* 2. The home page renders the RTOps design system primitives.
|
||||
* 3. Self-hosted webfonts (no Google Fonts CDN — spec §7).
|
||||
* 4. No JS console errors on first load.
|
||||
* 5. API health endpoint returns the expected JSON.
|
||||
*/
|
||||
|
||||
test.describe('M0 — bootstrap smoke', () => {
|
||||
const consoleErrors: string[] = [];
|
||||
const externalRequests: string[] = [];
|
||||
|
||||
test.beforeEach(({ page }) => {
|
||||
consoleErrors.length = 0;
|
||||
externalRequests.length = 0;
|
||||
|
||||
page.on('console', (msg) => {
|
||||
if (msg.type() === 'error') consoleErrors.push(msg.text());
|
||||
});
|
||||
page.on('pageerror', (err) => consoleErrors.push(`pageerror: ${err.message}`));
|
||||
page.on('request', (req: Request) => {
|
||||
const url = req.url();
|
||||
if (
|
||||
url.includes('fonts.googleapis.com') ||
|
||||
url.includes('fonts.gstatic.com') ||
|
||||
url.includes('cdn.jsdelivr.net') ||
|
||||
url.includes('unpkg.com')
|
||||
) {
|
||||
externalRequests.push(url);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
test('home page loads and renders the RTOps header', async ({ page }) => {
|
||||
const resp = await page.goto('/');
|
||||
expect(resp?.status(), 'home page should respond 200').toBe(200);
|
||||
await expect(page).toHaveTitle(/Metamorph/);
|
||||
|
||||
const h1 = page.getByRole('heading', { level: 1 });
|
||||
await expect(h1).toContainText('Metamorph');
|
||||
await expect(h1).toContainText('Purple Team Platform');
|
||||
});
|
||||
|
||||
test('API health card eventually shows OK', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
// The "API" card binds the health probe; wait for the green-accent state.
|
||||
const apiCard = page.locator('h3', { hasText: /^API$/ }).locator('..');
|
||||
await expect(apiCard).toContainText(/version\s+\d+/i, { timeout: 10_000 });
|
||||
await expect(apiCard).toContainText('ok');
|
||||
});
|
||||
|
||||
test('design system primitives render with the expected accent classes', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
// Tags from the demo row.
|
||||
await expect(page.getByText('EVASION', { exact: true })).toBeVisible();
|
||||
await expect(page.getByText('C2', { exact: true })).toBeVisible();
|
||||
await expect(page.getByText('LATERAL', { exact: true })).toBeVisible();
|
||||
// Flow nodes.
|
||||
await expect(page.getByText('recon', { exact: true })).toBeVisible();
|
||||
await expect(page.getByText('impact', { exact: true })).toBeVisible();
|
||||
// Buttons.
|
||||
await expect(page.getByRole('button', { name: /primary/i })).toBeVisible();
|
||||
});
|
||||
|
||||
test('body uses self-hosted IBM Plex Sans, no Google Fonts requests', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
// Wait for fonts to settle.
|
||||
await page.evaluate(() => document.fonts.ready);
|
||||
|
||||
const bodyFont = await page.evaluate(() =>
|
||||
window.getComputedStyle(document.body).fontFamily.toLowerCase(),
|
||||
);
|
||||
expect(bodyFont).toContain('ibm plex sans');
|
||||
|
||||
// Header is mono.
|
||||
const h1Font = await page.evaluate(() => {
|
||||
const h1 = document.querySelector('h1');
|
||||
return h1 ? window.getComputedStyle(h1).fontFamily.toLowerCase() : '';
|
||||
});
|
||||
expect(h1Font).toContain('jetbrains mono');
|
||||
|
||||
// No request must hit Google Fonts or any other CDN — see spec §7.
|
||||
expect(externalRequests, `unexpected CDN traffic: ${externalRequests.join(', ')}`).toEqual([]);
|
||||
});
|
||||
|
||||
test('background uses the RTOps deep navy token', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
const bg = await page.evaluate(() => window.getComputedStyle(document.body).backgroundColor);
|
||||
// tasks/design.md: --bg = #0a0e1a → rgb(10, 14, 26)
|
||||
expect(bg).toBe('rgb(10, 14, 26)');
|
||||
});
|
||||
|
||||
test('no JS console errors on first load', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
await page.waitForLoadState('networkidle');
|
||||
// The auth provider attempts a silent /auth/refresh at mount; without a
|
||||
// refresh cookie the server returns 401 and the browser logs a generic
|
||||
// "Failed to load resource" warning. That's expected for unauthenticated
|
||||
// visitors and doesn't constitute a real error.
|
||||
const realErrors = consoleErrors.filter(
|
||||
(e) => !/Failed to load resource.*401/i.test(e),
|
||||
);
|
||||
expect(realErrors, `console errors: ${realErrors.join(' | ')}`).toEqual([]);
|
||||
});
|
||||
|
||||
test('API health endpoint returns the expected JSON shape', async ({ request }) => {
|
||||
const resp = await request.get('/api/v1/health');
|
||||
expect(resp.status()).toBe(200);
|
||||
const body = (await resp.json()) as { status: string; version: string };
|
||||
expect(body.status).toBe('ok');
|
||||
expect(body.version).toMatch(/^\d+\.\d+\.\d+/);
|
||||
});
|
||||
|
||||
test('CORS headers are set when the SPA origin asks for them', async ({ request }) => {
|
||||
const resp = await request.get('/api/v1/health', {
|
||||
headers: { Origin: 'http://localhost:8080' },
|
||||
});
|
||||
expect(resp.status()).toBe(200);
|
||||
// flask-cors echoes back the configured origin when allowed.
|
||||
const allowed = resp.headers()['access-control-allow-origin'];
|
||||
expect(allowed === 'http://localhost:8080' || allowed === '*').toBeTruthy();
|
||||
});
|
||||
});
|
||||
50
e2e/tests/m1-db.spec.ts
Normal file
50
e2e/tests/m1-db.spec.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
import { expect, test } from '@playwright/test';
|
||||
|
||||
/**
|
||||
* M1 — DB schema visibility checks.
|
||||
* Validates that the diagnostic endpoint reflects an applied migration and
|
||||
* that the SPA renders the resulting state in the Database card.
|
||||
*/
|
||||
|
||||
test.describe('M1 — DB schema', () => {
|
||||
test('GET /api/v1/diag/db returns alembic revision and table count', async ({ request }) => {
|
||||
const resp = await request.get('/api/v1/diag/db');
|
||||
expect(resp.status()).toBe(200);
|
||||
const body = (await resp.json()) as {
|
||||
reachable: boolean;
|
||||
alembic_revision: string | null;
|
||||
table_count: number;
|
||||
};
|
||||
expect(body.reachable).toBe(true);
|
||||
expect(body.alembic_revision).toMatch(/^[0-9a-f]{8,}$/);
|
||||
// 26 application tables + alembic_version. Allow ≥26 to be tolerant of future migrations.
|
||||
expect(body.table_count).toBeGreaterThanOrEqual(26);
|
||||
});
|
||||
|
||||
test('Database card shows the revision short-hash and the table count', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
const dbCard = page.locator('h3', { hasText: /^Database$/ }).locator('..');
|
||||
// Wait for the probing state to resolve.
|
||||
await expect(dbCard).toContainText(/revision\s+[0-9a-f]{8}/i, { timeout: 10_000 });
|
||||
|
||||
const count = await dbCard.getByTestId('db-table-count').innerText();
|
||||
expect(Number(count)).toBeGreaterThanOrEqual(26);
|
||||
|
||||
await expect(dbCard).toContainText('Alembic head reached');
|
||||
});
|
||||
|
||||
test('Roadmap card reflects M1 done', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
const roadmap = page.locator('h3', { hasText: /^Roadmap$/ }).locator('..');
|
||||
// Tolerate trailing "+ M2 done" / "+ M3 done" — the contract is "M1 is past, next is named".
|
||||
await expect(roadmap).toContainText(/M1.*done/i);
|
||||
await expect(roadmap).toContainText(/Next:/i);
|
||||
});
|
||||
|
||||
test('Footer mentions M1', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
const footer = page.locator('footer');
|
||||
await expect(footer).toContainText(/M0\s+bootstrap/i);
|
||||
await expect(footer).toContainText(/M1\s+db\s+schema/i);
|
||||
});
|
||||
});
|
||||
167
e2e/tests/m2-auth.spec.ts
Normal file
167
e2e/tests/m2-auth.spec.ts
Normal file
@@ -0,0 +1,167 @@
|
||||
import { expect, test, type APIRequestContext } from '@playwright/test';
|
||||
|
||||
/**
|
||||
* M2 — Auth flow.
|
||||
*
|
||||
* Each test starts from a clean DB by hitting an internal helper that
|
||||
* truncates users/refresh_tokens/invitations and force-mints a fresh install
|
||||
* token. The helper is the `/api/v1/diag/reset` endpoint exposed *only* when
|
||||
* `APP_ENV=test` — see backend/app/api/diag.py.
|
||||
*
|
||||
* The flow exercised: setup → login → me → invite → register → 2nd login.
|
||||
*/
|
||||
|
||||
const ADMIN_EMAIL = `admin-${Math.floor(Math.random() * 1e6)}@metamorph.local`;
|
||||
const ADMIN_PASSWORD = 'AdminPass1234!';
|
||||
const ALICE_EMAIL = `alice-${Math.floor(Math.random() * 1e6)}@metamorph.local`;
|
||||
const ALICE_PASSWORD = 'AlicePass1234!';
|
||||
|
||||
interface ResetPayload {
|
||||
install_token: string;
|
||||
}
|
||||
|
||||
async function resetAndMintToken(request: APIRequestContext): Promise<string> {
|
||||
const r = await request.post('/api/v1/diag/reset');
|
||||
expect(r.status(), `reset endpoint must respond 200 — got ${r.status()}`).toBe(200);
|
||||
const body = (await r.json()) as ResetPayload;
|
||||
expect(body.install_token).toMatch(/^[A-Za-z0-9_-]{30,}$/);
|
||||
return body.install_token;
|
||||
}
|
||||
|
||||
test.describe.configure({ mode: 'serial' });
|
||||
|
||||
test.describe('M2 — auth flow', () => {
|
||||
let installToken: string;
|
||||
let invitationToken: string;
|
||||
|
||||
test.beforeAll(async ({ request }) => {
|
||||
installToken = await resetAndMintToken(request);
|
||||
});
|
||||
|
||||
test('setup status is uncompleted before bootstrap', async ({ request }) => {
|
||||
const r = await request.get('/api/v1/setup');
|
||||
expect(r.status()).toBe(200);
|
||||
expect((await r.json()).completed).toBe(false);
|
||||
});
|
||||
|
||||
test('SPA setup form creates the first admin', async ({ page }) => {
|
||||
await page.goto('/setup');
|
||||
await page.getByLabel(/install token/i).fill(installToken);
|
||||
await page.getByLabel(/admin email/i).fill(ADMIN_EMAIL);
|
||||
await page.getByLabel('Password', { exact: true }).fill(ADMIN_PASSWORD);
|
||||
await page.getByLabel(/confirm password/i).fill(ADMIN_PASSWORD);
|
||||
await page.getByRole('button', { name: /create admin/i }).click();
|
||||
await expect(page.getByText(/admin created/i)).toBeVisible();
|
||||
// Auto-redirect lands us on /login.
|
||||
await expect(page).toHaveURL(/\/login$/, { timeout: 5000 });
|
||||
});
|
||||
|
||||
test('SPA login works and reveals the profile page', async ({ page }) => {
|
||||
await page.goto('/login');
|
||||
await page.getByLabel(/email/i).fill(ADMIN_EMAIL);
|
||||
await page.getByLabel(/password/i).fill(ADMIN_PASSWORD);
|
||||
await page.getByRole('button', { name: /sign in/i }).click();
|
||||
// Lands on home with header showing the admin email.
|
||||
await expect(page).toHaveURL(/\/$/);
|
||||
await expect(page.getByTestId('me-email')).toHaveText(ADMIN_EMAIL);
|
||||
|
||||
// Visit profile to check identity card. The email appears both in the nav
|
||||
// bar (testid me-email) and in the Identity card (<code>) — that's two
|
||||
// locator matches, so we look at the card-side <code> explicitly.
|
||||
await page.getByRole('link', { name: /profile/i }).click();
|
||||
await expect(page.getByRole('code').filter({ hasText: ADMIN_EMAIL })).toBeVisible();
|
||||
await expect(page.getByText(/admin\s+account/i)).toBeVisible();
|
||||
});
|
||||
|
||||
test('admin issues an invitation via the API and the front renders the registration form', async ({
|
||||
page,
|
||||
request,
|
||||
}) => {
|
||||
// Reuse the page session: read access token from /auth/me cookie chain. The
|
||||
// SPA keeps it in memory, so we exercise the API via a fresh API request
|
||||
// logged in with the same credentials.
|
||||
const login = await request.post('/api/v1/auth/login', {
|
||||
data: { email: ADMIN_EMAIL, password: ADMIN_PASSWORD },
|
||||
});
|
||||
expect(login.status()).toBe(200);
|
||||
const access = (await login.json()).access_token as string;
|
||||
|
||||
const created = await request.post('/api/v1/invitations', {
|
||||
headers: { Authorization: `Bearer ${access}` },
|
||||
data: { email_hint: ALICE_EMAIL },
|
||||
});
|
||||
expect(created.status()).toBe(201);
|
||||
invitationToken = (await created.json()).token as string;
|
||||
expect(invitationToken).toMatch(/^[A-Za-z0-9_-]{30,}$/);
|
||||
|
||||
// Open the registration page and confirm the preview loaded.
|
||||
await page.goto(`/register?token=${encodeURIComponent(invitationToken)}`);
|
||||
await expect(page.getByText(/account.*registration/i)).toBeVisible();
|
||||
// Email pre-filled from the hint.
|
||||
const emailInput = page.getByLabel(/email/i);
|
||||
await expect(emailInput).toHaveValue(ALICE_EMAIL);
|
||||
});
|
||||
|
||||
test('invitee submits the registration form and can log in', async ({ page }) => {
|
||||
await page.goto(`/register?token=${encodeURIComponent(invitationToken)}`);
|
||||
await page.getByLabel(/email/i).fill(ALICE_EMAIL);
|
||||
await page.getByLabel('Password', { exact: true }).fill(ALICE_PASSWORD);
|
||||
await page.getByLabel(/confirm password/i).fill(ALICE_PASSWORD);
|
||||
await page.getByRole('button', { name: /create account/i }).click();
|
||||
await expect(page.getByText(/account created/i)).toBeVisible();
|
||||
await expect(page).toHaveURL(/\/login$/, { timeout: 5000 });
|
||||
|
||||
await page.getByLabel(/email/i).fill(ALICE_EMAIL);
|
||||
await page.getByLabel(/password/i).fill(ALICE_PASSWORD);
|
||||
await page.getByRole('button', { name: /sign in/i }).click();
|
||||
await expect(page.getByTestId('me-email')).toHaveText(ALICE_EMAIL);
|
||||
});
|
||||
|
||||
test('non-admin gets 403 on the admin invitations endpoint', async ({ request }) => {
|
||||
const login = await request.post('/api/v1/auth/login', {
|
||||
data: { email: ALICE_EMAIL, password: ALICE_PASSWORD },
|
||||
});
|
||||
const access = (await login.json()).access_token as string;
|
||||
const r = await request.post('/api/v1/invitations', {
|
||||
headers: { Authorization: `Bearer ${access}` },
|
||||
data: {},
|
||||
});
|
||||
expect(r.status()).toBe(403);
|
||||
});
|
||||
|
||||
test('refresh token rotation works through the SPA', async ({ page }) => {
|
||||
// Login fresh.
|
||||
await page.goto('/login');
|
||||
await page.getByLabel(/email/i).fill(ALICE_EMAIL);
|
||||
await page.getByLabel(/password/i).fill(ALICE_PASSWORD);
|
||||
await page.getByRole('button', { name: /sign in/i }).click();
|
||||
await expect(page.getByTestId('me-email')).toHaveText(ALICE_EMAIL);
|
||||
|
||||
// Force a refresh via the API client interceptor: clear the in-memory access
|
||||
// token and trigger a request that needs auth.
|
||||
await page.evaluate(async () => {
|
||||
const r = await fetch('/api/v1/auth/refresh', {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
});
|
||||
return r.ok;
|
||||
});
|
||||
// After refresh the page is still authenticated.
|
||||
await page.reload();
|
||||
await expect(page.getByTestId('me-email')).toHaveText(ALICE_EMAIL);
|
||||
});
|
||||
|
||||
test('logout clears the session and redirects to login', async ({ page }) => {
|
||||
await page.goto('/login');
|
||||
await page.getByLabel(/email/i).fill(ALICE_EMAIL);
|
||||
await page.getByLabel(/password/i).fill(ALICE_PASSWORD);
|
||||
await page.getByRole('button', { name: /sign in/i }).click();
|
||||
await expect(page.getByTestId('me-email')).toHaveText(ALICE_EMAIL);
|
||||
|
||||
await page.getByRole('button', { name: /logout/i }).click();
|
||||
await expect(page).toHaveURL(/\/login$/);
|
||||
// Going to /profile while logged out must redirect.
|
||||
await page.goto('/profile');
|
||||
await expect(page).toHaveURL(/\/login/);
|
||||
});
|
||||
});
|
||||
230
e2e/tests/m3-rbac.spec.ts
Normal file
230
e2e/tests/m3-rbac.spec.ts
Normal file
@@ -0,0 +1,230 @@
|
||||
import { expect, test, type APIRequestContext, type Page } from '@playwright/test';
|
||||
|
||||
/**
|
||||
* M3 — RBAC, group management, user assignment.
|
||||
*
|
||||
* Flow:
|
||||
* 1. Reset + bootstrap a fresh admin.
|
||||
* 2. Admin visits /admin/groups and creates a custom group `pentest-red` with
|
||||
* only `mission.read` + `mission.write_red_fields`.
|
||||
* 3. Admin issues an invitation pre-assigned to that custom group.
|
||||
* 4. Invitee accepts, logs in, hits the API: mission.read OK, but admin-only
|
||||
* group.create returns 403 — proving the union-of-perms decorator works.
|
||||
* 5. Admin attempts to demote himself → server returns 409 last_admin_protected.
|
||||
*/
|
||||
|
||||
const ADMIN_EMAIL = `admin-${Math.floor(Math.random() * 1e6)}@metamorph.local`;
|
||||
const ADMIN_PASSWORD = 'AdminPass1234!';
|
||||
const BOB_EMAIL = `bob-${Math.floor(Math.random() * 1e6)}@metamorph.local`;
|
||||
const BOB_PASSWORD = 'BobPass1234!';
|
||||
const GROUP_NAME = `pentest-red-${Math.floor(Math.random() * 1e6)}`;
|
||||
|
||||
interface ResetPayload {
|
||||
install_token: string;
|
||||
}
|
||||
|
||||
async function resetAndMintToken(request: APIRequestContext): Promise<string> {
|
||||
const r = await request.post('/api/v1/diag/reset');
|
||||
expect(r.status()).toBe(200);
|
||||
const body = (await r.json()) as ResetPayload;
|
||||
return body.install_token;
|
||||
}
|
||||
|
||||
async function loginAndGetAccess(
|
||||
request: APIRequestContext,
|
||||
email: string,
|
||||
password: string,
|
||||
): Promise<string> {
|
||||
const r = await request.post('/api/v1/auth/login', {
|
||||
data: { email, password },
|
||||
});
|
||||
expect(r.status()).toBe(200);
|
||||
return (await r.json()).access_token as string;
|
||||
}
|
||||
|
||||
/** Authenticate the page session via the SPA login form. */
|
||||
async function loginViaSpa(page: Page, email: string, password: string) {
|
||||
await page.goto('/login');
|
||||
await page.getByLabel(/email/i).fill(email);
|
||||
await page.getByLabel(/password/i).fill(password);
|
||||
await page.getByRole('button', { name: /sign in/i }).click();
|
||||
await expect(page.getByTestId('me-email')).toHaveText(email);
|
||||
}
|
||||
|
||||
test.describe.configure({ mode: 'serial' });
|
||||
|
||||
test.describe('M3 — RBAC management', () => {
|
||||
let installToken: string;
|
||||
let customGroupId: string;
|
||||
|
||||
test.beforeAll(async ({ request }) => {
|
||||
installToken = await resetAndMintToken(request);
|
||||
// Bootstrap the first admin via API to keep the e2e focused on RBAC.
|
||||
const setup = await request.post('/api/v1/setup', {
|
||||
data: {
|
||||
install_token: installToken,
|
||||
email: ADMIN_EMAIL,
|
||||
password: ADMIN_PASSWORD,
|
||||
display_name: 'Admin',
|
||||
},
|
||||
});
|
||||
expect(setup.status()).toBe(201);
|
||||
});
|
||||
|
||||
test('admin sees Admin nav links after login', async ({ page }) => {
|
||||
await loginViaSpa(page, ADMIN_EMAIL, ADMIN_PASSWORD);
|
||||
// Nav now shows the admin links.
|
||||
await expect(page.getByRole('link', { name: /^users$/i })).toBeVisible();
|
||||
await expect(page.getByRole('link', { name: /^groups$/i })).toBeVisible();
|
||||
await expect(page.getByRole('link', { name: /^invitations$/i })).toBeVisible();
|
||||
});
|
||||
|
||||
test('catalogue page lists the seeded permissions', async ({ request }) => {
|
||||
const access = await loginAndGetAccess(request, ADMIN_EMAIL, ADMIN_PASSWORD);
|
||||
const r = await request.get('/api/v1/permissions', {
|
||||
headers: { Authorization: `Bearer ${access}` },
|
||||
});
|
||||
expect(r.status()).toBe(200);
|
||||
const body = (await r.json()) as { items: Array<{ code: string }> };
|
||||
const codes = body.items.map((p) => p.code);
|
||||
// Smoke-check several families.
|
||||
expect(codes).toEqual(expect.arrayContaining([
|
||||
'user.read',
|
||||
'group.create',
|
||||
'invitation.create',
|
||||
'mission.write_red_fields',
|
||||
'mission.write_blue_fields',
|
||||
'mitre.sync',
|
||||
]));
|
||||
});
|
||||
|
||||
test('admin creates a custom group with only red-write perms via the SPA', async ({ page }) => {
|
||||
await loginViaSpa(page, ADMIN_EMAIL, ADMIN_PASSWORD);
|
||||
await page.goto('/admin/groups');
|
||||
await page.getByTestId('create-group').click();
|
||||
const modal = page.getByTestId('group-create-modal');
|
||||
await expect(modal).toBeVisible();
|
||||
await modal.getByLabel(/^name$/i).fill(GROUP_NAME);
|
||||
await modal.getByTestId('perm-mission.read').check();
|
||||
await modal.getByTestId('perm-mission.write_red_fields').check();
|
||||
await modal.getByTestId('group-create-save').click();
|
||||
|
||||
// The new card is visible in the listing.
|
||||
await expect(modal).not.toBeVisible();
|
||||
await expect(page.getByText(GROUP_NAME)).toBeVisible();
|
||||
});
|
||||
|
||||
test('admin invites Bob pre-assigned to the custom group', async ({ page, request }) => {
|
||||
// Fetch the group id (needed for the invitation API).
|
||||
const access = await loginAndGetAccess(request, ADMIN_EMAIL, ADMIN_PASSWORD);
|
||||
const groups = await request.get('/api/v1/groups', {
|
||||
headers: { Authorization: `Bearer ${access}` },
|
||||
});
|
||||
const items = ((await groups.json()) as { items: Array<{ id: string; name: string }> }).items;
|
||||
customGroupId = items.find((g) => g.name === GROUP_NAME)!.id;
|
||||
expect(customGroupId).toBeTruthy();
|
||||
|
||||
// Issue invitation via API (creating an invitation through the UI is covered in M2).
|
||||
const created = await request.post('/api/v1/invitations', {
|
||||
headers: { Authorization: `Bearer ${access}` },
|
||||
data: { email_hint: BOB_EMAIL, group_ids: [customGroupId] },
|
||||
});
|
||||
expect(created.status()).toBe(201);
|
||||
const token = (await created.json()).token as string;
|
||||
|
||||
// Bob completes registration.
|
||||
await page.goto(`/register?token=${encodeURIComponent(token)}`);
|
||||
await page.getByLabel(/email/i).fill(BOB_EMAIL);
|
||||
await page.getByLabel('Password', { exact: true }).fill(BOB_PASSWORD);
|
||||
await page.getByLabel(/confirm password/i).fill(BOB_PASSWORD);
|
||||
await page.getByRole('button', { name: /create account/i }).click();
|
||||
await expect(page).toHaveURL(/\/login$/, { timeout: 5000 });
|
||||
});
|
||||
|
||||
test('Bob can read missions list but is forbidden from admin endpoints', async ({ request }) => {
|
||||
const access = await loginAndGetAccess(request, BOB_EMAIL, BOB_PASSWORD);
|
||||
// Inspect /auth/me to confirm his perms.
|
||||
const me = await request.get('/api/v1/auth/me', {
|
||||
headers: { Authorization: `Bearer ${access}` },
|
||||
});
|
||||
const body = (await me.json()) as {
|
||||
is_admin: boolean;
|
||||
permissions: string[];
|
||||
groups: string[];
|
||||
};
|
||||
expect(body.is_admin).toBe(false);
|
||||
expect(body.groups).toContain(GROUP_NAME);
|
||||
expect(body.permissions).toEqual(
|
||||
expect.arrayContaining(['mission.read', 'mission.write_red_fields']),
|
||||
);
|
||||
expect(body.permissions).not.toContain('mission.write_blue_fields');
|
||||
|
||||
// Bob does NOT have user.read → /users returns 403.
|
||||
const usersList = await request.get('/api/v1/users', {
|
||||
headers: { Authorization: `Bearer ${access}` },
|
||||
});
|
||||
expect(usersList.status()).toBe(403);
|
||||
|
||||
// Bob does NOT have group.create → POST /groups returns 403.
|
||||
const groupCreate = await request.post('/api/v1/groups', {
|
||||
headers: { Authorization: `Bearer ${access}` },
|
||||
data: { name: 'wont-happen', description: null },
|
||||
});
|
||||
expect(groupCreate.status()).toBe(403);
|
||||
});
|
||||
|
||||
test('non-admin SPA visitor cannot reach /admin/* routes', async ({ page }) => {
|
||||
await loginViaSpa(page, BOB_EMAIL, BOB_PASSWORD);
|
||||
// Direct nav to /admin/users — RequireAdmin redirects to /.
|
||||
await page.goto('/admin/users');
|
||||
await expect(page).toHaveURL(/\/$/);
|
||||
// The nav also hides the admin links.
|
||||
await expect(page.getByRole('link', { name: /^users$/i })).toHaveCount(0);
|
||||
});
|
||||
|
||||
test('last-admin protection prevents the bootstrap admin from being deleted', async ({ request }) => {
|
||||
const access = await loginAndGetAccess(request, ADMIN_EMAIL, ADMIN_PASSWORD);
|
||||
const me = await request.get('/api/v1/auth/me', {
|
||||
headers: { Authorization: `Bearer ${access}` },
|
||||
});
|
||||
const adminId = (await me.json()).id as string;
|
||||
|
||||
const del = await request.delete(`/api/v1/users/${adminId}`, {
|
||||
headers: { Authorization: `Bearer ${access}` },
|
||||
});
|
||||
expect(del.status()).toBe(409);
|
||||
expect((await del.json()).error).toBe('last_admin_protected');
|
||||
});
|
||||
|
||||
test('admin promotes Bob and the new perms take effect', async ({ request }) => {
|
||||
const access = await loginAndGetAccess(request, ADMIN_EMAIL, ADMIN_PASSWORD);
|
||||
// Find Bob.
|
||||
const list = await request.get(`/api/v1/users?q=${encodeURIComponent(BOB_EMAIL)}`, {
|
||||
headers: { Authorization: `Bearer ${access}` },
|
||||
});
|
||||
const bob = ((await list.json()) as { items: Array<{ id: string; email: string }> }).items.find(
|
||||
(u) => u.email === BOB_EMAIL,
|
||||
)!;
|
||||
|
||||
// Find admin group id.
|
||||
const groups = await request.get('/api/v1/groups', {
|
||||
headers: { Authorization: `Bearer ${access}` },
|
||||
});
|
||||
const adminGroup = ((await groups.json()) as { items: Array<{ id: string; name: string }> }).items.find(
|
||||
(g) => g.name === 'admin',
|
||||
)!;
|
||||
|
||||
const r = await request.put(`/api/v1/users/${bob.id}/groups`, {
|
||||
headers: { Authorization: `Bearer ${access}` },
|
||||
data: { group_ids: [customGroupId, adminGroup.id] },
|
||||
});
|
||||
expect(r.status()).toBe(200);
|
||||
|
||||
// Bob now has admin rights via group membership.
|
||||
const bobAccess = await loginAndGetAccess(request, BOB_EMAIL, BOB_PASSWORD);
|
||||
const groupsAsBob = await request.get('/api/v1/groups', {
|
||||
headers: { Authorization: `Bearer ${bobAccess}` },
|
||||
});
|
||||
expect(groupsAsBob.status()).toBe(200);
|
||||
});
|
||||
});
|
||||
18
e2e/tsconfig.json
Normal file
18
e2e/tsconfig.json
Normal file
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"lib": ["ES2022", "DOM"],
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noImplicitOverride": true,
|
||||
"skipLibCheck": true,
|
||||
"esModuleInterop": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"types": ["node"]
|
||||
},
|
||||
"include": ["playwright.config.ts", "tests/**/*"]
|
||||
}
|
||||
7
frontend/.dockerignore
Normal file
7
frontend/.dockerignore
Normal file
@@ -0,0 +1,7 @@
|
||||
node_modules
|
||||
dist
|
||||
.vite
|
||||
*.log
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
18
frontend/.eslintrc.cjs
Normal file
18
frontend/.eslintrc.cjs
Normal file
@@ -0,0 +1,18 @@
|
||||
/* eslint-env node */
|
||||
module.exports = {
|
||||
root: true,
|
||||
env: { browser: true, es2022: true },
|
||||
extends: [
|
||||
'eslint:recommended',
|
||||
'plugin:@typescript-eslint/recommended',
|
||||
'plugin:react-hooks/recommended',
|
||||
],
|
||||
ignorePatterns: ['dist', '.eslintrc.cjs', 'node_modules'],
|
||||
parser: '@typescript-eslint/parser',
|
||||
parserOptions: { ecmaVersion: 'latest', sourceType: 'module' },
|
||||
plugins: ['react-refresh'],
|
||||
rules: {
|
||||
'react-refresh/only-export-components': ['warn', { allowConstantExport: true }],
|
||||
'@typescript-eslint/no-unused-vars': ['error', { argsIgnorePattern: '^_' }],
|
||||
},
|
||||
};
|
||||
8
frontend/.prettierrc
Normal file
8
frontend/.prettierrc
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"singleQuote": true,
|
||||
"semi": true,
|
||||
"trailingComma": "all",
|
||||
"printWidth": 100,
|
||||
"tabWidth": 2,
|
||||
"arrowParens": "always"
|
||||
}
|
||||
31
frontend/Dockerfile
Normal file
31
frontend/Dockerfile
Normal file
@@ -0,0 +1,31 @@
|
||||
# syntax=docker/dockerfile:1.7
|
||||
|
||||
# === Stage 1: build the SPA bundle ===
|
||||
FROM docker.io/library/node:20-alpine AS builder
|
||||
|
||||
ARG VITE_API_BASE_URL=/api/v1
|
||||
ENV VITE_API_BASE_URL=${VITE_API_BASE_URL}
|
||||
|
||||
WORKDIR /app
|
||||
COPY package.json ./
|
||||
# When a lockfile is committed (npm/pnpm/yarn), prefer `npm ci` for reproducibility.
|
||||
RUN if [ -f package-lock.json ]; then npm ci; else npm install --no-audit --no-fund; fi
|
||||
|
||||
COPY tsconfig*.json vite.config.ts tailwind.config.ts postcss.config.js index.html ./
|
||||
COPY src ./src
|
||||
|
||||
RUN npm run build
|
||||
|
||||
# === Stage 2: serve via nginx ===
|
||||
FROM docker.io/library/nginx:1.27-alpine AS runtime
|
||||
|
||||
# Drop the default config and use ours.
|
||||
RUN rm -f /etc/nginx/conf.d/default.conf
|
||||
COPY nginx.conf /etc/nginx/conf.d/metamorph.conf
|
||||
|
||||
COPY --from=builder /app/dist /usr/share/nginx/html
|
||||
|
||||
EXPOSE 80
|
||||
|
||||
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
|
||||
CMD wget -qO- http://127.0.0.1/healthz || exit 1
|
||||
39
frontend/README.md
Normal file
39
frontend/README.md
Normal file
@@ -0,0 +1,39 @@
|
||||
# Metamorph frontend
|
||||
|
||||
Vite + React 18 + TypeScript + TailwindCSS. Design tokens from `../tasks/design.md` are in `tailwind.config.ts`.
|
||||
|
||||
## Local dev
|
||||
|
||||
```bash
|
||||
npm install
|
||||
npm run dev # http://localhost:5173 (proxies /api/* to http://localhost:8000)
|
||||
```
|
||||
|
||||
## Build
|
||||
|
||||
```bash
|
||||
npm run build # outputs to dist/
|
||||
npm run preview # serves dist/ on http://localhost:8080
|
||||
```
|
||||
|
||||
## Quality
|
||||
|
||||
```bash
|
||||
npm run typecheck
|
||||
npm run lint
|
||||
npm run format
|
||||
```
|
||||
|
||||
## Layout
|
||||
|
||||
```
|
||||
src/
|
||||
├── App.tsx # M0 home page (health check + design tokens demo)
|
||||
├── main.tsx
|
||||
├── index.css # Tailwind base + tinted-accent utilities
|
||||
├── components/ui/ # RTOps design primitives: Card, Tag, SectionHeader, FlowNode, Button
|
||||
├── lib/
|
||||
│ ├── api.ts # fetch wrapper (M2 will replace with auth-aware client)
|
||||
│ └── cn.ts # classnames + ACCENTS palette
|
||||
└── vite-env.d.ts
|
||||
```
|
||||
13
frontend/index.html
Normal file
13
frontend/index.html
Normal file
@@ -0,0 +1,13 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="color-scheme" content="dark" />
|
||||
<title>Metamorph</title>
|
||||
</head>
|
||||
<body class="bg-bg text-text font-sans">
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
45
frontend/nginx.conf
Normal file
45
frontend/nginx.conf
Normal file
@@ -0,0 +1,45 @@
|
||||
server {
|
||||
listen 80;
|
||||
listen [::]:80;
|
||||
server_name _;
|
||||
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
# Reasonable hardening — TLS is terminated by an external reverse proxy
|
||||
# so we don't add HSTS here (let the edge own that header).
|
||||
add_header X-Content-Type-Options "nosniff" always;
|
||||
add_header X-Frame-Options "DENY" always;
|
||||
add_header Referrer-Policy "same-origin" always;
|
||||
|
||||
# Internal liveness for the docker healthcheck.
|
||||
location = /healthz {
|
||||
access_log off;
|
||||
return 200 "ok\n";
|
||||
add_header Content-Type text/plain;
|
||||
}
|
||||
|
||||
# Proxy API calls to the Flask service on the compose network.
|
||||
location /api/ {
|
||||
proxy_pass http://api:8000;
|
||||
proxy_http_version 1.1;
|
||||
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_read_timeout 60s;
|
||||
client_max_body_size 64m;
|
||||
}
|
||||
|
||||
# SPA fallback — every unknown route returns index.html.
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
|
||||
# Long cache for hashed assets.
|
||||
location ~* \.(?:js|css|woff2?|svg|png|jpg|jpeg|webp|ico)$ {
|
||||
expires 30d;
|
||||
add_header Cache-Control "public, immutable";
|
||||
try_files $uri =404;
|
||||
}
|
||||
}
|
||||
43
frontend/package.json
Normal file
43
frontend/package.json
Normal file
@@ -0,0 +1,43 @@
|
||||
{
|
||||
"name": "metamorph-front",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
"preview": "vite preview --port 8080",
|
||||
"lint": "eslint .",
|
||||
"typecheck": "tsc -b --pretty",
|
||||
"format": "prettier --write \"src/**/*.{ts,tsx,css,json,html}\"",
|
||||
"format:check": "prettier --check \"src/**/*.{ts,tsx,css,json,html}\""
|
||||
},
|
||||
"dependencies": {
|
||||
"@fontsource/ibm-plex-sans": "^5.0.20",
|
||||
"@fontsource/jetbrains-mono": "^5.0.20",
|
||||
"@tanstack/react-query": "^5.51.0",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-router-dom": "^6.26.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^20.12.7",
|
||||
"@types/react": "^18.3.3",
|
||||
"@types/react-dom": "^18.3.0",
|
||||
"@typescript-eslint/eslint-plugin": "^7.13.0",
|
||||
"@typescript-eslint/parser": "^7.13.0",
|
||||
"@vitejs/plugin-react": "^4.3.1",
|
||||
"autoprefixer": "^10.4.19",
|
||||
"eslint": "^8.57.0",
|
||||
"eslint-plugin-react-hooks": "^4.6.2",
|
||||
"eslint-plugin-react-refresh": "^0.4.7",
|
||||
"postcss": "^8.4.38",
|
||||
"prettier": "^3.3.0",
|
||||
"tailwindcss": "^3.4.4",
|
||||
"typescript": "^5.4.5",
|
||||
"vite": "^5.3.1"
|
||||
}
|
||||
}
|
||||
6
frontend/postcss.config.js
Normal file
6
frontend/postcss.config.js
Normal file
@@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
};
|
||||
85
frontend/src/App.tsx
Normal file
85
frontend/src/App.tsx
Normal file
@@ -0,0 +1,85 @@
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import { BrowserRouter, Navigate, Route, Routes } from 'react-router-dom';
|
||||
|
||||
import { Layout } from '@/components/Layout';
|
||||
import { RequireAdmin } from '@/components/RequireAdmin';
|
||||
import { RequireAuth } from '@/components/RequireAuth';
|
||||
import { AdminGroupsPage } from '@/pages/AdminGroupsPage';
|
||||
import { AdminInvitationsPage } from '@/pages/AdminInvitationsPage';
|
||||
import { AdminUsersPage } from '@/pages/AdminUsersPage';
|
||||
import { HomePage } from '@/pages/HomePage';
|
||||
import { LoginPage } from '@/pages/LoginPage';
|
||||
import { ProfilePage } from '@/pages/ProfilePage';
|
||||
import { RegisterPage } from '@/pages/RegisterPage';
|
||||
import { SetupPage } from '@/pages/SetupPage';
|
||||
import { AuthContext, useProvideAuth } from '@/lib/auth';
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
retry: false,
|
||||
refetchOnWindowFocus: false,
|
||||
staleTime: 30_000,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
function AuthProvider({ children }: { children: React.ReactNode }) {
|
||||
const auth = useProvideAuth();
|
||||
return <AuthContext.Provider value={auth}>{children}</AuthContext.Provider>;
|
||||
}
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<BrowserRouter>
|
||||
<AuthProvider>
|
||||
<Routes>
|
||||
<Route element={<Layout />}>
|
||||
<Route path="/login" element={<LoginPage />} />
|
||||
<Route path="/setup" element={<SetupPage />} />
|
||||
<Route path="/register" element={<RegisterPage />} />
|
||||
{/* Home page stays public — it's an ops dashboard, not sensitive. */}
|
||||
<Route path="/" element={<HomePage />} />
|
||||
<Route
|
||||
path="/profile"
|
||||
element={
|
||||
<RequireAuth>
|
||||
<ProfilePage />
|
||||
</RequireAuth>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/admin/users"
|
||||
element={
|
||||
<RequireAdmin>
|
||||
<AdminUsersPage />
|
||||
</RequireAdmin>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/admin/groups"
|
||||
element={
|
||||
<RequireAdmin>
|
||||
<AdminGroupsPage />
|
||||
</RequireAdmin>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/admin/invitations"
|
||||
element={
|
||||
<RequireAdmin>
|
||||
<AdminInvitationsPage />
|
||||
</RequireAdmin>
|
||||
}
|
||||
/>
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Route>
|
||||
</Routes>
|
||||
</AuthProvider>
|
||||
</BrowserRouter>
|
||||
</QueryClientProvider>
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
||||
76
frontend/src/components/Layout.tsx
Normal file
76
frontend/src/components/Layout.tsx
Normal file
@@ -0,0 +1,76 @@
|
||||
import { Link, NavLink, Outlet, useNavigate } from 'react-router-dom';
|
||||
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { useAuth } from '@/lib/auth';
|
||||
import { cn } from '@/lib/cn';
|
||||
|
||||
export function Layout() {
|
||||
const { state, logout } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const navItem = (to: string, label: string) => (
|
||||
<NavLink
|
||||
to={to}
|
||||
end
|
||||
className={({ isActive }) =>
|
||||
cn(
|
||||
'font-mono text-xs uppercase tracking-wider2 px-3 py-1 rounded',
|
||||
isActive ? 'text-cyan accent-fill-cyan' : 'text-text-dim hover:text-text-bright',
|
||||
)
|
||||
}
|
||||
>
|
||||
{label}
|
||||
</NavLink>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="px-[60px] py-10">
|
||||
<div className="mx-auto max-w-page">
|
||||
<header className="flex items-baseline justify-between mb-12">
|
||||
<Link to="/" className="font-mono text-[24px] font-bold tracking-tight text-text-bright">
|
||||
<span className="text-red">Meta</span>
|
||||
<span>morph</span>
|
||||
</Link>
|
||||
<nav className="flex items-center gap-2" aria-label="Primary">
|
||||
{state.user ? (
|
||||
<>
|
||||
{navItem('/', 'Home')}
|
||||
{navItem('/profile', 'Profile')}
|
||||
{state.user.is_admin && (
|
||||
<>
|
||||
{navItem('/admin/users', 'Users')}
|
||||
{navItem('/admin/groups', 'Groups')}
|
||||
{navItem('/admin/invitations', 'Invitations')}
|
||||
</>
|
||||
)}
|
||||
<span className="font-mono text-2xs text-text-dim ml-2" data-testid="me-email">
|
||||
{state.user.email}
|
||||
</span>
|
||||
<Button
|
||||
accent="rose"
|
||||
onClick={async () => {
|
||||
await logout();
|
||||
navigate('/login', { replace: true });
|
||||
}}
|
||||
>
|
||||
Logout
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{navItem('/login', 'Login')}
|
||||
{navItem('/setup', 'Setup')}
|
||||
</>
|
||||
)}
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
<Outlet />
|
||||
|
||||
<footer className="mt-[60px] py-8 border-t border-border text-center font-mono text-xs text-text-dim">
|
||||
metamorph · M0 bootstrap · M1 db schema · M2 auth · M3 rbac · design system from tasks/design.md
|
||||
</footer>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
19
frontend/src/components/RequireAdmin.tsx
Normal file
19
frontend/src/components/RequireAdmin.tsx
Normal file
@@ -0,0 +1,19 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import { Navigate } from 'react-router-dom';
|
||||
|
||||
import { useAuth } from '@/lib/auth';
|
||||
|
||||
/** Server still arbitrates — this is a UI gate so non-admins don't see admin routes. */
|
||||
export function RequireAdmin({ children }: { children: ReactNode }) {
|
||||
const { state } = useAuth();
|
||||
if (state.loading) {
|
||||
return <p className="font-mono text-xs text-text-dim p-8">Loading session…</p>;
|
||||
}
|
||||
if (!state.user) {
|
||||
return <Navigate to="/login" replace />;
|
||||
}
|
||||
if (!state.user.is_admin) {
|
||||
return <Navigate to="/" replace />;
|
||||
}
|
||||
return <>{children}</>;
|
||||
}
|
||||
16
frontend/src/components/RequireAuth.tsx
Normal file
16
frontend/src/components/RequireAuth.tsx
Normal file
@@ -0,0 +1,16 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import { Navigate, useLocation } from 'react-router-dom';
|
||||
|
||||
import { useAuth } from '@/lib/auth';
|
||||
|
||||
export function RequireAuth({ children }: { children: ReactNode }) {
|
||||
const { state } = useAuth();
|
||||
const loc = useLocation();
|
||||
if (state.loading) {
|
||||
return <p className="font-mono text-xs text-text-dim p-8">Loading session…</p>;
|
||||
}
|
||||
if (!state.user) {
|
||||
return <Navigate to="/login" state={{ from: loc.pathname }} replace />;
|
||||
}
|
||||
return <>{children}</>;
|
||||
}
|
||||
39
frontend/src/components/ui/Alert.tsx
Normal file
39
frontend/src/components/ui/Alert.tsx
Normal file
@@ -0,0 +1,39 @@
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
import { cn, type Accent } from '@/lib/cn';
|
||||
|
||||
interface AlertProps {
|
||||
accent: Accent;
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
/** Optional ARIA role override; defaults to "alert" for errors. */
|
||||
role?: string;
|
||||
}
|
||||
|
||||
const ACCENT_FILL: Record<Accent, string> = {
|
||||
red: 'accent-fill-red',
|
||||
orange: 'accent-fill-orange',
|
||||
yellow: 'accent-fill-yellow',
|
||||
green: 'accent-fill-green',
|
||||
cyan: 'accent-fill-cyan',
|
||||
blue: 'accent-fill-blue',
|
||||
purple: 'accent-fill-purple',
|
||||
pink: 'accent-fill-pink',
|
||||
rose: 'accent-fill-rose',
|
||||
teal: 'accent-fill-teal',
|
||||
};
|
||||
|
||||
export function Alert({ accent, children, className, role }: AlertProps) {
|
||||
return (
|
||||
<div
|
||||
role={role ?? (accent === 'red' || accent === 'rose' ? 'alert' : 'status')}
|
||||
className={cn(
|
||||
'rounded-md border border-current/30 px-3 py-2 font-mono text-xs',
|
||||
ACCENT_FILL[accent],
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
45
frontend/src/components/ui/Button.tsx
Normal file
45
frontend/src/components/ui/Button.tsx
Normal file
@@ -0,0 +1,45 @@
|
||||
import type { ButtonHTMLAttributes, ReactNode } from 'react';
|
||||
|
||||
import { cn, type Accent } from '@/lib/cn';
|
||||
|
||||
interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
|
||||
accent?: Accent;
|
||||
variant?: 'solid' | 'outline' | 'ghost';
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
const ACCENT_OUTLINE: Record<Accent, string> = {
|
||||
red: 'border-red text-red hover:bg-red/10',
|
||||
orange: 'border-orange text-orange hover:bg-orange/10',
|
||||
yellow: 'border-yellow text-yellow hover:bg-yellow/10',
|
||||
green: 'border-green text-green hover:bg-green/10',
|
||||
cyan: 'border-cyan text-cyan hover:bg-cyan/10',
|
||||
blue: 'border-blue text-blue hover:bg-blue/10',
|
||||
purple: 'border-purple text-purple hover:bg-purple/10',
|
||||
pink: 'border-pink text-pink hover:bg-pink/10',
|
||||
rose: 'border-rose text-rose hover:bg-rose/10',
|
||||
teal: 'border-teal text-teal hover:bg-teal/10',
|
||||
};
|
||||
|
||||
/** Minimal button matching the briefing aesthetic — no shadows, thin borders. */
|
||||
export function Button({
|
||||
accent = 'cyan',
|
||||
variant = 'outline',
|
||||
className,
|
||||
children,
|
||||
...rest
|
||||
}: ButtonProps) {
|
||||
const base =
|
||||
'inline-flex items-center justify-center rounded-md border px-3 py-2 font-mono text-xs font-medium uppercase tracking-wider2 disabled:opacity-50 disabled:pointer-events-none';
|
||||
const variantCls =
|
||||
variant === 'outline'
|
||||
? ACCENT_OUTLINE[accent]
|
||||
: variant === 'ghost'
|
||||
? 'border-transparent text-text hover:bg-bg-card'
|
||||
: 'border-transparent bg-bg-card text-text-bright';
|
||||
return (
|
||||
<button className={cn(base, variantCls, className)} {...rest}>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
50
frontend/src/components/ui/Card.tsx
Normal file
50
frontend/src/components/ui/Card.tsx
Normal file
@@ -0,0 +1,50 @@
|
||||
import type { HTMLAttributes, ReactNode } from 'react';
|
||||
|
||||
import { cn, type Accent } from '@/lib/cn';
|
||||
|
||||
interface CardProps extends Omit<HTMLAttributes<HTMLDivElement>, 'title'> {
|
||||
/** Accent border color — distinguishes the card's category. */
|
||||
accent?: Accent;
|
||||
/** Card heading. Renamed from the native HTMLAttributes.title (string-only). */
|
||||
title?: ReactNode;
|
||||
/** Subtitle / metadata line below the title. */
|
||||
sub?: ReactNode;
|
||||
children?: ReactNode;
|
||||
}
|
||||
|
||||
const ACCENT_BORDER: Record<Accent, string> = {
|
||||
red: 'border-red',
|
||||
orange: 'border-orange',
|
||||
yellow: 'border-yellow',
|
||||
green: 'border-green',
|
||||
cyan: 'border-cyan',
|
||||
blue: 'border-blue',
|
||||
purple: 'border-purple',
|
||||
pink: 'border-pink',
|
||||
rose: 'border-rose',
|
||||
teal: 'border-teal',
|
||||
};
|
||||
|
||||
/** Card from design.md §5.3 — shared chrome, accent-only differentiation. */
|
||||
export function Card({ accent, title, sub, children, className, ...rest }: CardProps) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'bg-bg-card rounded-lg border p-5',
|
||||
accent ? ACCENT_BORDER[accent] : 'border-border',
|
||||
className,
|
||||
)}
|
||||
{...rest}
|
||||
>
|
||||
{title && (
|
||||
<h3 className="font-mono text-sm font-semibold text-text-bright mb-1">{title}</h3>
|
||||
)}
|
||||
{sub && (
|
||||
<div className="font-mono text-4xs uppercase tracking-wider2 text-text-dim mb-3">
|
||||
{sub}
|
||||
</div>
|
||||
)}
|
||||
{children && <div className="text-xs leading-[1.7]">{children}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
37
frontend/src/components/ui/FlowNode.tsx
Normal file
37
frontend/src/components/ui/FlowNode.tsx
Normal file
@@ -0,0 +1,37 @@
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
import { cn, type Accent } from '@/lib/cn';
|
||||
|
||||
interface FlowNodeProps {
|
||||
accent?: Accent;
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const ACCENT_BORDER_TEXT: Record<Accent, string> = {
|
||||
red: 'border-red text-red',
|
||||
orange: 'border-orange text-orange',
|
||||
yellow: 'border-yellow text-yellow',
|
||||
green: 'border-green text-green',
|
||||
cyan: 'border-cyan text-cyan',
|
||||
blue: 'border-blue text-blue',
|
||||
purple: 'border-purple text-purple',
|
||||
pink: 'border-pink text-pink',
|
||||
rose: 'border-rose text-rose',
|
||||
teal: 'border-teal text-teal',
|
||||
};
|
||||
|
||||
/** Flow node from design.md §5.5 — chained horizontally with arrows in flex rows. */
|
||||
export function FlowNode({ accent, children, className }: FlowNodeProps) {
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
'inline-block bg-bg-card rounded-md border px-3 py-2 font-mono text-4xs whitespace-nowrap shrink-0',
|
||||
accent ? ACCENT_BORDER_TEXT[accent] : 'border-border text-text',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
62
frontend/src/components/ui/Modal.tsx
Normal file
62
frontend/src/components/ui/Modal.tsx
Normal file
@@ -0,0 +1,62 @@
|
||||
import { useEffect, useRef, type ReactNode } from 'react';
|
||||
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { SectionHeader } from '@/components/ui/SectionHeader';
|
||||
import { type Accent } from '@/lib/cn';
|
||||
|
||||
interface ModalProps {
|
||||
open: boolean;
|
||||
title: string;
|
||||
accent?: Accent;
|
||||
onClose: () => void;
|
||||
children: ReactNode;
|
||||
/** Optional name to give the dialog role for screen readers / Playwright. */
|
||||
testid?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Centered modal with a backdrop. Closes on Escape and on backdrop click.
|
||||
* The accessible name comes from the SectionHeader's `highlight`, so the dialog
|
||||
* can be located via `getByRole('dialog', { name: ... })`.
|
||||
*/
|
||||
export function Modal({ open, title, accent = 'cyan', onClose, children, testid }: ModalProps) {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
function onKey(e: KeyboardEvent) {
|
||||
if (e.key === 'Escape') onClose();
|
||||
}
|
||||
document.addEventListener('keydown', onKey);
|
||||
return () => document.removeEventListener('keydown', onKey);
|
||||
}, [open, onClose]);
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-center justify-center bg-black/70 p-4"
|
||||
onMouseDown={(e) => {
|
||||
if (e.target === e.currentTarget) onClose();
|
||||
}}
|
||||
role="presentation"
|
||||
>
|
||||
<div
|
||||
ref={ref}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={title}
|
||||
data-testid={testid}
|
||||
className="w-full max-w-2xl rounded-lg border border-border bg-bg-base p-6 shadow-2xl"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<SectionHeader prefix="Edit" highlight={title} accent={accent} className="mt-0 mb-4" />
|
||||
<Button variant="ghost" onClick={onClose} aria-label="Close dialog">
|
||||
✕
|
||||
</Button>
|
||||
</div>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
51
frontend/src/components/ui/SectionHeader.tsx
Normal file
51
frontend/src/components/ui/SectionHeader.tsx
Normal file
@@ -0,0 +1,51 @@
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
import { cn, type Accent } from '@/lib/cn';
|
||||
|
||||
interface SectionHeaderProps {
|
||||
/** Plain text leading the colored word. */
|
||||
prefix?: string;
|
||||
/** The single colored word in the title. */
|
||||
highlight: string;
|
||||
accent?: Accent;
|
||||
description?: ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const ACCENT_TEXT: Record<Accent, string> = {
|
||||
red: 'text-red',
|
||||
orange: 'text-orange',
|
||||
yellow: 'text-yellow',
|
||||
green: 'text-green',
|
||||
cyan: 'text-cyan',
|
||||
blue: 'text-blue',
|
||||
purple: 'text-purple',
|
||||
pink: 'text-pink',
|
||||
rose: 'text-rose',
|
||||
teal: 'text-teal',
|
||||
};
|
||||
|
||||
/**
|
||||
* Section header from design.md §5.2 — every h2 starts with a cyan `//`,
|
||||
* a plain word, and exactly one colored word.
|
||||
*/
|
||||
export function SectionHeader({
|
||||
prefix,
|
||||
highlight,
|
||||
accent = 'red',
|
||||
description,
|
||||
className,
|
||||
}: SectionHeaderProps) {
|
||||
return (
|
||||
<div className={cn('mt-[60px] mb-[30px]', className)}>
|
||||
<h2 className="font-mono text-lg font-semibold text-text-bright pb-3 border-b border-border">
|
||||
<span className="text-cyan">{'// '}</span>
|
||||
{prefix && <span>{prefix} </span>}
|
||||
<span className={ACCENT_TEXT[accent]}>{highlight}</span>
|
||||
</h2>
|
||||
{description && (
|
||||
<p className="font-mono text-xs text-text-dim mt-2">{description}</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
37
frontend/src/components/ui/Tag.tsx
Normal file
37
frontend/src/components/ui/Tag.tsx
Normal file
@@ -0,0 +1,37 @@
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
import { cn, type Accent } from '@/lib/cn';
|
||||
|
||||
interface TagProps {
|
||||
accent: Accent;
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const ACCENT_FILL: Record<Accent, string> = {
|
||||
red: 'accent-fill-red',
|
||||
orange: 'accent-fill-orange',
|
||||
yellow: 'accent-fill-yellow',
|
||||
green: 'accent-fill-green',
|
||||
cyan: 'accent-fill-cyan',
|
||||
blue: 'accent-fill-blue',
|
||||
purple: 'accent-fill-purple',
|
||||
pink: 'accent-fill-pink',
|
||||
rose: 'accent-fill-rose',
|
||||
teal: 'accent-fill-teal',
|
||||
};
|
||||
|
||||
/** Tag/pill from design.md §5.4 — 9px uppercase mono, tinted fill. */
|
||||
export function Tag({ accent, children, className }: TagProps) {
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
'inline-block rounded font-mono text-3xs font-semibold uppercase tracking-wider2 px-2 py-[3px] mr-1 mb-2',
|
||||
ACCENT_FILL[accent],
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
48
frontend/src/components/ui/TextField.tsx
Normal file
48
frontend/src/components/ui/TextField.tsx
Normal file
@@ -0,0 +1,48 @@
|
||||
import { forwardRef, useId, type InputHTMLAttributes } from 'react';
|
||||
|
||||
import { cn } from '@/lib/cn';
|
||||
|
||||
interface TextFieldProps extends InputHTMLAttributes<HTMLInputElement> {
|
||||
label: string;
|
||||
hint?: string;
|
||||
errorText?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Form field with explicit label/input association via `htmlFor` / `id`.
|
||||
* The hint and error text are rendered as siblings, NOT inside the `<label>`,
|
||||
* so the accessible name of the input remains exactly the `label` prop —
|
||||
* crucial for `getByLabel(...)` selectors in Playwright.
|
||||
*/
|
||||
export const TextField = forwardRef<HTMLInputElement, TextFieldProps>(function TextField(
|
||||
{ label, hint, errorText, className, id, ...rest },
|
||||
ref,
|
||||
) {
|
||||
const fallbackId = useId();
|
||||
const inputId = id ?? fallbackId;
|
||||
return (
|
||||
<div className="block">
|
||||
<label
|
||||
htmlFor={inputId}
|
||||
className="block font-mono text-3xs font-semibold uppercase tracking-wider2 text-text-dim"
|
||||
>
|
||||
{label}
|
||||
</label>
|
||||
<input
|
||||
ref={ref}
|
||||
id={inputId}
|
||||
className={cn(
|
||||
'mt-1 w-full rounded-md border bg-bg-card px-3 py-2 font-mono text-xs text-text-bright placeholder:text-text-dim',
|
||||
errorText ? 'border-red' : 'border-border focus:border-cyan',
|
||||
'focus:outline-none',
|
||||
className,
|
||||
)}
|
||||
{...rest}
|
||||
/>
|
||||
{hint && !errorText && (
|
||||
<p className="mt-1 font-mono text-2xs text-text-dim">{hint}</p>
|
||||
)}
|
||||
{errorText && <p className="mt-1 font-mono text-2xs text-red">{errorText}</p>}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
49
frontend/src/index.css
Normal file
49
frontend/src/index.css
Normal file
@@ -0,0 +1,49 @@
|
||||
/* Self-hosted webfonts — no runtime CDN (cf. spec §7). */
|
||||
@import '@fontsource/jetbrains-mono/300.css';
|
||||
@import '@fontsource/jetbrains-mono/400.css';
|
||||
@import '@fontsource/jetbrains-mono/500.css';
|
||||
@import '@fontsource/jetbrains-mono/600.css';
|
||||
@import '@fontsource/jetbrains-mono/700.css';
|
||||
@import '@fontsource/ibm-plex-sans/300.css';
|
||||
@import '@fontsource/ibm-plex-sans/400.css';
|
||||
@import '@fontsource/ibm-plex-sans/500.css';
|
||||
@import '@fontsource/ibm-plex-sans/600.css';
|
||||
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
@layer base {
|
||||
html,
|
||||
body,
|
||||
#root {
|
||||
@apply min-h-screen;
|
||||
}
|
||||
body {
|
||||
@apply bg-bg text-text font-sans;
|
||||
line-height: 1.6;
|
||||
}
|
||||
/* No transitions / hovers / animations baseline (cf. design.md §7). */
|
||||
*:focus-visible {
|
||||
outline: 1px solid theme('colors.cyan');
|
||||
outline-offset: 2px;
|
||||
}
|
||||
::selection {
|
||||
background: rgb(6 182 212 / 0.25);
|
||||
color: theme('colors.text-bright');
|
||||
}
|
||||
}
|
||||
|
||||
@layer utilities {
|
||||
/* Tinted accent fill — see design.md §2 "tinted fills". */
|
||||
.accent-fill-red { background: rgb(239 68 68 / 0.15); color: theme('colors.red'); }
|
||||
.accent-fill-orange { background: rgb(245 158 11 / 0.15); color: theme('colors.orange'); }
|
||||
.accent-fill-yellow { background: rgb(234 179 8 / 0.15); color: theme('colors.yellow'); }
|
||||
.accent-fill-green { background: rgb(16 185 129 / 0.15); color: theme('colors.green'); }
|
||||
.accent-fill-cyan { background: rgb(6 182 212 / 0.15); color: theme('colors.cyan'); }
|
||||
.accent-fill-blue { background: rgb(59 130 246 / 0.15); color: theme('colors.blue'); }
|
||||
.accent-fill-purple { background: rgb(139 92 246 / 0.15); color: theme('colors.purple'); }
|
||||
.accent-fill-pink { background: rgb(236 72 153 / 0.15); color: theme('colors.pink'); }
|
||||
.accent-fill-rose { background: rgb(244 63 94 / 0.15); color: theme('colors.rose'); }
|
||||
.accent-fill-teal { background: rgb(20 184 166 / 0.15); color: theme('colors.teal'); }
|
||||
}
|
||||
72
frontend/src/lib/admin.ts
Normal file
72
frontend/src/lib/admin.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
/**
|
||||
* Shared types + query keys for admin pages (users / groups / invitations).
|
||||
* Keeps the React Query cache coherent across the 3 admin pages.
|
||||
*/
|
||||
|
||||
export interface AdminUser {
|
||||
id: string;
|
||||
email: string;
|
||||
display_name: string | null;
|
||||
locale: string;
|
||||
is_active: boolean;
|
||||
deleted_at: string | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
groups: Array<{ id: string; name: string }>;
|
||||
}
|
||||
|
||||
export interface AdminUserListResponse {
|
||||
items: AdminUser[];
|
||||
total: number;
|
||||
limit: number;
|
||||
offset: number;
|
||||
}
|
||||
|
||||
export interface AdminGroup {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string | null;
|
||||
is_system: boolean;
|
||||
members_count: number;
|
||||
permissions: string[];
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface AdminGroupListResponse {
|
||||
items: AdminGroup[];
|
||||
total: number;
|
||||
}
|
||||
|
||||
export interface AdminPermission {
|
||||
id: string;
|
||||
code: string;
|
||||
description: string | null;
|
||||
}
|
||||
|
||||
export interface AdminInvitation {
|
||||
id: string;
|
||||
email_hint: string | null;
|
||||
expires_at: string;
|
||||
groups: string[];
|
||||
}
|
||||
|
||||
export const adminKeys = {
|
||||
users: ['admin', 'users'] as const,
|
||||
user: (id: string) => ['admin', 'users', id] as const,
|
||||
groups: ['admin', 'groups'] as const,
|
||||
group: (id: string) => ['admin', 'groups', id] as const,
|
||||
permissions: ['admin', 'permissions'] as const,
|
||||
invitations: ['admin', 'invitations'] as const,
|
||||
};
|
||||
|
||||
/** Group permission codes by family for the multi-select UI. */
|
||||
export function groupPermsByFamily(codes: string[]): Record<string, string[]> {
|
||||
const out: Record<string, string[]> = {};
|
||||
for (const code of codes) {
|
||||
const [family] = code.split('.', 1);
|
||||
(out[family] ??= []).push(code);
|
||||
}
|
||||
for (const family of Object.keys(out)) out[family].sort();
|
||||
return out;
|
||||
}
|
||||
141
frontend/src/lib/api.ts
Normal file
141
frontend/src/lib/api.ts
Normal file
@@ -0,0 +1,141 @@
|
||||
/**
|
||||
* Auth-aware fetch wrapper.
|
||||
*
|
||||
* Strategy:
|
||||
* - Access token kept in module memory (never in localStorage).
|
||||
* - Refresh token lives in an HTTPOnly cookie set by the backend.
|
||||
* - On 401, we try ONE silent refresh via /auth/refresh, retry, then give up.
|
||||
*
|
||||
* Consumers go through `apiFetch`, `apiGet`, `apiPost` helpers.
|
||||
*/
|
||||
|
||||
const BASE_URL = (import.meta.env.VITE_API_BASE_URL as string | undefined) ?? '/api/v1';
|
||||
|
||||
type AccessTokenListener = (token: string | null) => void;
|
||||
|
||||
let accessToken: string | null = null;
|
||||
const listeners = new Set<AccessTokenListener>();
|
||||
|
||||
export function setAccessToken(t: string | null) {
|
||||
accessToken = t;
|
||||
for (const l of listeners) l(t);
|
||||
}
|
||||
|
||||
export function getAccessToken(): string | null {
|
||||
return accessToken;
|
||||
}
|
||||
|
||||
export function onAccessTokenChange(fn: AccessTokenListener): () => void {
|
||||
listeners.add(fn);
|
||||
return () => listeners.delete(fn);
|
||||
}
|
||||
|
||||
/** Returns true if the refresh succeeded and `accessToken` is updated. */
|
||||
async function silentRefresh(): Promise<boolean> {
|
||||
try {
|
||||
const res = await fetch(`${BASE_URL}/auth/refresh`, {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
});
|
||||
if (!res.ok) return false;
|
||||
const body = (await res.json()) as { access_token: string };
|
||||
if (!body.access_token) return false;
|
||||
setAccessToken(body.access_token);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
interface ApiOptions extends RequestInit {
|
||||
/** Skip the silent-refresh path — used by /auth/* endpoints themselves. */
|
||||
noRefresh?: boolean;
|
||||
/** Skip Authorization header — used for /setup and unauthenticated probes. */
|
||||
anonymous?: boolean;
|
||||
}
|
||||
|
||||
export async function apiFetch(path: string, opts: ApiOptions = {}): Promise<Response> {
|
||||
const headers = new Headers(opts.headers);
|
||||
headers.set('Accept', 'application/json');
|
||||
if (!headers.has('Content-Type') && opts.body && !(opts.body instanceof FormData)) {
|
||||
headers.set('Content-Type', 'application/json');
|
||||
}
|
||||
if (!opts.anonymous && accessToken) {
|
||||
headers.set('Authorization', `Bearer ${accessToken}`);
|
||||
}
|
||||
|
||||
let res = await fetch(`${BASE_URL}${path}`, {
|
||||
...opts,
|
||||
headers,
|
||||
credentials: 'include',
|
||||
});
|
||||
|
||||
if (res.status === 401 && !opts.noRefresh && !opts.anonymous) {
|
||||
const refreshed = await silentRefresh();
|
||||
if (refreshed) {
|
||||
headers.set('Authorization', `Bearer ${accessToken}`);
|
||||
res = await fetch(`${BASE_URL}${path}`, {
|
||||
...opts,
|
||||
headers,
|
||||
credentials: 'include',
|
||||
});
|
||||
}
|
||||
}
|
||||
return res;
|
||||
}
|
||||
|
||||
export class ApiError extends Error {
|
||||
constructor(
|
||||
public status: number,
|
||||
public payload: unknown,
|
||||
message?: string,
|
||||
) {
|
||||
super(message ?? `HTTP ${status}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function parseOrThrow<T>(res: Response): Promise<T> {
|
||||
if (res.status === 204) return undefined as T;
|
||||
const isJson = (res.headers.get('content-type') ?? '').includes('application/json');
|
||||
const body = isJson ? await res.json() : await res.text();
|
||||
if (!res.ok) throw new ApiError(res.status, body);
|
||||
return body as T;
|
||||
}
|
||||
|
||||
export async function apiGet<T>(path: string, opts?: ApiOptions): Promise<T> {
|
||||
return parseOrThrow<T>(await apiFetch(path, { ...opts, method: 'GET' }));
|
||||
}
|
||||
|
||||
export async function apiPost<T>(path: string, body?: unknown, opts?: ApiOptions): Promise<T> {
|
||||
return parseOrThrow<T>(
|
||||
await apiFetch(path, {
|
||||
...opts,
|
||||
method: 'POST',
|
||||
body: body !== undefined ? JSON.stringify(body) : undefined,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
export async function apiPatch<T>(path: string, body?: unknown, opts?: ApiOptions): Promise<T> {
|
||||
return parseOrThrow<T>(
|
||||
await apiFetch(path, {
|
||||
...opts,
|
||||
method: 'PATCH',
|
||||
body: body !== undefined ? JSON.stringify(body) : undefined,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
export async function apiPut<T>(path: string, body?: unknown, opts?: ApiOptions): Promise<T> {
|
||||
return parseOrThrow<T>(
|
||||
await apiFetch(path, {
|
||||
...opts,
|
||||
method: 'PUT',
|
||||
body: body !== undefined ? JSON.stringify(body) : undefined,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
export async function apiDelete<T>(path: string, opts?: ApiOptions): Promise<T> {
|
||||
return parseOrThrow<T>(await apiFetch(path, { ...opts, method: 'DELETE' }));
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user