From 4c25e198fcbf966a93055e03561fba4473c9ef2e Mon Sep 17 00:00:00 2001 From: Knacky Date: Mon, 11 May 2026 06:05:27 +0200 Subject: [PATCH] Milestone 3 --- .env.example | 31 + .gitignore | 46 + .pre-commit-config.yaml | 46 + CHANGELOG.md | 150 ++ Makefile | 214 ++ README.md | 129 ++ backend/.dockerignore | 8 + backend/Dockerfile | 85 + backend/README.md | 39 + backend/alembic.ini | 49 + backend/alembic/env.py | 76 + backend/alembic/script.py.mako | 28 + backend/alembic/versions/.gitkeep | 0 ...260510_1040_24765a5014b6_initial_schema.py | 446 ++++ backend/app/__init__.py | 3 + backend/app/api/__init__.py | 0 backend/app/api/_validation.py | 30 + backend/app/api/auth.py | 157 ++ backend/app/api/diag.py | 93 + backend/app/api/groups.py | 169 ++ backend/app/api/health.py | 14 + backend/app/api/invitations.py | 146 ++ backend/app/api/permissions.py | 17 + backend/app/api/setup.py | 79 + backend/app/api/users.py | 185 ++ backend/app/api/v1.py | 24 + backend/app/cli.py | 65 + backend/app/core/__init__.py | 0 backend/app/core/auth_decorators.py | 139 ++ backend/app/core/config.py | 76 + backend/app/core/install_token.py | 147 ++ backend/app/core/jwt_tokens.py | 97 + backend/app/core/logging.py | 34 + backend/app/core/rate_limit.py | 29 + backend/app/core/security.py | 62 + backend/app/db/__init__.py | 15 + backend/app/db/base.py | 23 + backend/app/db/mixins.py | 56 + backend/app/db/session.py | 47 + backend/app/db/types.py | 27 + backend/app/i18n/__init__.py | 0 backend/app/main.py | 72 + backend/app/models/__init__.py | 73 + backend/app/models/auth.py | 188 ++ backend/app/models/evidence.py | 51 + backend/app/models/mission.py | 316 +++ backend/app/models/mitre.py | 86 + backend/app/models/notification.py | 41 + backend/app/models/setting.py | 37 + backend/app/models/template.py | 174 ++ backend/app/services/__init__.py | 0 backend/app/services/auth.py | 224 ++ backend/app/services/bootstrap.py | 98 + backend/app/services/groups.py | 210 ++ backend/app/services/invitations.py | 188 ++ backend/app/services/permissions_seed.py | 179 ++ backend/app/services/users.py | 204 ++ backend/pyproject.toml | 60 + backend/tests/__init__.py | 0 backend/tests/conftest.py | 25 + backend/tests/test_auth_flow.py | 299 +++ backend/tests/test_health.py | 14 + backend/tests/test_rbac.py | 344 +++ backend/tests/test_schema.py | 234 +++ docker-compose.yml | 82 + e2e/.eslintrc.cjs | 12 + e2e/.gitignore | 5 + e2e/README.md | 66 + e2e/package-lock.json | 1841 +++++++++++++++++ e2e/package.json | 26 + e2e/playwright.config.ts | 60 + e2e/tests/m0-smoke.spec.ts | 127 ++ e2e/tests/m1-db.spec.ts | 50 + e2e/tests/m2-auth.spec.ts | 167 ++ e2e/tests/m3-rbac.spec.ts | 230 ++ e2e/tsconfig.json | 18 + frontend/.dockerignore | 7 + frontend/.eslintrc.cjs | 18 + frontend/.prettierrc | 8 + frontend/Dockerfile | 31 + frontend/README.md | 39 + frontend/index.html | 13 + frontend/nginx.conf | 45 + frontend/package.json | 43 + frontend/postcss.config.js | 6 + frontend/src/App.tsx | 85 + frontend/src/components/Layout.tsx | 76 + frontend/src/components/RequireAdmin.tsx | 19 + frontend/src/components/RequireAuth.tsx | 16 + frontend/src/components/ui/Alert.tsx | 39 + frontend/src/components/ui/Button.tsx | 45 + frontend/src/components/ui/Card.tsx | 50 + frontend/src/components/ui/FlowNode.tsx | 37 + frontend/src/components/ui/Modal.tsx | 62 + frontend/src/components/ui/SectionHeader.tsx | 51 + frontend/src/components/ui/Tag.tsx | 37 + frontend/src/components/ui/TextField.tsx | 48 + frontend/src/index.css | 49 + frontend/src/lib/admin.ts | 72 + frontend/src/lib/api.ts | 141 ++ frontend/src/lib/auth.ts | 125 ++ frontend/src/lib/cn.ts | 19 + frontend/src/main.tsx | 14 + frontend/src/pages/AdminGroupsPage.tsx | 348 ++++ frontend/src/pages/AdminInvitationsPage.tsx | 233 +++ frontend/src/pages/AdminUsersPage.tsx | 260 +++ frontend/src/pages/HomePage.tsx | 185 ++ frontend/src/pages/LoginPage.tsx | 95 + frontend/src/pages/ProfilePage.tsx | 137 ++ frontend/src/pages/RegisterPage.tsx | 163 ++ frontend/src/pages/SetupPage.tsx | 144 ++ frontend/src/vite-env.d.ts | 9 + frontend/tailwind.config.ts | 66 + frontend/tsconfig.app.json | 29 + frontend/tsconfig.json | 7 + frontend/tsconfig.node.json | 18 + frontend/vite.config.ts | 29 + tasks/design.md | 376 ++++ tasks/lessons.md | 76 + tasks/spec.md | 174 ++ tasks/testing-m0.md | 247 +++ tasks/testing-m1.md | 218 ++ tasks/testing-m2.md | 212 ++ tasks/testing-m3.md | 102 + tasks/todo.md | 284 +++ 125 files changed, 13489 insertions(+) create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 .pre-commit-config.yaml create mode 100644 CHANGELOG.md create mode 100644 Makefile create mode 100644 README.md create mode 100644 backend/.dockerignore create mode 100644 backend/Dockerfile create mode 100644 backend/README.md create mode 100644 backend/alembic.ini create mode 100644 backend/alembic/env.py create mode 100644 backend/alembic/script.py.mako create mode 100644 backend/alembic/versions/.gitkeep create mode 100644 backend/alembic/versions/20260510_1040_24765a5014b6_initial_schema.py create mode 100644 backend/app/__init__.py create mode 100644 backend/app/api/__init__.py create mode 100644 backend/app/api/_validation.py create mode 100644 backend/app/api/auth.py create mode 100644 backend/app/api/diag.py create mode 100644 backend/app/api/groups.py create mode 100644 backend/app/api/health.py create mode 100644 backend/app/api/invitations.py create mode 100644 backend/app/api/permissions.py create mode 100644 backend/app/api/setup.py create mode 100644 backend/app/api/users.py create mode 100644 backend/app/api/v1.py create mode 100644 backend/app/cli.py create mode 100644 backend/app/core/__init__.py create mode 100644 backend/app/core/auth_decorators.py create mode 100644 backend/app/core/config.py create mode 100644 backend/app/core/install_token.py create mode 100644 backend/app/core/jwt_tokens.py create mode 100644 backend/app/core/logging.py create mode 100644 backend/app/core/rate_limit.py create mode 100644 backend/app/core/security.py create mode 100644 backend/app/db/__init__.py create mode 100644 backend/app/db/base.py create mode 100644 backend/app/db/mixins.py create mode 100644 backend/app/db/session.py create mode 100644 backend/app/db/types.py create mode 100644 backend/app/i18n/__init__.py create mode 100644 backend/app/main.py create mode 100644 backend/app/models/__init__.py create mode 100644 backend/app/models/auth.py create mode 100644 backend/app/models/evidence.py create mode 100644 backend/app/models/mission.py create mode 100644 backend/app/models/mitre.py create mode 100644 backend/app/models/notification.py create mode 100644 backend/app/models/setting.py create mode 100644 backend/app/models/template.py create mode 100644 backend/app/services/__init__.py create mode 100644 backend/app/services/auth.py create mode 100644 backend/app/services/bootstrap.py create mode 100644 backend/app/services/groups.py create mode 100644 backend/app/services/invitations.py create mode 100644 backend/app/services/permissions_seed.py create mode 100644 backend/app/services/users.py create mode 100644 backend/pyproject.toml create mode 100644 backend/tests/__init__.py create mode 100644 backend/tests/conftest.py create mode 100644 backend/tests/test_auth_flow.py create mode 100644 backend/tests/test_health.py create mode 100644 backend/tests/test_rbac.py create mode 100644 backend/tests/test_schema.py create mode 100644 docker-compose.yml create mode 100644 e2e/.eslintrc.cjs create mode 100644 e2e/.gitignore create mode 100644 e2e/README.md create mode 100644 e2e/package-lock.json create mode 100644 e2e/package.json create mode 100644 e2e/playwright.config.ts create mode 100644 e2e/tests/m0-smoke.spec.ts create mode 100644 e2e/tests/m1-db.spec.ts create mode 100644 e2e/tests/m2-auth.spec.ts create mode 100644 e2e/tests/m3-rbac.spec.ts create mode 100644 e2e/tsconfig.json create mode 100644 frontend/.dockerignore create mode 100644 frontend/.eslintrc.cjs create mode 100644 frontend/.prettierrc create mode 100644 frontend/Dockerfile create mode 100644 frontend/README.md create mode 100644 frontend/index.html create mode 100644 frontend/nginx.conf create mode 100644 frontend/package.json create mode 100644 frontend/postcss.config.js create mode 100644 frontend/src/App.tsx create mode 100644 frontend/src/components/Layout.tsx create mode 100644 frontend/src/components/RequireAdmin.tsx create mode 100644 frontend/src/components/RequireAuth.tsx create mode 100644 frontend/src/components/ui/Alert.tsx create mode 100644 frontend/src/components/ui/Button.tsx create mode 100644 frontend/src/components/ui/Card.tsx create mode 100644 frontend/src/components/ui/FlowNode.tsx create mode 100644 frontend/src/components/ui/Modal.tsx create mode 100644 frontend/src/components/ui/SectionHeader.tsx create mode 100644 frontend/src/components/ui/Tag.tsx create mode 100644 frontend/src/components/ui/TextField.tsx create mode 100644 frontend/src/index.css create mode 100644 frontend/src/lib/admin.ts create mode 100644 frontend/src/lib/api.ts create mode 100644 frontend/src/lib/auth.ts create mode 100644 frontend/src/lib/cn.ts create mode 100644 frontend/src/main.tsx create mode 100644 frontend/src/pages/AdminGroupsPage.tsx create mode 100644 frontend/src/pages/AdminInvitationsPage.tsx create mode 100644 frontend/src/pages/AdminUsersPage.tsx create mode 100644 frontend/src/pages/HomePage.tsx create mode 100644 frontend/src/pages/LoginPage.tsx create mode 100644 frontend/src/pages/ProfilePage.tsx create mode 100644 frontend/src/pages/RegisterPage.tsx create mode 100644 frontend/src/pages/SetupPage.tsx create mode 100644 frontend/src/vite-env.d.ts create mode 100644 frontend/tailwind.config.ts create mode 100644 frontend/tsconfig.app.json create mode 100644 frontend/tsconfig.json create mode 100644 frontend/tsconfig.node.json create mode 100644 frontend/vite.config.ts create mode 100644 tasks/design.md create mode 100644 tasks/lessons.md create mode 100644 tasks/spec.md create mode 100644 tasks/testing-m0.md create mode 100644 tasks/testing-m1.md create mode 100644 tasks/testing-m2.md create mode 100644 tasks/testing-m3.md create mode 100644 tasks/todo.md diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..e6d7d5b --- /dev/null +++ b/.env.example @@ -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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..02d2ee4 --- /dev/null +++ b/.gitignore @@ -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/ diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..787b7e8 --- /dev/null +++ b/.pre-commit-config.yaml @@ -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 diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..9072044 --- /dev/null +++ b/CHANGELOG.md @@ -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/` + `POST /invitations/accept/` + `POST /invitations//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 + `` 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__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_
_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` redefined `title` as `ReactNode`, but the native `title` is `string`. `tsc -b` failed with TS2430 during `vite build`. Switched to `Omit, '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.md` + at least one `e2e/tests/m-*.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 `` 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. diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..6710723 --- /dev/null +++ b/Makefile @@ -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 diff --git a/README.md b/README.md new file mode 100644 index 0000000..411eb67 --- /dev/null +++ b/README.md @@ -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 +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: +- API health: (proxied) or + +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.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. diff --git a/backend/.dockerignore b/backend/.dockerignore new file mode 100644 index 0000000..f801b4c --- /dev/null +++ b/backend/.dockerignore @@ -0,0 +1,8 @@ +__pycache__/ +*.pyc +.pytest_cache/ +.ruff_cache/ +.venv/ +.env +.env.* +!.env.example diff --git a/backend/Dockerfile b/backend/Dockerfile new file mode 100644 index 0000000..e4d1713 --- /dev/null +++ b/backend/Dockerfile @@ -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"] diff --git a/backend/README.md b/backend/README.md new file mode 100644 index 0000000..1d5524b --- /dev/null +++ b/backend/README.md @@ -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 . +``` diff --git a/backend/alembic.ini b/backend/alembic.ini new file mode 100644 index 0000000..fb0b897 --- /dev/null +++ b/backend/alembic.ini @@ -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 diff --git a/backend/alembic/env.py b/backend/alembic/env.py new file mode 100644 index 0000000..3061458 --- /dev/null +++ b/backend/alembic/env.py @@ -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() diff --git a/backend/alembic/script.py.mako b/backend/alembic/script.py.mako new file mode 100644 index 0000000..9e885b8 --- /dev/null +++ b/backend/alembic/script.py.mako @@ -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"} diff --git a/backend/alembic/versions/.gitkeep b/backend/alembic/versions/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/backend/alembic/versions/20260510_1040_24765a5014b6_initial_schema.py b/backend/alembic/versions/20260510_1040_24765a5014b6_initial_schema.py new file mode 100644 index 0000000..ac1afbf --- /dev/null +++ b/backend/alembic/versions/20260510_1040_24765a5014b6_initial_schema.py @@ -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 ### diff --git a/backend/app/__init__.py b/backend/app/__init__.py new file mode 100644 index 0000000..bdf5481 --- /dev/null +++ b/backend/app/__init__.py @@ -0,0 +1,3 @@ +"""Metamorph backend API package.""" + +__version__ = "0.1.0" diff --git a/backend/app/api/__init__.py b/backend/app/api/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/api/_validation.py b/backend/app/api/_validation.py new file mode 100644 index 0000000..c438f06 --- /dev/null +++ b/backend/app/api/_validation.py @@ -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)] diff --git a/backend/app/api/auth.py b/backend/app/api/auth.py new file mode 100644 index 0000000..fad8e80 --- /dev/null +++ b/backend/app/api/auth.py @@ -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 diff --git a/backend/app/api/diag.py b/backend/app/api/diag.py new file mode 100644 index 0000000..f8c96da --- /dev/null +++ b/backend/app/api/diag.py @@ -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}) diff --git a/backend/app/api/groups.py b/backend/app/api/groups.py new file mode 100644 index 0000000..f4c0aca --- /dev/null +++ b/backend/app/api/groups.py @@ -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("/") +@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("/") +@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("/") +@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("//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)) diff --git a/backend/app/api/health.py b/backend/app/api/health.py new file mode 100644 index 0000000..7e499a5 --- /dev/null +++ b/backend/app/api/health.py @@ -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__}) diff --git a/backend/app/api/invitations.py b/backend/app/api/invitations.py new file mode 100644 index 0000000..291f252 --- /dev/null +++ b/backend/app/api/invitations.py @@ -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("//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/") +@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/") +@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) diff --git a/backend/app/api/permissions.py b/backend/app/api/permissions.py new file mode 100644 index 0000000..163ac78 --- /dev/null +++ b/backend/app/api/permissions.py @@ -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()}) diff --git a/backend/app/api/setup.py b/backend/app/api/setup.py new file mode 100644 index 0000000..0d328cb --- /dev/null +++ b/backend/app/api/setup.py @@ -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, + ) diff --git a/backend/app/api/users.py b/backend/app/api/users.py new file mode 100644 index 0000000..bc4bfbc --- /dev/null +++ b/backend/app/api/users.py @@ -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("/") +@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("/") +@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("/") +@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("//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)) diff --git a/backend/app/api/v1.py b/backend/app/api/v1.py new file mode 100644 index 0000000..747ad0f --- /dev/null +++ b/backend/app/api/v1.py @@ -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) diff --git a/backend/app/cli.py b/backend/app/cli.py new file mode 100644 index 0000000..c452d21 --- /dev/null +++ b/backend/app/cli.py @@ -0,0 +1,65 @@ +"""Flask CLI entry point. + +Used as `flask --app app.cli metamorph ` (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) diff --git a/backend/app/core/__init__.py b/backend/app/core/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/core/auth_decorators.py b/backend/app/core/auth_decorators.py new file mode 100644 index 0000000..55d7772 --- /dev/null +++ b/backend/app/core/auth_decorators.py @@ -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()) diff --git a/backend/app/core/config.py b/backend/app/core/config.py new file mode 100644 index 0000000..e53932f --- /dev/null +++ b/backend/app/core/config.py @@ -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() diff --git a/backend/app/core/install_token.py b/backend/app/core/install_token.py new file mode 100644 index 0000000..8c4f80d --- /dev/null +++ b/backend/app/core/install_token.py @@ -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": "", "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 diff --git a/backend/app/core/jwt_tokens.py b/backend/app/core/jwt_tokens.py new file mode 100644 index 0000000..9bcf506 --- /dev/null +++ b/backend/app/core/jwt_tokens.py @@ -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), + ) diff --git a/backend/app/core/logging.py b/backend/app/core/logging.py new file mode 100644 index 0000000..fd21ece --- /dev/null +++ b/backend/app/core/logging.py @@ -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) diff --git a/backend/app/core/rate_limit.py b/backend/app/core/rate_limit.py new file mode 100644 index 0000000..b1e6942 --- /dev/null +++ b/backend/app/core/rate_limit.py @@ -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"), +) diff --git a/backend/app/core/security.py b/backend/app/core/security.py new file mode 100644 index 0000000..220bec8 --- /dev/null +++ b/backend/app/core/security.py @@ -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) diff --git a/backend/app/db/__init__.py b/backend/app/db/__init__.py new file mode 100644 index 0000000..040f832 --- /dev/null +++ b/backend/app/db/__init__.py @@ -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", +] diff --git a/backend/app/db/base.py b/backend/app/db/base.py new file mode 100644 index 0000000..ab23339 --- /dev/null +++ b/backend/app/db/base.py @@ -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) diff --git a/backend/app/db/mixins.py b/backend/app/db/mixins.py new file mode 100644 index 0000000..c8fc292 --- /dev/null +++ b/backend/app/db/mixins.py @@ -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_
_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, + ) diff --git a/backend/app/db/session.py b/backend/app/db/session.py new file mode 100644 index 0000000..72e2346 --- /dev/null +++ b/backend/app/db/session.py @@ -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() diff --git a/backend/app/db/types.py b/backend/app/db/types.py new file mode 100644 index 0000000..e181f7f --- /dev/null +++ b/backend/app/db/types.py @@ -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") diff --git a/backend/app/i18n/__init__.py b/backend/app/i18n/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/main.py b/backend/app/main.py new file mode 100644 index 0000000..9d29cfa --- /dev/null +++ b/backend/app/main.py @@ -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() diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py new file mode 100644 index 0000000..0086e8b --- /dev/null +++ b/backend/app/models/__init__.py @@ -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", +] diff --git a/backend/app/models/auth.py b/backend/app/models/auth.py new file mode 100644 index 0000000..562edc4 --- /dev/null +++ b/backend/app/models/auth.py @@ -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 `.` 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"), + ) diff --git a/backend/app/models/evidence.py b/backend/app/models/evidence.py new file mode 100644 index 0000000..677ee5c --- /dev/null +++ b/backend/app/models/evidence.py @@ -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", + ), + ) diff --git a/backend/app/models/mission.py b/backend/app/models/mission.py new file mode 100644 index 0000000..0679fae --- /dev/null +++ b/backend/app/models/mission.py @@ -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"), + ) diff --git a/backend/app/models/mitre.py b/backend/app/models/mitre.py new file mode 100644 index 0000000..ccc9b16 --- /dev/null +++ b/backend/app/models/mitre.py @@ -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, + ) diff --git a/backend/app/models/notification.py b/backend/app/models/notification.py new file mode 100644 index 0000000..b09b65a --- /dev/null +++ b/backend/app/models/notification.py @@ -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", + ), + ) diff --git a/backend/app/models/setting.py b/backend/app/models/setting.py new file mode 100644 index 0000000..6d94773 --- /dev/null +++ b/backend/app/models/setting.py @@ -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) diff --git a/backend/app/models/template.py b/backend/app/models/template.py new file mode 100644 index 0000000..9f6756a --- /dev/null +++ b/backend/app/models/template.py @@ -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"), + ) diff --git a/backend/app/services/__init__.py b/backend/app/services/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/services/auth.py b/backend/app/services/auth.py new file mode 100644 index 0000000..a1caf0a --- /dev/null +++ b/backend/app/services/auth.py @@ -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", +] diff --git a/backend/app/services/bootstrap.py b/backend/app/services/bootstrap.py new file mode 100644 index 0000000..34ed3b4 --- /dev/null +++ b/backend/app/services/bootstrap.py @@ -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) diff --git a/backend/app/services/groups.py b/backend/app/services/groups.py new file mode 100644 index 0000000..1270486 --- /dev/null +++ b/backend/app/services/groups.py @@ -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 + ] diff --git a/backend/app/services/invitations.py b/backend/app/services/invitations.py new file mode 100644 index 0000000..4d1973d --- /dev/null +++ b/backend/app/services/invitations.py @@ -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 diff --git a/backend/app/services/permissions_seed.py b/backend/app/services/permissions_seed.py new file mode 100644 index 0000000..643e911 --- /dev/null +++ b/backend/app/services/permissions_seed.py @@ -0,0 +1,179 @@ +"""Atomic permission catalogue + seed for the 3 default system groups. + +Permissions follow the `.` 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} diff --git a/backend/app/services/users.py b/backend/app/services/users.py new file mode 100644 index 0000000..9e2f676 --- /dev/null +++ b/backend/app/services/users.py @@ -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 diff --git a/backend/pyproject.toml b/backend/pyproject.toml new file mode 100644 index 0000000..123db81 --- /dev/null +++ b/backend/pyproject.toml @@ -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" diff --git a/backend/tests/__init__.py b/backend/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py new file mode 100644 index 0000000..bd19750 --- /dev/null +++ b/backend/tests/conftest.py @@ -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 diff --git a/backend/tests/test_auth_flow.py b/backend/tests/test_auth_flow.py new file mode 100644 index 0000000..b6d4b5f --- /dev/null +++ b/backend/tests/test_auth_flow.py @@ -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"]) diff --git a/backend/tests/test_health.py b/backend/tests/test_health.py new file mode 100644 index 0000000..e26872b --- /dev/null +++ b/backend/tests/test_health.py @@ -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 diff --git a/backend/tests/test_rbac.py b/backend/tests/test_rbac.py new file mode 100644 index 0000000..4753ca2 --- /dev/null +++ b/backend/tests/test_rbac.py @@ -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 diff --git a/backend/tests/test_schema.py b/backend/tests/test_schema.py new file mode 100644 index 0000000..eeed205 --- /dev/null +++ b/backend/tests/test_schema.py @@ -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_
_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}) diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..a2d11ba --- /dev/null +++ b/docker-compose.yml @@ -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 diff --git a/e2e/.eslintrc.cjs b/e2e/.eslintrc.cjs new file mode 100644 index 0000000..0ccc92b --- /dev/null +++ b/e2e/.eslintrc.cjs @@ -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: '^_' }], + }, +}; diff --git a/e2e/.gitignore b/e2e/.gitignore new file mode 100644 index 0000000..618ce76 --- /dev/null +++ b/e2e/.gitignore @@ -0,0 +1,5 @@ +node_modules +playwright-report +test-results +playwright/.cache +*.log diff --git a/e2e/README.md b/e2e/README.md new file mode 100644 index 0000000..0ae0be0 --- /dev/null +++ b/e2e/README.md @@ -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-*.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-*.spec.ts # one spec per milestone, added as features land +├── playwright.config.ts +├── tsconfig.json +└── package.json +``` diff --git a/e2e/package-lock.json b/e2e/package-lock.json new file mode 100644 index 0000000..fc47eaf --- /dev/null +++ b/e2e/package-lock.json @@ -0,0 +1,1841 @@ +{ + "name": "metamorph-e2e", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "metamorph-e2e", + "version": "0.1.0", + "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" + } + }, + "node_modules/@eslint-community/eslint-utils": { + "version": "4.9.1", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", + "integrity": "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + } + }, + "node_modules/@eslint-community/regexpp": { + "version": "4.12.2", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", + "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/@eslint/eslintrc": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-2.1.4.tgz", + "integrity": "sha512-269Z39MS6wVJtsoUl10L60WdkhJVdPG24Q4eZTH3nnF6lpvSShEK3wQjDX9JRWAUPvPh7COouPpU9IrqaZFvtQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ajv": "^6.12.4", + "debug": "^4.3.2", + "espree": "^9.6.0", + "globals": "^13.19.0", + "ignore": "^5.2.0", + "import-fresh": "^3.2.1", + "js-yaml": "^4.1.0", + "minimatch": "^3.1.2", + "strip-json-comments": "^3.1.1" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint/eslintrc/node_modules/brace-expansion": { + "version": "1.1.14", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.14.tgz", + "integrity": "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/@eslint/eslintrc/node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/@eslint/js": { + "version": "8.57.1", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-8.57.1.tgz", + "integrity": "sha512-d9zaMRSTIKDLhctzH12MtXvJKSSUhaHcjV+2Z+GK+EEY7XKpP5yR4x+N3TAcHTcu963nIr+TMcCb4DBCYX1z6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + } + }, + "node_modules/@humanwhocodes/config-array": { + "version": "0.13.0", + "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.13.0.tgz", + "integrity": "sha512-DZLEEqFWQFiyK6h5YIeynKx7JlvCYWL0cImfSRXZ9l4Sg2efkFGTuFf6vzXjK1cq6IYkU+Eg/JizXw+TD2vRNw==", + "deprecated": "Use @eslint/config-array instead", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanwhocodes/object-schema": "^2.0.3", + "debug": "^4.3.1", + "minimatch": "^3.0.5" + }, + "engines": { + "node": ">=10.10.0" + } + }, + "node_modules/@humanwhocodes/config-array/node_modules/brace-expansion": { + "version": "1.1.14", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.14.tgz", + "integrity": "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/@humanwhocodes/config-array/node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/object-schema": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/object-schema/-/object-schema-2.0.3.tgz", + "integrity": "sha512-93zYdMES/c1D69yZiKDBj0V24vqNzB/koF26KPaagAfd3P/4gUlh3Dys5ogAK+Exi9QyzlD8x/08Zt7wIKcDcA==", + "deprecated": "Use @eslint/object-schema instead", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/@nodelib/fs.scandir": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", + "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "2.0.5", + "run-parallel": "^1.1.9" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.stat": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", + "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.walk": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", + "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.scandir": "2.1.5", + "fastq": "^1.6.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@playwright/test": { + "version": "1.59.1", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.59.1.tgz", + "integrity": "sha512-PG6q63nQg5c9rIi4/Z5lR5IVF7yU5MqmKaPOe0HSc0O2cX1fPi96sUQu5j7eo4gKCkB2AnNGoWt7y4/Xx3Kcqg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.59.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@types/node": { + "version": "20.19.40", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.40.tgz", + "integrity": "sha512-xxx6M2IpSTnnKcR0cMvIiohkiCx20/oRPtWGbenFygKCGl3zqUzdNjQ/1V4solq1LU+dgv0nQzeGOuqkqZGg0Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@typescript-eslint/eslint-plugin": { + "version": "7.18.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-7.18.0.tgz", + "integrity": "sha512-94EQTWZ40mzBc42ATNIBimBEDltSJ9RQHCC8vc/PDbxi4k8dVwUAv4o98dk50M1zB+JGFxp43FP7f8+FP8R6Sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/regexpp": "^4.10.0", + "@typescript-eslint/scope-manager": "7.18.0", + "@typescript-eslint/type-utils": "7.18.0", + "@typescript-eslint/utils": "7.18.0", + "@typescript-eslint/visitor-keys": "7.18.0", + "graphemer": "^1.4.0", + "ignore": "^5.3.1", + "natural-compare": "^1.4.0", + "ts-api-utils": "^1.3.0" + }, + "engines": { + "node": "^18.18.0 || >=20.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "@typescript-eslint/parser": "^7.0.0", + "eslint": "^8.56.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@typescript-eslint/parser": { + "version": "7.18.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-7.18.0.tgz", + "integrity": "sha512-4Z+L8I2OqhZV8qA132M4wNL30ypZGYOQVBfMgxDH/K5UX0PNqTu1c6za9ST5r9+tavvHiTWmBnKzpCJ/GlVFtg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "@typescript-eslint/scope-manager": "7.18.0", + "@typescript-eslint/types": "7.18.0", + "@typescript-eslint/typescript-estree": "7.18.0", + "@typescript-eslint/visitor-keys": "7.18.0", + "debug": "^4.3.4" + }, + "engines": { + "node": "^18.18.0 || >=20.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.56.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@typescript-eslint/scope-manager": { + "version": "7.18.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-7.18.0.tgz", + "integrity": "sha512-jjhdIE/FPF2B7Z1uzc6i3oWKbGcHb87Qw7AWj6jmEqNOfDFbJWtjt/XfwCpvNkpGWlcJaog5vTR+VV8+w9JflA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "7.18.0", + "@typescript-eslint/visitor-keys": "7.18.0" + }, + "engines": { + "node": "^18.18.0 || >=20.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/type-utils": { + "version": "7.18.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-7.18.0.tgz", + "integrity": "sha512-XL0FJXuCLaDuX2sYqZUUSOJ2sG5/i1AAze+axqmLnSkNEVMVYLF+cbwlB2w8D1tinFuSikHmFta+P+HOofrLeA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/typescript-estree": "7.18.0", + "@typescript-eslint/utils": "7.18.0", + "debug": "^4.3.4", + "ts-api-utils": "^1.3.0" + }, + "engines": { + "node": "^18.18.0 || >=20.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.56.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@typescript-eslint/types": { + "version": "7.18.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-7.18.0.tgz", + "integrity": "sha512-iZqi+Ds1y4EDYUtlOOC+aUmxnE9xS/yCigkjA7XpTKV6nCBd3Hp/PRGGmdwnfkV2ThMyYldP1wRpm/id99spTQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || >=20.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/typescript-estree": { + "version": "7.18.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-7.18.0.tgz", + "integrity": "sha512-aP1v/BSPnnyhMHts8cf1qQ6Q1IFwwRvAQGRvBFkWlo3/lH29OXA3Pts+c10nxRxIBrDnoMqzhgdwVe5f2D6OzA==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "@typescript-eslint/types": "7.18.0", + "@typescript-eslint/visitor-keys": "7.18.0", + "debug": "^4.3.4", + "globby": "^11.1.0", + "is-glob": "^4.0.3", + "minimatch": "^9.0.4", + "semver": "^7.6.0", + "ts-api-utils": "^1.3.0" + }, + "engines": { + "node": "^18.18.0 || >=20.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@typescript-eslint/utils": { + "version": "7.18.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-7.18.0.tgz", + "integrity": "sha512-kK0/rNa2j74XuHVcoCZxdFBMF+aq/vH83CXAOHieC+2Gis4mF8jJXT5eAfyD3K0sAxtPuwxaIOIOvhwzVDt/kw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.4.0", + "@typescript-eslint/scope-manager": "7.18.0", + "@typescript-eslint/types": "7.18.0", + "@typescript-eslint/typescript-estree": "7.18.0" + }, + "engines": { + "node": "^18.18.0 || >=20.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.56.0" + } + }, + "node_modules/@typescript-eslint/visitor-keys": { + "version": "7.18.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-7.18.0.tgz", + "integrity": "sha512-cDF0/Gf81QpY3xYyJKDV14Zwdmid5+uuENhjH2EqFaF0ni+yAyq/LzMaIJdhNJXZI7uLzwIlA+V7oWoyn6Curg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "7.18.0", + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^18.18.0 || >=20.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@ungap/structured-clone": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.1.tgz", + "integrity": "sha512-mUFwbeTqrVgDQxFveS+df2yfap6iuP20NAKAsBt5jDEoOTDew+zwLAOilHCeQJOVSvmgCX4ogqIrA0mnyr08yQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/acorn": { + "version": "8.16.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", + "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/ajv": { + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", + "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true, + "license": "Python-2.0" + }, + "node_modules/array-union": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/array-union/-/array-union-2.1.0.tgz", + "integrity": "sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/brace-expansion": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.0.tgz", + "integrity": "sha512-TN1kCZAgdgweJhWWpgKYrQaMNHcDULHkWwQIspdtjV4Y5aurRdZpjAqn6yX3FPqTA9ngHCc4hJxMAMgGfve85w==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/callsites": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", + "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" + }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "dev": true, + "license": "MIT" + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/deep-is": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/dir-glob": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/dir-glob/-/dir-glob-3.0.1.tgz", + "integrity": "sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-type": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/doctrine": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-3.0.0.tgz", + "integrity": "sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "esutils": "^2.0.2" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint": { + "version": "8.57.1", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-8.57.1.tgz", + "integrity": "sha512-ypowyDxpVSYpkXr9WPv2PAZCtNip1Mv5KTW0SCurXv/9iOpcrH9PaqUElksqEB6pChqHGDRCFTyrZlGhnLNGiA==", + "deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.2.0", + "@eslint-community/regexpp": "^4.6.1", + "@eslint/eslintrc": "^2.1.4", + "@eslint/js": "8.57.1", + "@humanwhocodes/config-array": "^0.13.0", + "@humanwhocodes/module-importer": "^1.0.1", + "@nodelib/fs.walk": "^1.2.8", + "@ungap/structured-clone": "^1.2.0", + "ajv": "^6.12.4", + "chalk": "^4.0.0", + "cross-spawn": "^7.0.2", + "debug": "^4.3.2", + "doctrine": "^3.0.0", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^7.2.2", + "eslint-visitor-keys": "^3.4.3", + "espree": "^9.6.1", + "esquery": "^1.4.2", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "^6.0.1", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "globals": "^13.19.0", + "graphemer": "^1.4.0", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "is-path-inside": "^3.0.3", + "js-yaml": "^4.1.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "levn": "^0.4.1", + "lodash.merge": "^4.6.2", + "minimatch": "^3.1.2", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3", + "strip-ansi": "^6.0.1", + "text-table": "^0.2.0" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-scope": { + "version": "7.2.2", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-7.2.2.tgz", + "integrity": "sha512-dOt21O7lTMhDM+X9mB4GX+DZrZtCUJPL/wlcTqxyrx5IvO0IYtILdtrQGQp+8n5S0gwSVmOf9NQrjMOgfQZlIg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint/node_modules/brace-expansion": { + "version": "1.1.14", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.14.tgz", + "integrity": "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/eslint/node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/espree": { + "version": "9.6.1", + "resolved": "https://registry.npmjs.org/espree/-/espree-9.6.1.tgz", + "integrity": "sha512-oruZaFkjorTpF32kDSI5/75ViwGeZginGGy2NoOSg3Q9bnwlnmDm4HLnkl0RE3n+njDXR037aY1+x58Z/zFdwQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.9.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^3.4.1" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/esquery": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz", + "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "estraverse": "^5.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-glob": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", + "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "^2.0.2", + "@nodelib/fs.walk": "^1.2.3", + "glob-parent": "^5.1.2", + "merge2": "^1.3.0", + "micromatch": "^4.0.8" + }, + "engines": { + "node": ">=8.6.0" + } + }, + "node_modules/fast-glob/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fastq": { + "version": "1.20.1", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz", + "integrity": "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==", + "dev": true, + "license": "ISC", + "dependencies": { + "reusify": "^1.0.4" + } + }, + "node_modules/file-entry-cache": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-6.0.1.tgz", + "integrity": "sha512-7Gps/XWymbLk2QLYK4NzpMOrYjMhdIxXuIvy2QBsLE6ljuodKvdkWs/cpyJJ3CVIVpH0Oi1Hvg1ovbMzLdFBBg==", + "dev": true, + "license": "MIT", + "dependencies": { + "flat-cache": "^3.0.4" + }, + "engines": { + "node": "^10.12.0 || >=12.0.0" + } + }, + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "dev": true, + "license": "MIT", + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/flat-cache": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-3.2.0.tgz", + "integrity": "sha512-CYcENa+FtcUKLmhhqyctpclsq7QF38pKjZHsGNiSQF5r4FtoKDWabFDl3hzaEQMvT1LHEysw5twgLvpYYb4vbw==", + "dev": true, + "license": "MIT", + "dependencies": { + "flatted": "^3.2.9", + "keyv": "^4.5.3", + "rimraf": "^3.0.2" + }, + "engines": { + "node": "^10.12.0 || >=12.0.0" + } + }, + "node_modules/flatted": { + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz", + "integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==", + "dev": true, + "license": "ISC" + }, + "node_modules/fs.realpath": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", + "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", + "dev": true, + "license": "ISC" + }, + "node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/glob": { + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "dev": true, + "license": "ISC", + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/glob/node_modules/brace-expansion": { + "version": "1.1.14", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.14.tgz", + "integrity": "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/glob/node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/globals": { + "version": "13.24.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-13.24.0.tgz", + "integrity": "sha512-AhO5QUcj8llrbG09iWhPU2B204J1xnPeL8kQmVorSsy+Sjj1sk8gIyh6cUocGmH4L0UuhAJy+hJMRA4mgA4mFQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "type-fest": "^0.20.2" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/globby": { + "version": "11.1.0", + "resolved": "https://registry.npmjs.org/globby/-/globby-11.1.0.tgz", + "integrity": "sha512-jhIXaOzy1sb8IyocaruWSn1TjmnBVs8Ayhcy83rmxNJ8q2uWKCAj3CnJY+KpGSXCueAPc0i05kVvVKtP1t9S3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "array-union": "^2.1.0", + "dir-glob": "^3.0.1", + "fast-glob": "^3.2.9", + "ignore": "^5.2.0", + "merge2": "^1.4.1", + "slash": "^3.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/graphemer": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/graphemer/-/graphemer-1.4.0.tgz", + "integrity": "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==", + "dev": true, + "license": "MIT" + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/import-fresh": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", + "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "parent-module": "^1.0.0", + "resolve-from": "^4.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.19" + } + }, + "node_modules/inflight": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", + "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", + "deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.", + "dev": true, + "license": "ISC", + "dependencies": { + "once": "^1.3.0", + "wrappy": "1" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/is-path-inside": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/is-path-inside/-/is-path-inside-3.0.3.tgz", + "integrity": "sha512-Fd4gABb+ycGAmKou8eMftCupSir5lRxqf4aD/vd0cD2qc4HL07OjCeuHMr8Ro4CoMaeCKDB0/ECBOVWjTwUvPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, + "node_modules/js-yaml": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", + "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", + "dev": true, + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/json-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/keyv": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "json-buffer": "3.0.1" + } + }, + "node_modules/levn": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/lodash.merge": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", + "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/merge2": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", + "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/micromatch": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "dev": true, + "license": "MIT", + "dependencies": { + "braces": "^3.0.3", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/minimatch": { + "version": "9.0.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", + "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.2" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true, + "license": "MIT" + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "dev": true, + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/optionator": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.5" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/parent-module": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", + "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "callsites": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-is-absolute": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", + "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-type": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz", + "integrity": "sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/picomatch": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/playwright": { + "version": "1.59.1", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.59.1.tgz", + "integrity": "sha512-C8oWjPR3F81yljW9o5OxcWzfh6avkVwDD2VYdwIGqTkl+OGFISgypqzfu7dOe4QNLL2aqcWBmI3PMtLIK233lw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.59.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.59.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.59.1.tgz", + "integrity": "sha512-HBV/RJg81z5BiiZ9yPzIiClYV/QMsDCKUyogwH9p3MCP6IYjUFu/MActgYAvK0oWyV9NlwM3GLBjADyWgydVyg==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/prelude-ls": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/queue-microtask": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", + "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/resolve-from": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", + "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/reusify": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", + "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", + "dev": true, + "license": "MIT", + "engines": { + "iojs": ">=1.0.0", + "node": ">=0.10.0" + } + }, + "node_modules/rimraf": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", + "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==", + "deprecated": "Rimraf versions prior to v4 are no longer supported", + "dev": true, + "license": "ISC", + "dependencies": { + "glob": "^7.1.3" + }, + "bin": { + "rimraf": "bin.js" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/run-parallel": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", + "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "queue-microtask": "^1.2.2" + } + }, + "node_modules/semver": { + "version": "7.8.0", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.0.tgz", + "integrity": "sha512-AcM7dV/5ul4EekoQ29Agm5vri8JNqRyj39o0qpX6vDF2GZrtutZl5RwgD1XnZjiTAfncsJhMI48QQH3sN87YNA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/slash": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz", + "integrity": "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-json-comments": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", + "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/text-table": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz", + "integrity": "sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==", + "dev": true, + "license": "MIT" + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/ts-api-utils": { + "version": "1.4.3", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-1.4.3.tgz", + "integrity": "sha512-i3eMG77UTMD0hZhgRS562pv83RC6ukSAC2GMNWc+9dieh/+jDM5u5YG+NHX6VNDRHQcHwmsTHctP9LhbC3WxVw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=16" + }, + "peerDependencies": { + "typescript": ">=4.2.0" + } + }, + "node_modules/type-check": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/type-fest": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.20.2.tgz", + "integrity": "sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==", + "dev": true, + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "punycode": "^2.1.0" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/word-wrap": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + } + } +} diff --git a/e2e/package.json b/e2e/package.json new file mode 100644 index 0000000..75a36d7 --- /dev/null +++ b/e2e/package.json @@ -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" + } +} diff --git a/e2e/playwright.config.ts b/e2e/playwright.config.ts new file mode 100644 index 0000000..40f8e6d --- /dev/null +++ b/e2e/playwright.config.ts @@ -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', + }, + } + : {}), +}); diff --git a/e2e/tests/m0-smoke.spec.ts b/e2e/tests/m0-smoke.spec.ts new file mode 100644 index 0000000..0afad7e --- /dev/null +++ b/e2e/tests/m0-smoke.spec.ts @@ -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(); + }); +}); diff --git a/e2e/tests/m1-db.spec.ts b/e2e/tests/m1-db.spec.ts new file mode 100644 index 0000000..41d485b --- /dev/null +++ b/e2e/tests/m1-db.spec.ts @@ -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); + }); +}); diff --git a/e2e/tests/m2-auth.spec.ts b/e2e/tests/m2-auth.spec.ts new file mode 100644 index 0000000..42e9c69 --- /dev/null +++ b/e2e/tests/m2-auth.spec.ts @@ -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 { + 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 () — that's two + // locator matches, so we look at the card-side 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/); + }); +}); diff --git a/e2e/tests/m3-rbac.spec.ts b/e2e/tests/m3-rbac.spec.ts new file mode 100644 index 0000000..2e1b8e8 --- /dev/null +++ b/e2e/tests/m3-rbac.spec.ts @@ -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 { + 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 { + 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); + }); +}); diff --git a/e2e/tsconfig.json b/e2e/tsconfig.json new file mode 100644 index 0000000..a22cc57 --- /dev/null +++ b/e2e/tsconfig.json @@ -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/**/*"] +} diff --git a/frontend/.dockerignore b/frontend/.dockerignore new file mode 100644 index 0000000..59b3b02 --- /dev/null +++ b/frontend/.dockerignore @@ -0,0 +1,7 @@ +node_modules +dist +.vite +*.log +.env +.env.* +!.env.example diff --git a/frontend/.eslintrc.cjs b/frontend/.eslintrc.cjs new file mode 100644 index 0000000..0ae4b9d --- /dev/null +++ b/frontend/.eslintrc.cjs @@ -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: '^_' }], + }, +}; diff --git a/frontend/.prettierrc b/frontend/.prettierrc new file mode 100644 index 0000000..3c15efe --- /dev/null +++ b/frontend/.prettierrc @@ -0,0 +1,8 @@ +{ + "singleQuote": true, + "semi": true, + "trailingComma": "all", + "printWidth": 100, + "tabWidth": 2, + "arrowParens": "always" +} diff --git a/frontend/Dockerfile b/frontend/Dockerfile new file mode 100644 index 0000000..076d233 --- /dev/null +++ b/frontend/Dockerfile @@ -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 diff --git a/frontend/README.md b/frontend/README.md new file mode 100644 index 0000000..db70b1d --- /dev/null +++ b/frontend/README.md @@ -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 +``` diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..88762b2 --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,13 @@ + + + + + + + Metamorph + + +
+ + + diff --git a/frontend/nginx.conf b/frontend/nginx.conf new file mode 100644 index 0000000..866d359 --- /dev/null +++ b/frontend/nginx.conf @@ -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; + } +} diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..1f7e91f --- /dev/null +++ b/frontend/package.json @@ -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" + } +} diff --git a/frontend/postcss.config.js b/frontend/postcss.config.js new file mode 100644 index 0000000..2aa7205 --- /dev/null +++ b/frontend/postcss.config.js @@ -0,0 +1,6 @@ +export default { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +}; diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx new file mode 100644 index 0000000..6245b25 --- /dev/null +++ b/frontend/src/App.tsx @@ -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 {children}; +} + +function App() { + return ( + + + + + }> + } /> + } /> + } /> + {/* Home page stays public — it's an ops dashboard, not sensitive. */} + } /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + } /> + + + + + + ); +} + +export default App; diff --git a/frontend/src/components/Layout.tsx b/frontend/src/components/Layout.tsx new file mode 100644 index 0000000..843ebca --- /dev/null +++ b/frontend/src/components/Layout.tsx @@ -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) => ( + + 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} + + ); + + return ( +
+
+
+ + Meta + morph + + +
+ + + +
+ metamorph · M0 bootstrap · M1 db schema · M2 auth · M3 rbac · design system from tasks/design.md +
+
+
+ ); +} diff --git a/frontend/src/components/RequireAdmin.tsx b/frontend/src/components/RequireAdmin.tsx new file mode 100644 index 0000000..ff50df6 --- /dev/null +++ b/frontend/src/components/RequireAdmin.tsx @@ -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

Loading session…

; + } + if (!state.user) { + return ; + } + if (!state.user.is_admin) { + return ; + } + return <>{children}; +} diff --git a/frontend/src/components/RequireAuth.tsx b/frontend/src/components/RequireAuth.tsx new file mode 100644 index 0000000..2d9169d --- /dev/null +++ b/frontend/src/components/RequireAuth.tsx @@ -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

Loading session…

; + } + if (!state.user) { + return ; + } + return <>{children}; +} diff --git a/frontend/src/components/ui/Alert.tsx b/frontend/src/components/ui/Alert.tsx new file mode 100644 index 0000000..eb4727a --- /dev/null +++ b/frontend/src/components/ui/Alert.tsx @@ -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 = { + 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 ( +
+ {children} +
+ ); +} diff --git a/frontend/src/components/ui/Button.tsx b/frontend/src/components/ui/Button.tsx new file mode 100644 index 0000000..597412b --- /dev/null +++ b/frontend/src/components/ui/Button.tsx @@ -0,0 +1,45 @@ +import type { ButtonHTMLAttributes, ReactNode } from 'react'; + +import { cn, type Accent } from '@/lib/cn'; + +interface ButtonProps extends ButtonHTMLAttributes { + accent?: Accent; + variant?: 'solid' | 'outline' | 'ghost'; + children: ReactNode; +} + +const ACCENT_OUTLINE: Record = { + 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 ( + + ); +} diff --git a/frontend/src/components/ui/Card.tsx b/frontend/src/components/ui/Card.tsx new file mode 100644 index 0000000..ab2db86 --- /dev/null +++ b/frontend/src/components/ui/Card.tsx @@ -0,0 +1,50 @@ +import type { HTMLAttributes, ReactNode } from 'react'; + +import { cn, type Accent } from '@/lib/cn'; + +interface CardProps extends Omit, '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 = { + 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 ( +
+ {title && ( +

{title}

+ )} + {sub && ( +
+ {sub} +
+ )} + {children &&
{children}
} +
+ ); +} diff --git a/frontend/src/components/ui/FlowNode.tsx b/frontend/src/components/ui/FlowNode.tsx new file mode 100644 index 0000000..94210e9 --- /dev/null +++ b/frontend/src/components/ui/FlowNode.tsx @@ -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 = { + 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 ( + + {children} + + ); +} diff --git a/frontend/src/components/ui/Modal.tsx b/frontend/src/components/ui/Modal.tsx new file mode 100644 index 0000000..0bdc464 --- /dev/null +++ b/frontend/src/components/ui/Modal.tsx @@ -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(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 ( +
{ + if (e.target === e.currentTarget) onClose(); + }} + role="presentation" + > +
+
+ + +
+ {children} +
+
+ ); +} diff --git a/frontend/src/components/ui/SectionHeader.tsx b/frontend/src/components/ui/SectionHeader.tsx new file mode 100644 index 0000000..c464b46 --- /dev/null +++ b/frontend/src/components/ui/SectionHeader.tsx @@ -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 = { + 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 ( +
+

+ {'// '} + {prefix && {prefix} } + {highlight} +

+ {description && ( +

{description}

+ )} +
+ ); +} diff --git a/frontend/src/components/ui/Tag.tsx b/frontend/src/components/ui/Tag.tsx new file mode 100644 index 0000000..5db7087 --- /dev/null +++ b/frontend/src/components/ui/Tag.tsx @@ -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 = { + 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 ( + + {children} + + ); +} diff --git a/frontend/src/components/ui/TextField.tsx b/frontend/src/components/ui/TextField.tsx new file mode 100644 index 0000000..dc88ad3 --- /dev/null +++ b/frontend/src/components/ui/TextField.tsx @@ -0,0 +1,48 @@ +import { forwardRef, useId, type InputHTMLAttributes } from 'react'; + +import { cn } from '@/lib/cn'; + +interface TextFieldProps extends InputHTMLAttributes { + 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 `
_active` partiel | +| 5 | `test_expected_foreign_keys` | 14 paires FK clés (red→users, blue→users, evidence→test, etc.) | +| 6 | `test_expected_check_constraints` | Les 10 CHECK constraints fonctionnelles | +| 7 | `test_alembic_at_head` | `SELECT version_num FROM alembic_version` non-vide | +| 8 | `test_exactly_one_mitre_fk_check_enforced` | Test négatif INSERT — viole le CHECK | + +Le test runner s'appuie sur le stage `test` du Dockerfile backend (`--target test` avec uv et les dev extras), spawné en container éphémère sur le réseau du compose. Le runtime stays minimal. + +### 4.2 — Suite e2e Playwright (M0 + M1) + +```bash +make e2e +``` + +**Attendu** : `12 passed`. Détail : +- 8 tests M0 (smoke bootstrap) +- 4 tests M1 (`e2e/tests/m1-db.spec.ts`) : + 1. `GET /api/v1/diag/db` renvoie une revision Alembic en hex et `table_count >= 26` + 2. La home page rend la card « Database » avec le short-hash de la revision et le compteur + 3. La card « Roadmap » indique « M0 + M1 done » et cite M2 + 4. Le footer mentionne `M0 bootstrap` + `M1 db schema` + +Le rapport HTML est dans `e2e/playwright-report/`. + +### 4.3 — Endpoint diagnostique direct + +```bash +curl -s http://localhost:8080/api/v1/diag/db | jq +``` + +**Attendu** : +```json +{ + "reachable": true, + "alembic_revision": "24765a5014b6", + "table_count": 27 +} +``` + +Quand la DB est down (ex : `make down` sur le service `db` seul), l'endpoint renvoie `503` avec `{"reachable": false, "error": "database_unreachable"}`. + +## 5. Génération d'une nouvelle migration (workflow dev) + +```bash +# 1. Modifier un modèle dans backend/app/models/ +# 2. Générer la migration via Alembic dans le container : +make migrate-revision MSG="add foo column to mission_tests" + +# Le fichier est créé dans le container — copie-le sur l'host pour le commit : +podman cp metamorph-api:/app/alembic/versions/. backend/alembic/versions/ + +# 3. Relire le fichier généré, le formatter (`make fmt`) +# 4. Rebuild + apply : +make rebuild && make up && make migrate +``` + +## 6. DoD M1 — checklist (extraits de `tasks/todo.md`) + +- [x] `make migrate` applique le schéma sur DB vide +- [x] `\dt` montre les 27 tables (26 métier + alembic_version) +- [x] FK + CHECK + indexes en place (32 FK / 9 CHECK / 14 UQ / 12 partial) +- [x] Naming convention Alembic stable (préfixes `pk_/fk_/ck_/uq_/ix_`) +- [x] Soft delete partout sauf jointures simples (`deleted_at` + index partiel) +- [x] Audit minimal (`created_at`/`updated_at`) sur les tables principales +- [x] Tests d'intégration pytest verts (9 passed) +- [x] M0 e2e ne régresse pas + +## 7. Pièges connus + +- **`COMPOSE` cible le dernier stage du Dockerfile par défaut** : si on ajoute un stage après `runtime` (ici `test`), il faut explicitement `target: runtime` dans `docker-compose.yml`. Sinon `make up` lance pytest au lieu de gunicorn — le container exit en boucle. +- **Alembic autogenerate dans le container** : le fichier est créé dans `/app/alembic/versions/` du container. Le récupérer sur l'host via `podman cp` avant rebuild, sinon perdu. +- **Post-write hook `ruff`** : retiré d'`alembic.ini` parce que ruff est dev-only et n'est pas dans l'image runtime. Formatter les migrations à la main avec `make fmt` après génération. +- **`change-me-strong` (placeholder de `.env.example`)** est rejeté par `model_validator` en `APP_ENV=prod`. Pour les tests on a élargi le bypass à `APP_ENV in ("dev", "test")`. + +## 8. Teardown + +```bash +make down # garde les volumes +make clean # supprime aussi les volumes (DESTRUCTEUR) +``` diff --git a/tasks/testing-m2.md b/tasks/testing-m2.md new file mode 100644 index 0000000..2a091f9 --- /dev/null +++ b/tasks/testing-m2.md @@ -0,0 +1,212 @@ +--- +type: testing +project: Metamorph +milestone: M2 +date: "2026-05-10" +--- + +# Comment tester M2 (auth, bootstrap, invitations) + +> Procédure de validation manuelle + automatisée pour M2 (auth JWT, /setup, invitations, RBAC). Toutes les commandes se lancent depuis la racine. + +## 0. Prérequis + +Voir `tasks/testing-m0.md §0`. M2 n'ajoute aucune dépendance host (le pytest tourne dans un container éphémère via `make test-api`). + +## 1. Bootstrap stack vide → premier admin + +```bash +make env +make up +make migrate # 27 tables (M1) +``` + +Récupère le token install dans les logs : +```bash +make logs-api | grep -E "INSTALL TOKEN" | tail -1 +# ou : podman logs metamorph-api 2>&1 | grep "INSTALL TOKEN" +``` + +Si la stack a été utilisée et le token consommé, force-mint un nouveau : +```bash +make print-install-token-force +``` + +## 2. /setup — création du 1er admin via curl + +```bash +TOKEN="" +curl -s -X POST http://localhost:8080/api/v1/setup \ + -H "Content-Type: application/json" \ + -d "{\"install_token\":\"$TOKEN\",\"email\":\"admin@metamorph.local\",\"password\":\"AdminPass1234!\"}" | jq +# Attendu : {"ok":true,"user_id":"..."} +``` + +Vérifie que `/setup` ne peut plus être consommé : +```bash +curl -s http://localhost:8080/api/v1/setup | jq +# Attendu : {"completed":true} +``` + +## 3. /setup via la SPA + +Ouvrir http://127.0.0.1:8080/setup dans le navigateur. Le formulaire affiche : +- **Install token** (paste depuis les logs) +- **Admin email** +- **Display name (optional)** +- **Password** + **Confirm password** (≥ 8 chars) + +Le bouton « Create admin » appelle `POST /api/v1/setup` puis redirige vers `/login`. Si la stack a déjà un admin, la page affiche « Already done · Go to login → ». + +## 4. Login + /auth/me + +```bash +LOGIN=$(curl -s -c /tmp/cookies.txt -X POST http://localhost:8080/api/v1/auth/login \ + -H "Content-Type: application/json" \ + -d '{"email":"admin@metamorph.local","password":"AdminPass1234!"}') +echo "$LOGIN" | jq +ACCESS=$(echo "$LOGIN" | jq -r .access_token) + +curl -s http://localhost:8080/api/v1/auth/me -H "Authorization: Bearer $ACCESS" | jq +# Attendu : {is_admin: true, groups: ["admin"], email: "...", ...} +``` + +Le cookie `metamorph_refresh` est dans `/tmp/cookies.txt` (HTTPOnly, scope `/api/v1/auth/`). + +Côté SPA : `/login` → email + password → redirige vers `/`. Le header affiche l'email courant via le testid `me-email` (``). + +## 5. Refresh rotation + +```bash +curl -s -b /tmp/cookies.txt -c /tmp/cookies.txt -X POST http://localhost:8080/api/v1/auth/refresh | jq +# Attendu : nouveau access_token, le cookie refresh est rotaté +``` + +L'ancien refresh token devient `revoked_at IS NOT NULL` en base ; un 2e usage du même refresh révoque la chaîne entière (détection de réutilisation). + +## 6. Invitation → register → 2e login + +```bash +# Admin crée invitation +INV=$(curl -s -X POST http://localhost:8080/api/v1/invitations \ + -H "Authorization: Bearer $ACCESS" -H "Content-Type: application/json" \ + -d '{"email_hint":"alice@metamorph.local"}') +echo "$INV" | jq +INV_TOKEN=$(echo "$INV" | jq -r .token) + +# Preview (anonyme) +curl -s "http://localhost:8080/api/v1/invitations/preview/$INV_TOKEN" | jq + +# Acceptance +curl -s -X POST "http://localhost:8080/api/v1/invitations/accept/$INV_TOKEN" \ + -H "Content-Type: application/json" \ + -d '{"email":"alice@metamorph.local","password":"AlicePass1234!"}' | jq + +# Login Alice +curl -s -X POST http://localhost:8080/api/v1/auth/login \ + -H "Content-Type: application/json" \ + -d '{"email":"alice@metamorph.local","password":"AlicePass1234!"}' | jq -r .access_token | head -c 40 +``` + +Côté SPA : ouvrir `http://127.0.0.1:8080/register?token=`. La page affiche le hint email, demande password + confirm, redirige vers `/login` après acceptance. + +## 7. RBAC — non-admin reçoit 403 + +```bash +ALICE_ACCESS=$(curl -s -X POST http://localhost:8080/api/v1/auth/login \ + -H "Content-Type: application/json" \ + -d '{"email":"alice@metamorph.local","password":"AlicePass1234!"}' | jq -r .access_token) + +curl -s -o /dev/null -w "HTTP %{http_code}\n" -X POST http://localhost:8080/api/v1/invitations \ + -H "Authorization: Bearer $ALICE_ACCESS" -H "Content-Type: application/json" -d '{}' +# Attendu : HTTP 403 +``` + +## 8. Change password (force logout-all) + +```bash +curl -s -X POST http://localhost:8080/api/v1/auth/change-password \ + -H "Authorization: Bearer $ACCESS" -H "Content-Type: application/json" \ + -d '{"current_password":"AdminPass1234!","new_password":"AdminPass5678!"}' | jq +# Attendu : {"ok":true} +``` + +Tous les refresh tokens du user sont révoqués → toute autre session existante repasse par /login. + +## 9. Profile (SPA) + +Une fois loggué, ouvrir `/profile` : +- Carte **Identity** (email, display name, locale) +- Carte **Groups** (tags) +- Carte **Permissions** (admin → tag « ADMIN — bypasses checks ») +- Carte **Change password** (current + new + confirm → submit → redirige vers /login après 1.5 s) + +Logout via le bouton du header → redirige vers `/login`. Tenter `/profile` sans session → redirige vers `/login`. + +## 10. Tests automatisés + +### 10.1 — Backend pytest + +```bash +make test-api +``` + +**Attendu** : **24 passed** (1 health + 8 schema + 15 auth flow). + +Détail M2 (`tests/test_auth_flow.py`) : +| Test | Couvre | +|---|---| +| setup_status_starts_uncompleted | /setup état initial | +| setup_creates_first_admin | bootstrap consomme le token, crée admin | +| setup_status_now_completed | idempotence | +| setup_replay_is_blocked | 409 si déjà fait | +| login_and_me | flux login → /me complet | +| login_with_wrong_password_returns_401 | aucune énumération | +| me_without_token_returns_401 | bearer requis | +| refresh_rotates_and_old_token_is_revoked | rotation chaîne | +| refresh_with_no_cookie_returns_401 | cookie obligatoire | +| logout_clears_cookie_and_is_idempotent | idempotence | +| admin_creates_invitation_and_invitee_accepts | flux complet | +| unauthenticated_cannot_create_invitation | auth requis | +| non_admin_cannot_create_invitation | RBAC 403 | +| used_invitation_cannot_be_accepted_twice | usage unique | +| change_password_revokes_all_refresh_tokens | logout-all | + +### 10.2 — Playwright e2e + +```bash +make e2e +``` + +**Attendu** : **20 passed** (8 M0 + 4 M1 + 8 M2). + +Détail M2 (`e2e/tests/m2-auth.spec.ts`) : +| Test | Couvre | +|---|---| +| setup status is uncompleted before bootstrap | API contract | +| SPA setup form creates the first admin | UI /setup | +| SPA login works and reveals the profile page | UI /login + /profile | +| admin issues an invitation via the API and the front renders the registration form | UI /register?token=… | +| invitee submits the registration form and can log in | UI register accept | +| non-admin gets 403 on the admin invitations endpoint | RBAC | +| refresh token rotation works through the SPA | rotation | +| logout clears the session and redirects to login | logout | + +Le rapport HTML : `e2e/playwright-report/index.html`. JUnit : `e2e/playwright-report/junit.xml`. + +## 11. Pièges connus (M2 spécifiques) + +- **Cookie `Secure` en HTTP** : `secure=True` fait rejeter le cookie par le browser sur HTTP. Métamorph utilise `secure = APP_ENV in ("prod","staging")` — en `dev`/`test` le cookie est non-secure. En prod, le reverse proxy externe doit terminer la TLS. +- **`pydantic.EmailStr` rejette les TLD réservés** (`.local`, `.corp`, `.test`) avec `globally_deliverable=True`. Métamorph utilise un type `Email` permissif via regex (cf. `app/api/_validation.py`). +- **`getByLabel` Playwright** récupère le **nom accessible** de l'input. Si la `