Milestone 3

This commit is contained in:
Knacky
2026-05-11 06:05:27 +02:00
commit 4c25e198fc
125 changed files with 13489 additions and 0 deletions

8
backend/.dockerignore Normal file
View File

@@ -0,0 +1,8 @@
__pycache__/
*.pyc
.pytest_cache/
.ruff_cache/
.venv/
.env
.env.*
!.env.example

85
backend/Dockerfile Normal file
View File

@@ -0,0 +1,85 @@
# syntax=docker/dockerfile:1.7
# === Stage 1: install deps with uv ===
FROM docker.io/library/python:3.12-slim AS deps
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1 \
PIP_DISABLE_PIP_VERSION_CHECK=1
# Install uv (fast, reproducible Python package manager)
RUN apt-get update && apt-get install -y --no-install-recommends curl ca-certificates \
&& rm -rf /var/lib/apt/lists/* \
&& curl -LsSf https://astral.sh/uv/install.sh | sh \
&& ln -s /root/.local/bin/uv /usr/local/bin/uv
WORKDIR /app
COPY pyproject.toml ./
# Resolve & install deps into a dedicated venv. After running `uv lock` locally,
# switch this to `uv sync --frozen --no-dev` for fully reproducible builds.
RUN uv venv /opt/venv \
&& uv pip install --python /opt/venv/bin/python --no-cache .
# === Stage 2: runtime ===
FROM docker.io/library/python:3.12-slim AS runtime
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1 \
PATH="/opt/venv/bin:$PATH"
# Non-root user
RUN groupadd --gid 10001 metamorph \
&& useradd --uid 10001 --gid metamorph --shell /usr/sbin/nologin --create-home metamorph \
&& mkdir -p /data/evidence \
&& chown -R metamorph:metamorph /data
COPY --from=deps /opt/venv /opt/venv
WORKDIR /app
COPY --chown=metamorph:metamorph app ./app
COPY --chown=metamorph:metamorph alembic ./alembic
COPY --chown=metamorph:metamorph alembic.ini pyproject.toml ./
USER metamorph
EXPOSE 8000
# Healthcheck hits the local API.
HEALTHCHECK --interval=30s --timeout=3s --start-period=10s --retries=3 \
CMD python -c "import urllib.request,sys; \
sys.exit(0 if urllib.request.urlopen('http://127.0.0.1:8000/api/v1/health',timeout=2).status==200 else 1)"
CMD ["gunicorn", "app.main:app", \
"--bind", "0.0.0.0:8000", \
"--workers", "2", \
"--threads", "4", \
"--access-logfile", "-", \
"--error-logfile", "-", \
"--log-level", "info"]
# === Stage 3: test image — runtime deps + dev extras + tests dir ===
# Built only when explicitly targeted (`build --target test`). Not used in prod.
FROM docker.io/library/python:3.12-slim AS test
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1 \
PATH="/opt/venv/bin:$PATH"
RUN apt-get update && apt-get install -y --no-install-recommends curl ca-certificates \
&& rm -rf /var/lib/apt/lists/* \
&& curl -LsSf https://astral.sh/uv/install.sh | sh \
&& ln -s /root/.local/bin/uv /usr/local/bin/uv
COPY --from=deps /opt/venv /opt/venv
WORKDIR /app
COPY pyproject.toml ./
# Install the dev extras (pytest, ruff, httpx) on top of the runtime venv.
RUN uv pip install --python /opt/venv/bin/python --no-cache ".[dev]"
COPY app ./app
COPY alembic ./alembic
COPY alembic.ini ./
COPY tests ./tests
CMD ["python", "-m", "pytest", "tests", "-v"]

39
backend/README.md Normal file
View File

@@ -0,0 +1,39 @@
# Metamorph backend
Flask 3 API. See repo root `README.md` for the big picture.
## Layout
```
app/
├── api/ # HTTP layer (blueprints), versioned under /api/v1
├── core/ # config (env-driven), structured logging
├── db/ # SQLAlchemy session + Alembic (M1+)
├── models/ # ORM models (M1+)
├── services/ # domain logic (M2+)
└── i18n/ # message catalogs (M13)
tests/ # pytest
```
## Local dev
Requires [uv](https://github.com/astral-sh/uv) and a reachable Postgres (M1+; not needed yet for `/health`).
```bash
uv sync # install deps from pyproject.toml
uv run flask --app app.main run --debug --port 8000
curl http://localhost:8000/api/v1/health
```
## Tests
```bash
uv run pytest
```
## Lint
```bash
uv run ruff check .
uv run ruff format .
```

49
backend/alembic.ini Normal file
View 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
View 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()

View 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"}

View File

View File

@@ -0,0 +1,446 @@
"""initial schema
Revision ID: 24765a5014b6
Revises:
Create Date: 2026-05-10 10:40:31.816149
"""
from __future__ import annotations
from collections.abc import Sequence
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
# revision identifiers, used by Alembic.
revision: str = '24765a5014b6'
down_revision: str | None = None
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('detection_levels',
sa.Column('key', sa.String(length=40), nullable=False),
sa.Column('label_fr', sa.String(length=80), nullable=False),
sa.Column('label_en', sa.String(length=80), nullable=False),
sa.Column('color_token', sa.String(length=16), nullable=False),
sa.Column('position', sa.Integer(), nullable=False),
sa.Column('is_default', sa.Boolean(), nullable=False),
sa.Column('is_system', sa.Boolean(), nullable=False),
sa.Column('id', sa.Uuid(), nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.PrimaryKeyConstraint('id', name=op.f('pk_detection_levels')),
sa.UniqueConstraint('key', name=op.f('uq_detection_levels_key'))
)
op.create_table('groups',
sa.Column('name', sa.String(length=80), nullable=False),
sa.Column('description', sa.Text(), nullable=True),
sa.Column('is_system', sa.Boolean(), nullable=False),
sa.Column('id', sa.Uuid(), nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.Column('deleted_at', sa.DateTime(timezone=True), nullable=True),
sa.PrimaryKeyConstraint('id', name=op.f('pk_groups'))
)
op.create_index('ix_groups_active', 'groups', ['deleted_at'], unique=False, postgresql_where='deleted_at IS NULL')
op.create_index('uq_groups_name_active', 'groups', ['name'], unique=True, postgresql_where='deleted_at IS NULL')
op.create_table('missions',
sa.Column('name', sa.String(length=255), nullable=False),
sa.Column('client_target', sa.String(length=255), nullable=True),
sa.Column('date_start', sa.Date(), nullable=True),
sa.Column('date_end', sa.Date(), nullable=True),
sa.Column('status', sa.String(length=16), nullable=False),
sa.Column('description_md', sa.Text(), nullable=True),
sa.Column('visibility_mode', sa.String(length=16), nullable=False),
sa.Column('id', sa.Uuid(), nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.Column('deleted_at', sa.DateTime(timezone=True), nullable=True),
sa.CheckConstraint("status IN ('draft', 'in_progress', 'completed', 'archived')", name=op.f('ck_missions_status_valid')),
sa.CheckConstraint("visibility_mode IN ('whitebox', 'titles_only', 'executed_only')", name=op.f('ck_missions_visibility_mode_valid')),
sa.PrimaryKeyConstraint('id', name=op.f('pk_missions'))
)
op.create_index('ix_missions_active', 'missions', ['deleted_at'], unique=False, postgresql_where='deleted_at IS NULL')
op.create_index('ix_missions_status', 'missions', ['status'], unique=False)
op.create_table('mitre_tactics',
sa.Column('external_id', sa.String(length=16), nullable=False),
sa.Column('short_name', sa.String(length=80), nullable=False),
sa.Column('name', sa.String(length=120), nullable=False),
sa.Column('description', sa.Text(), nullable=True),
sa.Column('url', sa.String(length=512), nullable=True),
sa.Column('id', sa.Uuid(), nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.PrimaryKeyConstraint('id', name=op.f('pk_mitre_tactics')),
sa.UniqueConstraint('external_id', name=op.f('uq_mitre_tactics_external_id'))
)
op.create_table('mitre_techniques',
sa.Column('external_id', sa.String(length=16), nullable=False),
sa.Column('name', sa.String(length=255), nullable=False),
sa.Column('description', sa.Text(), nullable=True),
sa.Column('url', sa.String(length=512), nullable=True),
sa.Column('id', sa.Uuid(), nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.PrimaryKeyConstraint('id', name=op.f('pk_mitre_techniques')),
sa.UniqueConstraint('external_id', name=op.f('uq_mitre_techniques_external_id'))
)
op.create_index('ix_mitre_techniques_name', 'mitre_techniques', ['name'], unique=False)
op.create_table('permissions',
sa.Column('code', sa.String(length=80), nullable=False),
sa.Column('description', sa.Text(), nullable=True),
sa.Column('id', sa.Uuid(), nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.PrimaryKeyConstraint('id', name=op.f('pk_permissions')),
sa.UniqueConstraint('code', name=op.f('uq_permissions_code'))
)
op.create_table('scenario_templates',
sa.Column('name', sa.String(length=255), nullable=False),
sa.Column('description', sa.Text(), nullable=True),
sa.Column('id', sa.Uuid(), nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.Column('deleted_at', sa.DateTime(timezone=True), nullable=True),
sa.PrimaryKeyConstraint('id', name=op.f('pk_scenario_templates'))
)
op.create_index('ix_scenario_templates_active', 'scenario_templates', ['deleted_at'], unique=False, postgresql_where='deleted_at IS NULL')
op.create_index('ix_scenario_templates_name', 'scenario_templates', ['name'], unique=False)
op.create_table('settings',
sa.Column('key', sa.String(length=80), nullable=False),
sa.Column('value', postgresql.JSONB(astext_type=sa.Text()), nullable=False),
sa.Column('description', sa.Text(), nullable=True),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.PrimaryKeyConstraint('key', name=op.f('pk_settings'))
)
op.create_table('test_templates',
sa.Column('name', sa.String(length=255), nullable=False),
sa.Column('description', sa.Text(), nullable=True),
sa.Column('objective', sa.Text(), nullable=True),
sa.Column('procedure_md', sa.Text(), nullable=True),
sa.Column('prerequisites_md', sa.Text(), nullable=True),
sa.Column('expected_result_red_md', sa.Text(), nullable=True),
sa.Column('expected_detection_blue_md', sa.Text(), nullable=True),
sa.Column('opsec_level', sa.String(length=8), nullable=False),
sa.Column('tags', sa.ARRAY(sa.String(length=64)), server_default='{}', nullable=False),
sa.Column('expected_iocs', sa.ARRAY(sa.String(length=255)), server_default='{}', nullable=False),
sa.Column('id', sa.Uuid(), nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.Column('deleted_at', sa.DateTime(timezone=True), nullable=True),
sa.CheckConstraint("opsec_level IN ('low', 'medium', 'high')", name=op.f('ck_test_templates_opsec_level_valid')),
sa.PrimaryKeyConstraint('id', name=op.f('pk_test_templates'))
)
op.create_index('ix_test_templates_active', 'test_templates', ['deleted_at'], unique=False, postgresql_where='deleted_at IS NULL')
op.create_index('ix_test_templates_name', 'test_templates', ['name'], unique=False)
op.create_table('users',
sa.Column('email', sa.String(length=254), nullable=False),
sa.Column('password_hash', sa.String(length=255), nullable=False),
sa.Column('display_name', sa.String(length=120), nullable=True),
sa.Column('locale', sa.String(length=8), nullable=False),
sa.Column('is_active', sa.Boolean(), nullable=False),
sa.Column('id', sa.Uuid(), nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.Column('deleted_at', sa.DateTime(timezone=True), nullable=True),
sa.PrimaryKeyConstraint('id', name=op.f('pk_users'))
)
op.create_index('ix_users_active', 'users', ['deleted_at'], unique=False, postgresql_where='deleted_at IS NULL')
op.create_index('uq_users_email_active', 'users', ['email'], unique=True, postgresql_where='deleted_at IS NULL')
op.create_table('group_permissions',
sa.Column('group_id', sa.Uuid(), nullable=False),
sa.Column('permission_id', sa.Uuid(), nullable=False),
sa.ForeignKeyConstraint(['group_id'], ['groups.id'], name=op.f('fk_group_permissions_group_id_groups'), ondelete='CASCADE'),
sa.ForeignKeyConstraint(['permission_id'], ['permissions.id'], name=op.f('fk_group_permissions_permission_id_permissions'), ondelete='CASCADE'),
sa.PrimaryKeyConstraint('group_id', 'permission_id', name=op.f('pk_group_permissions'))
)
op.create_table('invitations',
sa.Column('token_hash', sa.String(length=128), nullable=False),
sa.Column('email_hint', sa.String(length=254), nullable=True),
sa.Column('created_by_user_id', sa.Uuid(), nullable=False),
sa.Column('expires_at', sa.DateTime(timezone=True), nullable=False),
sa.Column('consumed_at', sa.DateTime(timezone=True), nullable=True),
sa.Column('revoked_at', sa.DateTime(timezone=True), nullable=True),
sa.Column('consumed_by_user_id', sa.Uuid(), nullable=True),
sa.Column('id', sa.Uuid(), nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.ForeignKeyConstraint(['consumed_by_user_id'], ['users.id'], name=op.f('fk_invitations_consumed_by_user_id_users'), ondelete='SET NULL'),
sa.ForeignKeyConstraint(['created_by_user_id'], ['users.id'], name=op.f('fk_invitations_created_by_user_id_users'), ondelete='RESTRICT'),
sa.PrimaryKeyConstraint('id', name=op.f('pk_invitations')),
sa.UniqueConstraint('token_hash', name=op.f('uq_invitations_token_hash'))
)
op.create_index('ix_invitations_expires_at', 'invitations', ['expires_at'], unique=False)
op.create_table('mission_categories',
sa.Column('mission_id', sa.Uuid(), nullable=False),
sa.Column('name', sa.String(length=120), nullable=False),
sa.Column('color_token', sa.String(length=16), nullable=True),
sa.Column('position', sa.Integer(), nullable=False),
sa.Column('id', sa.Uuid(), nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.Column('deleted_at', sa.DateTime(timezone=True), nullable=True),
sa.ForeignKeyConstraint(['mission_id'], ['missions.id'], name=op.f('fk_mission_categories_mission_id_missions'), ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id', name=op.f('pk_mission_categories')),
sa.UniqueConstraint('mission_id', 'name', name='uq_mission_categories_name'),
sa.UniqueConstraint('mission_id', 'position', name='uq_mission_categories_position')
)
op.create_index('ix_mission_categories_active', 'mission_categories', ['deleted_at'], unique=False, postgresql_where='deleted_at IS NULL')
op.create_table('mission_members',
sa.Column('mission_id', sa.Uuid(), nullable=False),
sa.Column('user_id', sa.Uuid(), nullable=False),
sa.Column('role_hint', sa.String(length=8), nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.CheckConstraint("role_hint IN ('red', 'blue')", name=op.f('ck_mission_members_role_hint_valid')),
sa.ForeignKeyConstraint(['mission_id'], ['missions.id'], name=op.f('fk_mission_members_mission_id_missions'), ondelete='CASCADE'),
sa.ForeignKeyConstraint(['user_id'], ['users.id'], name=op.f('fk_mission_members_user_id_users'), ondelete='CASCADE'),
sa.PrimaryKeyConstraint('mission_id', 'user_id', name=op.f('pk_mission_members'))
)
op.create_index('ix_mission_members_user', 'mission_members', ['user_id'], unique=False)
op.create_table('mission_scenarios',
sa.Column('mission_id', sa.Uuid(), nullable=False),
sa.Column('source_scenario_template_id', sa.Uuid(), nullable=True),
sa.Column('snapshot_name', sa.String(length=255), nullable=False),
sa.Column('snapshot_description', sa.Text(), nullable=True),
sa.Column('position', sa.Integer(), nullable=False),
sa.Column('id', sa.Uuid(), nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.Column('deleted_at', sa.DateTime(timezone=True), nullable=True),
sa.ForeignKeyConstraint(['mission_id'], ['missions.id'], name=op.f('fk_mission_scenarios_mission_id_missions'), ondelete='CASCADE'),
sa.ForeignKeyConstraint(['source_scenario_template_id'], ['scenario_templates.id'], name=op.f('fk_mission_scenarios_source_scenario_template_id_scenario_templates'), ondelete='SET NULL'),
sa.PrimaryKeyConstraint('id', name=op.f('pk_mission_scenarios')),
sa.UniqueConstraint('mission_id', 'position', name='uq_mission_scenarios_position')
)
op.create_index('ix_mission_scenarios_active', 'mission_scenarios', ['deleted_at'], unique=False, postgresql_where='deleted_at IS NULL')
op.create_index('ix_mission_scenarios_mission', 'mission_scenarios', ['mission_id'], unique=False)
op.create_table('mitre_subtechniques',
sa.Column('external_id', sa.String(length=16), nullable=False),
sa.Column('name', sa.String(length=255), nullable=False),
sa.Column('description', sa.Text(), nullable=True),
sa.Column('url', sa.String(length=512), nullable=True),
sa.Column('technique_id', sa.Uuid(), nullable=False),
sa.Column('id', sa.Uuid(), nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.ForeignKeyConstraint(['technique_id'], ['mitre_techniques.id'], name=op.f('fk_mitre_subtechniques_technique_id_mitre_techniques'), ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id', name=op.f('pk_mitre_subtechniques')),
sa.UniqueConstraint('external_id', name=op.f('uq_mitre_subtechniques_external_id'))
)
op.create_index('ix_mitre_subtechniques_technique_id', 'mitre_subtechniques', ['technique_id'], unique=False)
op.create_table('mitre_technique_tactics',
sa.Column('technique_id', sa.Uuid(), nullable=False),
sa.Column('tactic_id', sa.Uuid(), nullable=False),
sa.ForeignKeyConstraint(['tactic_id'], ['mitre_tactics.id'], name=op.f('fk_mitre_technique_tactics_tactic_id_mitre_tactics'), ondelete='CASCADE'),
sa.ForeignKeyConstraint(['technique_id'], ['mitre_techniques.id'], name=op.f('fk_mitre_technique_tactics_technique_id_mitre_techniques'), ondelete='CASCADE'),
sa.PrimaryKeyConstraint('technique_id', 'tactic_id', name=op.f('pk_mitre_technique_tactics'))
)
op.create_table('notifications',
sa.Column('user_id', sa.Uuid(), nullable=False),
sa.Column('type', sa.String(length=64), nullable=False),
sa.Column('payload', postgresql.JSONB(astext_type=sa.Text()), server_default='{}', nullable=False),
sa.Column('read_at', sa.DateTime(timezone=True), nullable=True),
sa.Column('id', sa.Uuid(), nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.ForeignKeyConstraint(['user_id'], ['users.id'], name=op.f('fk_notifications_user_id_users'), ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id', name=op.f('pk_notifications'))
)
op.create_index('ix_notifications_user_unread', 'notifications', ['user_id', 'created_at'], unique=False, postgresql_where='read_at IS NULL')
op.create_table('refresh_tokens',
sa.Column('user_id', sa.Uuid(), nullable=False),
sa.Column('jti', sa.String(length=64), nullable=False),
sa.Column('token_hash', sa.String(length=128), nullable=False),
sa.Column('issued_at', sa.DateTime(timezone=True), nullable=False),
sa.Column('expires_at', sa.DateTime(timezone=True), nullable=False),
sa.Column('revoked_at', sa.DateTime(timezone=True), nullable=True),
sa.Column('replaced_by_id', sa.Uuid(), nullable=True),
sa.Column('id', sa.Uuid(), nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.ForeignKeyConstraint(['replaced_by_id'], ['refresh_tokens.id'], name=op.f('fk_refresh_tokens_replaced_by_id_refresh_tokens'), ondelete='SET NULL'),
sa.ForeignKeyConstraint(['user_id'], ['users.id'], name=op.f('fk_refresh_tokens_user_id_users'), ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id', name=op.f('pk_refresh_tokens')),
sa.UniqueConstraint('jti', name='uq_refresh_tokens_jti')
)
op.create_index('ix_refresh_tokens_user_id_expires_at', 'refresh_tokens', ['user_id', 'expires_at'], unique=False)
op.create_table('scenario_template_tests',
sa.Column('scenario_template_id', sa.Uuid(), nullable=False),
sa.Column('test_template_id', sa.Uuid(), nullable=False),
sa.Column('position', sa.Integer(), nullable=False),
sa.Column('id', sa.Uuid(), nullable=False),
sa.ForeignKeyConstraint(['scenario_template_id'], ['scenario_templates.id'], name=op.f('fk_scenario_template_tests_scenario_template_id_scenario_templates'), ondelete='CASCADE'),
sa.ForeignKeyConstraint(['test_template_id'], ['test_templates.id'], name=op.f('fk_scenario_template_tests_test_template_id_test_templates'), ondelete='RESTRICT'),
sa.PrimaryKeyConstraint('id', name=op.f('pk_scenario_template_tests')),
sa.UniqueConstraint('scenario_template_id', 'position', name='uq_scenario_template_tests_position')
)
op.create_index('ix_scenario_template_tests_scenario', 'scenario_template_tests', ['scenario_template_id'], unique=False)
op.create_index('ix_scenario_template_tests_test', 'scenario_template_tests', ['test_template_id'], unique=False)
op.create_table('user_groups',
sa.Column('user_id', sa.Uuid(), nullable=False),
sa.Column('group_id', sa.Uuid(), nullable=False),
sa.ForeignKeyConstraint(['group_id'], ['groups.id'], name=op.f('fk_user_groups_group_id_groups'), ondelete='CASCADE'),
sa.ForeignKeyConstraint(['user_id'], ['users.id'], name=op.f('fk_user_groups_user_id_users'), ondelete='CASCADE'),
sa.PrimaryKeyConstraint('user_id', 'group_id', name=op.f('pk_user_groups'))
)
op.create_table('invitation_groups',
sa.Column('invitation_id', sa.Uuid(), nullable=False),
sa.Column('group_id', sa.Uuid(), nullable=False),
sa.ForeignKeyConstraint(['group_id'], ['groups.id'], name=op.f('fk_invitation_groups_group_id_groups'), ondelete='CASCADE'),
sa.ForeignKeyConstraint(['invitation_id'], ['invitations.id'], name=op.f('fk_invitation_groups_invitation_id_invitations'), ondelete='CASCADE'),
sa.PrimaryKeyConstraint('invitation_id', 'group_id', name=op.f('pk_invitation_groups'))
)
op.create_table('mission_tests',
sa.Column('scenario_id', sa.Uuid(), nullable=False),
sa.Column('source_test_template_id', sa.Uuid(), nullable=True),
sa.Column('position', sa.Integer(), nullable=False),
sa.Column('snapshot_name', sa.String(length=255), nullable=False),
sa.Column('snapshot_description', sa.Text(), nullable=True),
sa.Column('snapshot_objective', sa.Text(), nullable=True),
sa.Column('snapshot_procedure_md', sa.Text(), nullable=True),
sa.Column('snapshot_prerequisites_md', sa.Text(), nullable=True),
sa.Column('snapshot_expected_red_md', sa.Text(), nullable=True),
sa.Column('snapshot_expected_blue_md', sa.Text(), nullable=True),
sa.Column('snapshot_opsec_level', sa.String(length=8), nullable=False),
sa.Column('snapshot_tags', sa.ARRAY(sa.String(length=64)), server_default='{}', nullable=False),
sa.Column('snapshot_expected_iocs', sa.ARRAY(sa.String(length=255)), server_default='{}', nullable=False),
sa.Column('state', sa.String(length=24), nullable=False),
sa.Column('executed_at', sa.DateTime(timezone=True), nullable=True),
sa.Column('executed_at_overridden', sa.Boolean(), nullable=False),
sa.Column('red_command', sa.Text(), nullable=True),
sa.Column('red_output', sa.Text(), nullable=True),
sa.Column('red_comment_md', sa.Text(), nullable=True),
sa.Column('blue_comment_md', sa.Text(), nullable=True),
sa.Column('detection_level_id', sa.Uuid(), nullable=True),
sa.Column('category_id', sa.Uuid(), nullable=True),
sa.Column('id', sa.Uuid(), nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.Column('deleted_at', sa.DateTime(timezone=True), nullable=True),
sa.CheckConstraint("snapshot_opsec_level IN ('low', 'medium', 'high')", name=op.f('ck_mission_tests_snapshot_opsec_level_valid')),
sa.CheckConstraint("state IN ('pending', 'executed', 'reviewed_by_blue', 'skipped', 'blocked')", name=op.f('ck_mission_tests_state_valid')),
sa.ForeignKeyConstraint(['category_id'], ['mission_categories.id'], name=op.f('fk_mission_tests_category_id_mission_categories'), ondelete='SET NULL'),
sa.ForeignKeyConstraint(['detection_level_id'], ['detection_levels.id'], name=op.f('fk_mission_tests_detection_level_id_detection_levels'), ondelete='SET NULL'),
sa.ForeignKeyConstraint(['scenario_id'], ['mission_scenarios.id'], name=op.f('fk_mission_tests_scenario_id_mission_scenarios'), ondelete='CASCADE'),
sa.ForeignKeyConstraint(['source_test_template_id'], ['test_templates.id'], name=op.f('fk_mission_tests_source_test_template_id_test_templates'), ondelete='SET NULL'),
sa.PrimaryKeyConstraint('id', name=op.f('pk_mission_tests')),
sa.UniqueConstraint('scenario_id', 'position', name='uq_mission_tests_position')
)
op.create_index('ix_mission_tests_active', 'mission_tests', ['deleted_at'], unique=False, postgresql_where='deleted_at IS NULL')
op.create_index('ix_mission_tests_state', 'mission_tests', ['state'], unique=False)
op.create_table('test_template_mitre_tags',
sa.Column('id', sa.Uuid(), nullable=False),
sa.Column('test_template_id', sa.Uuid(), nullable=False),
sa.Column('mitre_kind', sa.String(length=16), nullable=False),
sa.Column('tactic_id', sa.Uuid(), nullable=True),
sa.Column('technique_id', sa.Uuid(), nullable=True),
sa.Column('subtechnique_id', sa.Uuid(), nullable=True),
sa.CheckConstraint("mitre_kind IN ('tactic', 'technique', 'subtechnique')", name=op.f('ck_test_template_mitre_tags_mitre_kind_valid')),
sa.CheckConstraint('(CASE WHEN tactic_id IS NOT NULL THEN 1 ELSE 0 END) + (CASE WHEN technique_id IS NOT NULL THEN 1 ELSE 0 END) + (CASE WHEN subtechnique_id IS NOT NULL THEN 1 ELSE 0 END) = 1', name=op.f('ck_test_template_mitre_tags_exactly_one_mitre_fk')),
sa.ForeignKeyConstraint(['subtechnique_id'], ['mitre_subtechniques.id'], name=op.f('fk_test_template_mitre_tags_subtechnique_id_mitre_subtechniques'), ondelete='CASCADE'),
sa.ForeignKeyConstraint(['tactic_id'], ['mitre_tactics.id'], name=op.f('fk_test_template_mitre_tags_tactic_id_mitre_tactics'), ondelete='CASCADE'),
sa.ForeignKeyConstraint(['technique_id'], ['mitre_techniques.id'], name=op.f('fk_test_template_mitre_tags_technique_id_mitre_techniques'), ondelete='CASCADE'),
sa.ForeignKeyConstraint(['test_template_id'], ['test_templates.id'], name=op.f('fk_test_template_mitre_tags_test_template_id_test_templates'), ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id', name=op.f('pk_test_template_mitre_tags')),
sa.UniqueConstraint('test_template_id', 'tactic_id', 'technique_id', 'subtechnique_id', name='uq_test_template_mitre_tag')
)
op.create_index('ix_test_template_mitre_tags_template', 'test_template_mitre_tags', ['test_template_id'], unique=False)
op.create_table('evidence_files',
sa.Column('mission_test_id', sa.Uuid(), nullable=False),
sa.Column('sha256', sa.String(length=64), nullable=False),
sa.Column('mime', sa.String(length=127), nullable=False),
sa.Column('size_bytes', sa.BigInteger(), nullable=False),
sa.Column('storage_path', sa.Text(), nullable=False),
sa.Column('original_filename', sa.String(length=255), nullable=False),
sa.Column('uploaded_by_user_id', sa.Uuid(), nullable=True),
sa.Column('uploaded_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.Column('id', sa.Uuid(), nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.Column('deleted_at', sa.DateTime(timezone=True), nullable=True),
sa.ForeignKeyConstraint(['mission_test_id'], ['mission_tests.id'], name=op.f('fk_evidence_files_mission_test_id_mission_tests'), ondelete='CASCADE'),
sa.ForeignKeyConstraint(['uploaded_by_user_id'], ['users.id'], name=op.f('fk_evidence_files_uploaded_by_user_id_users'), ondelete='SET NULL'),
sa.PrimaryKeyConstraint('id', name=op.f('pk_evidence_files'))
)
op.create_index('ix_evidence_files_active', 'evidence_files', ['deleted_at'], unique=False, postgresql_where='deleted_at IS NULL')
op.create_index('ix_evidence_files_mission_test', 'evidence_files', ['mission_test_id'], unique=False)
op.create_index('ix_evidence_files_sha256', 'evidence_files', ['sha256'], unique=False)
op.create_table('mission_test_mitre_tags',
sa.Column('id', sa.Uuid(), nullable=False),
sa.Column('mission_test_id', sa.Uuid(), nullable=False),
sa.Column('mitre_kind', sa.String(length=16), nullable=False),
sa.Column('mitre_external_id', sa.String(length=16), nullable=False),
sa.Column('mitre_name', sa.String(length=255), nullable=False),
sa.Column('mitre_url', sa.String(length=512), nullable=True),
sa.CheckConstraint("mitre_kind IN ('tactic', 'technique', 'subtechnique')", name=op.f('ck_mission_test_mitre_tags_mitre_kind_valid')),
sa.ForeignKeyConstraint(['mission_test_id'], ['mission_tests.id'], name=op.f('fk_mission_test_mitre_tags_mission_test_id_mission_tests'), ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id', name=op.f('pk_mission_test_mitre_tags')),
sa.UniqueConstraint('mission_test_id', 'mitre_external_id', name='uq_mission_test_mitre_tag')
)
op.create_index('ix_mission_test_mitre_tags_test', 'mission_test_mitre_tags', ['mission_test_id'], unique=False)
# ### end Alembic commands ###
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.drop_index('ix_mission_test_mitre_tags_test', table_name='mission_test_mitre_tags')
op.drop_table('mission_test_mitre_tags')
op.drop_index('ix_evidence_files_sha256', table_name='evidence_files')
op.drop_index('ix_evidence_files_mission_test', table_name='evidence_files')
op.drop_index('ix_evidence_files_active', table_name='evidence_files', postgresql_where='deleted_at IS NULL')
op.drop_table('evidence_files')
op.drop_index('ix_test_template_mitre_tags_template', table_name='test_template_mitre_tags')
op.drop_table('test_template_mitre_tags')
op.drop_index('ix_mission_tests_state', table_name='mission_tests')
op.drop_index('ix_mission_tests_active', table_name='mission_tests', postgresql_where='deleted_at IS NULL')
op.drop_table('mission_tests')
op.drop_table('invitation_groups')
op.drop_table('user_groups')
op.drop_index('ix_scenario_template_tests_test', table_name='scenario_template_tests')
op.drop_index('ix_scenario_template_tests_scenario', table_name='scenario_template_tests')
op.drop_table('scenario_template_tests')
op.drop_index('ix_refresh_tokens_user_id_expires_at', table_name='refresh_tokens')
op.drop_table('refresh_tokens')
op.drop_index('ix_notifications_user_unread', table_name='notifications', postgresql_where='read_at IS NULL')
op.drop_table('notifications')
op.drop_table('mitre_technique_tactics')
op.drop_index('ix_mitre_subtechniques_technique_id', table_name='mitre_subtechniques')
op.drop_table('mitre_subtechniques')
op.drop_index('ix_mission_scenarios_mission', table_name='mission_scenarios')
op.drop_index('ix_mission_scenarios_active', table_name='mission_scenarios', postgresql_where='deleted_at IS NULL')
op.drop_table('mission_scenarios')
op.drop_index('ix_mission_members_user', table_name='mission_members')
op.drop_table('mission_members')
op.drop_index('ix_mission_categories_active', table_name='mission_categories', postgresql_where='deleted_at IS NULL')
op.drop_table('mission_categories')
op.drop_index('ix_invitations_expires_at', table_name='invitations')
op.drop_table('invitations')
op.drop_table('group_permissions')
op.drop_index('uq_users_email_active', table_name='users', postgresql_where='deleted_at IS NULL')
op.drop_index('ix_users_active', table_name='users', postgresql_where='deleted_at IS NULL')
op.drop_table('users')
op.drop_index('ix_test_templates_name', table_name='test_templates')
op.drop_index('ix_test_templates_active', table_name='test_templates', postgresql_where='deleted_at IS NULL')
op.drop_table('test_templates')
op.drop_table('settings')
op.drop_index('ix_scenario_templates_name', table_name='scenario_templates')
op.drop_index('ix_scenario_templates_active', table_name='scenario_templates', postgresql_where='deleted_at IS NULL')
op.drop_table('scenario_templates')
op.drop_table('permissions')
op.drop_index('ix_mitre_techniques_name', table_name='mitre_techniques')
op.drop_table('mitre_techniques')
op.drop_table('mitre_tactics')
op.drop_index('ix_missions_status', table_name='missions')
op.drop_index('ix_missions_active', table_name='missions', postgresql_where='deleted_at IS NULL')
op.drop_table('missions')
op.drop_index('uq_groups_name_active', table_name='groups', postgresql_where='deleted_at IS NULL')
op.drop_index('ix_groups_active', table_name='groups', postgresql_where='deleted_at IS NULL')
op.drop_table('groups')
op.drop_table('detection_levels')
# ### end Alembic commands ###

3
backend/app/__init__.py Normal file
View File

@@ -0,0 +1,3 @@
"""Metamorph backend API package."""
__version__ = "0.1.0"

View File

View File

@@ -0,0 +1,30 @@
"""Lightweight email validator that tolerates internal/lab TLDs (.local, .corp, …).
`pydantic.EmailStr` relies on `email-validator` with `globally_deliverable=True`,
which rejects RFC 6761 special-use domains. Red-team and corporate intranet
deployments routinely use such suffixes — we accept any RFC-shape email and
defer deliverability checks to the operator.
"""
from __future__ import annotations
import re
from typing import Annotated
from pydantic import AfterValidator
# Permissive RFC-shape pattern: local-part 1..64 chars, domain has at least one
# dot, each label is 1..63 chars of letters/digits/hyphens, total ≤ 254.
_EMAIL_RE = re.compile(
r"^(?=.{1,254}$)[A-Za-z0-9._%+\-]{1,64}@[A-Za-z0-9](?:[A-Za-z0-9\-]{0,61}[A-Za-z0-9])?(?:\.[A-Za-z0-9](?:[A-Za-z0-9\-]{0,61}[A-Za-z0-9])?)+$"
)
def _validate_email(value: str) -> str:
v = value.strip()
if not _EMAIL_RE.match(v):
raise ValueError("not a valid email address")
return v.lower()
Email = Annotated[str, AfterValidator(_validate_email)]

157
backend/app/api/auth.py Normal file
View File

@@ -0,0 +1,157 @@
"""Authentication endpoints.
`POST /auth/login` returns the access token in the body and sets the refresh
token in an HTTPOnly cookie scoped to `/api/v1/auth/`. The cookie is
`Secure; SameSite=Strict` and only the matching paths can read it.
"""
from __future__ import annotations
import logging
from flask import Blueprint, g, jsonify, make_response, request
from pydantic import BaseModel, Field, ValidationError
from app.api._validation import Email
from app.core.auth_decorators import require_auth
from app.core.config import settings
from app.core.rate_limit import limiter
from app.services import auth as auth_svc
bp = Blueprint("auth", __name__, url_prefix="/auth")
log = logging.getLogger("metamorph.api.auth")
REFRESH_COOKIE_NAME = "metamorph_refresh"
REFRESH_COOKIE_PATH = "/api/v1/auth/"
class LoginPayload(BaseModel):
email: Email
password: str = Field(min_length=1)
class ChangePasswordPayload(BaseModel):
current_password: str = Field(min_length=1)
new_password: str = Field(min_length=8)
def _set_refresh_cookie(resp, token: str, expires_at) -> None:
resp.set_cookie(
REFRESH_COOKIE_NAME,
token,
expires=expires_at,
httponly=True,
secure=True, # spec §M2; localhost is a secure context for modern browsers
samesite="Strict",
path=REFRESH_COOKIE_PATH,
)
def _clear_refresh_cookie(resp) -> None:
resp.set_cookie(
REFRESH_COOKIE_NAME,
"",
expires=0,
httponly=True,
secure=True, # spec §M2; localhost is a secure context for modern browsers
samesite="Strict",
path=REFRESH_COOKIE_PATH,
)
def _read_refresh_cookie() -> str | None:
return request.cookies.get(REFRESH_COOKIE_NAME)
def _serialize_pair(pair: auth_svc.TokenPair) -> dict:
return {
"access_token": pair.access_token,
"token_type": "Bearer",
"user_id": str(pair.user_id),
}
@bp.post("/login")
@limiter.limit("10 per minute")
def login():
try:
payload = LoginPayload.model_validate(request.get_json(silent=True) or {})
except ValidationError as e:
return jsonify({"error": "invalid_request", "details": e.errors()}), 400
try:
pair = auth_svc.login(payload.email, payload.password)
except auth_svc.InvalidCredentials:
return jsonify({"error": "invalid_credentials"}), 401
resp = make_response(jsonify(_serialize_pair(pair)))
_set_refresh_cookie(resp, pair.refresh_token, pair.refresh_expires_at)
return resp
@bp.post("/refresh")
@limiter.limit("10 per minute")
def refresh_endpoint():
raw = _read_refresh_cookie()
if not raw:
return jsonify({"error": "no_refresh_cookie"}), 401
try:
pair = auth_svc.refresh(raw)
except auth_svc.TokenRevoked:
resp = make_response(jsonify({"error": "token_revoked"}), 401)
_clear_refresh_cookie(resp)
return resp
except auth_svc.InvalidCredentials:
resp = make_response(jsonify({"error": "invalid_refresh"}), 401)
_clear_refresh_cookie(resp)
return resp
resp = make_response(jsonify(_serialize_pair(pair)))
_set_refresh_cookie(resp, pair.refresh_token, pair.refresh_expires_at)
return resp
@bp.post("/logout")
def logout():
raw = _read_refresh_cookie()
if raw:
auth_svc.logout(raw)
resp = make_response(jsonify({"ok": True}))
_clear_refresh_cookie(resp)
return resp
@bp.get("/me")
@require_auth
def me():
u = g.current_user
return jsonify(
{
"id": str(u.id),
"email": u.email,
"display_name": u.display_name,
"locale": u.locale,
"is_admin": u.is_admin,
"groups": sorted(u.group_names),
"permissions": sorted(u.permissions),
}
)
@bp.post("/change-password")
@require_auth
@limiter.limit("5 per minute")
def change_password():
try:
payload = ChangePasswordPayload.model_validate(request.get_json(silent=True) or {})
except ValidationError as e:
return jsonify({"error": "invalid_request", "details": e.errors()}), 400
try:
auth_svc.change_password(g.current_user.id, payload.current_password, payload.new_password)
except auth_svc.InvalidCredentials:
return jsonify({"error": "current_password_incorrect"}), 400
except ValueError as e:
return jsonify({"error": "weak_password", "message": str(e)}), 400
resp = make_response(jsonify({"ok": True}))
_clear_refresh_cookie(resp)
return resp

93
backend/app/api/diag.py Normal file
View File

@@ -0,0 +1,93 @@
"""Operational diagnostics. No auth in v1 (M0/M1 only expose non-sensitive
counts and the current Alembic revision).
The `/diag/reset` endpoint is **test-only** — it requires `APP_ENV=test` and
is the bedrock of the e2e suite (clean DB + freshly minted install token).
"""
from __future__ import annotations
import logging
from flask import Blueprint, abort, jsonify
from sqlalchemy import text
from sqlalchemy.exc import SQLAlchemyError
from app.core.config import settings
from app.core.install_token import regenerate_install_token
from app.db.session import get_engine
bp = Blueprint("diag", __name__, url_prefix="/diag")
log = logging.getLogger("metamorph.diag")
@bp.get("/db")
def db_diag():
"""Return the Alembic revision and the count of public-schema tables."""
try:
with get_engine().connect() as conn:
revision = conn.execute(
text("SELECT version_num FROM alembic_version")
).scalar()
table_count = conn.execute(
text(
"SELECT count(*) FROM information_schema.tables "
"WHERE table_schema='public' AND table_type='BASE TABLE'"
)
).scalar_one()
except SQLAlchemyError as e:
log.warning("metamorph.diag.db_unreachable", extra={"error": str(e)})
return jsonify({"reachable": False, "error": "database_unreachable"}), 503
return jsonify(
{
"reachable": True,
"alembic_revision": revision,
"table_count": int(table_count),
}
)
@bp.post("/reset")
def reset_test_state():
"""TEST-ONLY: wipe users/auth tables and mint a fresh install token.
Refuses unless `APP_ENV=test`. Used by the Playwright suite to start each
auth scenario from a deterministic state.
"""
# NOTE: this endpoint is the test-suite reset hook. Allowed in `dev` too so
# the e2e suite can run against a normal `make up` stack, but in dev it is
# destructive — equivalent to `make clean` for the auth tables. Production
# (APP_ENV=prod/staging) is locked out.
if settings.APP_ENV not in ("dev", "test"):
abort(403, description="diag/reset is only available in dev/test")
if settings.APP_ENV == "dev":
log.warning("metamorph.diag.reset_in_dev_environment")
try:
with get_engine().begin() as conn:
conn.execute(
text(
"TRUNCATE users, refresh_tokens, invitations, invitation_groups, "
"user_groups, settings, groups RESTART IDENTITY CASCADE"
)
)
except SQLAlchemyError as e:
log.error("metamorph.diag.reset_failed", extra={"error": str(e)})
return jsonify({"reset": False, "error": "database_error"}), 500
token = regenerate_install_token()
# Clear the in-memory rate-limit counters so the e2e suite that follows can
# log in repeatedly without hitting `/auth/login`/`/auth/refresh` limits.
# The limiter uses `memory://` in dev (cf. `app/core/rate_limit.py`).
try:
from app.core.rate_limit import limiter # noqa: PLC0415 — avoid import cycle
if limiter.enabled:
limiter.reset()
except Exception as e: # noqa: BLE001
log.warning("metamorph.diag.rate_limit_reset_failed", extra={"error": str(e)})
log.warning("metamorph.diag.reset_completed")
return jsonify({"reset": True, "install_token": token})

169
backend/app/api/groups.py Normal file
View File

@@ -0,0 +1,169 @@
"""Admin endpoints for groups + their permission bindings."""
from __future__ import annotations
import logging
import uuid
from flask import Blueprint, jsonify, request
from pydantic import BaseModel, Field, ValidationError
from app.core.auth_decorators import require_auth, require_perm
from app.services import groups as groups_svc
bp = Blueprint("groups", __name__, url_prefix="/groups")
log = logging.getLogger("metamorph.api.groups")
def _serialize(g: groups_svc.GroupView) -> dict:
return {
"id": str(g.id),
"name": g.name,
"description": g.description,
"is_system": g.is_system,
"members_count": g.members_count,
"permissions": g.permissions,
"created_at": g.created_at.isoformat(),
"updated_at": g.updated_at.isoformat(),
}
class CreateGroupPayload(BaseModel):
name: str = Field(min_length=1, max_length=80)
description: str | None = Field(default=None, max_length=2000)
model_config = {"extra": "forbid"}
class UpdateGroupPayload(BaseModel):
name: str | None = Field(default=None, min_length=1, max_length=80)
description: str | None = Field(default=None, max_length=2000)
model_config = {"extra": "forbid"}
class SetPermissionsPayload(BaseModel):
codes: list[str] = Field(default_factory=list)
model_config = {"extra": "forbid"}
def _parse_uuid_or_400(raw: str):
try:
return uuid.UUID(raw)
except ValueError:
return None
@bp.get("")
@require_auth
@require_perm("group.read")
def list_groups():
rows = groups_svc.list_groups()
return jsonify({"items": [_serialize(g) for g in rows], "total": len(rows)})
@bp.get("/<group_id>")
@require_auth
@require_perm("group.read")
def get_group(group_id: str):
gid = _parse_uuid_or_400(group_id)
if gid is None:
return jsonify({"error": "invalid_id"}), 400
try:
g = groups_svc.get_group(gid)
except groups_svc.GroupNotFound:
return jsonify({"error": "not_found"}), 404
return jsonify(_serialize(g))
@bp.post("")
@require_auth
@require_perm("group.create")
def create_group():
try:
payload = CreateGroupPayload.model_validate(request.get_json(silent=True) or {})
except ValidationError as e:
return jsonify({"error": "invalid_request", "details": e.errors()}), 400
try:
g = groups_svc.create_group(name=payload.name, description=payload.description)
except groups_svc.GroupNameConflict as e:
return jsonify({"error": "name_conflict", "message": str(e)}), 409
except ValueError as e:
return jsonify({"error": "invalid_request", "message": str(e)}), 400
log.info("metamorph.group.created", extra={"group_id": str(g.id), "group_name": g.name})
return jsonify(_serialize(g)), 201
@bp.patch("/<group_id>")
@require_auth
@require_perm("group.update")
def update_group(group_id: str):
gid = _parse_uuid_or_400(group_id)
if gid is None:
return jsonify({"error": "invalid_id"}), 400
raw = request.get_json(silent=True) or {}
try:
payload = UpdateGroupPayload.model_validate(raw)
except ValidationError as e:
return jsonify({"error": "invalid_request", "details": e.errors()}), 400
description_unset = "description" not in raw
try:
g = groups_svc.update_group(
gid,
name=payload.name,
description=... if description_unset else payload.description,
)
except groups_svc.GroupNotFound:
return jsonify({"error": "not_found"}), 404
except groups_svc.SystemGroupProtected as e:
return jsonify({"error": "system_group_protected", "message": str(e)}), 409
except groups_svc.GroupNameConflict as e:
return jsonify({"error": "name_conflict", "message": str(e)}), 409
except ValueError as e:
return jsonify({"error": "invalid_request", "message": str(e)}), 400
log.info("metamorph.group.updated", extra={"group_id": str(gid), "fields": sorted(raw.keys())})
return jsonify(_serialize(g))
@bp.delete("/<group_id>")
@require_auth
@require_perm("group.delete")
def soft_delete(group_id: str):
gid = _parse_uuid_or_400(group_id)
if gid is None:
return jsonify({"error": "invalid_id"}), 400
try:
groups_svc.soft_delete_group(gid)
except groups_svc.GroupNotFound:
return jsonify({"error": "not_found"}), 404
except groups_svc.SystemGroupProtected as e:
return jsonify({"error": "system_group_protected", "message": str(e)}), 409
log.info("metamorph.group.soft_deleted", extra={"group_id": str(gid)})
return jsonify({"ok": True})
@bp.put("/<group_id>/permissions")
@require_auth
@require_perm("group.update")
def set_permissions(group_id: str):
gid = _parse_uuid_or_400(group_id)
if gid is None:
return jsonify({"error": "invalid_id"}), 400
try:
payload = SetPermissionsPayload.model_validate(request.get_json(silent=True) or {})
except ValidationError as e:
return jsonify({"error": "invalid_request", "details": e.errors()}), 400
try:
g = groups_svc.set_group_permissions(gid, payload.codes)
except groups_svc.GroupNotFound:
return jsonify({"error": "not_found"}), 404
except groups_svc.SystemGroupProtected as e:
return jsonify({"error": "system_group_protected", "message": str(e)}), 409
except ValueError as e:
return jsonify({"error": "invalid_request", "message": str(e)}), 400
log.info(
"metamorph.group.permissions_set",
extra={"group_id": str(gid), "count": len(payload.codes)},
)
return jsonify(_serialize(g))

14
backend/app/api/health.py Normal file
View File

@@ -0,0 +1,14 @@
"""Health endpoint — no DB dependency, used by orchestrators and the SPA."""
from __future__ import annotations
from flask import Blueprint, jsonify
from app import __version__
bp = Blueprint("health", __name__)
@bp.get("/health")
def health():
return jsonify({"status": "ok", "version": __version__})

View File

@@ -0,0 +1,146 @@
"""Invitation endpoints — admin issues, invitee previews + accepts."""
from __future__ import annotations
import logging
import uuid
from flask import Blueprint, g, jsonify, make_response, request
from pydantic import BaseModel, Field, ValidationError
from app.api._validation import Email
from app.core.auth_decorators import require_auth, require_perm
from app.core.rate_limit import limiter
from app.services import invitations as inv_svc
bp = Blueprint("invitations", __name__, url_prefix="/invitations")
log = logging.getLogger("metamorph.api.invitations")
class CreateInvitationPayload(BaseModel):
email_hint: Email | None = None
group_ids: list[uuid.UUID] = Field(default_factory=list)
ttl_days: int | None = Field(default=None, ge=1, le=30)
class AcceptInvitationPayload(BaseModel):
email: Email
password: str = Field(min_length=8)
display_name: str | None = None
@bp.post("")
@require_auth
@require_perm("invitation.create")
def create():
try:
payload = CreateInvitationPayload.model_validate(request.get_json(silent=True) or {})
except ValidationError as e:
return jsonify({"error": "invalid_request", "details": e.errors()}), 400
from datetime import timedelta
ttl = (
timedelta(days=payload.ttl_days)
if payload.ttl_days is not None
else inv_svc.INVITATION_TTL
)
result = inv_svc.create_invitation(
created_by_user_id=g.current_user.id,
email_hint=payload.email_hint,
group_ids=payload.group_ids,
ttl=ttl,
)
log.info(
"metamorph.invitation.created",
extra={
"invitation_id": str(result.invitation_id),
"by_user_id": str(g.current_user.id),
"expires_at": result.expires_at.isoformat(),
},
)
return make_response(
jsonify(
{
"id": str(result.invitation_id),
"token": result.raw_token, # shown ONCE
"expires_at": result.expires_at.isoformat(),
}
),
201,
)
@bp.get("")
@require_auth
@require_perm("invitation.read")
def list_active():
rows = inv_svc.list_active()
return jsonify(
[
{
"id": str(r.id),
"email_hint": r.email_hint,
"expires_at": r.expires_at.isoformat(),
"groups": [g.name for g in r.pre_assigned_groups],
}
for r in rows
]
)
@bp.post("/<invitation_id>/revoke")
@require_auth
@require_perm("invitation.revoke")
def revoke(invitation_id: str):
try:
iid = uuid.UUID(invitation_id)
except ValueError:
return jsonify({"error": "invalid_id"}), 400
ok = inv_svc.revoke(iid)
if not ok:
return jsonify({"error": "not_revocable"}), 404
return jsonify({"ok": True})
@bp.get("/preview/<token>")
@limiter.limit("20 per minute")
def preview(token: str):
p = inv_svc.preview(token)
return jsonify(
{
"is_valid": p.is_valid,
"reason": p.reason,
"email_hint": p.email_hint,
"expires_at": p.expires_at.isoformat(),
"groups": p.groups,
}
)
@bp.post("/accept/<token>")
@limiter.limit("10 per minute")
def accept(token: str):
try:
payload = AcceptInvitationPayload.model_validate(request.get_json(silent=True) or {})
except ValidationError as e:
return jsonify({"error": "invalid_request", "details": e.errors()}), 400
try:
user_id = inv_svc.accept(
token,
email=payload.email,
password=payload.password,
display_name=payload.display_name,
)
except inv_svc.InvitationExpired:
return jsonify({"error": "invitation_expired"}), 410
except inv_svc.InvitationConsumed:
return jsonify({"error": "invitation_consumed"}), 410
except inv_svc.InvitationRevoked:
return jsonify({"error": "invitation_revoked"}), 410
except inv_svc.InvitationError as e:
return jsonify({"error": "invitation_invalid", "message": str(e)}), 400
except ValueError as e:
return jsonify({"error": "invalid_request", "message": str(e)}), 400
return make_response(jsonify({"ok": True, "user_id": str(user_id)}), 201)

View File

@@ -0,0 +1,17 @@
"""Read-only catalogue of platform permission codes."""
from __future__ import annotations
from flask import Blueprint, jsonify
from app.core.auth_decorators import require_auth, require_perm
from app.services import groups as groups_svc
bp = Blueprint("permissions", __name__, url_prefix="/permissions")
@bp.get("")
@require_auth
@require_perm("group.read")
def list_permissions():
return jsonify({"items": groups_svc.list_permissions()})

79
backend/app/api/setup.py Normal file
View File

@@ -0,0 +1,79 @@
"""Bootstrap endpoint — consumes the install token to create the first admin."""
from __future__ import annotations
import logging
from flask import Blueprint, jsonify, make_response, request
from pydantic import BaseModel, Field, ValidationError
from app.api._validation import Email
from sqlalchemy import select
from app.core.rate_limit import limiter
from app.db.session import session_scope
from app.models.auth import User
from app.services.bootstrap import (
BootstrapError,
bootstrap_admin,
ensure_system_groups,
)
bp = Blueprint("setup", __name__, url_prefix="/setup")
log = logging.getLogger("metamorph.api.setup")
class SetupPayload(BaseModel):
install_token: str = Field(min_length=20)
email: Email
password: str = Field(min_length=8)
display_name: str | None = None
@bp.get("")
def setup_status():
"""Tell the SPA whether the bootstrap has already been done.
Used by the front to redirect to /setup vs /login on first paint.
"""
with session_scope() as s:
any_user = s.scalar(select(User.id).limit(1)) is not None
return jsonify({"completed": any_user})
@bp.post("")
@limiter.limit("5 per minute")
def setup():
try:
payload = SetupPayload.model_validate(request.get_json(silent=True) or {})
except ValidationError as e:
return jsonify({"error": "invalid_request", "details": e.errors()}), 400
try:
result = bootstrap_admin(
install_token=payload.install_token,
email=payload.email,
password=payload.password,
display_name=payload.display_name,
)
except BootstrapError as e:
return jsonify({"error": "bootstrap_failed", "message": str(e)}), 409
except ValueError as e:
return jsonify({"error": "invalid_request", "message": str(e)}), 400
log.warning(
"metamorph.bootstrap.completed",
extra={"user_id": str(result.user_id), "admin_group_id": str(result.admin_group_id)},
)
# Make sure the redteam/blueteam groups exist too (idempotent).
ensure_system_groups()
return make_response(
jsonify(
{
"ok": True,
"user_id": str(result.user_id),
}
),
201,
)

185
backend/app/api/users.py Normal file
View File

@@ -0,0 +1,185 @@
"""Admin endpoints for user management.
Note: self-service updates (own display name, locale, password) belong to
`/auth/*`; this blueprint is admin-only.
"""
from __future__ import annotations
import logging
import uuid
from flask import Blueprint, jsonify, request
from pydantic import BaseModel, Field, ValidationError
from app.core.auth_decorators import require_auth, require_perm
from app.services import users as users_svc
bp = Blueprint("users", __name__, url_prefix="/users")
log = logging.getLogger("metamorph.api.users")
def _serialize(u: users_svc.UserView) -> dict:
return {
"id": str(u.id),
"email": u.email,
"display_name": u.display_name,
"locale": u.locale,
"is_active": u.is_active,
"deleted_at": u.deleted_at.isoformat() if u.deleted_at else None,
"created_at": u.created_at.isoformat(),
"updated_at": u.updated_at.isoformat(),
"groups": [{"id": str(gid), "name": name} for gid, name in u.groups],
}
class UpdateUserPayload(BaseModel):
# display_name: omitted = no change, null = clear, str = set.
# Tri-state encoded with a `default-unset` sentinel via model_extra.
display_name: str | None = None
locale: str | None = Field(default=None, pattern=r"^[a-z]{2}$")
is_active: bool | None = None
model_config = {"extra": "forbid"}
class SetGroupsPayload(BaseModel):
group_ids: list[uuid.UUID]
model_config = {"extra": "forbid"}
def _parse_uuid_or_400(raw: str):
try:
return uuid.UUID(raw)
except ValueError:
return None
@bp.get("")
@require_auth
@require_perm("user.read")
def list_users():
q = request.args.get("q") or None
is_active_raw = request.args.get("is_active")
is_active: bool | None
if is_active_raw is None:
is_active = None
elif is_active_raw.lower() in ("true", "1", "yes"):
is_active = True
elif is_active_raw.lower() in ("false", "0", "no"):
is_active = False
else:
return jsonify({"error": "invalid_is_active"}), 400
try:
limit = int(request.args.get("limit", "50"))
offset = int(request.args.get("offset", "0"))
except ValueError:
return jsonify({"error": "invalid_pagination"}), 400
limit = max(1, min(limit, 200))
offset = max(0, offset)
rows, total = users_svc.list_users(q=q, is_active=is_active, limit=limit, offset=offset)
return jsonify(
{
"items": [_serialize(u) for u in rows],
"total": total,
"limit": limit,
"offset": offset,
}
)
@bp.get("/<user_id>")
@require_auth
@require_perm("user.read")
def get_user(user_id: str):
uid = _parse_uuid_or_400(user_id)
if uid is None:
return jsonify({"error": "invalid_id"}), 400
try:
u = users_svc.get_user(uid)
except users_svc.UserNotFound:
return jsonify({"error": "not_found"}), 404
return jsonify(_serialize(u))
@bp.patch("/<user_id>")
@require_auth
@require_perm("user.update")
def update_user(user_id: str):
uid = _parse_uuid_or_400(user_id)
if uid is None:
return jsonify({"error": "invalid_id"}), 400
raw = request.get_json(silent=True) or {}
try:
payload = UpdateUserPayload.model_validate(raw)
except ValidationError as e:
return jsonify({"error": "invalid_request", "details": e.errors()}), 400
# Distinguish "key absent" (no change) from "key=null" (clear) for display_name.
display_name_unset = "display_name" not in raw
try:
u = users_svc.update_user(
uid,
display_name=... if display_name_unset else payload.display_name,
locale=payload.locale,
is_active=payload.is_active,
)
except users_svc.UserNotFound:
return jsonify({"error": "not_found"}), 404
except users_svc.LastAdminProtected as e:
return jsonify({"error": "last_admin_protected", "message": str(e)}), 409
log.info(
"metamorph.user.updated",
extra={
"user_id": str(uid),
"fields": sorted(raw.keys()),
},
)
return jsonify(_serialize(u))
@bp.delete("/<user_id>")
@require_auth
@require_perm("user.delete")
def soft_delete(user_id: str):
uid = _parse_uuid_or_400(user_id)
if uid is None:
return jsonify({"error": "invalid_id"}), 400
try:
users_svc.soft_delete_user(uid)
except users_svc.UserNotFound:
return jsonify({"error": "not_found"}), 404
except users_svc.LastAdminProtected as e:
return jsonify({"error": "last_admin_protected", "message": str(e)}), 409
log.info("metamorph.user.soft_deleted", extra={"user_id": str(uid)})
return jsonify({"ok": True})
@bp.put("/<user_id>/groups")
@require_auth
@require_perm("user.update")
def set_groups(user_id: str):
uid = _parse_uuid_or_400(user_id)
if uid is None:
return jsonify({"error": "invalid_id"}), 400
try:
payload = SetGroupsPayload.model_validate(request.get_json(silent=True) or {})
except ValidationError as e:
return jsonify({"error": "invalid_request", "details": e.errors()}), 400
try:
u = users_svc.set_user_groups(uid, payload.group_ids)
except users_svc.UserNotFound:
return jsonify({"error": "not_found"}), 404
except users_svc.LastAdminProtected as e:
return jsonify({"error": "last_admin_protected", "message": str(e)}), 409
except ValueError as e:
return jsonify({"error": "invalid_request", "message": str(e)}), 400
log.info(
"metamorph.user.groups_set",
extra={"user_id": str(uid), "groups": [str(g) for g in payload.group_ids]},
)
return jsonify(_serialize(u))

24
backend/app/api/v1.py Normal file
View File

@@ -0,0 +1,24 @@
"""Aggregate v1 blueprint. Future blueprints (missions, ...) register here."""
from __future__ import annotations
from flask import Blueprint
from app.api.auth import bp as auth_bp
from app.api.diag import bp as diag_bp
from app.api.groups import bp as groups_bp
from app.api.health import bp as health_bp
from app.api.invitations import bp as invitations_bp
from app.api.permissions import bp as permissions_bp
from app.api.setup import bp as setup_bp
from app.api.users import bp as users_bp
bp = Blueprint("v1", __name__, url_prefix="/api/v1")
bp.register_blueprint(health_bp)
bp.register_blueprint(diag_bp)
bp.register_blueprint(setup_bp)
bp.register_blueprint(auth_bp)
bp.register_blueprint(invitations_bp)
bp.register_blueprint(users_bp)
bp.register_blueprint(groups_bp)
bp.register_blueprint(permissions_bp)

65
backend/app/cli.py Normal file
View File

@@ -0,0 +1,65 @@
"""Flask CLI entry point.
Used as `flask --app app.cli metamorph <subcommand>` (or via the make targets).
"""
from __future__ import annotations
import sys
import click
from flask import Flask
from flask.cli import AppGroup
from app.core.install_token import (
ensure_install_token,
log_install_token_banner,
regenerate_install_token,
)
from app.core.logging import configure_logging
from app.services.bootstrap import ensure_system_groups
from app.core.config import settings
def _create_cli_app() -> Flask:
configure_logging(settings.LOG_LEVEL)
return Flask("metamorph-cli")
app = _create_cli_app()
metamorph = AppGroup("metamorph", help="Metamorph admin commands.")
@metamorph.command("print-install-token")
@click.option(
"--force",
is_flag=True,
help="Always mint a fresh token even if one is already pending.",
)
def print_install_token(force: bool):
"""Mint and print the bootstrap install token (idempotent unless --force)."""
ensure_system_groups()
if force:
token = regenerate_install_token()
else:
token = ensure_install_token()
if token is None:
click.echo(
"No install token minted: either at least one user already exists, "
"or a token is already pending (use --force to mint a fresh one).",
err=True,
)
sys.exit(1)
log_install_token_banner(token)
@metamorph.command("seed-mitre")
def seed_mitre():
"""Placeholder for M4 — left so `make seed-mitre` doesn't crash."""
click.echo("MITRE seeding will land in M4. (no-op for now)", err=True)
sys.exit(0)
app.cli.add_command(metamorph)

View File

View File

@@ -0,0 +1,139 @@
"""Flask decorators for authentication + authorization.
Usage:
@bp.get("/whatever")
@require_auth # populates g.current_user
def whatever():
return jsonify(...)
@bp.post("/admin/users")
@require_auth
@require_perm("user.create") # checks the user's effective perms
def create_user():
...
`g.current_user` is a small `AuthenticatedUser` snapshot — no live ORM session.
"""
from __future__ import annotations
import logging
import uuid
from dataclasses import dataclass, field
from functools import wraps
from typing import Callable
import jwt
from flask import abort, g, request
from sqlalchemy import select
from app.core.jwt_tokens import decode_token
from app.db.session import session_scope
from app.models.auth import Permission, User
from app.services.bootstrap import ADMIN_GROUP_NAME
log = logging.getLogger("metamorph.auth")
@dataclass(frozen=True)
class AuthenticatedUser:
id: uuid.UUID
email: str
locale: str
display_name: str | None
is_admin: bool
permissions: frozenset[str] = field(default_factory=frozenset)
group_names: frozenset[str] = field(default_factory=frozenset)
def _load_authenticated_user(user_id: uuid.UUID) -> AuthenticatedUser | None:
with session_scope() as s:
user = s.get(User, user_id)
if user is None or user.deleted_at is not None or not user.is_active:
return None
group_names: set[str] = set()
permissions: set[str] = set()
for grp in user.groups:
if grp.deleted_at is not None:
continue
group_names.add(grp.name)
for perm in grp.permissions:
permissions.add(perm.code)
return AuthenticatedUser(
id=user.id,
email=user.email,
locale=user.locale,
display_name=user.display_name,
is_admin=ADMIN_GROUP_NAME in group_names,
permissions=frozenset(permissions),
group_names=frozenset(group_names),
)
def _extract_bearer() -> str | None:
raw = request.headers.get("Authorization", "")
if not raw.lower().startswith("bearer "):
return None
return raw[7:].strip() or None
def require_auth(fn: Callable):
@wraps(fn)
def wrapper(*args, **kwargs):
token = _extract_bearer()
if token is None:
abort(401, description="missing bearer token")
try:
claims = decode_token(token, expected_type="access")
except jwt.ExpiredSignatureError:
abort(401, description="access token expired")
except jwt.PyJWTError:
abort(401, description="invalid access token")
try:
user_id = uuid.UUID(claims.sub)
except ValueError:
abort(401, description="malformed subject")
snapshot = _load_authenticated_user(user_id)
if snapshot is None:
abort(401, description="user no longer active")
g.current_user = snapshot
return fn(*args, **kwargs)
return wrapper
def require_perm(*codes: str):
"""Require any one of the listed permission codes.
Members of the system `admin` group bypass the check.
"""
def decorator(fn: Callable):
@wraps(fn)
def wrapper(*args, **kwargs):
user: AuthenticatedUser | None = getattr(g, "current_user", None)
if user is None:
abort(401, description="not authenticated")
if user.is_admin:
return fn(*args, **kwargs)
if not any(code in user.permissions for code in codes):
log.info(
"metamorph.auth.permission_denied",
extra={
"user_id": str(user.id),
"required": list(codes),
"had": sorted(user.permissions),
},
)
abort(403, description="insufficient permissions")
return fn(*args, **kwargs)
return wrapper
return decorator
def fetch_all_permissions() -> list[str]:
"""Utility for debugging / admin UI: list every known permission code."""
with session_scope() as s:
return list(s.scalars(select(Permission.code).order_by(Permission.code)).all())

View File

@@ -0,0 +1,76 @@
"""Runtime configuration loaded from environment variables."""
from __future__ import annotations
from typing import Literal
from pydantic import Field, model_validator
from pydantic_settings import BaseSettings, SettingsConfigDict
# Sentinel values that .env.example ships with. If the runtime is configured
# in a non-dev environment with one of these still in place, we refuse to boot.
_DEV_JWT_SECRET = "change-me-to-a-long-random-string"
_DEV_DB_PASSWORD = "change-me-strong"
class Settings(BaseSettings):
model_config = SettingsConfigDict(
env_file=".env",
env_file_encoding="utf-8",
case_sensitive=True,
extra="ignore",
)
# === Runtime mode ===
# Set to "dev" to allow the default placeholder secrets. Anything else
# (e.g. "prod", "staging") forces strong values.
APP_ENV: Literal["dev", "prod", "staging", "test"] = "prod"
# === Postgres ===
POSTGRES_DB: str = "metamorph"
POSTGRES_USER: str = "metamorph"
POSTGRES_PASSWORD: str = ""
POSTGRES_HOST: str = "db"
POSTGRES_PORT: int = 5432
# === API ===
JWT_SECRET: str = Field(default="", min_length=0)
LOG_LEVEL: str = "INFO"
FRONT_ORIGIN: str = "http://localhost:8080"
EVIDENCE_DIR: str = "/data/evidence"
@property
def cors_origins(self) -> list[str]:
return [o.strip() for o in self.FRONT_ORIGIN.split(",") if o.strip()]
@property
def database_url(self) -> str:
"""SQLAlchemy URL using the psycopg3 driver."""
return (
f"postgresql+psycopg://{self.POSTGRES_USER}:{self.POSTGRES_PASSWORD}"
f"@{self.POSTGRES_HOST}:{self.POSTGRES_PORT}/{self.POSTGRES_DB}"
)
@model_validator(mode="after")
def _enforce_secret_strength(self) -> "Settings":
"""Refuse to boot in prod/staging if secrets are missing or default.
`dev` and `test` are explicitly exempted so workstations and the
ephemeral test container don't need real secrets.
"""
if self.APP_ENV in ("dev", "test"):
return self
if not self.JWT_SECRET or self.JWT_SECRET == _DEV_JWT_SECRET or len(self.JWT_SECRET) < 32:
raise ValueError(
"JWT_SECRET is missing, default, or shorter than 32 chars. "
"Set APP_ENV=dev to bypass for local development."
)
if not self.POSTGRES_PASSWORD or self.POSTGRES_PASSWORD == _DEV_DB_PASSWORD:
raise ValueError(
"POSTGRES_PASSWORD is missing or default. "
"Set APP_ENV=dev to bypass for local development."
)
return self
settings = Settings()

View File

@@ -0,0 +1,147 @@
"""First-admin install token.
When the `users` table is empty at boot, we mint a one-shot opaque token,
store its SHA-256 in `settings(key='install_token_hash')`, and log the raw
token to stdout. The operator copies it from the logs and posts it to
`/api/v1/setup` with the desired admin credentials.
Idempotency: as long as the token row exists and no admin has consumed it,
subsequent boots reuse the same hash and re-emit the same token only if
explicitly invoked via `flask metamorph print-install-token`.
"""
from __future__ import annotations
import logging
from datetime import datetime, timedelta, timezone
from sqlalchemy import select
from app.core.security import generate_opaque_token, hash_opaque_token
from app.db.session import session_scope
from app.models.auth import User
from app.models.setting import Setting
INSTALL_TOKEN_KEY = "install_token"
log = logging.getLogger("metamorph.bootstrap")
# Setting JSONB shape: {"hash": "<sha256>", "issued_at": ISO, "expires_at": ISO|null, "consumed_at": ISO|null}
def _users_exist() -> bool:
with session_scope() as s:
return s.execute(select(User.id).limit(1)).first() is not None
def _read_setting() -> Setting | None:
with session_scope() as s:
return s.get(Setting, INSTALL_TOKEN_KEY)
def _write_setting(payload: dict) -> None:
with session_scope() as s:
existing = s.get(Setting, INSTALL_TOKEN_KEY)
if existing is None:
s.add(
Setting(
key=INSTALL_TOKEN_KEY,
value=payload,
description="One-shot bootstrap token for the first admin (M2).",
)
)
else:
existing.value = payload
def ensure_install_token(*, force: bool = False) -> str | None:
"""Mint a token if no users exist and no live token is on file.
Returns the raw token if newly minted (caller is responsible for logging it),
or None if the bootstrap is already consumed / not applicable.
"""
if _users_exist() and not force:
return None
setting = _read_setting()
if setting is not None and not force:
value = setting.value or {}
if value.get("consumed_at"):
return None # consumed, do not mint again
# A pending token exists; we don't know its raw value any more.
# Caller must `force=True` to mint a new one (CLI command will do that).
return None
token = generate_opaque_token()
_write_setting(
{
"hash": hash_opaque_token(token),
"issued_at": datetime.now(tz=timezone.utc).isoformat(),
"expires_at": None, # never expires until consumed
"consumed_at": None,
}
)
return token
def regenerate_install_token() -> str:
"""CLI helper: always mint and persist a fresh token (overwrites any pending one)."""
return ensure_install_token(force=True) or _force_mint()
def _force_mint() -> str:
token = generate_opaque_token()
_write_setting(
{
"hash": hash_opaque_token(token),
"issued_at": datetime.now(tz=timezone.utc).isoformat(),
"expires_at": None,
"consumed_at": None,
}
)
return token
def verify_install_token(token: str) -> bool:
"""Constant-time comparison against the stored hash."""
setting = _read_setting()
if setting is None or not setting.value:
return False
payload = setting.value
if payload.get("consumed_at"):
return False
expected = payload.get("hash")
if not expected:
return False
import hmac
return hmac.compare_digest(hash_opaque_token(token), expected)
def mark_install_token_consumed() -> None:
setting = _read_setting()
if setting is None:
return
payload = dict(setting.value or {})
payload["consumed_at"] = datetime.now(tz=timezone.utc).isoformat()
_write_setting(payload)
def log_install_token_banner(raw_token: str) -> None:
"""Pretty banner so the token is unmissable in container logs."""
sep = "=" * 72
log.warning(
"metamorph.install_token.minted",
extra={
"banner": sep,
"message_template": (
"BOOTSTRAP — copy the token below and POST it to /api/v1/setup "
"with your desired admin email + password. Save it: it is logged once."
),
"install_token": raw_token,
},
)
# Also dump a plain banner so the token is grep-friendly even if the JSON
# consumer hides `extra` fields.
print(sep, flush=True) # noqa: T201
print(f"INSTALL TOKEN: {raw_token}", flush=True) # noqa: T201
print(sep, flush=True) # noqa: T201

View File

@@ -0,0 +1,97 @@
"""JWT encoding / decoding.
Two token types:
- `access` — short-lived (1 h), in `Authorization: Bearer ...` headers, kept
client-side **in memory** only (cf. spec §M2).
- `refresh` — long-lived (30 d), in an HTTPOnly Secure SameSite=Strict cookie
scoped to `/api/v1/auth/`. Rotated on every successful refresh,
old `jti` revoked.
We sign HS256 with `settings.JWT_SECRET`. The `jti` claim links each token to
its DB row in `refresh_tokens` for revocation; access tokens are stateless.
"""
from __future__ import annotations
import secrets
import uuid
from dataclasses import dataclass
from datetime import datetime, timedelta, timezone
from typing import Literal
import jwt
from app.core.config import settings
ACCESS_TOKEN_TTL = timedelta(hours=1)
REFRESH_TOKEN_TTL = timedelta(days=30)
ALGORITHM = "HS256"
ISSUER = "metamorph"
TokenType = Literal["access", "refresh"]
@dataclass(frozen=True)
class TokenClaims:
sub: str # user id (UUID as string)
type: TokenType
jti: str
iat: datetime
exp: datetime
def _now() -> datetime:
return datetime.now(tz=timezone.utc)
def generate_jti() -> str:
"""Compact, URL-safe random identifier (≈22 chars)."""
return secrets.token_urlsafe(16)
def encode_token(
user_id: uuid.UUID | str,
token_type: TokenType,
*,
jti: str | None = None,
) -> tuple[str, TokenClaims]:
"""Return `(jwt_string, claims)`. `jti` is generated if not provided."""
now = _now()
ttl = ACCESS_TOKEN_TTL if token_type == "access" else REFRESH_TOKEN_TTL
claims = TokenClaims(
sub=str(user_id),
type=token_type,
jti=jti or generate_jti(),
iat=now,
exp=now + ttl,
)
payload = {
"iss": ISSUER,
"sub": claims.sub,
"type": claims.type,
"jti": claims.jti,
"iat": int(claims.iat.timestamp()),
"exp": int(claims.exp.timestamp()),
}
return jwt.encode(payload, settings.JWT_SECRET, algorithm=ALGORITHM), claims
def decode_token(token: str, *, expected_type: TokenType) -> TokenClaims:
"""Decode and validate a JWT. Raises `jwt.PyJWTError` on any failure."""
payload = jwt.decode(
token,
settings.JWT_SECRET,
algorithms=[ALGORITHM],
issuer=ISSUER,
options={"require": ["sub", "type", "jti", "iat", "exp"]},
)
if payload["type"] != expected_type:
raise jwt.InvalidTokenError(f"expected {expected_type} token, got {payload['type']}")
return TokenClaims(
sub=payload["sub"],
type=payload["type"],
jti=payload["jti"],
iat=datetime.fromtimestamp(payload["iat"], tz=timezone.utc),
exp=datetime.fromtimestamp(payload["exp"], tz=timezone.utc),
)

View File

@@ -0,0 +1,34 @@
"""JSON structured logging on stdout."""
from __future__ import annotations
import logging
import sys
from pythonjsonlogger import jsonlogger
def configure_logging(level: str = "INFO") -> None:
"""Replace the root handler with a single JSON stdout handler.
Fields emitted: ts, level, name, msg, plus any extras passed via `logger.X(..., extra={...})`.
"""
root = logging.getLogger()
root.setLevel(level.upper())
# Drop any pre-existing handlers (uvicorn/gunicorn add their own).
for h in list(root.handlers):
root.removeHandler(h)
handler = logging.StreamHandler(sys.stdout)
formatter = jsonlogger.JsonFormatter(
fmt="%(asctime)s %(levelname)s %(name)s %(message)s",
rename_fields={"asctime": "ts", "levelname": "level", "name": "logger"},
json_ensure_ascii=False,
)
handler.setFormatter(formatter)
root.addHandler(handler)
# Tame the noisy third parties unless explicitly debugging.
if level.upper() != "DEBUG":
logging.getLogger("werkzeug").setLevel(logging.WARNING)

View File

@@ -0,0 +1,29 @@
"""Shared flask-limiter instance.
Anchored on remote address. In-memory backend for v1 (single-process gunicorn
worker pool can drift; that's acceptable at this scale). M14 will switch to
Redis if it becomes a real concern.
The limiter is enforced in `APP_ENV in ("prod", "staging")` — dev and test
deployments share an in-memory backend that's noisy across hot-reloads and
would gate the Playwright e2e suite at 10 req/min/IP. The spec NF-security
requirement is explicitly a *production* one (cf. tasks/spec.md §6
NF-security); a staging deployment is exposed to humans so the same limits
apply there.
"""
from __future__ import annotations
from flask_limiter import Limiter
from flask_limiter.util import get_remote_address
from app.core.config import settings
limiter = Limiter(
key_func=get_remote_address,
default_limits=[],
storage_uri="memory://",
headers_enabled=True,
strategy="fixed-window",
enabled=settings.APP_ENV in ("prod", "staging"),
)

View File

@@ -0,0 +1,62 @@
"""Password hashing and constant-time secret hashing."""
from __future__ import annotations
import hashlib
import hmac
import secrets
from argon2 import PasswordHasher
from argon2.exceptions import VerifyMismatchError
# Argon2id with moderate cost. `time_cost=2`, `memory_cost=64MiB`, `parallelism=2`
# is well above OWASP minimums while staying snappy on a Debian small VM.
_hasher = PasswordHasher(
time_cost=2,
memory_cost=64 * 1024,
parallelism=2,
hash_len=32,
salt_len=16,
)
def hash_password(plaintext: str) -> str:
return _hasher.hash(plaintext)
def verify_password(stored_hash: str, plaintext: str) -> bool:
"""Constant-time verification. Returns False on mismatch, never raises."""
try:
return _hasher.verify(stored_hash, plaintext)
except VerifyMismatchError:
return False
except Exception: # corrupted hash or unsupported parameters
return False
def needs_rehash(stored_hash: str) -> bool:
"""True when Argon2 parameters have evolved since the hash was created."""
try:
return _hasher.check_needs_rehash(stored_hash)
except Exception:
return True
# === Opaque-token helpers (refresh tokens, invitation tokens) ===
#
# We never store the raw token in DB — only its SHA-256. Comparison uses
# `hmac.compare_digest` to dodge timing attacks. Tokens are URL-safe base64.
TOKEN_BYTES = 48 # 384 bits of entropy → 64 chars b64url
def generate_opaque_token() -> str:
return secrets.token_urlsafe(TOKEN_BYTES)
def hash_opaque_token(token: str) -> str:
return hashlib.sha256(token.encode("utf-8")).hexdigest()
def verify_opaque_token(token: str, stored_hash: str) -> bool:
return hmac.compare_digest(hash_opaque_token(token), stored_hash)

View 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
View 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
View 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
View 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
View 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")

View File

72
backend/app/main.py Normal file
View File

@@ -0,0 +1,72 @@
"""Flask application factory and WSGI entry point."""
from __future__ import annotations
import logging
from flask import Flask
from flask_cors import CORS
from app.api.v1 import bp as v1_bp
from app.core.config import settings
from app.core.install_token import (
ensure_install_token,
log_install_token_banner,
)
from app.core.logging import configure_logging
from app.core.rate_limit import limiter
from app.services.bootstrap import ensure_system_groups
from app.services.permissions_seed import seed_all as seed_permissions_and_bindings
def _try_bootstrap_at_boot(log: logging.Logger) -> None:
"""Best-effort: seed system groups + mint an install token if needed.
Wrapped in try/except because the DB may not be ready (or schema not
migrated yet) at the very first boot — gunicorn must still come up so the
operator can run `make migrate` and curl /setup afterwards.
"""
try:
ensure_system_groups()
seed_permissions_and_bindings()
token = ensure_install_token()
if token is not None:
log_install_token_banner(token)
else:
log.info("metamorph.bootstrap.skipped")
except Exception as e:
log.warning("metamorph.bootstrap.deferred", extra={"error": str(e)})
def create_app() -> Flask:
configure_logging(settings.LOG_LEVEL)
log = logging.getLogger("metamorph.boot")
app = Flask(__name__)
app.config["MAX_CONTENT_LENGTH"] = 64 * 1024 * 1024 # 64 MB hard cap; per-file limit is 25 MB.
CORS(
app,
origins=settings.cors_origins,
supports_credentials=True,
max_age=600,
)
limiter.init_app(app)
app.register_blueprint(v1_bp)
log.info(
"metamorph.api.boot",
extra={
"cors_origins": settings.cors_origins,
"log_level": settings.LOG_LEVEL,
"evidence_dir": settings.EVIDENCE_DIR,
},
)
_try_bootstrap_at_boot(log)
return app
# WSGI entry point used by gunicorn (`gunicorn app.main:app`).
app = create_app()

View 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
View 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"),
)

View 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",
),
)

View 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"),
)

View 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,
)

View 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",
),
)

View 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)

View 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"),
)

View File

View File

@@ -0,0 +1,224 @@
"""Auth domain logic: login, refresh rotation, logout, change_password.
Returns lightweight DTOs (dicts) — the API layer is responsible for HTTP shape.
Raises plain `ValueError` / `LookupError` / `PermissionError` and lets the API
layer translate them into HTTP statuses.
"""
from __future__ import annotations
import uuid
from dataclasses import dataclass
from datetime import datetime, timezone
from sqlalchemy import select
from app.core.jwt_tokens import (
REFRESH_TOKEN_TTL,
decode_token,
encode_token,
generate_jti,
)
from app.core.security import (
hash_opaque_token,
hash_password,
needs_rehash,
verify_opaque_token,
verify_password,
)
from app.db.session import session_scope
from app.models.auth import RefreshToken, User
class AuthError(Exception):
"""Base for auth-flow exceptions; HTTP layer maps to 401/403."""
class InvalidCredentials(AuthError):
pass
class TokenRevoked(AuthError):
pass
@dataclass
class TokenPair:
access_token: str
refresh_token: str
refresh_expires_at: datetime
user_id: uuid.UUID
def _now() -> datetime:
return datetime.now(tz=timezone.utc)
# === Login ===================================================================
def login(email: str, password: str) -> TokenPair:
email_norm = email.strip().lower()
with session_scope() as s:
user = s.scalar(
select(User).where(
User.email == email_norm,
User.deleted_at.is_(None),
User.is_active.is_(True),
)
)
if user is None or not verify_password(user.password_hash, password):
# Same error for "no such user" and "wrong password" — no account enumeration.
raise InvalidCredentials("invalid credentials")
if needs_rehash(user.password_hash):
user.password_hash = hash_password(password)
return _issue_token_pair(s, user.id)
# === Refresh rotation ========================================================
def refresh(raw_refresh_token: str) -> TokenPair:
"""Validate the refresh token, revoke the old one, mint a new pair.
Detects token reuse: if a refresh token that has already been rotated is
presented again, we revoke the entire chain (treat as compromise).
"""
try:
claims = decode_token(raw_refresh_token, expected_type="refresh")
except Exception as e:
raise InvalidCredentials("invalid refresh token") from e
token_hash = hash_opaque_token(raw_refresh_token)
with session_scope() as s:
rt = s.scalar(
select(RefreshToken).where(
RefreshToken.jti == claims.jti,
RefreshToken.token_hash == token_hash,
)
)
if rt is None:
raise InvalidCredentials("refresh token not recognised")
if rt.revoked_at is not None:
# Reuse of a revoked token → likely compromise. Cascade-revoke chain.
_revoke_chain(s, rt)
raise TokenRevoked("refresh token has been revoked")
if rt.expires_at <= _now():
raise InvalidCredentials("refresh token expired")
# Rotate: mark old as revoked + replaced_by, mint new.
new_pair = _issue_token_pair(s, rt.user_id)
new_jti = decode_token(new_pair.refresh_token, expected_type="refresh").jti
new_rt = s.scalar(select(RefreshToken).where(RefreshToken.jti == new_jti))
rt.revoked_at = _now()
rt.replaced_by_id = new_rt.id if new_rt else None
return new_pair
# === Logout ==================================================================
def logout(raw_refresh_token: str) -> None:
"""Revoke the refresh token. Idempotent — silently no-ops on bad tokens."""
try:
claims = decode_token(raw_refresh_token, expected_type="refresh")
except Exception:
return
with session_scope() as s:
rt = s.scalar(select(RefreshToken).where(RefreshToken.jti == claims.jti))
if rt is not None and rt.revoked_at is None:
rt.revoked_at = _now()
def logout_all_for_user(user_id: uuid.UUID) -> int:
"""Revoke every active refresh token for a user. Returns count revoked."""
now = _now()
with session_scope() as s:
active = s.scalars(
select(RefreshToken).where(
RefreshToken.user_id == user_id,
RefreshToken.revoked_at.is_(None),
)
).all()
for rt in active:
rt.revoked_at = now
return len(active)
# === Password change ========================================================
def change_password(user_id: uuid.UUID, current: str, new: str) -> None:
if len(new) < 8:
raise ValueError("new password must be at least 8 characters")
with session_scope() as s:
user = s.get(User, user_id)
if user is None or user.deleted_at is not None or not user.is_active:
raise LookupError("user not found")
if not verify_password(user.password_hash, current):
raise InvalidCredentials("current password is incorrect")
user.password_hash = hash_password(new)
# Force re-login on every other device.
logout_all_for_user(user_id)
# === Helpers =================================================================
def _issue_token_pair(s, user_id: uuid.UUID) -> TokenPair:
"""Issue a fresh access + refresh pair. The refresh row is persisted."""
access_jti = generate_jti()
refresh_jti = generate_jti()
access_token, _ = encode_token(user_id, "access", jti=access_jti)
refresh_token, refresh_claims = encode_token(user_id, "refresh", jti=refresh_jti)
s.add(
RefreshToken(
user_id=user_id,
jti=refresh_jti,
token_hash=hash_opaque_token(refresh_token),
issued_at=refresh_claims.iat,
expires_at=refresh_claims.exp,
)
)
s.flush() # ensure the row gets an id before we return
return TokenPair(
access_token=access_token,
refresh_token=refresh_token,
refresh_expires_at=refresh_claims.exp,
user_id=user_id,
)
def _revoke_chain(s, rt: RefreshToken) -> None:
"""When reuse is detected, revoke this token and its replacement chain."""
seen: set[uuid.UUID] = set()
cur: RefreshToken | None = rt
while cur is not None and cur.id not in seen:
seen.add(cur.id)
if cur.revoked_at is None:
cur.revoked_at = _now()
if cur.replaced_by_id:
cur = s.get(RefreshToken, cur.replaced_by_id)
else:
cur = None
__all__ = [
"AuthError",
"InvalidCredentials",
"TokenRevoked",
"TokenPair",
"REFRESH_TOKEN_TTL",
"login",
"refresh",
"logout",
"logout_all_for_user",
"change_password",
]

View File

@@ -0,0 +1,98 @@
"""Initial bootstrap : seed `admin` / `redteam` / `blueteam` system groups + first admin.
The detailed permission seeding lives in M3 (`mitre.sync` etc.); for M2 we only
need an `admin` group that effectively grants full access. We model that as an
absent permission set + a special `is_system` flag on the group, plus the
`@require_perm` decorator that bypasses checks for any user belonging to a
system `admin` group. M3 will fill in the atomic permissions.
"""
from __future__ import annotations
import uuid
from dataclasses import dataclass
from sqlalchemy import select
from app.core.install_token import (
mark_install_token_consumed,
verify_install_token,
)
from app.core.security import hash_password
from app.db.session import session_scope
from app.models.auth import Group, User, UserGroup
ADMIN_GROUP_NAME = "admin"
REDTEAM_GROUP_NAME = "redteam"
BLUETEAM_GROUP_NAME = "blueteam"
@dataclass
class BootstrapResult:
user_id: uuid.UUID
admin_group_id: uuid.UUID
class BootstrapError(Exception):
pass
def ensure_system_groups() -> dict[str, uuid.UUID]:
"""Create the three system groups if missing. Idempotent."""
out: dict[str, uuid.UUID] = {}
with session_scope() as s:
for name, desc in (
(ADMIN_GROUP_NAME, "Platform administrators — full access."),
(REDTEAM_GROUP_NAME, "Red team operators."),
(BLUETEAM_GROUP_NAME, "Blue team operators."),
):
grp = s.scalar(select(Group).where(Group.name == name, Group.is_system.is_(True)))
if grp is None:
grp = Group(name=name, description=desc, is_system=True)
s.add(grp)
s.flush()
out[name] = grp.id
return out
def bootstrap_admin(
*, install_token: str, email: str, password: str, display_name: str | None = None
) -> BootstrapResult:
"""Consume the install token, create the first admin user, attach to admin group."""
if not verify_install_token(install_token):
raise BootstrapError("invalid or already-consumed install token")
if len(password) < 8:
raise ValueError("password must be at least 8 characters")
email_norm = email.strip().lower()
# Re-check users count under transaction to avoid races.
with session_scope() as s:
if s.scalar(select(User.id).limit(1)) is not None:
raise BootstrapError("setup already done — at least one user exists")
groups = ensure_system_groups()
with session_scope() as s:
user = User(
email=email_norm,
display_name=(display_name or "").strip() or None,
password_hash=hash_password(password),
)
s.add(user)
s.flush()
s.add(UserGroup(user_id=user.id, group_id=groups[ADMIN_GROUP_NAME]))
admin_id = groups[ADMIN_GROUP_NAME]
user_id = user.id
mark_install_token_consumed()
# Re-seed the permission catalogue + system-group bindings. This is called
# at boot too, but on a fresh DB after `/diag/reset` the groups were just
# recreated above and have no permissions yet — seeding here keeps the
# bootstrap path self-contained.
from app.services.permissions_seed import seed_all # noqa: PLC0415 — avoid import cycle
seed_all()
return BootstrapResult(user_id=user_id, admin_group_id=admin_id)

View File

@@ -0,0 +1,210 @@
"""Admin-side group management: CRUD + permission bindings.
System groups (`is_system=True`: admin, redteam, blueteam) cannot be renamed
or deleted, but their permission bindings are seeded on boot and editable
afterwards (e.g. an admin can broaden `redteam`).
"""
from __future__ import annotations
import uuid
from dataclasses import dataclass
from datetime import datetime, timezone
from sqlalchemy import func, select
from app.db.session import session_scope
from app.models.auth import Group, GroupPermission, Permission, UserGroup
from app.services.bootstrap import ADMIN_GROUP_NAME
class GroupNotFound(Exception):
pass
class GroupNameConflict(Exception):
pass
class SystemGroupProtected(Exception):
"""Refusing to delete or rename a built-in system group."""
@dataclass(frozen=True)
class GroupView:
id: uuid.UUID
name: str
description: str | None
is_system: bool
deleted_at: datetime | None
members_count: int
permissions: list[str]
created_at: datetime
updated_at: datetime
def _to_view(g: Group, members_count: int) -> GroupView:
return GroupView(
id=g.id,
name=g.name,
description=g.description,
is_system=g.is_system,
deleted_at=g.deleted_at,
members_count=members_count,
permissions=sorted(p.code for p in g.permissions),
created_at=g.created_at,
updated_at=g.updated_at,
)
def _members_counts(s, group_ids: list[uuid.UUID]) -> dict[uuid.UUID, int]:
if not group_ids:
return {}
from app.models.auth import User as _U # local to avoid model cycles
rows = s.execute(
select(UserGroup.group_id, func.count(UserGroup.user_id))
.join(_U, _U.id == UserGroup.user_id)
.where(UserGroup.group_id.in_(group_ids), _U.deleted_at.is_(None))
.group_by(UserGroup.group_id)
).all()
return {gid: int(cnt) for gid, cnt in rows}
def list_groups(*, include_deleted: bool = False) -> list[GroupView]:
with session_scope() as s:
stmt = select(Group).order_by(Group.is_system.desc(), Group.name.asc())
if not include_deleted:
stmt = stmt.where(Group.deleted_at.is_(None))
rows = s.scalars(stmt).all()
counts = _members_counts(s, [g.id for g in rows])
return [_to_view(g, counts.get(g.id, 0)) for g in rows]
def get_group(group_id: uuid.UUID) -> GroupView:
with session_scope() as s:
g = s.get(Group, group_id)
if g is None or g.deleted_at is not None:
raise GroupNotFound()
counts = _members_counts(s, [g.id])
return _to_view(g, counts.get(g.id, 0))
def create_group(*, name: str, description: str | None) -> GroupView:
name_norm = name.strip()
if not name_norm:
raise ValueError("name is required")
with session_scope() as s:
existing = s.scalar(
select(Group).where(Group.name == name_norm, Group.deleted_at.is_(None))
)
if existing is not None:
raise GroupNameConflict(f"group name {name_norm!r} already in use")
g = Group(name=name_norm, description=(description or "").strip() or None, is_system=False)
s.add(g)
s.flush()
return _to_view(g, 0)
def update_group(
group_id: uuid.UUID,
*,
name: str | None = None,
description: str | None | object = ...,
) -> GroupView:
with session_scope() as s:
g = s.get(Group, group_id)
if g is None or g.deleted_at is not None:
raise GroupNotFound()
if name is not None:
name_norm = name.strip()
if not name_norm:
raise ValueError("name cannot be empty")
if g.is_system and name_norm != g.name:
raise SystemGroupProtected("system groups cannot be renamed")
if name_norm != g.name:
clash = s.scalar(
select(Group).where(
Group.name == name_norm,
Group.deleted_at.is_(None),
Group.id != g.id,
)
)
if clash is not None:
raise GroupNameConflict(f"group name {name_norm!r} already in use")
g.name = name_norm
if description is not ...:
if description in (None, ""):
g.description = None
else:
g.description = description.strip() or None
counts = _members_counts(s, [g.id])
return _to_view(g, counts.get(g.id, 0))
def soft_delete_group(group_id: uuid.UUID) -> None:
with session_scope() as s:
g = s.get(Group, group_id)
if g is None or g.deleted_at is not None:
raise GroupNotFound()
if g.is_system:
raise SystemGroupProtected("system groups cannot be deleted")
g.deleted_at = datetime.now(tz=timezone.utc)
def set_group_permissions(group_id: uuid.UUID, codes: list[str]) -> GroupView:
"""Replace the group's permission set with the given codes (validated)."""
desired_codes = set(codes)
with session_scope() as s:
g = s.get(Group, group_id)
if g is None or g.deleted_at is not None:
raise GroupNotFound()
# Preserve the invariant "the system `admin` group has every perm." The
# decorator's admin bypass relies on `is_admin` (group membership), not
# on the perm set, so a stripped admin group would still grant access —
# but the listing would look misleading and a future refactor could
# reasonably switch the bypass to a perm-based check.
if g.is_system and g.name == ADMIN_GROUP_NAME:
all_codes = {p.code for p in s.scalars(select(Permission)).all()}
if desired_codes != all_codes:
raise SystemGroupProtected(
"the admin group must keep every permission"
)
if desired_codes:
perms = s.scalars(select(Permission).where(Permission.code.in_(desired_codes))).all()
known = {p.code for p in perms}
unknown = desired_codes - known
if unknown:
raise ValueError(f"unknown permission codes: {sorted(unknown)}")
else:
perms = []
current = {p.code: p for p in g.permissions}
to_remove = set(current) - desired_codes
to_add = desired_codes - set(current)
for code in to_remove:
row = s.get(GroupPermission, (g.id, current[code].id))
if row is not None:
s.delete(row)
for p in perms:
if p.code in to_add:
s.add(GroupPermission(group_id=g.id, permission_id=p.id))
s.flush()
s.refresh(g)
counts = _members_counts(s, [g.id])
return _to_view(g, counts.get(g.id, 0))
def list_permissions() -> list[dict]:
"""Return the catalogue of all permissions known to the platform."""
with session_scope() as s:
rows = s.scalars(select(Permission).order_by(Permission.code.asc())).all()
return [
{"id": str(p.id), "code": p.code, "description": p.description}
for p in rows
]

View File

@@ -0,0 +1,188 @@
"""Invitation flow: admin issues a one-shot URL token, invitee accepts.
The raw token is shown to the admin once (returned by `create_invitation`)
and never persisted — only its SHA-256 lives in the DB. Pre-assigned groups
are attached at creation and applied at acceptance.
"""
from __future__ import annotations
import uuid
from dataclasses import dataclass
from datetime import datetime, timedelta, timezone
from typing import Iterable
from sqlalchemy import select
from app.core.security import (
generate_opaque_token,
hash_opaque_token,
hash_password,
)
from app.db.session import session_scope
from app.models.auth import Group, Invitation, InvitationGroup, User, UserGroup
INVITATION_TTL = timedelta(days=7)
class InvitationError(Exception):
pass
class InvitationExpired(InvitationError):
pass
class InvitationConsumed(InvitationError):
pass
class InvitationRevoked(InvitationError):
pass
@dataclass
class InvitationCreated:
invitation_id: uuid.UUID
raw_token: str
expires_at: datetime
@dataclass
class InvitationPreview:
email_hint: str | None
expires_at: datetime
groups: list[str]
is_valid: bool
reason: str | None
def _now() -> datetime:
return datetime.now(tz=timezone.utc)
def create_invitation(
*,
created_by_user_id: uuid.UUID,
email_hint: str | None,
group_ids: Iterable[uuid.UUID] = (),
ttl: timedelta = INVITATION_TTL,
) -> InvitationCreated:
raw = generate_opaque_token()
expires_at = _now() + ttl
with session_scope() as s:
inv = Invitation(
token_hash=hash_opaque_token(raw),
email_hint=email_hint.strip().lower() if email_hint else None,
created_by_user_id=created_by_user_id,
expires_at=expires_at,
)
s.add(inv)
s.flush()
for gid in group_ids:
s.add(InvitationGroup(invitation_id=inv.id, group_id=gid))
return InvitationCreated(
invitation_id=inv.id,
raw_token=raw,
expires_at=expires_at,
)
def _load_by_token(s, raw_token: str) -> Invitation | None:
return s.scalar(
select(Invitation).where(Invitation.token_hash == hash_opaque_token(raw_token))
)
def preview(raw_token: str) -> InvitationPreview:
with session_scope() as s:
inv = _load_by_token(s, raw_token)
if inv is None:
return InvitationPreview(None, _now(), [], False, "not_found")
groups = [g.name for g in inv.pre_assigned_groups]
if inv.revoked_at is not None:
return InvitationPreview(inv.email_hint, inv.expires_at, groups, False, "revoked")
if inv.consumed_at is not None:
return InvitationPreview(inv.email_hint, inv.expires_at, groups, False, "consumed")
if inv.expires_at <= _now():
return InvitationPreview(inv.email_hint, inv.expires_at, groups, False, "expired")
return InvitationPreview(inv.email_hint, inv.expires_at, groups, True, None)
def accept(raw_token: str, *, email: str, password: str, display_name: str | None) -> uuid.UUID:
"""Create the user, attach pre-assigned groups, mark invitation consumed."""
if len(password) < 8:
raise ValueError("password must be at least 8 characters")
email_norm = email.strip().lower()
with session_scope() as s:
inv = _load_by_token(s, raw_token)
if inv is None:
raise InvitationError("invitation not found")
if inv.revoked_at is not None:
raise InvitationRevoked("invitation revoked")
if inv.consumed_at is not None:
raise InvitationConsumed("invitation already consumed")
if inv.expires_at <= _now():
raise InvitationExpired("invitation expired")
# Email must not be already in use among active users.
existing = s.scalar(
select(User).where(User.email == email_norm, User.deleted_at.is_(None))
)
if existing is not None:
raise ValueError("email already in use")
user = User(
email=email_norm,
display_name=(display_name or "").strip() or None,
password_hash=hash_password(password),
)
s.add(user)
s.flush()
for grp in inv.pre_assigned_groups:
s.add(UserGroup(user_id=user.id, group_id=grp.id))
inv.consumed_at = _now()
inv.consumed_by_user_id = user.id
return user.id
def revoke(invitation_id: uuid.UUID) -> bool:
with session_scope() as s:
inv = s.get(Invitation, invitation_id)
if inv is None:
return False
if inv.revoked_at is not None or inv.consumed_at is not None:
return False
inv.revoked_at = _now()
return True
def list_active(*, limit: int = 100) -> list[Invitation]:
with session_scope() as s:
rows = s.scalars(
select(Invitation)
.where(
Invitation.consumed_at.is_(None),
Invitation.revoked_at.is_(None),
Invitation.expires_at > _now(),
)
.order_by(Invitation.created_at.desc())
.limit(limit)
).all()
# detach so caller can read after session closes
for r in rows:
s.expunge(r)
for g in r.pre_assigned_groups:
s.expunge(g)
return list(rows)
def find_group_id_by_name(name: str) -> uuid.UUID | None:
with session_scope() as s:
gid = s.scalar(
select(Group.id).where(Group.name == name, Group.deleted_at.is_(None))
)
return gid

View File

@@ -0,0 +1,179 @@
"""Atomic permission catalogue + seed for the 3 default system groups.
Permissions follow the `<entity>.<action>` convention. They are the ground truth
checked by `@require_perm`; admins bypass everything (cf. `auth_decorators.py`).
This module is the single place that lists every permission code shipped with
the platform. To add a new perm in a future milestone:
1. Add an entry to `PERMISSION_CATALOGUE`.
2. Decide which system group(s) should get it by default — edit
`_default_redteam_perms()` / `_default_blueteam_perms()` if relevant
(admin always gets everything, so no edit needed there).
3. The next boot picks it up; existing groups are *upgraded* (perms added),
never downgraded (we never remove perms from a system group, even if you
trim the catalogue — that would be a destructive op disguised as a seed).
The seed is idempotent and safe to call on every boot.
"""
from __future__ import annotations
import logging
from dataclasses import dataclass
from sqlalchemy import select
from app.db.session import session_scope
from app.models.auth import Group, GroupPermission, Permission
from app.services.bootstrap import (
ADMIN_GROUP_NAME,
BLUETEAM_GROUP_NAME,
REDTEAM_GROUP_NAME,
)
log = logging.getLogger("metamorph.permissions")
@dataclass(frozen=True)
class PermissionDef:
code: str
description: str
# === Catalogue ================================================================
#
# Order is presentation-only; the seed is idempotent. Grouped by family to keep
# diffs reviewable and to mirror the admin UI grouping in M3.6.
#
PERMISSION_CATALOGUE: tuple[PermissionDef, ...] = (
# users
PermissionDef("user.read", "View users."),
PermissionDef("user.create", "Create users (typically via invitation)."),
PermissionDef("user.update", "Update user metadata (display name, locale, active flag)."),
PermissionDef("user.delete", "Soft-delete a user."),
# groups
PermissionDef("group.read", "View groups and their permissions."),
PermissionDef("group.create", "Create a custom group."),
PermissionDef("group.update", "Edit a custom group (name, description, permissions, members)."),
PermissionDef("group.delete", "Soft-delete a custom group."),
# invitations
PermissionDef("invitation.read", "View pending invitations."),
PermissionDef("invitation.create", "Issue a new invitation URL."),
PermissionDef("invitation.revoke", "Revoke an unconsumed invitation."),
# test templates
PermissionDef("test_template.read", "View the test-template catalogue."),
PermissionDef("test_template.create", "Create a test template."),
PermissionDef("test_template.update", "Edit a test template."),
PermissionDef("test_template.delete", "Soft-delete a test template."),
# scenario templates
PermissionDef("scenario_template.read", "View the scenario-template catalogue."),
PermissionDef("scenario_template.create", "Create a scenario template."),
PermissionDef("scenario_template.update", "Edit a scenario template (and its ordered tests)."),
PermissionDef("scenario_template.delete", "Soft-delete a scenario template."),
# missions
PermissionDef("mission.read", "View missions (server still filters by membership for non-admin)."),
PermissionDef("mission.create", "Create a mission."),
PermissionDef("mission.update", "Edit mission metadata, scenarios, members."),
PermissionDef("mission.archive", "Move a mission to status=archived."),
PermissionDef("mission.delete", "Soft-delete a mission."),
PermissionDef("mission.write_red_fields", "Write red-side fields on a mission test."),
PermissionDef("mission.write_blue_fields", "Write blue-side fields and upload evidence."),
# detection levels + platform settings + MITRE sync
PermissionDef("detection_level.read", "View the detection-level taxonomy."),
PermissionDef("detection_level.update", "Edit the detection-level taxonomy."),
PermissionDef("setting.read", "Read platform settings."),
PermissionDef("setting.update", "Update platform settings."),
PermissionDef("mitre.sync", "Trigger a MITRE ATT&CK Enterprise re-sync."),
)
def _default_redteam_perms() -> frozenset[str]:
return frozenset(
{
# catalogue read-only
"test_template.read",
"scenario_template.read",
# MITRE/detection refs
"detection_level.read",
# missions: full lifecycle on red side
"mission.read",
"mission.create",
"mission.update",
"mission.archive",
"mission.write_red_fields",
}
)
def _default_blueteam_perms() -> frozenset[str]:
return frozenset(
{
"test_template.read",
"scenario_template.read",
"detection_level.read",
"mission.read",
"mission.write_blue_fields",
}
)
def _all_perm_codes() -> frozenset[str]:
return frozenset(p.code for p in PERMISSION_CATALOGUE)
def seed_permissions() -> dict[str, int]:
"""Insert any missing permissions. Returns counts: `created`, `total`."""
created = 0
with session_scope() as s:
existing_codes = set(s.scalars(select(Permission.code)).all())
for p in PERMISSION_CATALOGUE:
if p.code in existing_codes:
continue
s.add(Permission(code=p.code, description=p.description))
created += 1
return {"created": created, "total": len(PERMISSION_CATALOGUE)}
def _assign_perms_to_group(group_name: str, codes: frozenset[str]) -> int:
"""Attach the named perms to the given system group. Returns added count.
We never *remove* perms from a system group here — the seed is additive.
Admins/operators who want to revoke must do so explicitly via the UI/API.
"""
if not codes:
return 0
added = 0
with session_scope() as s:
group = s.scalar(select(Group).where(Group.name == group_name, Group.is_system.is_(True)))
if group is None:
raise RuntimeError(f"system group {group_name!r} missing — call ensure_system_groups() first")
existing_codes = {p.code for p in group.permissions}
perms = s.scalars(select(Permission).where(Permission.code.in_(codes))).all()
for p in perms:
if p.code in existing_codes:
continue
s.add(GroupPermission(group_id=group.id, permission_id=p.id))
added += 1
return added
def seed_default_group_permissions() -> dict[str, int]:
"""Bind the catalogue to the 3 default groups. Idempotent + additive."""
counts: dict[str, int] = {}
counts[ADMIN_GROUP_NAME] = _assign_perms_to_group(ADMIN_GROUP_NAME, _all_perm_codes())
counts[REDTEAM_GROUP_NAME] = _assign_perms_to_group(REDTEAM_GROUP_NAME, _default_redteam_perms())
counts[BLUETEAM_GROUP_NAME] = _assign_perms_to_group(BLUETEAM_GROUP_NAME, _default_blueteam_perms())
return counts
def seed_all() -> dict[str, dict[str, int]]:
"""One-shot helper: catalogue + default group bindings."""
perms = seed_permissions()
bindings = seed_default_group_permissions()
log.info(
"metamorph.permissions.seeded",
extra={"perms_created": perms["created"], "perms_total": perms["total"], "bindings": bindings},
)
return {"permissions": perms, "bindings": bindings}

View File

@@ -0,0 +1,204 @@
"""Admin-side user management: list, get, update, soft-delete, assign groups.
Self-service updates (locale, password, display_name) live in
`services.auth` — this module is for admin operations on other users.
"""
from __future__ import annotations
import uuid
from dataclasses import dataclass
from datetime import datetime, timezone
from typing import Iterable
from sqlalchemy import func, or_, select
from app.db.session import session_scope
from app.models.auth import Group, User, UserGroup
from app.services.bootstrap import ADMIN_GROUP_NAME
class UserNotFound(Exception):
pass
class LastAdminProtected(Exception):
"""Refusing to strip admin from the last active admin."""
class SystemGroupProtected(Exception):
"""Refusing to delete or rename a built-in system group."""
@dataclass(frozen=True)
class UserView:
id: uuid.UUID
email: str
display_name: str | None
locale: str
is_active: bool
deleted_at: datetime | None
created_at: datetime
updated_at: datetime
groups: list[tuple[uuid.UUID, str]]
def _to_view(u: User) -> UserView:
return UserView(
id=u.id,
email=u.email,
display_name=u.display_name,
locale=u.locale,
is_active=u.is_active,
deleted_at=u.deleted_at,
created_at=u.created_at,
updated_at=u.updated_at,
groups=[(g.id, g.name) for g in u.groups if g.deleted_at is None],
)
def list_users(
*,
q: str | None = None,
is_active: bool | None = None,
include_deleted: bool = False,
limit: int = 50,
offset: int = 0,
) -> tuple[list[UserView], int]:
"""Return (rows, total_count) with case-insensitive search on email + display_name."""
with session_scope() as s:
stmt = select(User)
count_stmt = select(func.count()).select_from(User)
if not include_deleted:
stmt = stmt.where(User.deleted_at.is_(None))
count_stmt = count_stmt.where(User.deleted_at.is_(None))
if is_active is not None:
stmt = stmt.where(User.is_active.is_(is_active))
count_stmt = count_stmt.where(User.is_active.is_(is_active))
if q:
like = f"%{q.lower()}%"
stmt = stmt.where(
or_(func.lower(User.email).like(like), func.lower(User.display_name).like(like))
)
count_stmt = count_stmt.where(
or_(func.lower(User.email).like(like), func.lower(User.display_name).like(like))
)
stmt = stmt.order_by(User.email.asc()).limit(limit).offset(offset)
rows = s.scalars(stmt).all()
total = int(s.scalar(count_stmt) or 0)
views = [_to_view(u) for u in rows]
return views, total
def get_user(user_id: uuid.UUID, *, include_deleted: bool = False) -> UserView:
with session_scope() as s:
u = s.get(User, user_id)
if u is None or (u.deleted_at is not None and not include_deleted):
raise UserNotFound()
return _to_view(u)
def update_user(
user_id: uuid.UUID,
*,
display_name: str | None | object = ...,
locale: str | None = None,
is_active: bool | None = None,
) -> UserView:
"""Partial update. Pass display_name=None to clear; omit to leave unchanged."""
with session_scope() as s:
u = s.get(User, user_id)
if u is None or u.deleted_at is not None:
raise UserNotFound()
if display_name is not ...:
if display_name in (None, ""):
u.display_name = None
else:
u.display_name = display_name.strip() or None
if locale is not None:
u.locale = locale
if is_active is not None:
# If deactivating the last active admin, refuse.
if not is_active and _is_last_active_admin(s, u):
raise LastAdminProtected("cannot deactivate the last active admin")
u.is_active = is_active
return _to_view(u)
def soft_delete_user(user_id: uuid.UUID) -> None:
with session_scope() as s:
u = s.get(User, user_id)
if u is None or u.deleted_at is not None:
raise UserNotFound()
if _is_last_active_admin(s, u):
raise LastAdminProtected("cannot delete the last active admin")
u.deleted_at = datetime.now(tz=timezone.utc)
u.is_active = False
def set_user_groups(user_id: uuid.UUID, group_ids: Iterable[uuid.UUID]) -> UserView:
"""Replace the user's group memberships with the given set."""
desired = set(group_ids)
with session_scope() as s:
u = s.get(User, user_id)
if u is None or u.deleted_at is not None:
raise UserNotFound()
# Resolve admin group id once.
admin_group_id = s.scalar(
select(Group.id).where(Group.name == ADMIN_GROUP_NAME, Group.is_system.is_(True))
)
is_currently_admin = admin_group_id in {g.id for g in u.groups}
will_be_admin = admin_group_id in desired
if is_currently_admin and not will_be_admin and _is_last_active_admin(s, u):
raise LastAdminProtected("cannot remove admin from the last active admin")
# Refuse silently for unknown groups: validate first.
if desired:
known = set(
s.scalars(
select(Group.id).where(Group.id.in_(desired), Group.deleted_at.is_(None))
).all()
)
unknown = desired - known
if unknown:
raise ValueError(f"unknown groups: {sorted(map(str, unknown))}")
current = {g.id for g in u.groups}
to_add = desired - current
to_remove = current - desired
for gid in to_remove:
row = s.get(UserGroup, (u.id, gid))
if row is not None:
s.delete(row)
for gid in to_add:
s.add(UserGroup(user_id=u.id, group_id=gid))
s.flush()
s.refresh(u)
return _to_view(u)
def _is_last_active_admin(s, user: User) -> bool:
"""True when `user` is currently in the admin system group and removing/blocking
them would leave the platform with zero active admins."""
admin_group_id = s.scalar(
select(Group.id).where(Group.name == ADMIN_GROUP_NAME, Group.is_system.is_(True))
)
if admin_group_id is None:
return False
if admin_group_id not in {g.id for g in user.groups}:
return False
other_admins = s.scalar(
select(func.count())
.select_from(User)
.join(UserGroup, UserGroup.user_id == User.id)
.where(
UserGroup.group_id == admin_group_id,
User.id != user.id,
User.deleted_at.is_(None),
User.is_active.is_(True),
)
)
return int(other_admins or 0) == 0

60
backend/pyproject.toml Normal file
View File

@@ -0,0 +1,60 @@
[project]
name = "metamorph-api"
version = "0.1.0"
description = "Metamorph backend API — collaborative purple-team platform."
requires-python = ">=3.12"
license = { text = "Proprietary" }
dependencies = [
"flask>=3.0,<4.0",
"flask-cors>=4.0,<5.0",
"flask-limiter>=3.7,<4.0",
"pydantic[email]>=2.6,<3.0",
"pydantic-settings>=2.2,<3.0",
"python-json-logger>=2.0,<3.0",
"gunicorn>=21.2,<22.0",
"sqlalchemy>=2.0,<3.0",
"alembic>=1.13,<2.0",
"psycopg[binary]>=3.1,<4.0",
"argon2-cffi>=23.1,<25.0",
"pyjwt>=2.8,<3.0",
]
[project.optional-dependencies]
dev = [
"pytest>=8.0,<9.0",
"pytest-cov>=5.0,<6.0",
"ruff>=0.4,<1.0",
"httpx>=0.27,<1.0",
]
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[tool.hatch.build.targets.wheel]
packages = ["app"]
[tool.ruff]
line-length = 100
target-version = "py312"
src = ["app", "tests"]
[tool.ruff.lint]
select = [
"E", # pycodestyle
"F", # pyflakes
"I", # isort
"B", # flake8-bugbear
"UP", # pyupgrade
"SIM", # flake8-simplify
"RUF", # ruff-specific
]
ignore = ["E501"] # line length handled by formatter
[tool.ruff.format]
quote-style = "double"
[tool.pytest.ini_options]
testpaths = ["tests"]
addopts = "-q --strict-markers"

View File

25
backend/tests/conftest.py Normal file
View 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

View File

@@ -0,0 +1,299 @@
"""End-to-end auth flow integration test (live DB).
Hits the Flask test client to exercise:
- /setup with the install token
- /auth/login + /auth/me
- /auth/refresh (rotation)
- /auth/logout (revocation, idempotency)
- /auth/change-password (forces logout-all)
- /invitations create + preview + accept
- RBAC: non-admin gets 403 on admin endpoint
The DB schema is left in place between tests; we use unique emails to avoid
collisions across runs. The install token is force-minted at the start.
"""
from __future__ import annotations
import json
import secrets
import uuid
import pytest
from sqlalchemy import text
from app.core.install_token import regenerate_install_token
from app.db.session import get_engine
from app.main import create_app
def _truncate_users(engine):
"""Wipe data so /setup has work to do. CASCADE handles dependent rows."""
with engine.begin() as conn:
conn.execute(
text(
"TRUNCATE users, refresh_tokens, invitations, invitation_groups, "
"user_groups, settings, groups RESTART IDENTITY CASCADE"
)
)
@pytest.fixture(scope="module")
def app(db_engine_or_skip):
_truncate_users(db_engine_or_skip)
flask_app = create_app()
flask_app.config.update(TESTING=True)
return flask_app
@pytest.fixture()
def client(app):
return app.test_client()
@pytest.fixture(scope="module")
def install_token(db_engine_or_skip):
return regenerate_install_token()
def _unique_email(prefix: str) -> str:
return f"{prefix}-{secrets.token_hex(4)}@metamorph.local"
# -- /setup -------------------------------------------------------------------
def test_setup_status_starts_uncompleted(client):
r = client.get("/api/v1/setup")
assert r.status_code == 200
assert r.get_json()["completed"] is False
def test_setup_creates_first_admin(client, install_token):
email = _unique_email("admin")
r = client.post(
"/api/v1/setup",
json={
"install_token": install_token,
"email": email,
"password": "AdminPass1234!",
"display_name": "Init Admin",
},
)
assert r.status_code == 201, r.get_data(as_text=True)
body = r.get_json()
assert "user_id" in body
pytest.shared_admin = {"email": email, "password": "AdminPass1234!", "id": body["user_id"]} # type: ignore[attr-defined]
def test_setup_status_now_completed(client):
assert client.get("/api/v1/setup").get_json()["completed"] is True
def test_setup_replay_is_blocked(client, install_token):
# Token already consumed — a second call must be refused.
r = client.post(
"/api/v1/setup",
json={
"install_token": install_token,
"email": _unique_email("replay"),
"password": "AdminPass1234!",
},
)
assert r.status_code == 409
# -- /auth/login + /auth/me ---------------------------------------------------
@pytest.fixture(scope="module")
def admin_credentials():
return getattr(pytest, "shared_admin") # populated by test_setup_creates_first_admin
def _login(client, email: str, password: str) -> tuple[str, dict]:
r = client.post("/api/v1/auth/login", json={"email": email, "password": password})
assert r.status_code == 200, r.get_data(as_text=True)
body = r.get_json()
return body["access_token"], dict(r.headers)
def test_login_and_me(client, admin_credentials):
access, _ = _login(client, admin_credentials["email"], admin_credentials["password"])
r = client.get("/api/v1/auth/me", headers={"Authorization": f"Bearer {access}"})
assert r.status_code == 200
me = r.get_json()
assert me["email"] == admin_credentials["email"]
assert me["is_admin"] is True
assert "admin" in me["groups"]
def test_login_with_wrong_password_returns_401(client, admin_credentials):
r = client.post(
"/api/v1/auth/login",
json={"email": admin_credentials["email"], "password": "wrong"},
)
assert r.status_code == 401
assert r.get_json()["error"] == "invalid_credentials"
def test_me_without_token_returns_401(client):
r = client.get("/api/v1/auth/me")
assert r.status_code == 401
# -- /auth/refresh rotation ---------------------------------------------------
def test_refresh_rotates_and_old_token_is_revoked(client, admin_credentials):
# Login fresh to get a refresh cookie on the test client.
client.post(
"/api/v1/auth/login",
json={"email": admin_credentials["email"], "password": admin_credentials["password"]},
)
# First refresh — should succeed.
r1 = client.post("/api/v1/auth/refresh")
assert r1.status_code == 200, r1.get_data(as_text=True)
new_access1 = r1.get_json()["access_token"]
assert new_access1
# Second refresh — uses the rotated cookie automatically (test client persists cookies).
r2 = client.post("/api/v1/auth/refresh")
assert r2.status_code == 200
assert r2.get_json()["access_token"] != new_access1
def test_refresh_with_no_cookie_returns_401(client):
fresh = client.application.test_client() # blank cookie jar
r = fresh.post("/api/v1/auth/refresh")
assert r.status_code == 401
# -- /auth/logout -------------------------------------------------------------
def test_logout_clears_cookie_and_is_idempotent(client, admin_credentials):
client.post(
"/api/v1/auth/login",
json={"email": admin_credentials["email"], "password": admin_credentials["password"]},
)
r1 = client.post("/api/v1/auth/logout")
assert r1.status_code == 200
# Second logout — no token, still 200.
r2 = client.post("/api/v1/auth/logout")
assert r2.status_code == 200
# -- /invitations -------------------------------------------------------------
def test_admin_creates_invitation_and_invitee_accepts(client, admin_credentials):
access, _ = _login(client, admin_credentials["email"], admin_credentials["password"])
inv_email = _unique_email("alice")
create = client.post(
"/api/v1/invitations",
headers={"Authorization": f"Bearer {access}"},
json={"email_hint": inv_email},
)
assert create.status_code == 201, create.get_data(as_text=True)
token = create.get_json()["token"]
preview = client.get(f"/api/v1/invitations/preview/{token}")
assert preview.status_code == 200
assert preview.get_json()["is_valid"] is True
accept = client.post(
f"/api/v1/invitations/accept/{token}",
json={"email": inv_email, "password": "AlicePass1234!", "display_name": "Alice"},
)
assert accept.status_code == 201, accept.get_data(as_text=True)
# Alice can now log in.
a_access, _ = _login(client, inv_email, "AlicePass1234!")
me = client.get("/api/v1/auth/me", headers={"Authorization": f"Bearer {a_access}"}).get_json()
assert me["is_admin"] is False
assert me["email"] == inv_email
def test_unauthenticated_cannot_create_invitation(client):
r = client.post("/api/v1/invitations", json={})
assert r.status_code == 401
def test_non_admin_cannot_create_invitation(client, admin_credentials):
# Create a non-admin user via invitation, then try as them.
access, _ = _login(client, admin_credentials["email"], admin_credentials["password"])
inv_email = _unique_email("bob")
create = client.post(
"/api/v1/invitations",
headers={"Authorization": f"Bearer {access}"},
json={"email_hint": inv_email},
)
token = create.get_json()["token"]
client.post(
f"/api/v1/invitations/accept/{token}",
json={"email": inv_email, "password": "BobPass1234!"},
)
bob_access, _ = _login(client, inv_email, "BobPass1234!")
r = client.post(
"/api/v1/invitations",
headers={"Authorization": f"Bearer {bob_access}"},
json={},
)
assert r.status_code == 403
def test_used_invitation_cannot_be_accepted_twice(client, admin_credentials):
access, _ = _login(client, admin_credentials["email"], admin_credentials["password"])
inv_email = _unique_email("carol")
token = client.post(
"/api/v1/invitations",
headers={"Authorization": f"Bearer {access}"},
json={"email_hint": inv_email},
).get_json()["token"]
first = client.post(
f"/api/v1/invitations/accept/{token}",
json={"email": inv_email, "password": "CarolPass1234!"},
)
assert first.status_code == 201
second = client.post(
f"/api/v1/invitations/accept/{token}",
json={"email": _unique_email("carol2"), "password": "OtherPass1234!"},
)
assert second.status_code == 410
assert second.get_json()["error"] == "invitation_consumed"
# -- change-password forces logout-all ----------------------------------------
def test_change_password_revokes_all_refresh_tokens(client, admin_credentials):
access, _ = _login(client, admin_credentials["email"], admin_credentials["password"])
# Trigger a couple of refreshes so we have multiple chains in DB.
client.post("/api/v1/auth/refresh")
client.post("/api/v1/auth/refresh")
# Change password.
new_pw = "AdminPass5678!"
r = client.post(
"/api/v1/auth/change-password",
headers={"Authorization": f"Bearer {access}"},
json={
"current_password": admin_credentials["password"],
"new_password": new_pw,
},
)
assert r.status_code == 200
admin_credentials["password"] = new_pw
# Existing refresh cookie must now be rejected.
r2 = client.post("/api/v1/auth/refresh")
assert r2.status_code == 401
# New login still works.
_login(client, admin_credentials["email"], admin_credentials["password"])

View File

@@ -0,0 +1,14 @@
"""M0 smoke test: the /api/v1/health endpoint returns 200 and the expected payload."""
from __future__ import annotations
from app.main import app
def test_health_returns_ok():
client = app.test_client()
resp = client.get("/api/v1/health")
assert resp.status_code == 200
body = resp.get_json()
assert body["status"] == "ok"
assert "version" in body

344
backend/tests/test_rbac.py Normal file
View File

@@ -0,0 +1,344 @@
"""Integration tests for M3: permission seed + users/groups/permissions APIs.
Exercises the Flask test client against a live Postgres. The DB is wiped at
module load so test ordering inside the module matters (see `pytest.shared_*`).
"""
from __future__ import annotations
import secrets
import pytest
from sqlalchemy import text
from app.core.install_token import regenerate_install_token
from app.main import create_app
from app.services.permissions_seed import PERMISSION_CATALOGUE
def _truncate_all(engine):
"""Wipe data plus permissions table. CASCADE handles dependent rows."""
with engine.begin() as conn:
conn.execute(
text(
"TRUNCATE users, refresh_tokens, invitations, invitation_groups, "
"user_groups, group_permissions, permissions, settings, groups "
"RESTART IDENTITY CASCADE"
)
)
@pytest.fixture(scope="module")
def app(db_engine_or_skip):
_truncate_all(db_engine_or_skip)
flask_app = create_app() # triggers bootstrap → seed_all()
flask_app.config.update(TESTING=True)
return flask_app
@pytest.fixture()
def client(app):
return app.test_client()
def _unique_email(prefix: str) -> str:
return f"{prefix}-{secrets.token_hex(4)}@metamorph.local"
def _login(client, email: str, password: str) -> str:
r = client.post("/api/v1/auth/login", json={"email": email, "password": password})
assert r.status_code == 200, r.get_data(as_text=True)
return r.get_json()["access_token"]
# -- M3.1 — Permissions seeded at boot -----------------------------------------
def test_permissions_catalogue_seeded(client):
"""Catalogue table has every code from PERMISSION_CATALOGUE."""
# We need an admin to call /permissions — bootstrap one via /setup.
token = regenerate_install_token()
email = _unique_email("admin")
r = client.post(
"/api/v1/setup",
json={
"install_token": token,
"email": email,
"password": "AdminPass1234!",
"display_name": "Admin",
},
)
assert r.status_code == 201, r.get_data(as_text=True)
pytest.shared_admin = {"email": email, "password": "AdminPass1234!", "user_id": r.get_json()["user_id"]} # type: ignore[attr-defined]
access = _login(client, email, "AdminPass1234!")
perms = client.get(
"/api/v1/permissions", headers={"Authorization": f"Bearer {access}"}
).get_json()
codes = {p["code"] for p in perms["items"]}
expected = {p.code for p in PERMISSION_CATALOGUE}
assert expected.issubset(codes)
def test_admin_group_has_every_permission(client):
admin = pytest.shared_admin
access = _login(client, admin["email"], admin["password"])
groups = client.get(
"/api/v1/groups", headers={"Authorization": f"Bearer {access}"}
).get_json()
admin_group = next(g for g in groups["items"] if g["name"] == "admin")
assert set(admin_group["permissions"]) == {p.code for p in PERMISSION_CATALOGUE}
assert admin_group["is_system"] is True
def test_redteam_group_has_red_perms_but_not_blue_write(client):
admin = pytest.shared_admin
access = _login(client, admin["email"], admin["password"])
groups = client.get(
"/api/v1/groups", headers={"Authorization": f"Bearer {access}"}
).get_json()
redteam = next(g for g in groups["items"] if g["name"] == "redteam")
assert "mission.write_red_fields" in redteam["permissions"]
assert "mission.write_blue_fields" not in redteam["permissions"]
assert "mission.create" in redteam["permissions"]
def test_blueteam_group_has_blue_perm_but_not_red_write(client):
admin = pytest.shared_admin
access = _login(client, admin["email"], admin["password"])
groups = client.get(
"/api/v1/groups", headers={"Authorization": f"Bearer {access}"}
).get_json()
blueteam = next(g for g in groups["items"] if g["name"] == "blueteam")
assert "mission.write_blue_fields" in blueteam["permissions"]
assert "mission.write_red_fields" not in blueteam["permissions"]
assert "mission.create" not in blueteam["permissions"]
# -- M3.2 — Users CRUD ---------------------------------------------------------
def _invite_user(client, admin_access: str, email: str, password: str, group_ids: list[str] | None = None) -> str:
"""Create + accept an invitation, return the new user's id."""
create = client.post(
"/api/v1/invitations",
headers={"Authorization": f"Bearer {admin_access}"},
json={"email_hint": email, "group_ids": group_ids or []},
)
assert create.status_code == 201, create.get_data(as_text=True)
token = create.get_json()["token"]
accept = client.post(
f"/api/v1/invitations/accept/{token}",
json={"email": email, "password": password, "display_name": email.split("@")[0]},
)
assert accept.status_code == 201, accept.get_data(as_text=True)
return accept.get_json()["user_id"]
def test_admin_lists_users(client):
admin = pytest.shared_admin
access = _login(client, admin["email"], admin["password"])
r = client.get("/api/v1/users", headers={"Authorization": f"Bearer {access}"})
assert r.status_code == 200
body = r.get_json()
assert body["total"] >= 1
emails = [u["email"] for u in body["items"]]
assert admin["email"] in emails
def test_admin_updates_a_user(client):
admin = pytest.shared_admin
access = _login(client, admin["email"], admin["password"])
bob_email = _unique_email("bob")
bob_id = _invite_user(client, access, bob_email, "BobPass1234!")
r = client.patch(
f"/api/v1/users/{bob_id}",
headers={"Authorization": f"Bearer {access}"},
json={"display_name": "Robert", "locale": "en"},
)
assert r.status_code == 200, r.get_data(as_text=True)
body = r.get_json()
assert body["display_name"] == "Robert"
assert body["locale"] == "en"
def test_admin_soft_deletes_a_user(client):
admin = pytest.shared_admin
access = _login(client, admin["email"], admin["password"])
target_email = _unique_email("ghost")
target_id = _invite_user(client, access, target_email, "GhostPass1234!")
r = client.delete(
f"/api/v1/users/{target_id}",
headers={"Authorization": f"Bearer {access}"},
)
assert r.status_code == 200, r.get_data(as_text=True)
# Listing must not return the deleted user by default.
listing = client.get(
"/api/v1/users", headers={"Authorization": f"Bearer {access}"}
).get_json()
assert target_email not in [u["email"] for u in listing["items"]]
def test_last_admin_cannot_be_deleted(client):
admin = pytest.shared_admin
access = _login(client, admin["email"], admin["password"])
r = client.delete(
f"/api/v1/users/{admin['user_id']}",
headers={"Authorization": f"Bearer {access}"},
)
assert r.status_code == 409
assert r.get_json()["error"] == "last_admin_protected"
def test_last_admin_cannot_be_deactivated(client):
admin = pytest.shared_admin
access = _login(client, admin["email"], admin["password"])
r = client.patch(
f"/api/v1/users/{admin['user_id']}",
headers={"Authorization": f"Bearer {access}"},
json={"is_active": False},
)
assert r.status_code == 409
assert r.get_json()["error"] == "last_admin_protected"
# -- M3.3 — Groups CRUD --------------------------------------------------------
def test_admin_creates_custom_group_and_assigns_perms(client):
admin = pytest.shared_admin
access = _login(client, admin["email"], admin["password"])
# Create the group
create = client.post(
"/api/v1/groups",
headers={"Authorization": f"Bearer {access}"},
json={"name": f"pentest-{secrets.token_hex(3)}", "description": "Test group"},
)
assert create.status_code == 201, create.get_data(as_text=True)
gid = create.get_json()["id"]
# Attach mission.read + mission.write_red_fields only
r = client.put(
f"/api/v1/groups/{gid}/permissions",
headers={"Authorization": f"Bearer {access}"},
json={"codes": ["mission.read", "mission.write_red_fields"]},
)
assert r.status_code == 200, r.get_data(as_text=True)
assert set(r.get_json()["permissions"]) == {"mission.read", "mission.write_red_fields"}
def test_system_group_cannot_be_renamed_or_deleted(client):
admin = pytest.shared_admin
access = _login(client, admin["email"], admin["password"])
groups = client.get(
"/api/v1/groups", headers={"Authorization": f"Bearer {access}"}
).get_json()
admin_group = next(g for g in groups["items"] if g["name"] == "admin")
rename = client.patch(
f"/api/v1/groups/{admin_group['id']}",
headers={"Authorization": f"Bearer {access}"},
json={"name": "superadmin"},
)
assert rename.status_code == 409
assert rename.get_json()["error"] == "system_group_protected"
delete = client.delete(
f"/api/v1/groups/{admin_group['id']}",
headers={"Authorization": f"Bearer {access}"},
)
assert delete.status_code == 409
assert delete.get_json()["error"] == "system_group_protected"
def test_setting_unknown_permission_code_returns_400(client):
admin = pytest.shared_admin
access = _login(client, admin["email"], admin["password"])
create = client.post(
"/api/v1/groups",
headers={"Authorization": f"Bearer {access}"},
json={"name": f"bad-perms-{secrets.token_hex(3)}", "description": None},
)
gid = create.get_json()["id"]
r = client.put(
f"/api/v1/groups/{gid}/permissions",
headers={"Authorization": f"Bearer {access}"},
json={"codes": ["bogus.permission"]},
)
assert r.status_code == 400
# -- M3 user ↔ group assignment ------------------------------------------------
def test_admin_assigns_user_to_custom_group_and_perms_apply(client):
admin = pytest.shared_admin
access = _login(client, admin["email"], admin["password"])
# Create a custom group that *only* grants user.read.
gname = f"readers-{secrets.token_hex(3)}"
group = client.post(
"/api/v1/groups",
headers={"Authorization": f"Bearer {access}"},
json={"name": gname, "description": None},
).get_json()
client.put(
f"/api/v1/groups/{group['id']}/permissions",
headers={"Authorization": f"Bearer {access}"},
json={"codes": ["user.read"]},
)
# Invite Dave, attach the new group via /users/{id}/groups.
dave_email = _unique_email("dave")
dave_id = _invite_user(client, access, dave_email, "DavePass1234!")
r = client.put(
f"/api/v1/users/{dave_id}/groups",
headers={"Authorization": f"Bearer {access}"},
json={"group_ids": [group["id"]]},
)
assert r.status_code == 200, r.get_data(as_text=True)
# Dave can now list users (user.read) but cannot create a group (group.create).
dave_access = _login(client, dave_email, "DavePass1234!")
can_read = client.get(
"/api/v1/users", headers={"Authorization": f"Bearer {dave_access}"}
)
assert can_read.status_code == 200
cannot_create_group = client.post(
"/api/v1/groups",
headers={"Authorization": f"Bearer {dave_access}"},
json={"name": "wont-happen", "description": None},
)
assert cannot_create_group.status_code == 403
def test_last_admin_cannot_lose_admin_membership(client):
admin = pytest.shared_admin
access = _login(client, admin["email"], admin["password"])
r = client.put(
f"/api/v1/users/{admin['user_id']}/groups",
headers={"Authorization": f"Bearer {access}"},
json={"group_ids": []},
)
assert r.status_code == 409
assert r.get_json()["error"] == "last_admin_protected"
# -- Permission enforcement ----------------------------------------------------
def test_non_admin_without_user_read_gets_403_on_users_list(client):
admin = pytest.shared_admin
access = _login(client, admin["email"], admin["password"])
# Invite Eve with no groups → no perms.
eve_email = _unique_email("eve")
_invite_user(client, access, eve_email, "EvePass1234!", group_ids=[])
eve_access = _login(client, eve_email, "EvePass1234!")
r = client.get("/api/v1/users", headers={"Authorization": f"Bearer {eve_access}"})
assert r.status_code == 403

View 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})