feat(m1): DB schema, migrations, diag visibility
23 tables + alembic_version covering the v1 data model:
- Auth/RBAC (8): users, groups, permissions, user_groups, group_permissions,
invitations, invitation_groups, refresh_tokens.
- MITRE (4): mitre_tactics, mitre_techniques, mitre_subtechniques + the
technique↔tactic many-to-many.
- Templates (4): test_templates, test_template_mitre_tags (3 nullable FKs +
CHECK exactly_one_mitre_fk), scenario_templates, scenario_template_tests
(UUID PK + UNIQUE(scenario_id, position) so a test can appear at multiple
positions).
- Missions (6): missions, mission_members, mission_scenarios, mission_tests,
mission_test_mitre_tags (deliberately denormalised — copies external_id +
name + url, no FK to mitre_* — so a re-sync of the catalogue can't purge
historical tags), mission_categories.
- Evidence/settings/notifications (5): evidence_files, settings (JSONB
value), detection_levels, notifications.
SQLAlchemy 2.x with Mapped[]/mapped_column(), pk_/fk_/ck_/uq_/ix_ naming
convention. Reusable mixins (UuidPkMixin, TimestampMixin, SoftDeleteMixin —
no auto __table_args__ since classes silently clobber the mixin's).
Soft delete: deleted_at + partial indexes ix_<table>_active WHERE deleted_at
IS NULL on 9 tables (users, groups, test_templates, scenario_templates,
missions, mission_scenarios, mission_tests, mission_categories,
evidence_files). Notifications gets ix_..._unread WHERE read_at IS NULL.
CHECK constraints for status / state / opsec_level / mitre_kind enums.
New API endpoint GET /api/v1/diag/db: returns alembic_revision (short hash)
and the public-schema table_count. 503 with {"reachable": false} on a DB
outage. Database card on the SPA home consumes it.
Test stage in backend/Dockerfile (--target test): runtime + dev extras +
tests/. New make test-api spins an ephemeral pytest container against the
live DB on the compose network. backend/tests/test_schema.py: 8 integration
tests (tables, FK pairs, CHECK constraints, partial indexes, alembic-at-head,
negative INSERT proving the exactly_one_mitre_fk CHECK fires).
e2e/tests/m1-db.spec.ts: 4 Playwright tests covering the diag endpoint
contract + the Database card + footer/roadmap labels.
DoD: make clean && make up && make migrate → 23 tables, 32 FKs, 9 CHECKs,
make test-api → 9 passed, make e2e → 12 passed.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
49
backend/alembic.ini
Normal file
49
backend/alembic.ini
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
# Alembic configuration. The actual DB URL is injected at runtime by `alembic/env.py`
|
||||||
|
# from `app.core.config.settings.database_url`, so we leave `sqlalchemy.url` empty.
|
||||||
|
[alembic]
|
||||||
|
script_location = alembic
|
||||||
|
prepend_sys_path = .
|
||||||
|
sqlalchemy.url =
|
||||||
|
|
||||||
|
# We use a YYYYMMDD_HHMM prefix on revision files for chronological readability.
|
||||||
|
file_template = %%(year)d%%(month).2d%%(day).2d_%%(hour).2d%%(minute).2d_%%(rev)s_%%(slug)s
|
||||||
|
|
||||||
|
[post_write_hooks]
|
||||||
|
# We deliberately disable post-write hooks: ruff is a dev-only dep, not installed
|
||||||
|
# in the runtime image where `alembic revision --autogenerate` runs in podman.
|
||||||
|
# Run `make fmt` on the host after generating a migration to format it.
|
||||||
|
hooks =
|
||||||
|
|
||||||
|
[loggers]
|
||||||
|
keys = root,sqlalchemy,alembic
|
||||||
|
|
||||||
|
[handlers]
|
||||||
|
keys = console
|
||||||
|
|
||||||
|
[formatters]
|
||||||
|
keys = generic
|
||||||
|
|
||||||
|
[logger_root]
|
||||||
|
level = WARNING
|
||||||
|
handlers = console
|
||||||
|
qualname =
|
||||||
|
|
||||||
|
[logger_sqlalchemy]
|
||||||
|
level = WARNING
|
||||||
|
handlers =
|
||||||
|
qualname = sqlalchemy.engine
|
||||||
|
|
||||||
|
[logger_alembic]
|
||||||
|
level = INFO
|
||||||
|
handlers =
|
||||||
|
qualname = alembic
|
||||||
|
|
||||||
|
[handler_console]
|
||||||
|
class = StreamHandler
|
||||||
|
args = (sys.stderr,)
|
||||||
|
level = NOTSET
|
||||||
|
formatter = generic
|
||||||
|
|
||||||
|
[formatter_generic]
|
||||||
|
format = %(levelname)-5.5s [%(name)s] %(message)s
|
||||||
|
datefmt = %Y-%m-%d %H:%M:%S
|
||||||
76
backend/alembic/env.py
Normal file
76
backend/alembic/env.py
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
"""Alembic environment.
|
||||||
|
|
||||||
|
We bypass `alembic.ini`'s `sqlalchemy.url` and pull the URL from the project's
|
||||||
|
Pydantic settings so a single .env governs both runtime and migrations.
|
||||||
|
|
||||||
|
Importing `app.models` registers every model on `Base.metadata`.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from logging.config import fileConfig
|
||||||
|
|
||||||
|
from alembic import context
|
||||||
|
from sqlalchemy import engine_from_config, pool
|
||||||
|
|
||||||
|
# noqa: F401 — registers models on Base.metadata
|
||||||
|
from app import models as _models # noqa: F401
|
||||||
|
from app.core.config import settings
|
||||||
|
from app.db.base import Base
|
||||||
|
|
||||||
|
config = context.config
|
||||||
|
|
||||||
|
if config.config_file_name is not None:
|
||||||
|
fileConfig(config.config_file_name)
|
||||||
|
|
||||||
|
# Inject the DB URL at runtime.
|
||||||
|
config.set_main_option("sqlalchemy.url", settings.database_url)
|
||||||
|
|
||||||
|
target_metadata = Base.metadata
|
||||||
|
|
||||||
|
|
||||||
|
def include_object(_object, _name, type_, _reflected, _compare_to): # type: ignore[no-untyped-def]
|
||||||
|
"""Skip alembic's internal version table from autogenerate diffs."""
|
||||||
|
if type_ == "table" and _name == "alembic_version":
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def run_migrations_offline() -> None:
|
||||||
|
"""Generate SQL without an engine — useful for review."""
|
||||||
|
context.configure(
|
||||||
|
url=config.get_main_option("sqlalchemy.url"),
|
||||||
|
target_metadata=target_metadata,
|
||||||
|
literal_binds=True,
|
||||||
|
dialect_opts={"paramstyle": "named"},
|
||||||
|
include_object=include_object,
|
||||||
|
compare_type=True,
|
||||||
|
compare_server_default=True,
|
||||||
|
)
|
||||||
|
with context.begin_transaction():
|
||||||
|
context.run_migrations()
|
||||||
|
|
||||||
|
|
||||||
|
def run_migrations_online() -> None:
|
||||||
|
"""Apply migrations against a live DB."""
|
||||||
|
connectable = engine_from_config(
|
||||||
|
config.get_section(config.config_ini_section, {}),
|
||||||
|
prefix="sqlalchemy.",
|
||||||
|
poolclass=pool.NullPool,
|
||||||
|
)
|
||||||
|
with connectable.connect() as connection:
|
||||||
|
context.configure(
|
||||||
|
connection=connection,
|
||||||
|
target_metadata=target_metadata,
|
||||||
|
include_object=include_object,
|
||||||
|
compare_type=True,
|
||||||
|
compare_server_default=True,
|
||||||
|
)
|
||||||
|
with context.begin_transaction():
|
||||||
|
context.run_migrations()
|
||||||
|
|
||||||
|
|
||||||
|
if context.is_offline_mode():
|
||||||
|
run_migrations_offline()
|
||||||
|
else:
|
||||||
|
run_migrations_online()
|
||||||
28
backend/alembic/script.py.mako
Normal file
28
backend/alembic/script.py.mako
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
"""${message}
|
||||||
|
|
||||||
|
Revision ID: ${up_revision}
|
||||||
|
Revises: ${down_revision | comma,n}
|
||||||
|
Create Date: ${create_date}
|
||||||
|
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import Sequence
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
${imports if imports else ""}
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = ${repr(up_revision)}
|
||||||
|
down_revision: str | None = ${repr(down_revision)}
|
||||||
|
branch_labels: str | Sequence[str] | None = ${repr(branch_labels)}
|
||||||
|
depends_on: str | Sequence[str] | None = ${repr(depends_on)}
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
${upgrades if upgrades else "pass"}
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
${downgrades if downgrades else "pass"}
|
||||||
0
backend/alembic/versions/.gitkeep
Normal file
0
backend/alembic/versions/.gitkeep
Normal file
@@ -0,0 +1,446 @@
|
|||||||
|
"""initial schema
|
||||||
|
|
||||||
|
Revision ID: 24765a5014b6
|
||||||
|
Revises:
|
||||||
|
Create Date: 2026-05-10 10:40:31.816149
|
||||||
|
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import Sequence
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from sqlalchemy.dialects import postgresql
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = '24765a5014b6'
|
||||||
|
down_revision: str | None = None
|
||||||
|
branch_labels: str | Sequence[str] | None = None
|
||||||
|
depends_on: str | Sequence[str] | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
op.create_table('detection_levels',
|
||||||
|
sa.Column('key', sa.String(length=40), nullable=False),
|
||||||
|
sa.Column('label_fr', sa.String(length=80), nullable=False),
|
||||||
|
sa.Column('label_en', sa.String(length=80), nullable=False),
|
||||||
|
sa.Column('color_token', sa.String(length=16), nullable=False),
|
||||||
|
sa.Column('position', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('is_default', sa.Boolean(), nullable=False),
|
||||||
|
sa.Column('is_system', sa.Boolean(), nullable=False),
|
||||||
|
sa.Column('id', sa.Uuid(), nullable=False),
|
||||||
|
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
|
||||||
|
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
|
||||||
|
sa.PrimaryKeyConstraint('id', name=op.f('pk_detection_levels')),
|
||||||
|
sa.UniqueConstraint('key', name=op.f('uq_detection_levels_key'))
|
||||||
|
)
|
||||||
|
op.create_table('groups',
|
||||||
|
sa.Column('name', sa.String(length=80), nullable=False),
|
||||||
|
sa.Column('description', sa.Text(), nullable=True),
|
||||||
|
sa.Column('is_system', sa.Boolean(), nullable=False),
|
||||||
|
sa.Column('id', sa.Uuid(), nullable=False),
|
||||||
|
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
|
||||||
|
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
|
||||||
|
sa.Column('deleted_at', sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.PrimaryKeyConstraint('id', name=op.f('pk_groups'))
|
||||||
|
)
|
||||||
|
op.create_index('ix_groups_active', 'groups', ['deleted_at'], unique=False, postgresql_where='deleted_at IS NULL')
|
||||||
|
op.create_index('uq_groups_name_active', 'groups', ['name'], unique=True, postgresql_where='deleted_at IS NULL')
|
||||||
|
op.create_table('missions',
|
||||||
|
sa.Column('name', sa.String(length=255), nullable=False),
|
||||||
|
sa.Column('client_target', sa.String(length=255), nullable=True),
|
||||||
|
sa.Column('date_start', sa.Date(), nullable=True),
|
||||||
|
sa.Column('date_end', sa.Date(), nullable=True),
|
||||||
|
sa.Column('status', sa.String(length=16), nullable=False),
|
||||||
|
sa.Column('description_md', sa.Text(), nullable=True),
|
||||||
|
sa.Column('visibility_mode', sa.String(length=16), nullable=False),
|
||||||
|
sa.Column('id', sa.Uuid(), nullable=False),
|
||||||
|
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
|
||||||
|
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
|
||||||
|
sa.Column('deleted_at', sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.CheckConstraint("status IN ('draft', 'in_progress', 'completed', 'archived')", name=op.f('ck_missions_status_valid')),
|
||||||
|
sa.CheckConstraint("visibility_mode IN ('whitebox', 'titles_only', 'executed_only')", name=op.f('ck_missions_visibility_mode_valid')),
|
||||||
|
sa.PrimaryKeyConstraint('id', name=op.f('pk_missions'))
|
||||||
|
)
|
||||||
|
op.create_index('ix_missions_active', 'missions', ['deleted_at'], unique=False, postgresql_where='deleted_at IS NULL')
|
||||||
|
op.create_index('ix_missions_status', 'missions', ['status'], unique=False)
|
||||||
|
op.create_table('mitre_tactics',
|
||||||
|
sa.Column('external_id', sa.String(length=16), nullable=False),
|
||||||
|
sa.Column('short_name', sa.String(length=80), nullable=False),
|
||||||
|
sa.Column('name', sa.String(length=120), nullable=False),
|
||||||
|
sa.Column('description', sa.Text(), nullable=True),
|
||||||
|
sa.Column('url', sa.String(length=512), nullable=True),
|
||||||
|
sa.Column('id', sa.Uuid(), nullable=False),
|
||||||
|
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
|
||||||
|
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
|
||||||
|
sa.PrimaryKeyConstraint('id', name=op.f('pk_mitre_tactics')),
|
||||||
|
sa.UniqueConstraint('external_id', name=op.f('uq_mitre_tactics_external_id'))
|
||||||
|
)
|
||||||
|
op.create_table('mitre_techniques',
|
||||||
|
sa.Column('external_id', sa.String(length=16), nullable=False),
|
||||||
|
sa.Column('name', sa.String(length=255), nullable=False),
|
||||||
|
sa.Column('description', sa.Text(), nullable=True),
|
||||||
|
sa.Column('url', sa.String(length=512), nullable=True),
|
||||||
|
sa.Column('id', sa.Uuid(), nullable=False),
|
||||||
|
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
|
||||||
|
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
|
||||||
|
sa.PrimaryKeyConstraint('id', name=op.f('pk_mitre_techniques')),
|
||||||
|
sa.UniqueConstraint('external_id', name=op.f('uq_mitre_techniques_external_id'))
|
||||||
|
)
|
||||||
|
op.create_index('ix_mitre_techniques_name', 'mitre_techniques', ['name'], unique=False)
|
||||||
|
op.create_table('permissions',
|
||||||
|
sa.Column('code', sa.String(length=80), nullable=False),
|
||||||
|
sa.Column('description', sa.Text(), nullable=True),
|
||||||
|
sa.Column('id', sa.Uuid(), nullable=False),
|
||||||
|
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
|
||||||
|
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
|
||||||
|
sa.PrimaryKeyConstraint('id', name=op.f('pk_permissions')),
|
||||||
|
sa.UniqueConstraint('code', name=op.f('uq_permissions_code'))
|
||||||
|
)
|
||||||
|
op.create_table('scenario_templates',
|
||||||
|
sa.Column('name', sa.String(length=255), nullable=False),
|
||||||
|
sa.Column('description', sa.Text(), nullable=True),
|
||||||
|
sa.Column('id', sa.Uuid(), nullable=False),
|
||||||
|
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
|
||||||
|
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
|
||||||
|
sa.Column('deleted_at', sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.PrimaryKeyConstraint('id', name=op.f('pk_scenario_templates'))
|
||||||
|
)
|
||||||
|
op.create_index('ix_scenario_templates_active', 'scenario_templates', ['deleted_at'], unique=False, postgresql_where='deleted_at IS NULL')
|
||||||
|
op.create_index('ix_scenario_templates_name', 'scenario_templates', ['name'], unique=False)
|
||||||
|
op.create_table('settings',
|
||||||
|
sa.Column('key', sa.String(length=80), nullable=False),
|
||||||
|
sa.Column('value', postgresql.JSONB(astext_type=sa.Text()), nullable=False),
|
||||||
|
sa.Column('description', sa.Text(), nullable=True),
|
||||||
|
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
|
||||||
|
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
|
||||||
|
sa.PrimaryKeyConstraint('key', name=op.f('pk_settings'))
|
||||||
|
)
|
||||||
|
op.create_table('test_templates',
|
||||||
|
sa.Column('name', sa.String(length=255), nullable=False),
|
||||||
|
sa.Column('description', sa.Text(), nullable=True),
|
||||||
|
sa.Column('objective', sa.Text(), nullable=True),
|
||||||
|
sa.Column('procedure_md', sa.Text(), nullable=True),
|
||||||
|
sa.Column('prerequisites_md', sa.Text(), nullable=True),
|
||||||
|
sa.Column('expected_result_red_md', sa.Text(), nullable=True),
|
||||||
|
sa.Column('expected_detection_blue_md', sa.Text(), nullable=True),
|
||||||
|
sa.Column('opsec_level', sa.String(length=8), nullable=False),
|
||||||
|
sa.Column('tags', sa.ARRAY(sa.String(length=64)), server_default='{}', nullable=False),
|
||||||
|
sa.Column('expected_iocs', sa.ARRAY(sa.String(length=255)), server_default='{}', nullable=False),
|
||||||
|
sa.Column('id', sa.Uuid(), nullable=False),
|
||||||
|
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
|
||||||
|
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
|
||||||
|
sa.Column('deleted_at', sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.CheckConstraint("opsec_level IN ('low', 'medium', 'high')", name=op.f('ck_test_templates_opsec_level_valid')),
|
||||||
|
sa.PrimaryKeyConstraint('id', name=op.f('pk_test_templates'))
|
||||||
|
)
|
||||||
|
op.create_index('ix_test_templates_active', 'test_templates', ['deleted_at'], unique=False, postgresql_where='deleted_at IS NULL')
|
||||||
|
op.create_index('ix_test_templates_name', 'test_templates', ['name'], unique=False)
|
||||||
|
op.create_table('users',
|
||||||
|
sa.Column('email', sa.String(length=254), nullable=False),
|
||||||
|
sa.Column('password_hash', sa.String(length=255), nullable=False),
|
||||||
|
sa.Column('display_name', sa.String(length=120), nullable=True),
|
||||||
|
sa.Column('locale', sa.String(length=8), nullable=False),
|
||||||
|
sa.Column('is_active', sa.Boolean(), nullable=False),
|
||||||
|
sa.Column('id', sa.Uuid(), nullable=False),
|
||||||
|
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
|
||||||
|
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
|
||||||
|
sa.Column('deleted_at', sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.PrimaryKeyConstraint('id', name=op.f('pk_users'))
|
||||||
|
)
|
||||||
|
op.create_index('ix_users_active', 'users', ['deleted_at'], unique=False, postgresql_where='deleted_at IS NULL')
|
||||||
|
op.create_index('uq_users_email_active', 'users', ['email'], unique=True, postgresql_where='deleted_at IS NULL')
|
||||||
|
op.create_table('group_permissions',
|
||||||
|
sa.Column('group_id', sa.Uuid(), nullable=False),
|
||||||
|
sa.Column('permission_id', sa.Uuid(), nullable=False),
|
||||||
|
sa.ForeignKeyConstraint(['group_id'], ['groups.id'], name=op.f('fk_group_permissions_group_id_groups'), ondelete='CASCADE'),
|
||||||
|
sa.ForeignKeyConstraint(['permission_id'], ['permissions.id'], name=op.f('fk_group_permissions_permission_id_permissions'), ondelete='CASCADE'),
|
||||||
|
sa.PrimaryKeyConstraint('group_id', 'permission_id', name=op.f('pk_group_permissions'))
|
||||||
|
)
|
||||||
|
op.create_table('invitations',
|
||||||
|
sa.Column('token_hash', sa.String(length=128), nullable=False),
|
||||||
|
sa.Column('email_hint', sa.String(length=254), nullable=True),
|
||||||
|
sa.Column('created_by_user_id', sa.Uuid(), nullable=False),
|
||||||
|
sa.Column('expires_at', sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.Column('consumed_at', sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.Column('revoked_at', sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.Column('consumed_by_user_id', sa.Uuid(), nullable=True),
|
||||||
|
sa.Column('id', sa.Uuid(), nullable=False),
|
||||||
|
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
|
||||||
|
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
|
||||||
|
sa.ForeignKeyConstraint(['consumed_by_user_id'], ['users.id'], name=op.f('fk_invitations_consumed_by_user_id_users'), ondelete='SET NULL'),
|
||||||
|
sa.ForeignKeyConstraint(['created_by_user_id'], ['users.id'], name=op.f('fk_invitations_created_by_user_id_users'), ondelete='RESTRICT'),
|
||||||
|
sa.PrimaryKeyConstraint('id', name=op.f('pk_invitations')),
|
||||||
|
sa.UniqueConstraint('token_hash', name=op.f('uq_invitations_token_hash'))
|
||||||
|
)
|
||||||
|
op.create_index('ix_invitations_expires_at', 'invitations', ['expires_at'], unique=False)
|
||||||
|
op.create_table('mission_categories',
|
||||||
|
sa.Column('mission_id', sa.Uuid(), nullable=False),
|
||||||
|
sa.Column('name', sa.String(length=120), nullable=False),
|
||||||
|
sa.Column('color_token', sa.String(length=16), nullable=True),
|
||||||
|
sa.Column('position', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('id', sa.Uuid(), nullable=False),
|
||||||
|
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
|
||||||
|
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
|
||||||
|
sa.Column('deleted_at', sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.ForeignKeyConstraint(['mission_id'], ['missions.id'], name=op.f('fk_mission_categories_mission_id_missions'), ondelete='CASCADE'),
|
||||||
|
sa.PrimaryKeyConstraint('id', name=op.f('pk_mission_categories')),
|
||||||
|
sa.UniqueConstraint('mission_id', 'name', name='uq_mission_categories_name'),
|
||||||
|
sa.UniqueConstraint('mission_id', 'position', name='uq_mission_categories_position')
|
||||||
|
)
|
||||||
|
op.create_index('ix_mission_categories_active', 'mission_categories', ['deleted_at'], unique=False, postgresql_where='deleted_at IS NULL')
|
||||||
|
op.create_table('mission_members',
|
||||||
|
sa.Column('mission_id', sa.Uuid(), nullable=False),
|
||||||
|
sa.Column('user_id', sa.Uuid(), nullable=False),
|
||||||
|
sa.Column('role_hint', sa.String(length=8), nullable=False),
|
||||||
|
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
|
||||||
|
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
|
||||||
|
sa.CheckConstraint("role_hint IN ('red', 'blue')", name=op.f('ck_mission_members_role_hint_valid')),
|
||||||
|
sa.ForeignKeyConstraint(['mission_id'], ['missions.id'], name=op.f('fk_mission_members_mission_id_missions'), ondelete='CASCADE'),
|
||||||
|
sa.ForeignKeyConstraint(['user_id'], ['users.id'], name=op.f('fk_mission_members_user_id_users'), ondelete='CASCADE'),
|
||||||
|
sa.PrimaryKeyConstraint('mission_id', 'user_id', name=op.f('pk_mission_members'))
|
||||||
|
)
|
||||||
|
op.create_index('ix_mission_members_user', 'mission_members', ['user_id'], unique=False)
|
||||||
|
op.create_table('mission_scenarios',
|
||||||
|
sa.Column('mission_id', sa.Uuid(), nullable=False),
|
||||||
|
sa.Column('source_scenario_template_id', sa.Uuid(), nullable=True),
|
||||||
|
sa.Column('snapshot_name', sa.String(length=255), nullable=False),
|
||||||
|
sa.Column('snapshot_description', sa.Text(), nullable=True),
|
||||||
|
sa.Column('position', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('id', sa.Uuid(), nullable=False),
|
||||||
|
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
|
||||||
|
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
|
||||||
|
sa.Column('deleted_at', sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.ForeignKeyConstraint(['mission_id'], ['missions.id'], name=op.f('fk_mission_scenarios_mission_id_missions'), ondelete='CASCADE'),
|
||||||
|
sa.ForeignKeyConstraint(['source_scenario_template_id'], ['scenario_templates.id'], name=op.f('fk_mission_scenarios_source_scenario_template_id_scenario_templates'), ondelete='SET NULL'),
|
||||||
|
sa.PrimaryKeyConstraint('id', name=op.f('pk_mission_scenarios')),
|
||||||
|
sa.UniqueConstraint('mission_id', 'position', name='uq_mission_scenarios_position')
|
||||||
|
)
|
||||||
|
op.create_index('ix_mission_scenarios_active', 'mission_scenarios', ['deleted_at'], unique=False, postgresql_where='deleted_at IS NULL')
|
||||||
|
op.create_index('ix_mission_scenarios_mission', 'mission_scenarios', ['mission_id'], unique=False)
|
||||||
|
op.create_table('mitre_subtechniques',
|
||||||
|
sa.Column('external_id', sa.String(length=16), nullable=False),
|
||||||
|
sa.Column('name', sa.String(length=255), nullable=False),
|
||||||
|
sa.Column('description', sa.Text(), nullable=True),
|
||||||
|
sa.Column('url', sa.String(length=512), nullable=True),
|
||||||
|
sa.Column('technique_id', sa.Uuid(), nullable=False),
|
||||||
|
sa.Column('id', sa.Uuid(), nullable=False),
|
||||||
|
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
|
||||||
|
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
|
||||||
|
sa.ForeignKeyConstraint(['technique_id'], ['mitre_techniques.id'], name=op.f('fk_mitre_subtechniques_technique_id_mitre_techniques'), ondelete='CASCADE'),
|
||||||
|
sa.PrimaryKeyConstraint('id', name=op.f('pk_mitre_subtechniques')),
|
||||||
|
sa.UniqueConstraint('external_id', name=op.f('uq_mitre_subtechniques_external_id'))
|
||||||
|
)
|
||||||
|
op.create_index('ix_mitre_subtechniques_technique_id', 'mitre_subtechniques', ['technique_id'], unique=False)
|
||||||
|
op.create_table('mitre_technique_tactics',
|
||||||
|
sa.Column('technique_id', sa.Uuid(), nullable=False),
|
||||||
|
sa.Column('tactic_id', sa.Uuid(), nullable=False),
|
||||||
|
sa.ForeignKeyConstraint(['tactic_id'], ['mitre_tactics.id'], name=op.f('fk_mitre_technique_tactics_tactic_id_mitre_tactics'), ondelete='CASCADE'),
|
||||||
|
sa.ForeignKeyConstraint(['technique_id'], ['mitre_techniques.id'], name=op.f('fk_mitre_technique_tactics_technique_id_mitre_techniques'), ondelete='CASCADE'),
|
||||||
|
sa.PrimaryKeyConstraint('technique_id', 'tactic_id', name=op.f('pk_mitre_technique_tactics'))
|
||||||
|
)
|
||||||
|
op.create_table('notifications',
|
||||||
|
sa.Column('user_id', sa.Uuid(), nullable=False),
|
||||||
|
sa.Column('type', sa.String(length=64), nullable=False),
|
||||||
|
sa.Column('payload', postgresql.JSONB(astext_type=sa.Text()), server_default='{}', nullable=False),
|
||||||
|
sa.Column('read_at', sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.Column('id', sa.Uuid(), nullable=False),
|
||||||
|
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
|
||||||
|
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
|
||||||
|
sa.ForeignKeyConstraint(['user_id'], ['users.id'], name=op.f('fk_notifications_user_id_users'), ondelete='CASCADE'),
|
||||||
|
sa.PrimaryKeyConstraint('id', name=op.f('pk_notifications'))
|
||||||
|
)
|
||||||
|
op.create_index('ix_notifications_user_unread', 'notifications', ['user_id', 'created_at'], unique=False, postgresql_where='read_at IS NULL')
|
||||||
|
op.create_table('refresh_tokens',
|
||||||
|
sa.Column('user_id', sa.Uuid(), nullable=False),
|
||||||
|
sa.Column('jti', sa.String(length=64), nullable=False),
|
||||||
|
sa.Column('token_hash', sa.String(length=128), nullable=False),
|
||||||
|
sa.Column('issued_at', sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.Column('expires_at', sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.Column('revoked_at', sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.Column('replaced_by_id', sa.Uuid(), nullable=True),
|
||||||
|
sa.Column('id', sa.Uuid(), nullable=False),
|
||||||
|
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
|
||||||
|
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
|
||||||
|
sa.ForeignKeyConstraint(['replaced_by_id'], ['refresh_tokens.id'], name=op.f('fk_refresh_tokens_replaced_by_id_refresh_tokens'), ondelete='SET NULL'),
|
||||||
|
sa.ForeignKeyConstraint(['user_id'], ['users.id'], name=op.f('fk_refresh_tokens_user_id_users'), ondelete='CASCADE'),
|
||||||
|
sa.PrimaryKeyConstraint('id', name=op.f('pk_refresh_tokens')),
|
||||||
|
sa.UniqueConstraint('jti', name='uq_refresh_tokens_jti')
|
||||||
|
)
|
||||||
|
op.create_index('ix_refresh_tokens_user_id_expires_at', 'refresh_tokens', ['user_id', 'expires_at'], unique=False)
|
||||||
|
op.create_table('scenario_template_tests',
|
||||||
|
sa.Column('scenario_template_id', sa.Uuid(), nullable=False),
|
||||||
|
sa.Column('test_template_id', sa.Uuid(), nullable=False),
|
||||||
|
sa.Column('position', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('id', sa.Uuid(), nullable=False),
|
||||||
|
sa.ForeignKeyConstraint(['scenario_template_id'], ['scenario_templates.id'], name=op.f('fk_scenario_template_tests_scenario_template_id_scenario_templates'), ondelete='CASCADE'),
|
||||||
|
sa.ForeignKeyConstraint(['test_template_id'], ['test_templates.id'], name=op.f('fk_scenario_template_tests_test_template_id_test_templates'), ondelete='RESTRICT'),
|
||||||
|
sa.PrimaryKeyConstraint('id', name=op.f('pk_scenario_template_tests')),
|
||||||
|
sa.UniqueConstraint('scenario_template_id', 'position', name='uq_scenario_template_tests_position')
|
||||||
|
)
|
||||||
|
op.create_index('ix_scenario_template_tests_scenario', 'scenario_template_tests', ['scenario_template_id'], unique=False)
|
||||||
|
op.create_index('ix_scenario_template_tests_test', 'scenario_template_tests', ['test_template_id'], unique=False)
|
||||||
|
op.create_table('user_groups',
|
||||||
|
sa.Column('user_id', sa.Uuid(), nullable=False),
|
||||||
|
sa.Column('group_id', sa.Uuid(), nullable=False),
|
||||||
|
sa.ForeignKeyConstraint(['group_id'], ['groups.id'], name=op.f('fk_user_groups_group_id_groups'), ondelete='CASCADE'),
|
||||||
|
sa.ForeignKeyConstraint(['user_id'], ['users.id'], name=op.f('fk_user_groups_user_id_users'), ondelete='CASCADE'),
|
||||||
|
sa.PrimaryKeyConstraint('user_id', 'group_id', name=op.f('pk_user_groups'))
|
||||||
|
)
|
||||||
|
op.create_table('invitation_groups',
|
||||||
|
sa.Column('invitation_id', sa.Uuid(), nullable=False),
|
||||||
|
sa.Column('group_id', sa.Uuid(), nullable=False),
|
||||||
|
sa.ForeignKeyConstraint(['group_id'], ['groups.id'], name=op.f('fk_invitation_groups_group_id_groups'), ondelete='CASCADE'),
|
||||||
|
sa.ForeignKeyConstraint(['invitation_id'], ['invitations.id'], name=op.f('fk_invitation_groups_invitation_id_invitations'), ondelete='CASCADE'),
|
||||||
|
sa.PrimaryKeyConstraint('invitation_id', 'group_id', name=op.f('pk_invitation_groups'))
|
||||||
|
)
|
||||||
|
op.create_table('mission_tests',
|
||||||
|
sa.Column('scenario_id', sa.Uuid(), nullable=False),
|
||||||
|
sa.Column('source_test_template_id', sa.Uuid(), nullable=True),
|
||||||
|
sa.Column('position', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('snapshot_name', sa.String(length=255), nullable=False),
|
||||||
|
sa.Column('snapshot_description', sa.Text(), nullable=True),
|
||||||
|
sa.Column('snapshot_objective', sa.Text(), nullable=True),
|
||||||
|
sa.Column('snapshot_procedure_md', sa.Text(), nullable=True),
|
||||||
|
sa.Column('snapshot_prerequisites_md', sa.Text(), nullable=True),
|
||||||
|
sa.Column('snapshot_expected_red_md', sa.Text(), nullable=True),
|
||||||
|
sa.Column('snapshot_expected_blue_md', sa.Text(), nullable=True),
|
||||||
|
sa.Column('snapshot_opsec_level', sa.String(length=8), nullable=False),
|
||||||
|
sa.Column('snapshot_tags', sa.ARRAY(sa.String(length=64)), server_default='{}', nullable=False),
|
||||||
|
sa.Column('snapshot_expected_iocs', sa.ARRAY(sa.String(length=255)), server_default='{}', nullable=False),
|
||||||
|
sa.Column('state', sa.String(length=24), nullable=False),
|
||||||
|
sa.Column('executed_at', sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.Column('executed_at_overridden', sa.Boolean(), nullable=False),
|
||||||
|
sa.Column('red_command', sa.Text(), nullable=True),
|
||||||
|
sa.Column('red_output', sa.Text(), nullable=True),
|
||||||
|
sa.Column('red_comment_md', sa.Text(), nullable=True),
|
||||||
|
sa.Column('blue_comment_md', sa.Text(), nullable=True),
|
||||||
|
sa.Column('detection_level_id', sa.Uuid(), nullable=True),
|
||||||
|
sa.Column('category_id', sa.Uuid(), nullable=True),
|
||||||
|
sa.Column('id', sa.Uuid(), nullable=False),
|
||||||
|
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
|
||||||
|
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
|
||||||
|
sa.Column('deleted_at', sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.CheckConstraint("snapshot_opsec_level IN ('low', 'medium', 'high')", name=op.f('ck_mission_tests_snapshot_opsec_level_valid')),
|
||||||
|
sa.CheckConstraint("state IN ('pending', 'executed', 'reviewed_by_blue', 'skipped', 'blocked')", name=op.f('ck_mission_tests_state_valid')),
|
||||||
|
sa.ForeignKeyConstraint(['category_id'], ['mission_categories.id'], name=op.f('fk_mission_tests_category_id_mission_categories'), ondelete='SET NULL'),
|
||||||
|
sa.ForeignKeyConstraint(['detection_level_id'], ['detection_levels.id'], name=op.f('fk_mission_tests_detection_level_id_detection_levels'), ondelete='SET NULL'),
|
||||||
|
sa.ForeignKeyConstraint(['scenario_id'], ['mission_scenarios.id'], name=op.f('fk_mission_tests_scenario_id_mission_scenarios'), ondelete='CASCADE'),
|
||||||
|
sa.ForeignKeyConstraint(['source_test_template_id'], ['test_templates.id'], name=op.f('fk_mission_tests_source_test_template_id_test_templates'), ondelete='SET NULL'),
|
||||||
|
sa.PrimaryKeyConstraint('id', name=op.f('pk_mission_tests')),
|
||||||
|
sa.UniqueConstraint('scenario_id', 'position', name='uq_mission_tests_position')
|
||||||
|
)
|
||||||
|
op.create_index('ix_mission_tests_active', 'mission_tests', ['deleted_at'], unique=False, postgresql_where='deleted_at IS NULL')
|
||||||
|
op.create_index('ix_mission_tests_state', 'mission_tests', ['state'], unique=False)
|
||||||
|
op.create_table('test_template_mitre_tags',
|
||||||
|
sa.Column('id', sa.Uuid(), nullable=False),
|
||||||
|
sa.Column('test_template_id', sa.Uuid(), nullable=False),
|
||||||
|
sa.Column('mitre_kind', sa.String(length=16), nullable=False),
|
||||||
|
sa.Column('tactic_id', sa.Uuid(), nullable=True),
|
||||||
|
sa.Column('technique_id', sa.Uuid(), nullable=True),
|
||||||
|
sa.Column('subtechnique_id', sa.Uuid(), nullable=True),
|
||||||
|
sa.CheckConstraint("mitre_kind IN ('tactic', 'technique', 'subtechnique')", name=op.f('ck_test_template_mitre_tags_mitre_kind_valid')),
|
||||||
|
sa.CheckConstraint('(CASE WHEN tactic_id IS NOT NULL THEN 1 ELSE 0 END) + (CASE WHEN technique_id IS NOT NULL THEN 1 ELSE 0 END) + (CASE WHEN subtechnique_id IS NOT NULL THEN 1 ELSE 0 END) = 1', name=op.f('ck_test_template_mitre_tags_exactly_one_mitre_fk')),
|
||||||
|
sa.ForeignKeyConstraint(['subtechnique_id'], ['mitre_subtechniques.id'], name=op.f('fk_test_template_mitre_tags_subtechnique_id_mitre_subtechniques'), ondelete='CASCADE'),
|
||||||
|
sa.ForeignKeyConstraint(['tactic_id'], ['mitre_tactics.id'], name=op.f('fk_test_template_mitre_tags_tactic_id_mitre_tactics'), ondelete='CASCADE'),
|
||||||
|
sa.ForeignKeyConstraint(['technique_id'], ['mitre_techniques.id'], name=op.f('fk_test_template_mitre_tags_technique_id_mitre_techniques'), ondelete='CASCADE'),
|
||||||
|
sa.ForeignKeyConstraint(['test_template_id'], ['test_templates.id'], name=op.f('fk_test_template_mitre_tags_test_template_id_test_templates'), ondelete='CASCADE'),
|
||||||
|
sa.PrimaryKeyConstraint('id', name=op.f('pk_test_template_mitre_tags')),
|
||||||
|
sa.UniqueConstraint('test_template_id', 'tactic_id', 'technique_id', 'subtechnique_id', name='uq_test_template_mitre_tag')
|
||||||
|
)
|
||||||
|
op.create_index('ix_test_template_mitre_tags_template', 'test_template_mitre_tags', ['test_template_id'], unique=False)
|
||||||
|
op.create_table('evidence_files',
|
||||||
|
sa.Column('mission_test_id', sa.Uuid(), nullable=False),
|
||||||
|
sa.Column('sha256', sa.String(length=64), nullable=False),
|
||||||
|
sa.Column('mime', sa.String(length=127), nullable=False),
|
||||||
|
sa.Column('size_bytes', sa.BigInteger(), nullable=False),
|
||||||
|
sa.Column('storage_path', sa.Text(), nullable=False),
|
||||||
|
sa.Column('original_filename', sa.String(length=255), nullable=False),
|
||||||
|
sa.Column('uploaded_by_user_id', sa.Uuid(), nullable=True),
|
||||||
|
sa.Column('uploaded_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
|
||||||
|
sa.Column('id', sa.Uuid(), nullable=False),
|
||||||
|
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
|
||||||
|
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
|
||||||
|
sa.Column('deleted_at', sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.ForeignKeyConstraint(['mission_test_id'], ['mission_tests.id'], name=op.f('fk_evidence_files_mission_test_id_mission_tests'), ondelete='CASCADE'),
|
||||||
|
sa.ForeignKeyConstraint(['uploaded_by_user_id'], ['users.id'], name=op.f('fk_evidence_files_uploaded_by_user_id_users'), ondelete='SET NULL'),
|
||||||
|
sa.PrimaryKeyConstraint('id', name=op.f('pk_evidence_files'))
|
||||||
|
)
|
||||||
|
op.create_index('ix_evidence_files_active', 'evidence_files', ['deleted_at'], unique=False, postgresql_where='deleted_at IS NULL')
|
||||||
|
op.create_index('ix_evidence_files_mission_test', 'evidence_files', ['mission_test_id'], unique=False)
|
||||||
|
op.create_index('ix_evidence_files_sha256', 'evidence_files', ['sha256'], unique=False)
|
||||||
|
op.create_table('mission_test_mitre_tags',
|
||||||
|
sa.Column('id', sa.Uuid(), nullable=False),
|
||||||
|
sa.Column('mission_test_id', sa.Uuid(), nullable=False),
|
||||||
|
sa.Column('mitre_kind', sa.String(length=16), nullable=False),
|
||||||
|
sa.Column('mitre_external_id', sa.String(length=16), nullable=False),
|
||||||
|
sa.Column('mitre_name', sa.String(length=255), nullable=False),
|
||||||
|
sa.Column('mitre_url', sa.String(length=512), nullable=True),
|
||||||
|
sa.CheckConstraint("mitre_kind IN ('tactic', 'technique', 'subtechnique')", name=op.f('ck_mission_test_mitre_tags_mitre_kind_valid')),
|
||||||
|
sa.ForeignKeyConstraint(['mission_test_id'], ['mission_tests.id'], name=op.f('fk_mission_test_mitre_tags_mission_test_id_mission_tests'), ondelete='CASCADE'),
|
||||||
|
sa.PrimaryKeyConstraint('id', name=op.f('pk_mission_test_mitre_tags')),
|
||||||
|
sa.UniqueConstraint('mission_test_id', 'mitre_external_id', name='uq_mission_test_mitre_tag')
|
||||||
|
)
|
||||||
|
op.create_index('ix_mission_test_mitre_tags_test', 'mission_test_mitre_tags', ['mission_test_id'], unique=False)
|
||||||
|
# ### end Alembic commands ###
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
op.drop_index('ix_mission_test_mitre_tags_test', table_name='mission_test_mitre_tags')
|
||||||
|
op.drop_table('mission_test_mitre_tags')
|
||||||
|
op.drop_index('ix_evidence_files_sha256', table_name='evidence_files')
|
||||||
|
op.drop_index('ix_evidence_files_mission_test', table_name='evidence_files')
|
||||||
|
op.drop_index('ix_evidence_files_active', table_name='evidence_files', postgresql_where='deleted_at IS NULL')
|
||||||
|
op.drop_table('evidence_files')
|
||||||
|
op.drop_index('ix_test_template_mitre_tags_template', table_name='test_template_mitre_tags')
|
||||||
|
op.drop_table('test_template_mitre_tags')
|
||||||
|
op.drop_index('ix_mission_tests_state', table_name='mission_tests')
|
||||||
|
op.drop_index('ix_mission_tests_active', table_name='mission_tests', postgresql_where='deleted_at IS NULL')
|
||||||
|
op.drop_table('mission_tests')
|
||||||
|
op.drop_table('invitation_groups')
|
||||||
|
op.drop_table('user_groups')
|
||||||
|
op.drop_index('ix_scenario_template_tests_test', table_name='scenario_template_tests')
|
||||||
|
op.drop_index('ix_scenario_template_tests_scenario', table_name='scenario_template_tests')
|
||||||
|
op.drop_table('scenario_template_tests')
|
||||||
|
op.drop_index('ix_refresh_tokens_user_id_expires_at', table_name='refresh_tokens')
|
||||||
|
op.drop_table('refresh_tokens')
|
||||||
|
op.drop_index('ix_notifications_user_unread', table_name='notifications', postgresql_where='read_at IS NULL')
|
||||||
|
op.drop_table('notifications')
|
||||||
|
op.drop_table('mitre_technique_tactics')
|
||||||
|
op.drop_index('ix_mitre_subtechniques_technique_id', table_name='mitre_subtechniques')
|
||||||
|
op.drop_table('mitre_subtechniques')
|
||||||
|
op.drop_index('ix_mission_scenarios_mission', table_name='mission_scenarios')
|
||||||
|
op.drop_index('ix_mission_scenarios_active', table_name='mission_scenarios', postgresql_where='deleted_at IS NULL')
|
||||||
|
op.drop_table('mission_scenarios')
|
||||||
|
op.drop_index('ix_mission_members_user', table_name='mission_members')
|
||||||
|
op.drop_table('mission_members')
|
||||||
|
op.drop_index('ix_mission_categories_active', table_name='mission_categories', postgresql_where='deleted_at IS NULL')
|
||||||
|
op.drop_table('mission_categories')
|
||||||
|
op.drop_index('ix_invitations_expires_at', table_name='invitations')
|
||||||
|
op.drop_table('invitations')
|
||||||
|
op.drop_table('group_permissions')
|
||||||
|
op.drop_index('uq_users_email_active', table_name='users', postgresql_where='deleted_at IS NULL')
|
||||||
|
op.drop_index('ix_users_active', table_name='users', postgresql_where='deleted_at IS NULL')
|
||||||
|
op.drop_table('users')
|
||||||
|
op.drop_index('ix_test_templates_name', table_name='test_templates')
|
||||||
|
op.drop_index('ix_test_templates_active', table_name='test_templates', postgresql_where='deleted_at IS NULL')
|
||||||
|
op.drop_table('test_templates')
|
||||||
|
op.drop_table('settings')
|
||||||
|
op.drop_index('ix_scenario_templates_name', table_name='scenario_templates')
|
||||||
|
op.drop_index('ix_scenario_templates_active', table_name='scenario_templates', postgresql_where='deleted_at IS NULL')
|
||||||
|
op.drop_table('scenario_templates')
|
||||||
|
op.drop_table('permissions')
|
||||||
|
op.drop_index('ix_mitre_techniques_name', table_name='mitre_techniques')
|
||||||
|
op.drop_table('mitre_techniques')
|
||||||
|
op.drop_table('mitre_tactics')
|
||||||
|
op.drop_index('ix_missions_status', table_name='missions')
|
||||||
|
op.drop_index('ix_missions_active', table_name='missions', postgresql_where='deleted_at IS NULL')
|
||||||
|
op.drop_table('missions')
|
||||||
|
op.drop_index('uq_groups_name_active', table_name='groups', postgresql_where='deleted_at IS NULL')
|
||||||
|
op.drop_index('ix_groups_active', table_name='groups', postgresql_where='deleted_at IS NULL')
|
||||||
|
op.drop_table('groups')
|
||||||
|
op.drop_table('detection_levels')
|
||||||
|
# ### end Alembic commands ###
|
||||||
93
backend/app/api/diag.py
Normal file
93
backend/app/api/diag.py
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
"""Operational diagnostics. No auth in v1 (M0/M1 only expose non-sensitive
|
||||||
|
counts and the current Alembic revision).
|
||||||
|
|
||||||
|
The `/diag/reset` endpoint is **test-only** — it requires `APP_ENV=test` and
|
||||||
|
is the bedrock of the e2e suite (clean DB + freshly minted install token).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from flask import Blueprint, abort, jsonify
|
||||||
|
from sqlalchemy import text
|
||||||
|
from sqlalchemy.exc import SQLAlchemyError
|
||||||
|
|
||||||
|
from app.core.config import settings
|
||||||
|
from app.core.install_token import regenerate_install_token
|
||||||
|
from app.db.session import get_engine
|
||||||
|
|
||||||
|
bp = Blueprint("diag", __name__, url_prefix="/diag")
|
||||||
|
log = logging.getLogger("metamorph.diag")
|
||||||
|
|
||||||
|
|
||||||
|
@bp.get("/db")
|
||||||
|
def db_diag():
|
||||||
|
"""Return the Alembic revision and the count of public-schema tables."""
|
||||||
|
try:
|
||||||
|
with get_engine().connect() as conn:
|
||||||
|
revision = conn.execute(
|
||||||
|
text("SELECT version_num FROM alembic_version")
|
||||||
|
).scalar()
|
||||||
|
table_count = conn.execute(
|
||||||
|
text(
|
||||||
|
"SELECT count(*) FROM information_schema.tables "
|
||||||
|
"WHERE table_schema='public' AND table_type='BASE TABLE'"
|
||||||
|
)
|
||||||
|
).scalar_one()
|
||||||
|
except SQLAlchemyError as e:
|
||||||
|
log.warning("metamorph.diag.db_unreachable", extra={"error": str(e)})
|
||||||
|
return jsonify({"reachable": False, "error": "database_unreachable"}), 503
|
||||||
|
|
||||||
|
return jsonify(
|
||||||
|
{
|
||||||
|
"reachable": True,
|
||||||
|
"alembic_revision": revision,
|
||||||
|
"table_count": int(table_count),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@bp.post("/reset")
|
||||||
|
def reset_test_state():
|
||||||
|
"""TEST-ONLY: wipe users/auth tables and mint a fresh install token.
|
||||||
|
|
||||||
|
Refuses unless `APP_ENV=test`. Used by the Playwright suite to start each
|
||||||
|
auth scenario from a deterministic state.
|
||||||
|
"""
|
||||||
|
# NOTE: this endpoint is the test-suite reset hook. Allowed in `dev` too so
|
||||||
|
# the e2e suite can run against a normal `make up` stack, but in dev it is
|
||||||
|
# destructive — equivalent to `make clean` for the auth tables. Production
|
||||||
|
# (APP_ENV=prod/staging) is locked out.
|
||||||
|
if settings.APP_ENV not in ("dev", "test"):
|
||||||
|
abort(403, description="diag/reset is only available in dev/test")
|
||||||
|
if settings.APP_ENV == "dev":
|
||||||
|
log.warning("metamorph.diag.reset_in_dev_environment")
|
||||||
|
|
||||||
|
try:
|
||||||
|
with get_engine().begin() as conn:
|
||||||
|
conn.execute(
|
||||||
|
text(
|
||||||
|
"TRUNCATE users, refresh_tokens, invitations, invitation_groups, "
|
||||||
|
"user_groups, settings, groups RESTART IDENTITY CASCADE"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
except SQLAlchemyError as e:
|
||||||
|
log.error("metamorph.diag.reset_failed", extra={"error": str(e)})
|
||||||
|
return jsonify({"reset": False, "error": "database_error"}), 500
|
||||||
|
|
||||||
|
token = regenerate_install_token()
|
||||||
|
|
||||||
|
# Clear the in-memory rate-limit counters so the e2e suite that follows can
|
||||||
|
# log in repeatedly without hitting `/auth/login`/`/auth/refresh` limits.
|
||||||
|
# The limiter uses `memory://` in dev (cf. `app/core/rate_limit.py`).
|
||||||
|
try:
|
||||||
|
from app.core.rate_limit import limiter # noqa: PLC0415 — avoid import cycle
|
||||||
|
|
||||||
|
if limiter.enabled:
|
||||||
|
limiter.reset()
|
||||||
|
except Exception as e: # noqa: BLE001
|
||||||
|
log.warning("metamorph.diag.rate_limit_reset_failed", extra={"error": str(e)})
|
||||||
|
|
||||||
|
log.warning("metamorph.diag.reset_completed")
|
||||||
|
return jsonify({"reset": True, "install_token": token})
|
||||||
15
backend/app/db/__init__.py
Normal file
15
backend/app/db/__init__.py
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
"""DB layer — base, session, mixins, shared enums."""
|
||||||
|
|
||||||
|
from app.db.base import Base
|
||||||
|
from app.db.mixins import SoftDeleteMixin, TimestampMixin, UuidPkMixin
|
||||||
|
from app.db.session import get_engine, get_sessionmaker, session_scope
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"Base",
|
||||||
|
"SoftDeleteMixin",
|
||||||
|
"TimestampMixin",
|
||||||
|
"UuidPkMixin",
|
||||||
|
"get_engine",
|
||||||
|
"get_sessionmaker",
|
||||||
|
"session_scope",
|
||||||
|
]
|
||||||
23
backend/app/db/base.py
Normal file
23
backend/app/db/base.py
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
"""Declarative base for all ORM models.
|
||||||
|
|
||||||
|
Naming convention is set explicitly so Alembic generates stable, reviewable
|
||||||
|
constraint names across migrations and Postgres versions.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from sqlalchemy import MetaData
|
||||||
|
from sqlalchemy.orm import DeclarativeBase
|
||||||
|
|
||||||
|
# https://alembic.sqlalchemy.org/en/latest/naming.html#integration-of-naming-conventions-into-operations-autogenerate
|
||||||
|
NAMING_CONVENTION = {
|
||||||
|
"ix": "ix_%(table_name)s_%(column_0_N_name)s",
|
||||||
|
"uq": "uq_%(table_name)s_%(column_0_N_name)s",
|
||||||
|
"ck": "ck_%(table_name)s_%(constraint_name)s",
|
||||||
|
"fk": "fk_%(table_name)s_%(column_0_N_name)s_%(referred_table_name)s",
|
||||||
|
"pk": "pk_%(table_name)s",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class Base(DeclarativeBase):
|
||||||
|
metadata = MetaData(naming_convention=NAMING_CONVENTION)
|
||||||
56
backend/app/db/mixins.py
Normal file
56
backend/app/db/mixins.py
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
"""Reusable column mixins.
|
||||||
|
|
||||||
|
Pattern: subclass `Base, TimestampMixin, SoftDeleteMixin` to get the columns.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from sqlalchemy import DateTime, Uuid, func
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
|
||||||
|
|
||||||
|
class UuidPkMixin:
|
||||||
|
"""Native UUID primary key, generated Python-side."""
|
||||||
|
|
||||||
|
id: Mapped[uuid.UUID] = mapped_column(
|
||||||
|
Uuid(as_uuid=True),
|
||||||
|
primary_key=True,
|
||||||
|
default=uuid.uuid4,
|
||||||
|
nullable=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TimestampMixin:
|
||||||
|
"""`created_at` / `updated_at` server-managed timestamps (UTC)."""
|
||||||
|
|
||||||
|
created_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime(timezone=True),
|
||||||
|
server_default=func.now(),
|
||||||
|
nullable=False,
|
||||||
|
)
|
||||||
|
updated_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime(timezone=True),
|
||||||
|
server_default=func.now(),
|
||||||
|
onupdate=func.now(),
|
||||||
|
nullable=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class SoftDeleteMixin:
|
||||||
|
"""Soft delete via a nullable `deleted_at` column.
|
||||||
|
|
||||||
|
NOTE: each soft-deletable model must declare its own `ix_<table>_active`
|
||||||
|
partial index in `__table_args__`. We deliberately don't auto-inject one
|
||||||
|
here because SQLAlchemy's `__table_args__` from a mixin gets clobbered as
|
||||||
|
soon as the model class declares its own — silently dropping the index.
|
||||||
|
Declaring it explicitly keeps the contract visible at the model site.
|
||||||
|
"""
|
||||||
|
|
||||||
|
deleted_at: Mapped[datetime | None] = mapped_column(
|
||||||
|
DateTime(timezone=True),
|
||||||
|
nullable=True,
|
||||||
|
default=None,
|
||||||
|
)
|
||||||
47
backend/app/db/session.py
Normal file
47
backend/app/db/session.py
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
"""Engine + sessionmaker. Lazily initialised so test code can swap the URL."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import Iterator
|
||||||
|
from contextlib import contextmanager
|
||||||
|
|
||||||
|
from sqlalchemy import create_engine
|
||||||
|
from sqlalchemy.engine import Engine
|
||||||
|
from sqlalchemy.orm import Session, sessionmaker
|
||||||
|
|
||||||
|
from app.core.config import settings
|
||||||
|
|
||||||
|
_engine: Engine | None = None
|
||||||
|
_SessionLocal: sessionmaker[Session] | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def get_engine() -> Engine:
|
||||||
|
global _engine
|
||||||
|
if _engine is None:
|
||||||
|
_engine = create_engine(
|
||||||
|
settings.database_url,
|
||||||
|
pool_pre_ping=True,
|
||||||
|
future=True,
|
||||||
|
)
|
||||||
|
return _engine
|
||||||
|
|
||||||
|
|
||||||
|
def get_sessionmaker() -> sessionmaker[Session]:
|
||||||
|
global _SessionLocal
|
||||||
|
if _SessionLocal is None:
|
||||||
|
_SessionLocal = sessionmaker(bind=get_engine(), expire_on_commit=False, future=True)
|
||||||
|
return _SessionLocal
|
||||||
|
|
||||||
|
|
||||||
|
@contextmanager
|
||||||
|
def session_scope() -> Iterator[Session]:
|
||||||
|
"""Context manager that commits on success, rolls back on error."""
|
||||||
|
s = get_sessionmaker()()
|
||||||
|
try:
|
||||||
|
yield s
|
||||||
|
s.commit()
|
||||||
|
except Exception:
|
||||||
|
s.rollback()
|
||||||
|
raise
|
||||||
|
finally:
|
||||||
|
s.close()
|
||||||
27
backend/app/db/types.py
Normal file
27
backend/app/db/types.py
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
"""Shared enum-like string sets used across models.
|
||||||
|
|
||||||
|
Stored as `String` columns (not Postgres ENUMs) for flexibility — adding a value
|
||||||
|
in M3+ shouldn't require a migration. CHECK constraints validate the value set
|
||||||
|
at the DB level.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
# Roles a user is hinted with on a mission. Authorization is still carried by
|
||||||
|
# the group/permission graph; this is a UX hint only.
|
||||||
|
MISSION_ROLE_HINTS = ("red", "blue")
|
||||||
|
|
||||||
|
# Mission lifecycle.
|
||||||
|
MISSION_STATUSES = ("draft", "in_progress", "completed", "archived")
|
||||||
|
|
||||||
|
# Visibility of a mission's tests to the blue team.
|
||||||
|
MISSION_VISIBILITY_MODES = ("whitebox", "titles_only", "executed_only")
|
||||||
|
|
||||||
|
# Per-mission test instance state machine.
|
||||||
|
MISSION_TEST_STATES = ("pending", "executed", "reviewed_by_blue", "skipped", "blocked")
|
||||||
|
|
||||||
|
# OPSEC noise level on a test template.
|
||||||
|
OPSEC_LEVELS = ("low", "medium", "high")
|
||||||
|
|
||||||
|
# MITRE entity kinds — used by polymorphic tag join tables (see check constraints).
|
||||||
|
MITRE_KINDS = ("tactic", "technique", "subtechnique")
|
||||||
73
backend/app/models/__init__.py
Normal file
73
backend/app/models/__init__.py
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
"""ORM models — every module must be imported here so Alembic's autogenerate
|
||||||
|
can see them via `Base.metadata`.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from app.models.auth import (
|
||||||
|
Group,
|
||||||
|
GroupPermission,
|
||||||
|
Invitation,
|
||||||
|
InvitationGroup,
|
||||||
|
Permission,
|
||||||
|
RefreshToken,
|
||||||
|
User,
|
||||||
|
UserGroup,
|
||||||
|
)
|
||||||
|
from app.models.evidence import EvidenceFile
|
||||||
|
from app.models.mission import (
|
||||||
|
Mission,
|
||||||
|
MissionCategory,
|
||||||
|
MissionMember,
|
||||||
|
MissionScenario,
|
||||||
|
MissionTest,
|
||||||
|
MissionTestMitreTag,
|
||||||
|
)
|
||||||
|
from app.models.mitre import (
|
||||||
|
MitreSubtechnique,
|
||||||
|
MitreTactic,
|
||||||
|
MitreTechnique,
|
||||||
|
MitreTechniqueTactic,
|
||||||
|
)
|
||||||
|
from app.models.notification import Notification
|
||||||
|
from app.models.setting import DetectionLevel, Setting
|
||||||
|
from app.models.template import (
|
||||||
|
ScenarioTemplate,
|
||||||
|
ScenarioTemplateTest,
|
||||||
|
TestTemplate,
|
||||||
|
TestTemplateMitreTag,
|
||||||
|
)
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
# auth
|
||||||
|
"Group",
|
||||||
|
"GroupPermission",
|
||||||
|
"Invitation",
|
||||||
|
"InvitationGroup",
|
||||||
|
"Permission",
|
||||||
|
"RefreshToken",
|
||||||
|
"User",
|
||||||
|
"UserGroup",
|
||||||
|
# evidence
|
||||||
|
"EvidenceFile",
|
||||||
|
# mission
|
||||||
|
"Mission",
|
||||||
|
"MissionCategory",
|
||||||
|
"MissionMember",
|
||||||
|
"MissionScenario",
|
||||||
|
"MissionTest",
|
||||||
|
"MissionTestMitreTag",
|
||||||
|
# mitre
|
||||||
|
"MitreSubtechnique",
|
||||||
|
"MitreTactic",
|
||||||
|
"MitreTechnique",
|
||||||
|
"MitreTechniqueTactic",
|
||||||
|
# notification
|
||||||
|
"Notification",
|
||||||
|
# setting
|
||||||
|
"DetectionLevel",
|
||||||
|
"Setting",
|
||||||
|
# template
|
||||||
|
"ScenarioTemplate",
|
||||||
|
"ScenarioTemplateTest",
|
||||||
|
"TestTemplate",
|
||||||
|
"TestTemplateMitreTag",
|
||||||
|
]
|
||||||
188
backend/app/models/auth.py
Normal file
188
backend/app/models/auth.py
Normal file
@@ -0,0 +1,188 @@
|
|||||||
|
"""Auth + RBAC: users, groups, permissions, invitations, refresh tokens."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
from sqlalchemy import ForeignKey, Index, String, Text, UniqueConstraint, Uuid
|
||||||
|
from sqlalchemy import DateTime as SADateTime
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
|
|
||||||
|
from app.db.base import Base
|
||||||
|
from app.db.mixins import SoftDeleteMixin, TimestampMixin, UuidPkMixin
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from app.models.evidence import EvidenceFile
|
||||||
|
from app.models.notification import Notification
|
||||||
|
|
||||||
|
|
||||||
|
class User(Base, UuidPkMixin, TimestampMixin, SoftDeleteMixin):
|
||||||
|
__tablename__ = "users"
|
||||||
|
|
||||||
|
email: Mapped[str] = mapped_column(String(254), nullable=False)
|
||||||
|
password_hash: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||||
|
display_name: Mapped[str | None] = mapped_column(String(120), nullable=True)
|
||||||
|
locale: Mapped[str] = mapped_column(String(8), default="fr", nullable=False)
|
||||||
|
is_active: Mapped[bool] = mapped_column(default=True, nullable=False)
|
||||||
|
|
||||||
|
groups: Mapped[list["Group"]] = relationship(
|
||||||
|
secondary="user_groups",
|
||||||
|
back_populates="users",
|
||||||
|
lazy="selectin",
|
||||||
|
)
|
||||||
|
refresh_tokens: Mapped[list["RefreshToken"]] = relationship(
|
||||||
|
back_populates="user",
|
||||||
|
cascade="all, delete-orphan",
|
||||||
|
)
|
||||||
|
notifications: Mapped[list["Notification"]] = relationship(
|
||||||
|
back_populates="user",
|
||||||
|
cascade="all, delete-orphan",
|
||||||
|
)
|
||||||
|
uploaded_evidence: Mapped[list["EvidenceFile"]] = relationship(
|
||||||
|
back_populates="uploaded_by",
|
||||||
|
)
|
||||||
|
|
||||||
|
__table_args__ = (
|
||||||
|
# Email uniqueness scoped to non-deleted rows so an admin can re-invite
|
||||||
|
# a previously-soft-deleted user.
|
||||||
|
Index(
|
||||||
|
"uq_users_email_active",
|
||||||
|
"email",
|
||||||
|
unique=True,
|
||||||
|
postgresql_where="deleted_at IS NULL",
|
||||||
|
),
|
||||||
|
Index("ix_users_active", "deleted_at", postgresql_where="deleted_at IS NULL"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class Group(Base, UuidPkMixin, TimestampMixin, SoftDeleteMixin):
|
||||||
|
__tablename__ = "groups"
|
||||||
|
|
||||||
|
name: Mapped[str] = mapped_column(String(80), nullable=False)
|
||||||
|
description: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
# Built-in groups (admin/redteam/blueteam) are protected from deletion.
|
||||||
|
is_system: Mapped[bool] = mapped_column(default=False, nullable=False)
|
||||||
|
|
||||||
|
users: Mapped[list[User]] = relationship(
|
||||||
|
secondary="user_groups",
|
||||||
|
back_populates="groups",
|
||||||
|
)
|
||||||
|
permissions: Mapped[list["Permission"]] = relationship(
|
||||||
|
secondary="group_permissions",
|
||||||
|
back_populates="groups",
|
||||||
|
lazy="selectin",
|
||||||
|
)
|
||||||
|
|
||||||
|
__table_args__ = (
|
||||||
|
Index(
|
||||||
|
"uq_groups_name_active",
|
||||||
|
"name",
|
||||||
|
unique=True,
|
||||||
|
postgresql_where="deleted_at IS NULL",
|
||||||
|
),
|
||||||
|
Index("ix_groups_active", "deleted_at", postgresql_where="deleted_at IS NULL"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class Permission(Base, UuidPkMixin, TimestampMixin):
|
||||||
|
"""Atomic permission. Code follows the `<entity>.<action>` convention."""
|
||||||
|
|
||||||
|
__tablename__ = "permissions"
|
||||||
|
|
||||||
|
code: Mapped[str] = mapped_column(String(80), unique=True, nullable=False)
|
||||||
|
description: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
|
||||||
|
groups: Mapped[list[Group]] = relationship(
|
||||||
|
secondary="group_permissions",
|
||||||
|
back_populates="permissions",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class UserGroup(Base):
|
||||||
|
"""User ↔ Group join — no soft delete, just attach/detach."""
|
||||||
|
|
||||||
|
__tablename__ = "user_groups"
|
||||||
|
|
||||||
|
user_id: Mapped[uuid.UUID] = mapped_column(
|
||||||
|
Uuid(as_uuid=True), ForeignKey("users.id", ondelete="CASCADE"), primary_key=True
|
||||||
|
)
|
||||||
|
group_id: Mapped[uuid.UUID] = mapped_column(
|
||||||
|
Uuid(as_uuid=True), ForeignKey("groups.id", ondelete="CASCADE"), primary_key=True
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class GroupPermission(Base):
|
||||||
|
"""Group ↔ Permission join."""
|
||||||
|
|
||||||
|
__tablename__ = "group_permissions"
|
||||||
|
|
||||||
|
group_id: Mapped[uuid.UUID] = mapped_column(
|
||||||
|
Uuid(as_uuid=True), ForeignKey("groups.id", ondelete="CASCADE"), primary_key=True
|
||||||
|
)
|
||||||
|
permission_id: Mapped[uuid.UUID] = mapped_column(
|
||||||
|
Uuid(as_uuid=True), ForeignKey("permissions.id", ondelete="CASCADE"), primary_key=True
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class Invitation(Base, UuidPkMixin, TimestampMixin):
|
||||||
|
__tablename__ = "invitations"
|
||||||
|
|
||||||
|
# Hash of the URL token, never the token itself.
|
||||||
|
token_hash: Mapped[str] = mapped_column(String(128), unique=True, nullable=False)
|
||||||
|
email_hint: Mapped[str | None] = mapped_column(String(254), nullable=True)
|
||||||
|
created_by_user_id: Mapped[uuid.UUID] = mapped_column(
|
||||||
|
Uuid(as_uuid=True), ForeignKey("users.id", ondelete="RESTRICT"), nullable=False
|
||||||
|
)
|
||||||
|
expires_at: Mapped[datetime] = mapped_column(SADateTime(timezone=True), nullable=False)
|
||||||
|
consumed_at: Mapped[datetime | None] = mapped_column(SADateTime(timezone=True), nullable=True)
|
||||||
|
revoked_at: Mapped[datetime | None] = mapped_column(SADateTime(timezone=True), nullable=True)
|
||||||
|
consumed_by_user_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||||
|
Uuid(as_uuid=True), ForeignKey("users.id", ondelete="SET NULL"), nullable=True
|
||||||
|
)
|
||||||
|
|
||||||
|
pre_assigned_groups: Mapped[list[Group]] = relationship(
|
||||||
|
secondary="invitation_groups",
|
||||||
|
lazy="selectin",
|
||||||
|
)
|
||||||
|
|
||||||
|
__table_args__ = (Index("ix_invitations_expires_at", "expires_at"),)
|
||||||
|
|
||||||
|
|
||||||
|
class InvitationGroup(Base):
|
||||||
|
"""Pre-assigned groups attached to an invitation; applied at acceptance."""
|
||||||
|
|
||||||
|
__tablename__ = "invitation_groups"
|
||||||
|
|
||||||
|
invitation_id: Mapped[uuid.UUID] = mapped_column(
|
||||||
|
Uuid(as_uuid=True), ForeignKey("invitations.id", ondelete="CASCADE"), primary_key=True
|
||||||
|
)
|
||||||
|
group_id: Mapped[uuid.UUID] = mapped_column(
|
||||||
|
Uuid(as_uuid=True), ForeignKey("groups.id", ondelete="CASCADE"), primary_key=True
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class RefreshToken(Base, UuidPkMixin, TimestampMixin):
|
||||||
|
"""Long-lived refresh tokens. The hash, never the token, is stored."""
|
||||||
|
|
||||||
|
__tablename__ = "refresh_tokens"
|
||||||
|
|
||||||
|
user_id: Mapped[uuid.UUID] = mapped_column(
|
||||||
|
Uuid(as_uuid=True), ForeignKey("users.id", ondelete="CASCADE"), nullable=False
|
||||||
|
)
|
||||||
|
jti: Mapped[str] = mapped_column(String(64), nullable=False)
|
||||||
|
token_hash: Mapped[str] = mapped_column(String(128), nullable=False)
|
||||||
|
issued_at: Mapped[datetime] = mapped_column(SADateTime(timezone=True), nullable=False)
|
||||||
|
expires_at: Mapped[datetime] = mapped_column(SADateTime(timezone=True), nullable=False)
|
||||||
|
revoked_at: Mapped[datetime | None] = mapped_column(SADateTime(timezone=True), nullable=True)
|
||||||
|
replaced_by_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||||
|
Uuid(as_uuid=True), ForeignKey("refresh_tokens.id", ondelete="SET NULL"), nullable=True
|
||||||
|
)
|
||||||
|
|
||||||
|
user: Mapped[User] = relationship(back_populates="refresh_tokens")
|
||||||
|
|
||||||
|
__table_args__ = (
|
||||||
|
UniqueConstraint("jti", name="uq_refresh_tokens_jti"),
|
||||||
|
Index("ix_refresh_tokens_user_id_expires_at", "user_id", "expires_at"),
|
||||||
|
)
|
||||||
51
backend/app/models/evidence.py
Normal file
51
backend/app/models/evidence.py
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
"""Blue-team evidence files attached to a `mission_test`."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
from sqlalchemy import BigInteger, DateTime, ForeignKey, Index, String, Text, Uuid, func
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
|
|
||||||
|
from app.db.base import Base
|
||||||
|
from app.db.mixins import SoftDeleteMixin, TimestampMixin, UuidPkMixin
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from app.models.auth import User
|
||||||
|
|
||||||
|
|
||||||
|
class EvidenceFile(Base, UuidPkMixin, TimestampMixin, SoftDeleteMixin):
|
||||||
|
__tablename__ = "evidence_files"
|
||||||
|
|
||||||
|
mission_test_id: Mapped[uuid.UUID] = mapped_column(
|
||||||
|
Uuid(as_uuid=True),
|
||||||
|
ForeignKey("mission_tests.id", ondelete="CASCADE"),
|
||||||
|
nullable=False,
|
||||||
|
)
|
||||||
|
sha256: Mapped[str] = mapped_column(String(64), nullable=False)
|
||||||
|
mime: Mapped[str] = mapped_column(String(127), nullable=False)
|
||||||
|
size_bytes: Mapped[int] = mapped_column(BigInteger, nullable=False)
|
||||||
|
storage_path: Mapped[str] = mapped_column(Text, nullable=False)
|
||||||
|
original_filename: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||||
|
uploaded_by_user_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||||
|
Uuid(as_uuid=True),
|
||||||
|
ForeignKey("users.id", ondelete="SET NULL"),
|
||||||
|
nullable=True,
|
||||||
|
)
|
||||||
|
uploaded_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime(timezone=True), server_default=func.now(), nullable=False
|
||||||
|
)
|
||||||
|
|
||||||
|
uploaded_by: Mapped["User | None"] = relationship(back_populates="uploaded_evidence")
|
||||||
|
|
||||||
|
__table_args__ = (
|
||||||
|
Index("ix_evidence_files_mission_test", "mission_test_id"),
|
||||||
|
Index("ix_evidence_files_sha256", "sha256"),
|
||||||
|
Index(
|
||||||
|
"ix_evidence_files_active",
|
||||||
|
"deleted_at",
|
||||||
|
postgresql_where="deleted_at IS NULL",
|
||||||
|
),
|
||||||
|
)
|
||||||
316
backend/app/models/mission.py
Normal file
316
backend/app/models/mission.py
Normal file
@@ -0,0 +1,316 @@
|
|||||||
|
"""Missions and snapshots.
|
||||||
|
|
||||||
|
A `Mission` references members and a tree of snapshot rows:
|
||||||
|
mission ─< mission_scenarios ─< mission_tests ─< (red/blue annotations)
|
||||||
|
|
||||||
|
Snapshots copy template fields verbatim so editing a template doesn't drift
|
||||||
|
already-running missions. `source_*_template_id` keep a soft pointer for
|
||||||
|
analytics, but the source rows can be soft-deleted without breaking the mission.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
from datetime import date, datetime
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from sqlalchemy import (
|
||||||
|
ARRAY,
|
||||||
|
CheckConstraint,
|
||||||
|
Date,
|
||||||
|
DateTime,
|
||||||
|
ForeignKey,
|
||||||
|
Index,
|
||||||
|
Integer,
|
||||||
|
String,
|
||||||
|
Text,
|
||||||
|
UniqueConstraint,
|
||||||
|
Uuid,
|
||||||
|
)
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
|
|
||||||
|
from app.db.base import Base
|
||||||
|
from app.db.mixins import SoftDeleteMixin, TimestampMixin, UuidPkMixin
|
||||||
|
|
||||||
|
# DateTime is no longer needed since MissionMember now uses TimestampMixin.
|
||||||
|
# The remaining DateTime usages in MissionTest (executed_at) keep the import below.
|
||||||
|
from app.db.types import (
|
||||||
|
MISSION_ROLE_HINTS,
|
||||||
|
MISSION_STATUSES,
|
||||||
|
MISSION_TEST_STATES,
|
||||||
|
MISSION_VISIBILITY_MODES,
|
||||||
|
MITRE_KINDS,
|
||||||
|
OPSEC_LEVELS,
|
||||||
|
)
|
||||||
|
|
||||||
|
# `mission_test_mitre_tags` deliberately denormalises the MITRE labels so a
|
||||||
|
# mission's tags survive a MITRE re-sync that drops the original entry. The
|
||||||
|
# FK columns were removed in favour of frozen `mitre_external_id` + `mitre_name`
|
||||||
|
# snapshots — see spec §11 ("snapshot vs reference" risk).
|
||||||
|
|
||||||
|
|
||||||
|
class Mission(Base, UuidPkMixin, TimestampMixin, SoftDeleteMixin):
|
||||||
|
__tablename__ = "missions"
|
||||||
|
|
||||||
|
name: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||||
|
client_target: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||||
|
date_start: Mapped[date | None] = mapped_column(Date, nullable=True)
|
||||||
|
date_end: Mapped[date | None] = mapped_column(Date, nullable=True)
|
||||||
|
status: Mapped[str] = mapped_column(String(16), default="draft", nullable=False)
|
||||||
|
description_md: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
visibility_mode: Mapped[str] = mapped_column(
|
||||||
|
String(16), default="whitebox", nullable=False
|
||||||
|
)
|
||||||
|
|
||||||
|
members: Mapped[list["MissionMember"]] = relationship(
|
||||||
|
back_populates="mission",
|
||||||
|
cascade="all, delete-orphan",
|
||||||
|
)
|
||||||
|
scenarios: Mapped[list["MissionScenario"]] = relationship(
|
||||||
|
back_populates="mission",
|
||||||
|
cascade="all, delete-orphan",
|
||||||
|
order_by="MissionScenario.position",
|
||||||
|
)
|
||||||
|
categories: Mapped[list["MissionCategory"]] = relationship(
|
||||||
|
back_populates="mission",
|
||||||
|
cascade="all, delete-orphan",
|
||||||
|
order_by="MissionCategory.position",
|
||||||
|
)
|
||||||
|
|
||||||
|
__table_args__ = (
|
||||||
|
CheckConstraint(
|
||||||
|
f"status IN ({', '.join(repr(v) for v in MISSION_STATUSES)})",
|
||||||
|
name="status_valid",
|
||||||
|
),
|
||||||
|
CheckConstraint(
|
||||||
|
f"visibility_mode IN ({', '.join(repr(v) for v in MISSION_VISIBILITY_MODES)})",
|
||||||
|
name="visibility_mode_valid",
|
||||||
|
),
|
||||||
|
Index("ix_missions_active", "deleted_at", postgresql_where="deleted_at IS NULL"),
|
||||||
|
Index("ix_missions_status", "status"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class MissionMember(Base, TimestampMixin):
|
||||||
|
"""A user's membership in a mission with a hint about their team side."""
|
||||||
|
|
||||||
|
__tablename__ = "mission_members"
|
||||||
|
|
||||||
|
mission_id: Mapped[uuid.UUID] = mapped_column(
|
||||||
|
Uuid(as_uuid=True),
|
||||||
|
ForeignKey("missions.id", ondelete="CASCADE"),
|
||||||
|
primary_key=True,
|
||||||
|
)
|
||||||
|
user_id: Mapped[uuid.UUID] = mapped_column(
|
||||||
|
Uuid(as_uuid=True),
|
||||||
|
ForeignKey("users.id", ondelete="CASCADE"),
|
||||||
|
primary_key=True,
|
||||||
|
)
|
||||||
|
role_hint: Mapped[str] = mapped_column(String(8), nullable=False)
|
||||||
|
|
||||||
|
mission: Mapped[Mission] = relationship(back_populates="members")
|
||||||
|
|
||||||
|
__table_args__ = (
|
||||||
|
CheckConstraint(
|
||||||
|
f"role_hint IN ({', '.join(repr(v) for v in MISSION_ROLE_HINTS)})",
|
||||||
|
name="role_hint_valid",
|
||||||
|
),
|
||||||
|
Index("ix_mission_members_user", "user_id"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class MissionScenario(Base, UuidPkMixin, TimestampMixin, SoftDeleteMixin):
|
||||||
|
"""Snapshot of a `scenario_template` instantiated within a mission."""
|
||||||
|
|
||||||
|
__tablename__ = "mission_scenarios"
|
||||||
|
|
||||||
|
mission_id: Mapped[uuid.UUID] = mapped_column(
|
||||||
|
Uuid(as_uuid=True),
|
||||||
|
ForeignKey("missions.id", ondelete="CASCADE"),
|
||||||
|
nullable=False,
|
||||||
|
)
|
||||||
|
source_scenario_template_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||||
|
Uuid(as_uuid=True),
|
||||||
|
ForeignKey("scenario_templates.id", ondelete="SET NULL"),
|
||||||
|
nullable=True,
|
||||||
|
)
|
||||||
|
snapshot_name: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||||
|
snapshot_description: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
position: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||||
|
|
||||||
|
mission: Mapped[Mission] = relationship(back_populates="scenarios")
|
||||||
|
tests: Mapped[list["MissionTest"]] = relationship(
|
||||||
|
back_populates="scenario",
|
||||||
|
cascade="all, delete-orphan",
|
||||||
|
order_by="MissionTest.position",
|
||||||
|
)
|
||||||
|
|
||||||
|
__table_args__ = (
|
||||||
|
UniqueConstraint(
|
||||||
|
"mission_id", "position", name="uq_mission_scenarios_position"
|
||||||
|
),
|
||||||
|
Index("ix_mission_scenarios_mission", "mission_id"),
|
||||||
|
Index(
|
||||||
|
"ix_mission_scenarios_active",
|
||||||
|
"deleted_at",
|
||||||
|
postgresql_where="deleted_at IS NULL",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class MissionTest(Base, UuidPkMixin, TimestampMixin, SoftDeleteMixin):
|
||||||
|
"""Snapshot of a `test_template` + execution state + red/blue annotations."""
|
||||||
|
|
||||||
|
__tablename__ = "mission_tests"
|
||||||
|
|
||||||
|
scenario_id: Mapped[uuid.UUID] = mapped_column(
|
||||||
|
Uuid(as_uuid=True),
|
||||||
|
ForeignKey("mission_scenarios.id", ondelete="CASCADE"),
|
||||||
|
nullable=False,
|
||||||
|
)
|
||||||
|
source_test_template_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||||
|
Uuid(as_uuid=True),
|
||||||
|
ForeignKey("test_templates.id", ondelete="SET NULL"),
|
||||||
|
nullable=True,
|
||||||
|
)
|
||||||
|
position: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||||
|
|
||||||
|
# --- Snapshot of the template (immutable after creation) ---
|
||||||
|
snapshot_name: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||||
|
snapshot_description: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
snapshot_objective: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
snapshot_procedure_md: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
snapshot_prerequisites_md: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
snapshot_expected_red_md: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
snapshot_expected_blue_md: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
snapshot_opsec_level: Mapped[str] = mapped_column(
|
||||||
|
String(8), default="medium", nullable=False
|
||||||
|
)
|
||||||
|
snapshot_tags: Mapped[list[str]] = mapped_column(
|
||||||
|
ARRAY(String(64)), nullable=False, server_default="{}"
|
||||||
|
)
|
||||||
|
snapshot_expected_iocs: Mapped[list[str]] = mapped_column(
|
||||||
|
ARRAY(String(255)), nullable=False, server_default="{}"
|
||||||
|
)
|
||||||
|
|
||||||
|
# --- Execution state ---
|
||||||
|
state: Mapped[str] = mapped_column(String(24), default="pending", nullable=False)
|
||||||
|
executed_at: Mapped[datetime | None] = mapped_column(
|
||||||
|
DateTime(timezone=True), nullable=True
|
||||||
|
)
|
||||||
|
executed_at_overridden: Mapped[bool] = mapped_column(default=False, nullable=False)
|
||||||
|
|
||||||
|
# --- Red side (text-only per spec §4) ---
|
||||||
|
red_command: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
red_output: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
red_comment_md: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
|
||||||
|
# --- Blue side ---
|
||||||
|
blue_comment_md: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
detection_level_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||||
|
Uuid(as_uuid=True),
|
||||||
|
ForeignKey("detection_levels.id", ondelete="SET NULL"),
|
||||||
|
nullable=True,
|
||||||
|
)
|
||||||
|
category_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||||
|
Uuid(as_uuid=True),
|
||||||
|
ForeignKey("mission_categories.id", ondelete="SET NULL"),
|
||||||
|
nullable=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
scenario: Mapped[MissionScenario] = relationship(back_populates="tests")
|
||||||
|
mitre_tags: Mapped[list["MissionTestMitreTag"]] = relationship(
|
||||||
|
back_populates="mission_test",
|
||||||
|
cascade="all, delete-orphan",
|
||||||
|
lazy="selectin",
|
||||||
|
)
|
||||||
|
|
||||||
|
__table_args__ = (
|
||||||
|
CheckConstraint(
|
||||||
|
f"snapshot_opsec_level IN ({', '.join(repr(v) for v in OPSEC_LEVELS)})",
|
||||||
|
name="snapshot_opsec_level_valid",
|
||||||
|
),
|
||||||
|
CheckConstraint(
|
||||||
|
f"state IN ({', '.join(repr(v) for v in MISSION_TEST_STATES)})",
|
||||||
|
name="state_valid",
|
||||||
|
),
|
||||||
|
UniqueConstraint("scenario_id", "position", name="uq_mission_tests_position"),
|
||||||
|
Index("ix_mission_tests_state", "state"),
|
||||||
|
Index(
|
||||||
|
"ix_mission_tests_active",
|
||||||
|
"deleted_at",
|
||||||
|
postgresql_where="deleted_at IS NULL",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class MissionCategory(Base, UuidPkMixin, TimestampMixin, SoftDeleteMixin):
|
||||||
|
"""Optional custom grouping override for the slide synthesis."""
|
||||||
|
|
||||||
|
__tablename__ = "mission_categories"
|
||||||
|
|
||||||
|
mission_id: Mapped[uuid.UUID] = mapped_column(
|
||||||
|
Uuid(as_uuid=True),
|
||||||
|
ForeignKey("missions.id", ondelete="CASCADE"),
|
||||||
|
nullable=False,
|
||||||
|
)
|
||||||
|
name: Mapped[str] = mapped_column(String(120), nullable=False)
|
||||||
|
color_token: Mapped[str | None] = mapped_column(String(16), nullable=True)
|
||||||
|
position: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||||
|
|
||||||
|
mission: Mapped[Mission] = relationship(back_populates="categories")
|
||||||
|
|
||||||
|
__table_args__ = (
|
||||||
|
UniqueConstraint(
|
||||||
|
"mission_id", "position", name="uq_mission_categories_position"
|
||||||
|
),
|
||||||
|
UniqueConstraint("mission_id", "name", name="uq_mission_categories_name"),
|
||||||
|
Index(
|
||||||
|
"ix_mission_categories_active",
|
||||||
|
"deleted_at",
|
||||||
|
postgresql_where="deleted_at IS NULL",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class MissionTestMitreTag(Base):
|
||||||
|
"""Frozen MITRE tag attached to a mission test.
|
||||||
|
|
||||||
|
DELIBERATELY DENORMALISED — no FK to mitre_* tables. The MITRE
|
||||||
|
`external_id` and human label are copied at tag-creation time so that a
|
||||||
|
later MITRE re-sync that drops the original entry cannot purge or alter
|
||||||
|
a mission's tags. See spec §11 (snapshot vs reference).
|
||||||
|
|
||||||
|
The companion `test_template_mitre_tags` table keeps the FK relationship
|
||||||
|
because templates are editable and admins can re-tag them after a sync.
|
||||||
|
"""
|
||||||
|
|
||||||
|
__tablename__ = "mission_test_mitre_tags"
|
||||||
|
|
||||||
|
id: Mapped[uuid.UUID] = mapped_column(
|
||||||
|
Uuid(as_uuid=True), primary_key=True, default=uuid.uuid4, nullable=False
|
||||||
|
)
|
||||||
|
mission_test_id: Mapped[uuid.UUID] = mapped_column(
|
||||||
|
Uuid(as_uuid=True),
|
||||||
|
ForeignKey("mission_tests.id", ondelete="CASCADE"),
|
||||||
|
nullable=False,
|
||||||
|
)
|
||||||
|
mitre_kind: Mapped[str] = mapped_column(String(16), nullable=False)
|
||||||
|
mitre_external_id: Mapped[str] = mapped_column(String(16), nullable=False)
|
||||||
|
mitre_name: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||||
|
mitre_url: Mapped[str | None] = mapped_column(String(512), nullable=True)
|
||||||
|
|
||||||
|
mission_test: Mapped[MissionTest] = relationship(back_populates="mitre_tags")
|
||||||
|
|
||||||
|
__table_args__: Any = (
|
||||||
|
CheckConstraint(
|
||||||
|
f"mitre_kind IN ({', '.join(repr(v) for v in MITRE_KINDS)})",
|
||||||
|
name="mitre_kind_valid",
|
||||||
|
),
|
||||||
|
UniqueConstraint(
|
||||||
|
"mission_test_id",
|
||||||
|
"mitre_external_id",
|
||||||
|
name="uq_mission_test_mitre_tag",
|
||||||
|
),
|
||||||
|
Index("ix_mission_test_mitre_tags_test", "mission_test_id"),
|
||||||
|
)
|
||||||
86
backend/app/models/mitre.py
Normal file
86
backend/app/models/mitre.py
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
"""MITRE ATT&CK reference tables.
|
||||||
|
|
||||||
|
Read-mostly. Hard delete (no soft-delete) — replaced by the periodic sync job.
|
||||||
|
A technique can map to multiple tactics (kill_chain_phases in STIX) hence the
|
||||||
|
M2M `technique_tactics` join. Sub-techniques inherit their parent's tactics
|
||||||
|
through the parent technique.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
from sqlalchemy import ForeignKey, Index, String, Text, Uuid
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
|
|
||||||
|
from app.db.base import Base
|
||||||
|
from app.db.mixins import TimestampMixin, UuidPkMixin
|
||||||
|
|
||||||
|
|
||||||
|
class MitreTactic(Base, UuidPkMixin, TimestampMixin):
|
||||||
|
__tablename__ = "mitre_tactics"
|
||||||
|
|
||||||
|
external_id: Mapped[str] = mapped_column(String(16), unique=True, nullable=False)
|
||||||
|
short_name: Mapped[str] = mapped_column(String(80), nullable=False)
|
||||||
|
name: Mapped[str] = mapped_column(String(120), nullable=False)
|
||||||
|
description: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
url: Mapped[str | None] = mapped_column(String(512), nullable=True)
|
||||||
|
|
||||||
|
techniques: Mapped[list["MitreTechnique"]] = relationship(
|
||||||
|
secondary="mitre_technique_tactics",
|
||||||
|
back_populates="tactics",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class MitreTechnique(Base, UuidPkMixin, TimestampMixin):
|
||||||
|
__tablename__ = "mitre_techniques"
|
||||||
|
|
||||||
|
external_id: Mapped[str] = mapped_column(String(16), unique=True, nullable=False)
|
||||||
|
name: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||||
|
description: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
url: Mapped[str | None] = mapped_column(String(512), nullable=True)
|
||||||
|
|
||||||
|
tactics: Mapped[list[MitreTactic]] = relationship(
|
||||||
|
secondary="mitre_technique_tactics",
|
||||||
|
back_populates="techniques",
|
||||||
|
lazy="selectin",
|
||||||
|
)
|
||||||
|
subtechniques: Mapped[list["MitreSubtechnique"]] = relationship(
|
||||||
|
back_populates="technique",
|
||||||
|
cascade="all, delete-orphan",
|
||||||
|
)
|
||||||
|
|
||||||
|
__table_args__ = (Index("ix_mitre_techniques_name", "name"),)
|
||||||
|
|
||||||
|
|
||||||
|
class MitreSubtechnique(Base, UuidPkMixin, TimestampMixin):
|
||||||
|
__tablename__ = "mitre_subtechniques"
|
||||||
|
|
||||||
|
external_id: Mapped[str] = mapped_column(String(16), unique=True, nullable=False)
|
||||||
|
name: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||||
|
description: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
url: Mapped[str | None] = mapped_column(String(512), nullable=True)
|
||||||
|
technique_id: Mapped[uuid.UUID] = mapped_column(
|
||||||
|
Uuid(as_uuid=True), ForeignKey("mitre_techniques.id", ondelete="CASCADE"), nullable=False
|
||||||
|
)
|
||||||
|
|
||||||
|
technique: Mapped[MitreTechnique] = relationship(back_populates="subtechniques")
|
||||||
|
|
||||||
|
__table_args__ = (Index("ix_mitre_subtechniques_technique_id", "technique_id"),)
|
||||||
|
|
||||||
|
|
||||||
|
class MitreTechniqueTactic(Base):
|
||||||
|
"""Many-to-many: a technique can serve several tactics (STIX kill_chain_phases)."""
|
||||||
|
|
||||||
|
__tablename__ = "mitre_technique_tactics"
|
||||||
|
|
||||||
|
technique_id: Mapped[uuid.UUID] = mapped_column(
|
||||||
|
Uuid(as_uuid=True),
|
||||||
|
ForeignKey("mitre_techniques.id", ondelete="CASCADE"),
|
||||||
|
primary_key=True,
|
||||||
|
)
|
||||||
|
tactic_id: Mapped[uuid.UUID] = mapped_column(
|
||||||
|
Uuid(as_uuid=True),
|
||||||
|
ForeignKey("mitre_tactics.id", ondelete="CASCADE"),
|
||||||
|
primary_key=True,
|
||||||
|
)
|
||||||
41
backend/app/models/notification.py
Normal file
41
backend/app/models/notification.py
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
"""In-app notifications. Mail is out-of-scope for v1 (spec §4)."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Any, TYPE_CHECKING
|
||||||
|
|
||||||
|
from sqlalchemy import DateTime, ForeignKey, Index, String, Uuid
|
||||||
|
from sqlalchemy.dialects.postgresql import JSONB
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
|
|
||||||
|
from app.db.base import Base
|
||||||
|
from app.db.mixins import TimestampMixin, UuidPkMixin
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from app.models.auth import User
|
||||||
|
|
||||||
|
|
||||||
|
class Notification(Base, UuidPkMixin, TimestampMixin):
|
||||||
|
__tablename__ = "notifications"
|
||||||
|
|
||||||
|
user_id: Mapped[uuid.UUID] = mapped_column(
|
||||||
|
Uuid(as_uuid=True),
|
||||||
|
ForeignKey("users.id", ondelete="CASCADE"),
|
||||||
|
nullable=False,
|
||||||
|
)
|
||||||
|
type: Mapped[str] = mapped_column(String(64), nullable=False)
|
||||||
|
payload: Mapped[Any] = mapped_column(JSONB, nullable=False, server_default="{}")
|
||||||
|
read_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||||
|
|
||||||
|
user: Mapped["User"] = relationship(back_populates="notifications")
|
||||||
|
|
||||||
|
__table_args__ = (
|
||||||
|
Index(
|
||||||
|
"ix_notifications_user_unread",
|
||||||
|
"user_id",
|
||||||
|
"created_at",
|
||||||
|
postgresql_where="read_at IS NULL",
|
||||||
|
),
|
||||||
|
)
|
||||||
37
backend/app/models/setting.py
Normal file
37
backend/app/models/setting.py
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
"""Platform settings (key/value JSONB) and admin-defined detection levels."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from sqlalchemy import Integer, String, Text
|
||||||
|
from sqlalchemy.dialects.postgresql import JSONB
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
|
||||||
|
from app.db.base import Base
|
||||||
|
from app.db.mixins import TimestampMixin, UuidPkMixin
|
||||||
|
|
||||||
|
|
||||||
|
class Setting(Base, TimestampMixin):
|
||||||
|
__tablename__ = "settings"
|
||||||
|
|
||||||
|
key: Mapped[str] = mapped_column(String(80), primary_key=True)
|
||||||
|
value: Mapped[Any] = mapped_column(JSONB, nullable=False)
|
||||||
|
description: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
|
||||||
|
|
||||||
|
class DetectionLevel(Base, UuidPkMixin, TimestampMixin):
|
||||||
|
"""Custom taxonomy admin can edit (cf. spec §F6).
|
||||||
|
|
||||||
|
Pre-seeded with detected_blocked / detected_alert / logged_only / not_detected.
|
||||||
|
"""
|
||||||
|
|
||||||
|
__tablename__ = "detection_levels"
|
||||||
|
|
||||||
|
key: Mapped[str] = mapped_column(String(40), unique=True, nullable=False)
|
||||||
|
label_fr: Mapped[str] = mapped_column(String(80), nullable=False)
|
||||||
|
label_en: Mapped[str] = mapped_column(String(80), nullable=False)
|
||||||
|
color_token: Mapped[str] = mapped_column(String(16), nullable=False)
|
||||||
|
position: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||||
|
is_default: Mapped[bool] = mapped_column(default=False, nullable=False)
|
||||||
|
is_system: Mapped[bool] = mapped_column(default=False, nullable=False)
|
||||||
174
backend/app/models/template.py
Normal file
174
backend/app/models/template.py
Normal file
@@ -0,0 +1,174 @@
|
|||||||
|
"""Reusable templates: test_templates and scenario_templates.
|
||||||
|
|
||||||
|
A `mission_scenarios` row is a snapshot copy of a `scenario_templates` row at
|
||||||
|
mission-creation time. Templates can therefore be edited freely without
|
||||||
|
disturbing already-running missions.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from sqlalchemy import (
|
||||||
|
ARRAY,
|
||||||
|
CheckConstraint,
|
||||||
|
ForeignKey,
|
||||||
|
Index,
|
||||||
|
Integer,
|
||||||
|
String,
|
||||||
|
Text,
|
||||||
|
UniqueConstraint,
|
||||||
|
Uuid,
|
||||||
|
)
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
|
|
||||||
|
from app.db.base import Base
|
||||||
|
from app.db.mixins import SoftDeleteMixin, TimestampMixin, UuidPkMixin
|
||||||
|
from app.db.types import MITRE_KINDS, OPSEC_LEVELS
|
||||||
|
|
||||||
|
|
||||||
|
class TestTemplate(Base, UuidPkMixin, TimestampMixin, SoftDeleteMixin):
|
||||||
|
__tablename__ = "test_templates"
|
||||||
|
|
||||||
|
name: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||||
|
description: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
objective: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
procedure_md: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
prerequisites_md: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
expected_result_red_md: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
expected_detection_blue_md: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
opsec_level: Mapped[str] = mapped_column(String(8), default="medium", nullable=False)
|
||||||
|
tags: Mapped[list[str]] = mapped_column(
|
||||||
|
ARRAY(String(64)), nullable=False, server_default="{}"
|
||||||
|
)
|
||||||
|
expected_iocs: Mapped[list[str]] = mapped_column(
|
||||||
|
ARRAY(String(255)), nullable=False, server_default="{}"
|
||||||
|
)
|
||||||
|
|
||||||
|
mitre_tags: Mapped[list["TestTemplateMitreTag"]] = relationship(
|
||||||
|
back_populates="test_template",
|
||||||
|
cascade="all, delete-orphan",
|
||||||
|
lazy="selectin",
|
||||||
|
)
|
||||||
|
|
||||||
|
__table_args__ = (
|
||||||
|
CheckConstraint(
|
||||||
|
f"opsec_level IN ({', '.join(repr(v) for v in OPSEC_LEVELS)})",
|
||||||
|
name="opsec_level_valid",
|
||||||
|
),
|
||||||
|
Index("ix_test_templates_active", "deleted_at", postgresql_where="deleted_at IS NULL"),
|
||||||
|
Index("ix_test_templates_name", "name"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestTemplateMitreTag(Base):
|
||||||
|
"""Polymorphic MITRE tag on a test template.
|
||||||
|
|
||||||
|
Exactly one of `tactic_id`, `technique_id`, `subtechnique_id` is set —
|
||||||
|
enforced by the CHECK constraint. This keeps FK integrity per MITRE level
|
||||||
|
while letting a single conceptual table answer "what's tagged on this test".
|
||||||
|
"""
|
||||||
|
|
||||||
|
__tablename__ = "test_template_mitre_tags"
|
||||||
|
|
||||||
|
id: Mapped[uuid.UUID] = mapped_column(
|
||||||
|
Uuid(as_uuid=True), primary_key=True, default=uuid.uuid4, nullable=False
|
||||||
|
)
|
||||||
|
test_template_id: Mapped[uuid.UUID] = mapped_column(
|
||||||
|
Uuid(as_uuid=True),
|
||||||
|
ForeignKey("test_templates.id", ondelete="CASCADE"),
|
||||||
|
nullable=False,
|
||||||
|
)
|
||||||
|
mitre_kind: Mapped[str] = mapped_column(String(16), nullable=False)
|
||||||
|
tactic_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||||
|
Uuid(as_uuid=True), ForeignKey("mitre_tactics.id", ondelete="CASCADE"), nullable=True
|
||||||
|
)
|
||||||
|
technique_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||||
|
Uuid(as_uuid=True), ForeignKey("mitre_techniques.id", ondelete="CASCADE"), nullable=True
|
||||||
|
)
|
||||||
|
subtechnique_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||||
|
Uuid(as_uuid=True),
|
||||||
|
ForeignKey("mitre_subtechniques.id", ondelete="CASCADE"),
|
||||||
|
nullable=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
test_template: Mapped[TestTemplate] = relationship(back_populates="mitre_tags")
|
||||||
|
|
||||||
|
__table_args__: Any = (
|
||||||
|
CheckConstraint(
|
||||||
|
f"mitre_kind IN ({', '.join(repr(v) for v in MITRE_KINDS)})",
|
||||||
|
name="mitre_kind_valid",
|
||||||
|
),
|
||||||
|
CheckConstraint(
|
||||||
|
"(CASE WHEN tactic_id IS NOT NULL THEN 1 ELSE 0 END) "
|
||||||
|
"+ (CASE WHEN technique_id IS NOT NULL THEN 1 ELSE 0 END) "
|
||||||
|
"+ (CASE WHEN subtechnique_id IS NOT NULL THEN 1 ELSE 0 END) = 1",
|
||||||
|
name="exactly_one_mitre_fk",
|
||||||
|
),
|
||||||
|
UniqueConstraint(
|
||||||
|
"test_template_id",
|
||||||
|
"tactic_id",
|
||||||
|
"technique_id",
|
||||||
|
"subtechnique_id",
|
||||||
|
name="uq_test_template_mitre_tag",
|
||||||
|
),
|
||||||
|
Index("ix_test_template_mitre_tags_template", "test_template_id"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class ScenarioTemplate(Base, UuidPkMixin, TimestampMixin, SoftDeleteMixin):
|
||||||
|
__tablename__ = "scenario_templates"
|
||||||
|
|
||||||
|
name: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||||
|
description: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
|
||||||
|
tests: Mapped[list["ScenarioTemplateTest"]] = relationship(
|
||||||
|
back_populates="scenario_template",
|
||||||
|
cascade="all, delete-orphan",
|
||||||
|
order_by="ScenarioTemplateTest.position",
|
||||||
|
)
|
||||||
|
|
||||||
|
__table_args__ = (
|
||||||
|
Index(
|
||||||
|
"ix_scenario_templates_active",
|
||||||
|
"deleted_at",
|
||||||
|
postgresql_where="deleted_at IS NULL",
|
||||||
|
),
|
||||||
|
Index("ix_scenario_templates_name", "name"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class ScenarioTemplateTest(Base, UuidPkMixin):
|
||||||
|
"""Ordered membership of a test template inside a scenario template.
|
||||||
|
|
||||||
|
UUID PK + UNIQUE(scenario_template_id, position) lets the same test appear
|
||||||
|
multiple times at different positions (chained operations are common in
|
||||||
|
purple-team scenarios).
|
||||||
|
"""
|
||||||
|
|
||||||
|
__tablename__ = "scenario_template_tests"
|
||||||
|
|
||||||
|
scenario_template_id: Mapped[uuid.UUID] = mapped_column(
|
||||||
|
Uuid(as_uuid=True),
|
||||||
|
ForeignKey("scenario_templates.id", ondelete="CASCADE"),
|
||||||
|
nullable=False,
|
||||||
|
)
|
||||||
|
test_template_id: Mapped[uuid.UUID] = mapped_column(
|
||||||
|
Uuid(as_uuid=True),
|
||||||
|
ForeignKey("test_templates.id", ondelete="RESTRICT"),
|
||||||
|
nullable=False,
|
||||||
|
)
|
||||||
|
position: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||||
|
|
||||||
|
scenario_template: Mapped[ScenarioTemplate] = relationship(back_populates="tests")
|
||||||
|
|
||||||
|
__table_args__ = (
|
||||||
|
UniqueConstraint(
|
||||||
|
"scenario_template_id",
|
||||||
|
"position",
|
||||||
|
name="uq_scenario_template_tests_position",
|
||||||
|
),
|
||||||
|
Index("ix_scenario_template_tests_scenario", "scenario_template_id"),
|
||||||
|
Index("ix_scenario_template_tests_test", "test_template_id"),
|
||||||
|
)
|
||||||
0
backend/tests/__init__.py
Normal file
0
backend/tests/__init__.py
Normal file
25
backend/tests/conftest.py
Normal file
25
backend/tests/conftest.py
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
"""Shared pytest fixtures.
|
||||||
|
|
||||||
|
The DB integration tests need a reachable Postgres on the URL configured in
|
||||||
|
`app.core.config.settings`. They are skipped automatically when the DB isn't up,
|
||||||
|
so unit tests still pass on a developer's bare laptop.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from sqlalchemy.exc import OperationalError
|
||||||
|
|
||||||
|
from app.db.session import get_engine
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="session")
|
||||||
|
def db_engine_or_skip():
|
||||||
|
"""Yield the SQLAlchemy engine, skipping the test if the DB is unreachable."""
|
||||||
|
engine = get_engine()
|
||||||
|
try:
|
||||||
|
with engine.connect() as conn:
|
||||||
|
conn.execute.__self__ # touch the connection
|
||||||
|
except OperationalError as e:
|
||||||
|
pytest.skip(f"Postgres unreachable: {e}", allow_module_level=False)
|
||||||
|
return engine
|
||||||
14
backend/tests/test_health.py
Normal file
14
backend/tests/test_health.py
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
"""M0 smoke test: the /api/v1/health endpoint returns 200 and the expected payload."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from app.main import app
|
||||||
|
|
||||||
|
|
||||||
|
def test_health_returns_ok():
|
||||||
|
client = app.test_client()
|
||||||
|
resp = client.get("/api/v1/health")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
body = resp.get_json()
|
||||||
|
assert body["status"] == "ok"
|
||||||
|
assert "version" in body
|
||||||
234
backend/tests/test_schema.py
Normal file
234
backend/tests/test_schema.py
Normal file
@@ -0,0 +1,234 @@
|
|||||||
|
"""M1 schema integration test.
|
||||||
|
|
||||||
|
Asserts that the migration has produced the expected tables, FK relations,
|
||||||
|
CHECK constraints, partial indexes, and that the alembic_version row is at head.
|
||||||
|
Skips automatically when Postgres is unreachable.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from sqlalchemy import inspect, text
|
||||||
|
|
||||||
|
EXPECTED_TABLES = {
|
||||||
|
# Auth / RBAC
|
||||||
|
"users",
|
||||||
|
"groups",
|
||||||
|
"permissions",
|
||||||
|
"user_groups",
|
||||||
|
"group_permissions",
|
||||||
|
"invitations",
|
||||||
|
"invitation_groups",
|
||||||
|
"refresh_tokens",
|
||||||
|
# MITRE
|
||||||
|
"mitre_tactics",
|
||||||
|
"mitre_techniques",
|
||||||
|
"mitre_subtechniques",
|
||||||
|
"mitre_technique_tactics",
|
||||||
|
# Templates
|
||||||
|
"test_templates",
|
||||||
|
"test_template_mitre_tags",
|
||||||
|
"scenario_templates",
|
||||||
|
"scenario_template_tests",
|
||||||
|
# Missions
|
||||||
|
"missions",
|
||||||
|
"mission_members",
|
||||||
|
"mission_scenarios",
|
||||||
|
"mission_tests",
|
||||||
|
"mission_test_mitre_tags",
|
||||||
|
"mission_categories",
|
||||||
|
# Evidence / settings / notifications
|
||||||
|
"evidence_files",
|
||||||
|
"settings",
|
||||||
|
"detection_levels",
|
||||||
|
"notifications",
|
||||||
|
# Alembic bookkeeping
|
||||||
|
"alembic_version",
|
||||||
|
}
|
||||||
|
|
||||||
|
# Tables that MUST carry a `deleted_at` column (soft delete).
|
||||||
|
SOFT_DELETE_TABLES = {
|
||||||
|
"users",
|
||||||
|
"groups",
|
||||||
|
"test_templates",
|
||||||
|
"scenario_templates",
|
||||||
|
"missions",
|
||||||
|
"mission_scenarios",
|
||||||
|
"mission_tests",
|
||||||
|
"mission_categories",
|
||||||
|
"evidence_files",
|
||||||
|
}
|
||||||
|
|
||||||
|
# Tables that MUST carry the standard `created_at` + `updated_at` pair.
|
||||||
|
TIMESTAMP_TABLES = {
|
||||||
|
"users",
|
||||||
|
"groups",
|
||||||
|
"test_templates",
|
||||||
|
"scenario_templates",
|
||||||
|
"missions",
|
||||||
|
"mission_scenarios",
|
||||||
|
"mission_tests",
|
||||||
|
"mission_categories",
|
||||||
|
"evidence_files",
|
||||||
|
}
|
||||||
|
|
||||||
|
# Spot-checked FK pairs (child_table, child_col, parent_table).
|
||||||
|
EXPECTED_FKS = {
|
||||||
|
("evidence_files", "mission_test_id", "mission_tests"),
|
||||||
|
("evidence_files", "uploaded_by_user_id", "users"),
|
||||||
|
("mission_members", "mission_id", "missions"),
|
||||||
|
("mission_members", "user_id", "users"),
|
||||||
|
("mission_scenarios", "mission_id", "missions"),
|
||||||
|
("mission_tests", "scenario_id", "mission_scenarios"),
|
||||||
|
("mission_tests", "detection_level_id", "detection_levels"),
|
||||||
|
("group_permissions", "group_id", "groups"),
|
||||||
|
("group_permissions", "permission_id", "permissions"),
|
||||||
|
("user_groups", "user_id", "users"),
|
||||||
|
("user_groups", "group_id", "groups"),
|
||||||
|
("refresh_tokens", "user_id", "users"),
|
||||||
|
("notifications", "user_id", "users"),
|
||||||
|
("mitre_subtechniques", "technique_id", "mitre_techniques"),
|
||||||
|
}
|
||||||
|
|
||||||
|
# CHECK constraint names we expect to see (namespace 'public' only).
|
||||||
|
# `mission_test_mitre_tags` deliberately lacks the exactly_one_mitre_fk check
|
||||||
|
# because it is denormalised — see app/models/mission.py docstring.
|
||||||
|
EXPECTED_CHECKS = {
|
||||||
|
"ck_missions_status_valid",
|
||||||
|
"ck_missions_visibility_mode_valid",
|
||||||
|
"ck_mission_tests_state_valid",
|
||||||
|
"ck_mission_tests_snapshot_opsec_level_valid",
|
||||||
|
"ck_test_templates_opsec_level_valid",
|
||||||
|
"ck_mission_members_role_hint_valid",
|
||||||
|
"ck_test_template_mitre_tags_mitre_kind_valid",
|
||||||
|
"ck_test_template_mitre_tags_exactly_one_mitre_fk",
|
||||||
|
"ck_mission_test_mitre_tags_mitre_kind_valid",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="module")
|
||||||
|
def insp(db_engine_or_skip):
|
||||||
|
return inspect(db_engine_or_skip)
|
||||||
|
|
||||||
|
|
||||||
|
def test_all_expected_tables_exist(insp):
|
||||||
|
actual = set(insp.get_table_names(schema="public"))
|
||||||
|
missing = EXPECTED_TABLES - actual
|
||||||
|
assert not missing, f"missing tables: {sorted(missing)}"
|
||||||
|
|
||||||
|
|
||||||
|
def test_soft_delete_columns_present(insp):
|
||||||
|
for tbl in sorted(SOFT_DELETE_TABLES):
|
||||||
|
cols = {c["name"] for c in insp.get_columns(tbl)}
|
||||||
|
assert "deleted_at" in cols, f"{tbl} missing deleted_at"
|
||||||
|
|
||||||
|
|
||||||
|
def test_standard_timestamp_columns_present(insp):
|
||||||
|
for tbl in sorted(TIMESTAMP_TABLES):
|
||||||
|
cols = {c["name"] for c in insp.get_columns(tbl)}
|
||||||
|
assert "created_at" in cols, f"{tbl} missing created_at"
|
||||||
|
assert "updated_at" in cols, f"{tbl} missing updated_at"
|
||||||
|
|
||||||
|
|
||||||
|
def test_partial_index_for_soft_delete(db_engine_or_skip):
|
||||||
|
"""Each soft-delete table must carry an `ix_<table>_active` partial index."""
|
||||||
|
with db_engine_or_skip.connect() as conn:
|
||||||
|
rows = conn.execute(
|
||||||
|
text(
|
||||||
|
"SELECT indexname FROM pg_indexes "
|
||||||
|
"WHERE schemaname='public' AND indexdef ILIKE '%deleted_at IS NULL%'"
|
||||||
|
)
|
||||||
|
).all()
|
||||||
|
names = {r[0] for r in rows}
|
||||||
|
for tbl in SOFT_DELETE_TABLES:
|
||||||
|
assert f"ix_{tbl}_active" in names, f"{tbl}: partial index missing — got {names}"
|
||||||
|
|
||||||
|
|
||||||
|
def test_expected_foreign_keys(insp):
|
||||||
|
all_fks = set()
|
||||||
|
for tbl in EXPECTED_TABLES:
|
||||||
|
if tbl == "alembic_version":
|
||||||
|
continue
|
||||||
|
for fk in insp.get_foreign_keys(tbl):
|
||||||
|
for col in fk["constrained_columns"]:
|
||||||
|
all_fks.add((tbl, col, fk["referred_table"]))
|
||||||
|
for triple in EXPECTED_FKS:
|
||||||
|
assert triple in all_fks, f"missing FK: {triple}"
|
||||||
|
|
||||||
|
|
||||||
|
def test_expected_check_constraints(db_engine_or_skip):
|
||||||
|
with db_engine_or_skip.connect() as conn:
|
||||||
|
rows = conn.execute(
|
||||||
|
text(
|
||||||
|
"SELECT conname FROM pg_constraint "
|
||||||
|
"WHERE contype='c' AND connamespace = 'public'::regnamespace"
|
||||||
|
)
|
||||||
|
).all()
|
||||||
|
names = {r[0] for r in rows}
|
||||||
|
missing = EXPECTED_CHECKS - names
|
||||||
|
assert not missing, f"missing CHECK constraints: {sorted(missing)}"
|
||||||
|
|
||||||
|
|
||||||
|
def test_alembic_at_head(db_engine_or_skip):
|
||||||
|
"""The DB must be at the latest migration after `make migrate`."""
|
||||||
|
with db_engine_or_skip.connect() as conn:
|
||||||
|
rev = conn.execute(text("SELECT version_num FROM alembic_version")).scalar_one()
|
||||||
|
assert rev, "alembic_version is empty — migrate didn't run"
|
||||||
|
assert len(rev) >= 8, f"unexpected alembic version: {rev}"
|
||||||
|
|
||||||
|
|
||||||
|
def test_exactly_one_mitre_fk_check_enforced(db_engine_or_skip):
|
||||||
|
"""Inserting a tag with two non-null FKs must raise (CHECK constraint)."""
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
from sqlalchemy.exc import IntegrityError
|
||||||
|
|
||||||
|
with db_engine_or_skip.begin() as conn:
|
||||||
|
# Seed a minimal test_template + tactic + technique to reference.
|
||||||
|
tmpl_id = uuid.uuid4()
|
||||||
|
tactic_id = uuid.uuid4()
|
||||||
|
technique_id = uuid.uuid4()
|
||||||
|
conn.execute(
|
||||||
|
text(
|
||||||
|
"INSERT INTO test_templates (id, name, opsec_level) "
|
||||||
|
"VALUES (:id, 'tmp', 'low')"
|
||||||
|
),
|
||||||
|
{"id": tmpl_id},
|
||||||
|
)
|
||||||
|
conn.execute(
|
||||||
|
text(
|
||||||
|
"INSERT INTO mitre_tactics (id, external_id, short_name, name) "
|
||||||
|
"VALUES (:id, 'TA0099', 'tmp', 'tmp')"
|
||||||
|
),
|
||||||
|
{"id": tactic_id},
|
||||||
|
)
|
||||||
|
conn.execute(
|
||||||
|
text(
|
||||||
|
"INSERT INTO mitre_techniques (id, external_id, name) "
|
||||||
|
"VALUES (:id, 'T9999', 'tmp')"
|
||||||
|
),
|
||||||
|
{"id": technique_id},
|
||||||
|
)
|
||||||
|
|
||||||
|
# Now try to insert a violating row — must fail.
|
||||||
|
with pytest.raises(IntegrityError):
|
||||||
|
with db_engine_or_skip.begin() as conn:
|
||||||
|
conn.execute(
|
||||||
|
text(
|
||||||
|
"INSERT INTO test_template_mitre_tags "
|
||||||
|
"(id, test_template_id, mitre_kind, tactic_id, technique_id) "
|
||||||
|
"VALUES (:id, :tmpl, 'tactic', :tac, :tech)"
|
||||||
|
),
|
||||||
|
{
|
||||||
|
"id": uuid.uuid4(),
|
||||||
|
"tmpl": tmpl_id,
|
||||||
|
"tac": tactic_id,
|
||||||
|
"tech": technique_id,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
# Cleanup so the test is rerunnable.
|
||||||
|
with db_engine_or_skip.begin() as conn:
|
||||||
|
conn.execute(text("DELETE FROM mitre_techniques WHERE id = :id"), {"id": technique_id})
|
||||||
|
conn.execute(text("DELETE FROM mitre_tactics WHERE id = :id"), {"id": tactic_id})
|
||||||
|
conn.execute(text("DELETE FROM test_templates WHERE id = :id"), {"id": tmpl_id})
|
||||||
50
e2e/tests/m1-db.spec.ts
Normal file
50
e2e/tests/m1-db.spec.ts
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
import { expect, test } from '@playwright/test';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* M1 — DB schema visibility checks.
|
||||||
|
* Validates that the diagnostic endpoint reflects an applied migration and
|
||||||
|
* that the SPA renders the resulting state in the Database card.
|
||||||
|
*/
|
||||||
|
|
||||||
|
test.describe('M1 — DB schema', () => {
|
||||||
|
test('GET /api/v1/diag/db returns alembic revision and table count', async ({ request }) => {
|
||||||
|
const resp = await request.get('/api/v1/diag/db');
|
||||||
|
expect(resp.status()).toBe(200);
|
||||||
|
const body = (await resp.json()) as {
|
||||||
|
reachable: boolean;
|
||||||
|
alembic_revision: string | null;
|
||||||
|
table_count: number;
|
||||||
|
};
|
||||||
|
expect(body.reachable).toBe(true);
|
||||||
|
expect(body.alembic_revision).toMatch(/^[0-9a-f]{8,}$/);
|
||||||
|
// 26 application tables + alembic_version. Allow ≥26 to be tolerant of future migrations.
|
||||||
|
expect(body.table_count).toBeGreaterThanOrEqual(26);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Database card shows the revision short-hash and the table count', async ({ page }) => {
|
||||||
|
await page.goto('/');
|
||||||
|
const dbCard = page.locator('h3', { hasText: /^Database$/ }).locator('..');
|
||||||
|
// Wait for the probing state to resolve.
|
||||||
|
await expect(dbCard).toContainText(/revision\s+[0-9a-f]{8}/i, { timeout: 10_000 });
|
||||||
|
|
||||||
|
const count = await dbCard.getByTestId('db-table-count').innerText();
|
||||||
|
expect(Number(count)).toBeGreaterThanOrEqual(26);
|
||||||
|
|
||||||
|
await expect(dbCard).toContainText('Alembic head reached');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Roadmap card reflects M1 done', async ({ page }) => {
|
||||||
|
await page.goto('/');
|
||||||
|
const roadmap = page.locator('h3', { hasText: /^Roadmap$/ }).locator('..');
|
||||||
|
// Tolerate trailing "+ M2 done" / "+ M3 done" — the contract is "M1 is past, next is named".
|
||||||
|
await expect(roadmap).toContainText(/M1.*done/i);
|
||||||
|
await expect(roadmap).toContainText(/Next:/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Footer mentions M1', async ({ page }) => {
|
||||||
|
await page.goto('/');
|
||||||
|
const footer = page.locator('footer');
|
||||||
|
await expect(footer).toContainText(/M0\s+bootstrap/i);
|
||||||
|
await expect(footer).toContainText(/M1\s+db\s+schema/i);
|
||||||
|
});
|
||||||
|
});
|
||||||
218
tasks/testing-m1.md
Normal file
218
tasks/testing-m1.md
Normal file
@@ -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_<table>_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)
|
||||||
|
```
|
||||||
Reference in New Issue
Block a user