From e995853f0d2a900b180d1a28f8340726a49078dd Mon Sep 17 00:00:00 2001 From: Knacky Date: Mon, 11 May 2026 06:16:24 +0200 Subject: [PATCH] feat(m1): DB schema, migrations, diag visibility MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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__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) --- 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/api/diag.py | 93 ++++ 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/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/tests/__init__.py | 0 backend/tests/conftest.py | 25 + backend/tests/test_health.py | 14 + backend/tests/test_schema.py | 234 +++++++++ e2e/tests/m1-db.spec.ts | 50 ++ tasks/testing-m1.md | 218 +++++++++ 25 files changed, 2367 insertions(+) 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/api/diag.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/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/tests/__init__.py create mode 100644 backend/tests/conftest.py create mode 100644 backend/tests/test_health.py create mode 100644 backend/tests/test_schema.py create mode 100644 e2e/tests/m1-db.spec.ts create mode 100644 tasks/testing-m1.md 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/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/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/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/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_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_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/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/tasks/testing-m1.md b/tasks/testing-m1.md new file mode 100644 index 0000000..16938e5 --- /dev/null +++ b/tasks/testing-m1.md @@ -0,0 +1,218 @@ +--- +type: testing +project: Metamorph +milestone: M1 +date: "2026-05-10" +--- + +# Comment tester M1 (schéma DB & migrations) + +> Procédure de validation manuelle + automatisée pour M1 : SQLAlchemy 2 + Alembic + 26 tables. Toutes les commandes se lancent depuis la racine du repo. + +## 0. Prérequis + +Voir `tasks/testing-m0.md §0` (Docker ou Podman, Make, Node 20+, etc.). Aucune dépendance Python locale n'est requise — pytest tourne dans un container éphémère bâti depuis le stage `test` du Dockerfile backend. + +## 1. Bootstrap (si la stack n'est pas déjà up) + +```bash +make env # crée .env si absent +make up # build + start de la stack (api / db / front) +make inspect-health # attends que les 3 soient healthy +``` + +## 2. Appliquer la migration + +```bash +make migrate # alembic upgrade head dans le container api +make migrate-status # confirme la revision courante = head +``` + +**Attendu** : +``` +INFO [alembic.runtime.migration] Will assume transactional DDL. +INFO [alembic.runtime.migration] Running upgrade -> 24765a5014b6, initial schema +``` + +et `make migrate-status` : +``` +24765a5014b6 (head) +--- +24765a5014b6 (head) +``` + +## 3. Tests fonctionnels manuels + +### 3.1 — Liste des tables + +```bash +make psql # ouvre psql dans le container db +\dt # une fois dans psql +``` + +**Attendu** : **27 lignes** (26 tables métier + `alembic_version`) : + +``` +detection_levels, evidence_files, group_permissions, groups, +invitation_groups, invitations, mission_categories, mission_members, +mission_scenarios, mission_test_mitre_tags, mission_tests, missions, +mitre_subtechniques, mitre_tactics, mitre_technique_tactics, +mitre_techniques, notifications, permissions, refresh_tokens, +scenario_template_tests, scenario_templates, settings, +test_template_mitre_tags, test_templates, user_groups, users, +alembic_version +``` + +Compter via SQL : +```bash +podman exec metamorph-db psql -U metamorph -d metamorph -tAc \ + "SELECT count(*) FROM information_schema.tables WHERE table_schema='public' AND table_type='BASE TABLE'" +# 27 +``` + +### 3.2 — Contraintes au niveau Postgres + +```bash +podman exec metamorph-db psql -U metamorph -d metamorph -tAc \ + "SELECT contype, count(*) FROM pg_constraint WHERE connamespace = 'public'::regnamespace GROUP BY contype ORDER BY contype" +``` + +**Attendu** : +- `c|9` — CHECK constraints (status valid, opsec_level valid, mitre_kind valid, exactly_one_mitre_fk uniquement sur `test_template_mitre_tags`, …) +- `f|32` — Foreign keys *(snapshot `mission_test_mitre_tags` n'a volontairement pas de FK MITRE)* +- `p|27` — Primary keys (1 par table) +- `u|14` — UNIQUE constraints + +### 3.3 — Index partiels (soft delete + unread notifications) + +```bash +podman exec metamorph-db psql -U metamorph -d metamorph -c \ + "SELECT indexname FROM pg_indexes WHERE schemaname='public' AND indexdef ILIKE '%WHERE%' ORDER BY 1" +``` + +**Attendu** (12 indexes) : +- `ix_evidence_files_active`, `ix_groups_active`, `ix_missions_active`, `ix_mission_categories_active`, `ix_mission_scenarios_active`, `ix_mission_tests_active`, `ix_scenario_templates_active`, `ix_test_templates_active`, `ix_users_active` — soft-delete partiels (9) +- `ix_notifications_user_unread` — `WHERE read_at IS NULL` +- `uq_users_email_active`, `uq_groups_name_active` — uniques scopés aux lignes actives + +### 3.4 — Test négatif d'un CHECK constraint (`exactly_one_mitre_fk`) + +```sql +-- Dans psql, doit échouer : +INSERT INTO test_templates (id, name, opsec_level) + VALUES (gen_random_uuid(), 'tmp', 'low'); +INSERT INTO mitre_tactics (id, external_id, short_name, name) + VALUES (gen_random_uuid(), 'TA0099', 'tmp', 'tmp'); +-- (puis tente une insertion avec deux FK MITRE non null — bloqué par CHECK) +``` + +Couvert automatiquement par `tests/test_schema.py::test_exactly_one_mitre_fk_check_enforced`. + +### 3.5 — Migration depuis DB totalement vide + +```bash +make clean # DESTRUCTEUR — supprime aussi les volumes +make up # re-spawn db vierge +# attends que db soit healthy +make migrate # applique 0001_initial sur DB vide +podman exec metamorph-db psql -U metamorph -d metamorph -tAc "SELECT count(*) FROM information_schema.tables WHERE table_schema='public'" +# attendu : 27 +``` + +## 4. Tests automatisés + +### 4.1 — Backend pytest (M1 schema integration) + +```bash +make test-api +``` + +**Attendu** : `9 passed in <1s` (1 health M0 + 8 schema M1). + +Détail des 8 tests M1 : + +| # | Test | Couvre | +|---|---|---| +| 1 | `test_all_expected_tables_exist` | Liste exhaustive des 26 tables métier | +| 2 | `test_soft_delete_columns_present` | `deleted_at` sur 6 tables | +| 3 | `test_standard_timestamp_columns_present` | `created_at`+`updated_at` sur 5 tables | +| 4 | `test_partial_index_for_soft_delete` | Index `ix_
_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) +```