- backend/tests/test_mitre.py: 12 integration tests using a hand-crafted
minimal STIX bundle (no network in tests). Covers parser
(revoked/deprecated skip, sub-technique parent linkage), seed idempotence,
persisted settings, checksum mismatch path, all four read endpoints, perm
enforcement on /mitre/sync, ILIKE search.
- e2e/tests/m4-mitre.spec.ts: 6 Playwright tests against the live stack.
beforeAll calls POST /mitre/sync once (real bundle, ~50 MB, ~1.1 s) then
the suite validates tactics ≥14, T1003 has ≥5 sub-techniques, the picker
walks tactic→technique→subtechnique with chip multi-select, and non-admin
sees /mitre but no Sync card.
- tasks/testing-m4.md: manual + automated checklist, air-gapped operator
notes, volume-permission caveat for pre-existing root-owned volumes.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- GET /api/v1/mitre/tactics, /techniques?tactic=&q=, /subtechniques?technique=&q=
(paginated, ILIKE search on name + external_id, @require_auth only — MITRE
is public reference material).
- GET /api/v1/mitre/status: last_sync, version, source_url + the pinned
defaults (default_url, default_version) for the SPA badge.
- POST /api/v1/mitre/sync: @require_perm("mitre.sync"). Body supports
{source, expected_sha256, allow_unverified} — defaults inherit the pin.
- /diag/reset now also TRUNCATEs the mitre_* tables alongside settings so a
freshly-reset stack has GET /mitre/status and GET /mitre/tactics agree
("no data, no last_sync"). Previously the catalogue persisted while the
metadata was wiped, leaving status to lie. The e2e suite re-syncs in
beforeAll.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- backend/app/services/mitre_seed.py: stdlib-only STIX 2.1 parser (urllib +
hashlib + json). Pinned to enterprise-attack-19.0.json with sha256
df520ea0775a57db7bff760145b02fed89290802913e056b7ed5970b02f3626a (~52 MB,
~1.1 s parse). Resolves sub-technique parents via
relationship[subtechnique-of] with a T1003.001→T1003 dotted-id fallback;
upserts on external_id, rebuilds the technique↔tactic M2M in a single
transaction so external readers never see an empty join. Persists
mitre_last_sync, mitre_version, mitre_source_url in the settings table.
- Custom URLs MUST be paired with expected_sha256 OR allow_unverified=true —
refuses silent integrity bypass.
- CLI: flask metamorph seed-mitre [--source path|url]
[--checksum-sha256 hex] [--skip-checksum]. Make target wraps it.
- Docker: /data/mitre/ chowned to the metamorph user at build; named volume
metamorph_mitre mounted from compose for cross-restart cache.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Permission catalogue (services/permissions_seed.py)
- 31 atomic codes across 10 families: user.*, group.*, invitation.*,
test_template.*, scenario_template.*, mission.* (incl.
mission.write_red_fields + mission.write_blue_fields),
detection_level.{read,update}, setting.{read,update}, mitre.sync.
- Default bindings: admin = all 31; 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).
- Seed runs at boot AND after /setup so a freshly truncated DB (via
/diag/reset) gets the bindings back via the bootstrap path. Idempotent +
additive (never removes a perm from a system group).
Users admin (services/users.py + api/users.py)
- list (q + is_active filter + pagination), get, patch (display_name /
locale / is_active with tri-state sentinel for clear-vs-unset),
soft-delete, set groups.
- Last-admin protection on update (deactivate), delete, and group-strip
(refusing to remove the admin group from the last active admin).
Groups admin (services/groups.py + api/groups.py)
- Full CRUD with system-group protection (no rename, no delete on
admin/redteam/blueteam).
- PUT /groups/{id}/permissions sets the perm list.
- Admin system group's perm set is locked to the full catalogue
(SystemGroupProtected → 409) — preserves the bypass invariant even if a
future refactor moves to perm-based checks.
Permissions read-only (api/permissions.py)
- GET /permissions returns the catalogue (admin or group.read holders).
/diag/reset extension
- After truncate + token mint, the limiter is also reset (limiter.reset())
so the Playwright suite doesn't hit 10/min budgets across spec files.
Guarded by limiter.enabled to no-op in APP_ENV=test.
Rate-limit scope (core/rate_limit.py)
- enabled = APP_ENV in ("prod", "staging"). A staging deployment serves
humans, so it gets the limits too. Dev/test stay unthrottled for
Playwright ergonomics. Spec §6 NF-security is an operator-facing
requirement.
Frontend chrome
- components/RequireAdmin.tsx + ui/Modal.tsx (reusable centered dialog
with accessible name + Escape + backdrop-click).
- Layout.tsx shows Admin nav links only when is_admin === true. Server
remains the arbiter — non-admins hitting /admin/* get redirected to /.
Frontend pages
- pages/AdminUsersPage.tsx, AdminGroupsPage.tsx, AdminInvitationsPage.tsx
with edit modals using TanStack Query mutations + multi-select for perms
grouped by family + copy-once invitation URL display.
- lib/admin.ts: shared types + query keys + groupPermsByFamily helper.
- lib/api.ts: apiPatch / apiPut / apiDelete added.
Playwright config (e2e/playwright.config.ts)
- workers: 1 + fullyParallel: false: spec files share the live Postgres,
so concurrent /diag/reset calls clobber each other. Intra-file order
preserved via test.describe.configure({ mode: 'serial' }).
Testing
- backend/tests/test_rbac.py: 15 integration tests (39 backend total — 1
health + 8 schema + 15 auth + 15 RBAC).
- e2e/tests/m3-rbac.spec.ts: 8 Playwright tests covering DoD §10 #2/#3
(28 e2e total — 8 M0 + 4 M1 + 8 M2 + 8 M3).
- tasks/testing-m3.md.
DoD: make test-api → 39 passed, make e2e → 28 passed. Spec-reviewer pass
applied (admin perm invariant + staging rate-limit scope).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
23 tables + alembic_version covering the v1 data model:
- Auth/RBAC (8): users, groups, permissions, user_groups, group_permissions,
invitations, invitation_groups, refresh_tokens.
- MITRE (4): mitre_tactics, mitre_techniques, mitre_subtechniques + the
technique↔tactic many-to-many.
- Templates (4): test_templates, test_template_mitre_tags (3 nullable FKs +
CHECK exactly_one_mitre_fk), scenario_templates, scenario_template_tests
(UUID PK + UNIQUE(scenario_id, position) so a test can appear at multiple
positions).
- Missions (6): missions, mission_members, mission_scenarios, mission_tests,
mission_test_mitre_tags (deliberately denormalised — copies external_id +
name + url, no FK to mitre_* — so a re-sync of the catalogue can't purge
historical tags), mission_categories.
- Evidence/settings/notifications (5): evidence_files, settings (JSONB
value), detection_levels, notifications.
SQLAlchemy 2.x with Mapped[]/mapped_column(), pk_/fk_/ck_/uq_/ix_ naming
convention. Reusable mixins (UuidPkMixin, TimestampMixin, SoftDeleteMixin —
no auto __table_args__ since classes silently clobber the mixin's).
Soft delete: deleted_at + partial indexes ix_<table>_active WHERE deleted_at
IS NULL on 9 tables (users, groups, test_templates, scenario_templates,
missions, mission_scenarios, mission_tests, mission_categories,
evidence_files). Notifications gets ix_..._unread WHERE read_at IS NULL.
CHECK constraints for status / state / opsec_level / mitre_kind enums.
New API endpoint GET /api/v1/diag/db: returns alembic_revision (short hash)
and the public-schema table_count. 503 with {"reachable": false} on a DB
outage. Database card on the SPA home consumes it.
Test stage in backend/Dockerfile (--target test): runtime + dev extras +
tests/. New make test-api spins an ephemeral pytest container against the
live DB on the compose network. backend/tests/test_schema.py: 8 integration
tests (tables, FK pairs, CHECK constraints, partial indexes, alembic-at-head,
negative INSERT proving the exactly_one_mitre_fk CHECK fires).
e2e/tests/m1-db.spec.ts: 4 Playwright tests covering the diag endpoint
contract + the Database card + footer/roadmap labels.
DoD: make clean && make up && make migrate → 23 tables, 32 FKs, 9 CHECKs,
make test-api → 9 passed, make e2e → 12 passed.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- Repo scaffolding: .gitignore, .env.example, Makefile, docker-compose.yml,
README.md, CHANGELOG.md, pre-commit config.
- Three-service stack: api (Flask 3), db (postgres:16-alpine), front (nginx
serving the Vite bundle). Named volumes metamorph_db + metamorph_evidence.
- Backend skeleton: Flask app factory, JSON structured logging on stdout,
GET /api/v1/health, multi-stage Dockerfile, pyproject.toml driven by uv,
Pydantic Settings with secret guard rails (refuses to boot in non-dev with
placeholders), APP_ENV gating.
- Frontend skeleton: Vite + React 18 + TypeScript strict + TailwindCSS, RTOps
design tokens from tasks/design.md, self-hosted JetBrains Mono / IBM Plex
Sans via @fontsource, base UI primitives (Card/Tag/SectionHeader/FlowNode/
Button), home page wired to /api/v1/health.
- Engine-agnostic Makefile: auto-detects docker or podman, picks the matching
compose driver. Targets: up/down/build/rebuild/dev/lint/fmt/test/migrate/
seed-mitre/print-install-token/e2e/inspect-health.
- Playwright suite: e2e/tests/m0-smoke.spec.ts (8 tests) + HTML + JUnit
reports + traces on retry.
- Docs: tasks/spec.md (finalized after Q&A), tasks/design.md, tasks/todo.md
(14 milestones), tasks/testing-m0.md, tasks/lessons.md.
DoD: make up + make health + make e2e all pass on podman 5.x (Fedora) and
docker. TLS terminated by external reverse proxy (spec §6 NF-network).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>