fix(backend): align with D-008/D-009 (drop ttp_version, seed F11 matrix)
D-009 reaffirms spec H32: no `ttp_version` table. Replayability lives solely on `run.snapshot_json`. The previous initial migration introduced a separate `ttp_version` aggregate by mistake — removed here. D-008 requires the bootstrap to seed exactly the three F11 groups (`rt_operator`, `rt_lead`, `soc_analyst`) with exactly the F11 permission matrix. The migration now: - inserts every `Permission` enum value into the `permission` table, - inserts the three groups with deterministic uuid5(NAMESPACE_DNS, ...) ids, - inserts the matching `group_permission` rows from GROUP_PERMISSIONS. Also renames `ttp.current_version` to `ttp.version` (matches §8 spec column name; the value remains informational per H32 / D-009).
This commit is contained in:
@@ -265,29 +265,13 @@ def upgrade() -> None:
|
||||
sa.Column("is_stealth_variant", sa.Boolean, nullable=False, server_default=sa.false()),
|
||||
sa.Column("source", TTP_SOURCE, nullable=False, server_default="custom"),
|
||||
sa.Column("tags", JSONB, nullable=False, server_default="[]"),
|
||||
sa.Column("current_version", sa.Integer, nullable=False, server_default="1"),
|
||||
sa.Column("version", sa.Integer, nullable=False, server_default="1"),
|
||||
sa.Column("is_published", sa.Boolean, nullable=False, server_default=sa.false()),
|
||||
sa.Column("created_by_id", UUID(as_uuid=True), sa.ForeignKey("user.id", ondelete="SET NULL")),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
||||
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
||||
)
|
||||
|
||||
op.create_table(
|
||||
"ttp_version",
|
||||
sa.Column("id", UUID(as_uuid=True), primary_key=True),
|
||||
sa.Column(
|
||||
"ttp_id",
|
||||
UUID(as_uuid=True),
|
||||
sa.ForeignKey("ttp.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
),
|
||||
sa.Column("version", sa.Integer, nullable=False),
|
||||
sa.Column("snapshot_json", JSONB, nullable=False),
|
||||
sa.Column("content_sha256", sa.String(64), nullable=False),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column("created_by_id", UUID(as_uuid=True), sa.ForeignKey("user.id", ondelete="SET NULL")),
|
||||
sa.UniqueConstraint("ttp_id", "version", name="uq_ttp_version_ttp_id_version"),
|
||||
)
|
||||
# No `ttp_version` table — H32 / D-009: snapshot lives on run.snapshot_json.
|
||||
|
||||
# -------------------------------------------------------------- scenario
|
||||
op.create_table(
|
||||
@@ -538,34 +522,72 @@ def upgrade() -> None:
|
||||
)
|
||||
|
||||
# ---------------------------------------------------------------- seed RBAC
|
||||
# D-008: exactly the 3 F11 groups, with exactly the F11 permission matrix.
|
||||
# The matrix is the authoritative source — see mimic.rbac.matrix.
|
||||
_seed_rbac()
|
||||
|
||||
|
||||
def _seed_rbac() -> None:
|
||||
"""Seed `permission` + `group` + `group_permission` from F11 (D-008)."""
|
||||
from uuid import NAMESPACE_DNS, uuid5 # noqa: PLC0415 (avoid pulling at migration import)
|
||||
|
||||
from mimic.rbac.matrix import GROUP_PERMISSIONS, GroupName, Permission # noqa: PLC0415
|
||||
|
||||
def _gid(name: GroupName) -> str:
|
||||
return str(uuid5(NAMESPACE_DNS, f"mimic.group.{name.value}"))
|
||||
|
||||
def _pid(code: Permission) -> str:
|
||||
return str(uuid5(NAMESPACE_DNS, f"mimic.permission.{code.value}"))
|
||||
|
||||
permission_table = sa.table(
|
||||
"permission",
|
||||
sa.column("id", UUID(as_uuid=True)),
|
||||
sa.column("code", sa.String),
|
||||
sa.column("description", sa.String),
|
||||
)
|
||||
op.bulk_insert(
|
||||
sa.table(
|
||||
"group",
|
||||
sa.column("id", UUID(as_uuid=True)),
|
||||
sa.column("name", sa.String),
|
||||
sa.column("description", sa.String),
|
||||
sa.column("created_at", sa.DateTime(timezone=True)),
|
||||
sa.column("updated_at", sa.DateTime(timezone=True)),
|
||||
),
|
||||
permission_table,
|
||||
[{"id": _pid(p), "code": p.value, "description": None} for p in Permission],
|
||||
)
|
||||
|
||||
group_table = sa.table(
|
||||
"group",
|
||||
sa.column("id", UUID(as_uuid=True)),
|
||||
sa.column("name", sa.String),
|
||||
sa.column("description", sa.String),
|
||||
)
|
||||
op.bulk_insert(
|
||||
group_table,
|
||||
[
|
||||
{
|
||||
"id": "11111111-0000-0000-0000-000000000001",
|
||||
"name": "rt_operator",
|
||||
"id": _gid(GroupName.RT_OPERATOR),
|
||||
"name": GroupName.RT_OPERATOR.value,
|
||||
"description": "Red team operator (per-engagement scope).",
|
||||
},
|
||||
{
|
||||
"id": "11111111-0000-0000-0000-000000000002",
|
||||
"name": "rt_lead",
|
||||
"id": _gid(GroupName.RT_LEAD),
|
||||
"name": GroupName.RT_LEAD.value,
|
||||
"description": "Red team lead (full RT privileges).",
|
||||
},
|
||||
{
|
||||
"id": "11111111-0000-0000-0000-000000000003",
|
||||
"name": "soc_analyst",
|
||||
"description": "SOC analyst (per-engagement, scoped via soc_session).",
|
||||
"id": _gid(GroupName.SOC_ANALYST),
|
||||
"name": GroupName.SOC_ANALYST.value,
|
||||
"description": "SOC analyst (scoped via soc_session).",
|
||||
},
|
||||
],
|
||||
)
|
||||
|
||||
group_permission_table = sa.table(
|
||||
"group_permission",
|
||||
sa.column("group_id", UUID(as_uuid=True)),
|
||||
sa.column("permission_id", UUID(as_uuid=True)),
|
||||
)
|
||||
rows: list[dict[str, str]] = []
|
||||
for group_name, perms in GROUP_PERMISSIONS.items():
|
||||
for perm in perms:
|
||||
rows.append({"group_id": _gid(group_name), "permission_id": _pid(perm)})
|
||||
op.bulk_insert(group_permission_table, rows)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
for table in (
|
||||
@@ -579,7 +601,6 @@ def downgrade() -> None:
|
||||
"run",
|
||||
"scenario_step",
|
||||
"scenario",
|
||||
"ttp_version",
|
||||
"ttp",
|
||||
"host",
|
||||
"c2_credential",
|
||||
|
||||
@@ -9,7 +9,7 @@ from mimic.db.models.report import Report
|
||||
from mimic.db.models.run import Run, RunStep, RunStepCleanup
|
||||
from mimic.db.models.scenario import Scenario, ScenarioStep
|
||||
from mimic.db.models.soc_session import SocSession
|
||||
from mimic.db.models.ttp import Ttp, TtpVersion
|
||||
from mimic.db.models.ttp import Ttp
|
||||
from mimic.db.models.user import User
|
||||
|
||||
__all__ = [
|
||||
@@ -31,7 +31,6 @@ __all__ = [
|
||||
"ScenarioStep",
|
||||
"SocSession",
|
||||
"Ttp",
|
||||
"TtpVersion",
|
||||
"User",
|
||||
"UserGroup",
|
||||
]
|
||||
|
||||
@@ -1,38 +1,29 @@
|
||||
"""TTP library + immutable version snapshots.
|
||||
"""TTP library.
|
||||
|
||||
Note (D-T-ttp-version): spec H32 originally said no `ttp_version` table (snapshot
|
||||
lives on `run.snapshot_json`). Sprint 0 reintroduces a `ttp_version` table for
|
||||
clean traceability across runs and to honor the kickoff data-model directive
|
||||
from the team-lead. `run.snapshot_json` remains the source of truth for replay,
|
||||
but each promotion / edit produces an immutable `ttp_version` row that's easier
|
||||
to diff and reference from imports / audit log.
|
||||
Spec H32 / D-009: there is **no** `ttp_version` table. The replayability
|
||||
snapshot lives solely on `run.snapshot_json` (a self-contained JSONB blob
|
||||
captured at run start). `ttp.version` stays here as a purely informational
|
||||
counter (§8) — bumped on edit, never referenced for replay.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from typing import TYPE_CHECKING
|
||||
from uuid import UUID
|
||||
|
||||
from sqlalchemy import (
|
||||
JSON,
|
||||
Boolean,
|
||||
DateTime,
|
||||
Enum,
|
||||
ForeignKey,
|
||||
Integer,
|
||||
String,
|
||||
Text,
|
||||
UniqueConstraint,
|
||||
)
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from mimic.db.base import Base, TimestampsMixin, UuidPkMixin
|
||||
from mimic.db.types import PayloadType, TtpSource
|
||||
|
||||
if TYPE_CHECKING:
|
||||
pass
|
||||
|
||||
|
||||
class Ttp(UuidPkMixin, TimestampsMixin, Base):
|
||||
__tablename__ = "ttp"
|
||||
@@ -61,45 +52,11 @@ class Ttp(UuidPkMixin, TimestampsMixin, Base):
|
||||
)
|
||||
tags: Mapped[list[str]] = mapped_column(JSON, default=list, nullable=False)
|
||||
|
||||
current_version: Mapped[int] = mapped_column(Integer, default=1, nullable=False)
|
||||
version: Mapped[int] = mapped_column(Integer, default=1, nullable=False)
|
||||
# Informational (§8). Bumped on edit. Never used for replay (H32 / D-009).
|
||||
is_published: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
|
||||
# is_published = promoted to the library (lead RT only — F11).
|
||||
# Promoted to the library (lead RT only — F11).
|
||||
|
||||
created_by_id: Mapped[UUID | None] = mapped_column(
|
||||
ForeignKey("user.id", ondelete="SET NULL")
|
||||
)
|
||||
|
||||
versions: Mapped[list[TtpVersion]] = relationship(
|
||||
back_populates="ttp",
|
||||
cascade="all, delete-orphan",
|
||||
order_by="TtpVersion.version",
|
||||
)
|
||||
|
||||
|
||||
class TtpVersion(UuidPkMixin, Base):
|
||||
"""Immutable snapshot of a TTP at the moment it was published or used.
|
||||
|
||||
Used by importers / audit / report builder. `run.snapshot_json` still
|
||||
embeds a full self-contained copy for replay independence.
|
||||
"""
|
||||
|
||||
__tablename__ = "ttp_version"
|
||||
__table_args__ = (
|
||||
UniqueConstraint("ttp_id", "version", name="uq_ttp_version_ttp_id_version"),
|
||||
)
|
||||
|
||||
ttp_id: Mapped[UUID] = mapped_column(
|
||||
ForeignKey("ttp.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
)
|
||||
version: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||
snapshot_json: Mapped[dict] = mapped_column(JSON, nullable=False)
|
||||
content_sha256: Mapped[str] = mapped_column(String(64), nullable=False)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), nullable=False
|
||||
)
|
||||
created_by_id: Mapped[UUID | None] = mapped_column(
|
||||
ForeignKey("user.id", ondelete="SET NULL")
|
||||
)
|
||||
|
||||
ttp: Mapped[Ttp] = relationship(back_populates="versions")
|
||||
|
||||
Reference in New Issue
Block a user