Compare commits
134 Commits
f1e0e0acf0
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 99e8feff48 | |||
| 8985eeaa03 | |||
| f13764d9e2 | |||
| e951567ba1 | |||
| 07403cbd9d | |||
| 504dfc52f5 | |||
| 0a6cb5510c | |||
| d932134975 | |||
| 545a84b137 | |||
| 3cd0f6fd99 | |||
| 66951086fb | |||
| 06c913955c | |||
| e4f768962f | |||
| d25d876c2c | |||
| 91442ede60 | |||
| 1d0d880929 | |||
| bbe7f49c86 | |||
| 4dea19cae9 | |||
| 60ab2e558f | |||
| 82ac9c7014 | |||
| 4809c4a662 | |||
| c753797019 | |||
| cf4a6c3cde | |||
| 0b60531677 | |||
| 0002601b50 | |||
| 8493a6a764 | |||
| 8d9e25703d | |||
| 68ab6406d4 | |||
| 09553f5c42 | |||
| 3a01facd46 | |||
| 32f4fd25bd | |||
| 8973f199b8 | |||
| 60f9464ec5 | |||
| 7416d1688f | |||
| 44fdb4dbd3 | |||
| 11080bd627 | |||
| 1b7fd6fb36 | |||
| 8fcd733d4a | |||
| 4692a8b93e | |||
| 7ec3c5bc8b | |||
| 8bdbe48fbe | |||
| fd94e55799 | |||
| f87959369b | |||
| bdc6579c10 | |||
| 92d65374ef | |||
| 9d66c30127 | |||
| c6bdded3f6 | |||
| 814cfa9671 | |||
| 6d6f87b968 | |||
| 4221d2858b | |||
| b48de743b6 | |||
| 8fe38dc661 | |||
| 0bb34f6834 | |||
| a3f86c7b31 | |||
| 337faf824d | |||
| 0fc2427f71 | |||
| be2a3fdced | |||
| 997b38d333 | |||
| 1535ddaa5d | |||
| a46aa157f3 | |||
| 5d22514f7f | |||
| 58c0143304 | |||
| f32f636f05 | |||
| 2afb886cd9 | |||
| 119db6f91d | |||
| d726e3adfe | |||
| 512b682cc5 | |||
| 8031682e9c | |||
| 0de9f4a6c0 | |||
| 4e31359fca | |||
| 226869d308 | |||
| adae7e617e | |||
| 4ddd7f9ec3 | |||
| ccc99439d5 | |||
| df49d336f7 | |||
| 0b6a664651 | |||
| 8cf8001de5 | |||
| 1c8fa436ab | |||
| 21c6febd22 | |||
| c234fd64c2 | |||
| b6da0e22c2 | |||
| 1ddbd6989f | |||
| bd26b09827 | |||
| fefe70baed | |||
| 1519ccc95c | |||
| a66fefa30b | |||
| b6b674d620 | |||
| 42b05379f7 | |||
| d6fbc2ffe8 | |||
| 949e5d42c4 | |||
| c0a88fc489 | |||
| 022c1c756a | |||
| 19c4866103 | |||
| f53e124c50 | |||
| 388c9773ab | |||
| d5e3df00f9 | |||
| 82033b5037 | |||
| 8a028bf0ed | |||
| b527eeac7d | |||
| e3cd75bb56 | |||
| 22be620665 | |||
| c41876b62f | |||
| 2d98ed5ec6 | |||
| 58d1da93ce | |||
| af65681179 | |||
| 164ef25393 | |||
| 83b4d2578a | |||
| aeaf1cf243 | |||
| 952b6f27d7 | |||
| b106780102 | |||
| 6b6375ddad | |||
| f19d10e19c | |||
| 858dd3e9e7 | |||
| 15ef555131 | |||
| 108743daa5 | |||
| 5bc71f677f | |||
| 959def2f49 | |||
| 6186c246a4 | |||
| 95b5ac50c6 | |||
| 4f5ffcf3f9 | |||
| 6749daa7cb | |||
| fd47db7bea | |||
| 12a5484003 | |||
| dc5f206bf5 | |||
| 6eadc7405b | |||
| 385d402a57 | |||
| 79b573fb60 | |||
| 1c91bd543d | |||
| f6eac1641c | |||
| a6b46f5f76 | |||
| 8e7f98301e | |||
| a113f3687c | |||
| 62e3bfebe8 | |||
| 74cb946317 |
@@ -23,7 +23,10 @@ TOKEN_EXPIRE_MINUTES=60
|
|||||||
# ── Initial Admin Account ────────────────────────────────────────────────────
|
# ── Initial Admin Account ────────────────────────────────────────────────────
|
||||||
# If ADMIN_PASSWORD is empty, a random password is auto-generated and
|
# If ADMIN_PASSWORD is empty, a random password is auto-generated and
|
||||||
# printed to the backend container logs on first startup.
|
# printed to the backend container logs on first startup.
|
||||||
|
# ADMIN_EMAIL is what you actually log in with (email is the unique
|
||||||
|
# identifier) — set it to a real address you control.
|
||||||
ADMIN_USERNAME=admin
|
ADMIN_USERNAME=admin
|
||||||
|
ADMIN_EMAIL=
|
||||||
ADMIN_PASSWORD=
|
ADMIN_PASSWORD=
|
||||||
|
|
||||||
# ── MinIO Object Storage ─────────────────────────────────────────────────────
|
# ── MinIO Object Storage ─────────────────────────────────────────────────────
|
||||||
@@ -39,6 +42,13 @@ CORS_ORIGINS=https://your-domain.com
|
|||||||
# ── Frontend ─────────────────────────────────────────────────────────────────
|
# ── Frontend ─────────────────────────────────────────────────────────────────
|
||||||
FRONTEND_PORT=80
|
FRONTEND_PORT=80
|
||||||
|
|
||||||
|
# ── Emails ────────────────────────────────────────────────────────────────────
|
||||||
|
# Base URL used to build links in outbound emails (set-password, etc).
|
||||||
|
# REQUIRED in production — must be THIS deployment's real public frontend
|
||||||
|
# URL. There is no safe default (it's unique per deployment); the backend
|
||||||
|
# refuses to start without it when AEGIS_ENV=production.
|
||||||
|
PLATFORM_URL=https://your-domain.com
|
||||||
|
|
||||||
# ── Environment flag ─────────────────────────────────────────────────────────
|
# ── Environment flag ─────────────────────────────────────────────────────────
|
||||||
# Set to "production" for production deployments (enforces SECRET_KEY, etc.)
|
# Set to "production" for production deployments (enforces SECRET_KEY, etc.)
|
||||||
AEGIS_ENV=production
|
AEGIS_ENV=production
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
"""Add approved_by, approved_at, rejection_reason to campaigns.
|
||||||
|
|
||||||
|
Revision ID: b054
|
||||||
|
Revises: b053
|
||||||
|
Create Date: 2026-07-02
|
||||||
|
"""
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from sqlalchemy.dialects import postgresql
|
||||||
|
|
||||||
|
revision = "b054"
|
||||||
|
down_revision = "b053"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.add_column(
|
||||||
|
"campaigns",
|
||||||
|
sa.Column("approved_by", postgresql.UUID(as_uuid=True), nullable=True),
|
||||||
|
)
|
||||||
|
op.add_column("campaigns", sa.Column("approved_at", sa.DateTime(), nullable=True))
|
||||||
|
op.add_column("campaigns", sa.Column("rejection_reason", sa.Text(), nullable=True))
|
||||||
|
op.create_foreign_key(
|
||||||
|
"fk_campaigns_approved_by_users",
|
||||||
|
"campaigns", "users",
|
||||||
|
["approved_by"], ["id"],
|
||||||
|
ondelete="SET NULL",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_constraint("fk_campaigns_approved_by_users", "campaigns", type_="foreignkey")
|
||||||
|
op.drop_column("campaigns", "rejection_reason")
|
||||||
|
op.drop_column("campaigns", "approved_at")
|
||||||
|
op.drop_column("campaigns", "approved_by")
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
"""Add campaign_modification_requests table.
|
||||||
|
|
||||||
|
Revision ID: b055
|
||||||
|
Revises: b054
|
||||||
|
Create Date: 2026-07-02
|
||||||
|
"""
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from sqlalchemy.dialects import postgresql
|
||||||
|
|
||||||
|
revision = "b055"
|
||||||
|
down_revision = "b054"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.create_table(
|
||||||
|
"campaign_modification_requests",
|
||||||
|
sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True),
|
||||||
|
sa.Column(
|
||||||
|
"campaign_id", postgresql.UUID(as_uuid=True),
|
||||||
|
sa.ForeignKey("campaigns.id", ondelete="CASCADE"), nullable=False,
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"requested_by", postgresql.UUID(as_uuid=True),
|
||||||
|
sa.ForeignKey("users.id", ondelete="SET NULL"), nullable=True,
|
||||||
|
),
|
||||||
|
sa.Column("action", sa.String(), nullable=False),
|
||||||
|
sa.Column(
|
||||||
|
"test_id", postgresql.UUID(as_uuid=True),
|
||||||
|
sa.ForeignKey("tests.id", ondelete="CASCADE"), nullable=False,
|
||||||
|
),
|
||||||
|
sa.Column("order_index", sa.Integer(), nullable=True),
|
||||||
|
sa.Column("phase", sa.String(), nullable=True),
|
||||||
|
sa.Column("justification", sa.Text(), nullable=False),
|
||||||
|
sa.Column("status", sa.String(), nullable=False, server_default="pending"),
|
||||||
|
sa.Column(
|
||||||
|
"reviewed_by", postgresql.UUID(as_uuid=True),
|
||||||
|
sa.ForeignKey("users.id", ondelete="SET NULL"), nullable=True,
|
||||||
|
),
|
||||||
|
sa.Column("reviewed_at", sa.DateTime(), nullable=True),
|
||||||
|
sa.Column("review_notes", sa.Text(), nullable=True),
|
||||||
|
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
|
||||||
|
)
|
||||||
|
op.create_index(
|
||||||
|
"ix_campaign_mod_requests_campaign",
|
||||||
|
"campaign_modification_requests", ["campaign_id"],
|
||||||
|
)
|
||||||
|
op.create_index(
|
||||||
|
"ix_campaign_mod_requests_status",
|
||||||
|
"campaign_modification_requests", ["status"],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_index("ix_campaign_mod_requests_status", table_name="campaign_modification_requests")
|
||||||
|
op.drop_index("ix_campaign_mod_requests_campaign", table_name="campaign_modification_requests")
|
||||||
|
op.drop_table("campaign_modification_requests")
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
"""Change campaign_modification_requests.test_id to nullable with ON DELETE SET NULL.
|
||||||
|
|
||||||
|
Approving a "remove_test" modification request deletes the underlying Test
|
||||||
|
row. With the original ON DELETE CASCADE, that delete cascaded and wiped
|
||||||
|
out the modification-request row itself, destroying the audit record
|
||||||
|
(justification, reviewer, decision) the request exists to preserve.
|
||||||
|
|
||||||
|
Revision ID: b056
|
||||||
|
Revises: b055
|
||||||
|
Create Date: 2026-07-03
|
||||||
|
"""
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
revision = "b056"
|
||||||
|
down_revision = "b055"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
_FK_NAME = "campaign_modification_requests_test_id_fkey"
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.alter_column(
|
||||||
|
"campaign_modification_requests", "test_id",
|
||||||
|
existing_type=sa.dialects.postgresql.UUID(as_uuid=True),
|
||||||
|
nullable=True,
|
||||||
|
)
|
||||||
|
op.drop_constraint(_FK_NAME, "campaign_modification_requests", type_="foreignkey")
|
||||||
|
op.create_foreign_key(
|
||||||
|
_FK_NAME,
|
||||||
|
"campaign_modification_requests", "tests",
|
||||||
|
["test_id"], ["id"],
|
||||||
|
ondelete="SET NULL",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_constraint(_FK_NAME, "campaign_modification_requests", type_="foreignkey")
|
||||||
|
op.create_foreign_key(
|
||||||
|
_FK_NAME,
|
||||||
|
"campaign_modification_requests", "tests",
|
||||||
|
["test_id"], ["id"],
|
||||||
|
ondelete="CASCADE",
|
||||||
|
)
|
||||||
|
op.alter_column(
|
||||||
|
"campaign_modification_requests", "test_id",
|
||||||
|
existing_type=sa.dialects.postgresql.UUID(as_uuid=True),
|
||||||
|
nullable=False,
|
||||||
|
)
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
"""Add red_review, blue_review states and review-assignment columns to tests.
|
||||||
|
|
||||||
|
Revision ID: b057
|
||||||
|
Revises: b056
|
||||||
|
Create Date: 2026-07-06
|
||||||
|
"""
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from sqlalchemy.dialects import postgresql
|
||||||
|
|
||||||
|
revision = "b057"
|
||||||
|
down_revision = "b056"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.execute("ALTER TYPE teststate ADD VALUE IF NOT EXISTS 'red_review'")
|
||||||
|
op.execute("ALTER TYPE teststate ADD VALUE IF NOT EXISTS 'blue_review'")
|
||||||
|
|
||||||
|
op.add_column("tests", sa.Column("red_reviewer_assignee", postgresql.UUID(as_uuid=True), sa.ForeignKey("users.id"), nullable=True))
|
||||||
|
op.add_column("tests", sa.Column("red_review_by", postgresql.UUID(as_uuid=True), sa.ForeignKey("users.id"), nullable=True))
|
||||||
|
op.add_column("tests", sa.Column("red_review_at", sa.DateTime(), nullable=True))
|
||||||
|
op.add_column("tests", sa.Column("red_review_notes", sa.Text(), nullable=True))
|
||||||
|
op.add_column("tests", sa.Column("blue_reviewer_assignee", postgresql.UUID(as_uuid=True), sa.ForeignKey("users.id"), nullable=True))
|
||||||
|
op.add_column("tests", sa.Column("blue_review_by", postgresql.UUID(as_uuid=True), sa.ForeignKey("users.id"), nullable=True))
|
||||||
|
op.add_column("tests", sa.Column("blue_review_at", sa.DateTime(), nullable=True))
|
||||||
|
op.add_column("tests", sa.Column("blue_review_notes", sa.Text(), nullable=True))
|
||||||
|
op.add_column("tests", sa.Column("system_gaps", sa.Text(), nullable=True))
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_column("tests", "system_gaps")
|
||||||
|
op.drop_column("tests", "blue_review_notes")
|
||||||
|
op.drop_column("tests", "blue_review_at")
|
||||||
|
op.drop_column("tests", "blue_review_by")
|
||||||
|
op.drop_column("tests", "blue_reviewer_assignee")
|
||||||
|
op.drop_column("tests", "red_review_notes")
|
||||||
|
op.drop_column("tests", "red_review_at")
|
||||||
|
op.drop_column("tests", "red_review_by")
|
||||||
|
op.drop_column("tests", "red_reviewer_assignee")
|
||||||
|
# PostgreSQL does not support removing enum values; downgrade is a no-op
|
||||||
|
# for the enum part, matching b046_add_disputed_test_state.py.
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
"""Convert tests.attack_success from Boolean to a 3-value enum.
|
||||||
|
|
||||||
|
Revision ID: b058
|
||||||
|
Revises: b057
|
||||||
|
Create Date: 2026-07-06
|
||||||
|
"""
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
revision = "b058"
|
||||||
|
down_revision = "b057"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
_ENUM_NAME = "attacksuccessresult"
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
attack_success_enum = sa.Enum(
|
||||||
|
"successful", "not_successful", "partially_successful", name=_ENUM_NAME
|
||||||
|
)
|
||||||
|
attack_success_enum.create(op.get_bind(), checkfirst=True)
|
||||||
|
|
||||||
|
op.add_column("tests", sa.Column("attack_success_new", attack_success_enum, nullable=True))
|
||||||
|
|
||||||
|
op.execute(
|
||||||
|
"UPDATE tests SET attack_success_new = (CASE "
|
||||||
|
"WHEN attack_success = true THEN 'successful' "
|
||||||
|
"WHEN attack_success = false THEN 'not_successful' "
|
||||||
|
f"ELSE NULL END)::{_ENUM_NAME}"
|
||||||
|
)
|
||||||
|
|
||||||
|
op.drop_column("tests", "attack_success")
|
||||||
|
op.alter_column("tests", "attack_success_new", new_column_name="attack_success")
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.add_column("tests", sa.Column("attack_success_bool", sa.Boolean(), nullable=True))
|
||||||
|
op.execute(
|
||||||
|
"UPDATE tests SET attack_success_bool = CASE "
|
||||||
|
"WHEN attack_success = 'successful' THEN true "
|
||||||
|
"WHEN attack_success = 'not_successful' THEN false "
|
||||||
|
"ELSE NULL END"
|
||||||
|
)
|
||||||
|
op.drop_column("tests", "attack_success")
|
||||||
|
op.alter_column("tests", "attack_success_bool", new_column_name="attack_success")
|
||||||
|
sa.Enum(name=_ENUM_NAME).drop(op.get_bind(), checkfirst=True)
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
"""Rename data_classification values to match the org's real scheme.
|
||||||
|
|
||||||
|
public -> public_release, internal -> general_use, sensitive -> confidential.
|
||||||
|
restricted is unchanged. Applies to tests, campaigns, and evidences tables
|
||||||
|
(all plain String(20) columns — no native enum type to alter).
|
||||||
|
|
||||||
|
Revision ID: b059
|
||||||
|
Revises: b058
|
||||||
|
Create Date: 2026-07-07
|
||||||
|
"""
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
revision = "b059"
|
||||||
|
down_revision = "b058"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
_TABLES = ("tests", "campaigns", "evidences")
|
||||||
|
|
||||||
|
_RENAMES_UP = {
|
||||||
|
"public": "public_release",
|
||||||
|
"internal": "general_use",
|
||||||
|
"sensitive": "confidential",
|
||||||
|
}
|
||||||
|
|
||||||
|
_RENAMES_DOWN = {v: k for k, v in _RENAMES_UP.items()}
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
for table in _TABLES:
|
||||||
|
for old, new in _RENAMES_UP.items():
|
||||||
|
op.execute(f"UPDATE {table} SET data_classification = '{new}' WHERE data_classification = '{old}'")
|
||||||
|
op.alter_column(table, "data_classification", server_default="confidential")
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
for table in _TABLES:
|
||||||
|
for new, old in _RENAMES_DOWN.items():
|
||||||
|
op.execute(f"UPDATE {table} SET data_classification = '{old}' WHERE data_classification = '{new}'")
|
||||||
|
op.alter_column(table, "data_classification", server_default="internal")
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
"""Rename data_classification values to match Jira's real Data Sensitivity field.
|
||||||
|
|
||||||
|
Jira's actual "Data Sensitivity" custom field (customfield_11814) has 6
|
||||||
|
options — Public, General Use, Internal Use Only, Trusted People, Customer
|
||||||
|
Content, PII — not the 4-tier scheme Aegis invented independently. This
|
||||||
|
migration replicates Jira's scheme so ticket sync stops guessing at a
|
||||||
|
mapping. Applies to tests, campaigns, and evidences tables (all plain
|
||||||
|
String(20) columns — no native enum type to alter).
|
||||||
|
|
||||||
|
public_release -> public, confidential -> internal_use_only,
|
||||||
|
restricted -> pii. general_use is unchanged.
|
||||||
|
|
||||||
|
Revision ID: b060
|
||||||
|
Revises: b059
|
||||||
|
Create Date: 2026-07-09
|
||||||
|
"""
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
revision = "b060"
|
||||||
|
down_revision = "b059"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
_TABLES = ("tests", "campaigns", "evidences")
|
||||||
|
|
||||||
|
_RENAMES_UP = {
|
||||||
|
"public_release": "public",
|
||||||
|
"confidential": "internal_use_only",
|
||||||
|
"restricted": "pii",
|
||||||
|
}
|
||||||
|
|
||||||
|
_RENAMES_DOWN = {v: k for k, v in _RENAMES_UP.items()}
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
for table in _TABLES:
|
||||||
|
for old, new in _RENAMES_UP.items():
|
||||||
|
op.execute(f"UPDATE {table} SET data_classification = '{new}' WHERE data_classification = '{old}'")
|
||||||
|
op.alter_column(table, "data_classification", server_default="internal_use_only")
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
for table in _TABLES:
|
||||||
|
for new, old in _RENAMES_DOWN.items():
|
||||||
|
op.execute(f"UPDATE {table} SET data_classification = '{old}' WHERE data_classification = '{new}'")
|
||||||
|
op.alter_column(table, "data_classification", server_default="confidential")
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
"""Archive round data on reopen instead of overwriting it.
|
||||||
|
|
||||||
|
Reopening a test for rework used to reset the live procedure/result fields
|
||||||
|
in place, losing the prior round's data (and clobbering the Phase Timeline,
|
||||||
|
which read those same mutable columns). This adds an append-only
|
||||||
|
``test_round_history`` table that snapshots a round's fields right before
|
||||||
|
they get reset, plus per-team round counters on ``tests`` so each round is
|
||||||
|
numbered.
|
||||||
|
|
||||||
|
Revision ID: b061
|
||||||
|
Revises: b060
|
||||||
|
Create Date: 2026-07-13
|
||||||
|
"""
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from alembic import op
|
||||||
|
from sqlalchemy.dialects import postgresql
|
||||||
|
|
||||||
|
revision = "b061"
|
||||||
|
down_revision = "b060"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.add_column("tests", sa.Column("red_round_number", sa.Integer(), nullable=False, server_default="1"))
|
||||||
|
op.add_column("tests", sa.Column("blue_round_number", sa.Integer(), nullable=False, server_default="1"))
|
||||||
|
|
||||||
|
op.create_table(
|
||||||
|
"test_round_history",
|
||||||
|
sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True),
|
||||||
|
sa.Column("test_id", postgresql.UUID(as_uuid=True), sa.ForeignKey("tests.id"), nullable=False),
|
||||||
|
sa.Column("team", sa.String(10), nullable=False),
|
||||||
|
sa.Column("round_number", sa.Integer(), nullable=False),
|
||||||
|
sa.Column("started_at", sa.DateTime(), nullable=True),
|
||||||
|
sa.Column("ended_at", sa.DateTime(), nullable=True),
|
||||||
|
sa.Column("paused_seconds", sa.Integer(), nullable=True),
|
||||||
|
sa.Column("procedure_text", sa.Text(), nullable=True),
|
||||||
|
sa.Column("tool_used", sa.String(), nullable=True),
|
||||||
|
# Plain strings, not the shared Postgres enum types tests.* uses —
|
||||||
|
# this is an archive table with no DB-level constraint needs, and
|
||||||
|
# reusing those native type names here would make Alembic try to
|
||||||
|
# (re)create them.
|
||||||
|
sa.Column("attack_success", sa.String(), nullable=True),
|
||||||
|
sa.Column("red_summary", sa.Text(), nullable=True),
|
||||||
|
sa.Column("execution_start_time", sa.DateTime(), nullable=True),
|
||||||
|
sa.Column("execution_end_time", sa.DateTime(), nullable=True),
|
||||||
|
sa.Column("detection_result", sa.String(), nullable=True),
|
||||||
|
sa.Column("containment_result", sa.String(), nullable=True),
|
||||||
|
sa.Column("detection_time", sa.DateTime(), nullable=True),
|
||||||
|
sa.Column("containment_time", sa.DateTime(), nullable=True),
|
||||||
|
sa.Column("blue_summary", sa.Text(), nullable=True),
|
||||||
|
sa.Column("review_notes", sa.Text(), nullable=True),
|
||||||
|
sa.Column("reviewed_by", postgresql.UUID(as_uuid=True), sa.ForeignKey("users.id"), nullable=True),
|
||||||
|
sa.Column("archived_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
|
||||||
|
)
|
||||||
|
op.create_index("ix_test_round_history_test_id", "test_round_history", ["test_id"])
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_index("ix_test_round_history_test_id", table_name="test_round_history")
|
||||||
|
op.drop_table("test_round_history")
|
||||||
|
op.drop_column("tests", "blue_round_number")
|
||||||
|
op.drop_column("tests", "red_round_number")
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
"""Add detect_procedure fields and procedure_suggestions review table.
|
||||||
|
|
||||||
|
Blue Team gets a "Detect Procedure" field (what they actually did to
|
||||||
|
detect the attack) mirroring Red's procedure_text, plus a matching
|
||||||
|
"suggested" field on test_templates. Commands extracted from either
|
||||||
|
side's procedure text are proposed as template improvements via a new
|
||||||
|
procedure_suggestions table, reviewed and approved/rejected by a lead
|
||||||
|
rather than written automatically.
|
||||||
|
|
||||||
|
Revision ID: b062
|
||||||
|
Revises: b061
|
||||||
|
Create Date: 2026-07-14
|
||||||
|
"""
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from alembic import op
|
||||||
|
from sqlalchemy.dialects import postgresql
|
||||||
|
|
||||||
|
revision = "b062"
|
||||||
|
down_revision = "b061"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.add_column("tests", sa.Column("detect_procedure", sa.Text(), nullable=True))
|
||||||
|
op.add_column(
|
||||||
|
"tests",
|
||||||
|
sa.Column("source_template_id", postgresql.UUID(as_uuid=True), sa.ForeignKey("test_templates.id"), nullable=True),
|
||||||
|
)
|
||||||
|
op.add_column("test_templates", sa.Column("detect_suggested_procedure", sa.Text(), nullable=True))
|
||||||
|
op.add_column("test_round_history", sa.Column("detect_procedure", sa.Text(), nullable=True))
|
||||||
|
|
||||||
|
op.create_table(
|
||||||
|
"procedure_suggestions",
|
||||||
|
sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True),
|
||||||
|
sa.Column("template_id", postgresql.UUID(as_uuid=True), sa.ForeignKey("test_templates.id"), nullable=False),
|
||||||
|
sa.Column("team", sa.String(10), nullable=False),
|
||||||
|
sa.Column("suggested_text", sa.Text(), nullable=False),
|
||||||
|
sa.Column("source_test_id", postgresql.UUID(as_uuid=True), sa.ForeignKey("tests.id"), nullable=True),
|
||||||
|
sa.Column("submitted_by", postgresql.UUID(as_uuid=True), sa.ForeignKey("users.id"), nullable=True),
|
||||||
|
sa.Column("status", sa.String(10), nullable=False, server_default="pending"),
|
||||||
|
sa.Column("reviewed_by", postgresql.UUID(as_uuid=True), sa.ForeignKey("users.id"), nullable=True),
|
||||||
|
sa.Column("reviewed_at", sa.DateTime(), nullable=True),
|
||||||
|
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
|
||||||
|
)
|
||||||
|
op.create_index("ix_procedure_suggestions_template_id", "procedure_suggestions", ["template_id"])
|
||||||
|
op.create_index("ix_procedure_suggestions_status", "procedure_suggestions", ["status"])
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_index("ix_procedure_suggestions_status", table_name="procedure_suggestions")
|
||||||
|
op.drop_index("ix_procedure_suggestions_template_id", table_name="procedure_suggestions")
|
||||||
|
op.drop_table("procedure_suggestions")
|
||||||
|
op.drop_column("test_round_history", "detect_procedure")
|
||||||
|
op.drop_column("test_templates", "detect_suggested_procedure")
|
||||||
|
op.drop_column("tests", "source_template_id")
|
||||||
|
op.drop_column("tests", "detect_procedure")
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
"""Merge TestTemplate.detect_suggested_procedure into expected_detection.
|
||||||
|
|
||||||
|
Two overlapping fields on TestTemplate — expected_detection (narrative
|
||||||
|
guidance, populated by imports and leads since long before this feature)
|
||||||
|
and detect_suggested_procedure (concrete commands, populated only via the
|
||||||
|
procedure-suggestion approval workflow) — are consolidated into one:
|
||||||
|
expected_detection. Any existing detect_suggested_procedure text is
|
||||||
|
appended (not overwritten) onto expected_detection before the column is
|
||||||
|
dropped, so nothing already approved is lost.
|
||||||
|
|
||||||
|
Revision ID: b063
|
||||||
|
Revises: b062
|
||||||
|
Create Date: 2026-07-15
|
||||||
|
"""
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
revision = "b063"
|
||||||
|
down_revision = "b062"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.execute(
|
||||||
|
"""
|
||||||
|
UPDATE test_templates
|
||||||
|
SET expected_detection = CASE
|
||||||
|
WHEN expected_detection IS NULL OR expected_detection = '' THEN detect_suggested_procedure
|
||||||
|
ELSE expected_detection || E'\n' || detect_suggested_procedure
|
||||||
|
END
|
||||||
|
WHERE detect_suggested_procedure IS NOT NULL AND detect_suggested_procedure != ''
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
op.drop_column("test_templates", "detect_suggested_procedure")
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.add_column("test_templates", sa.Column("detect_suggested_procedure", sa.Text(), nullable=True))
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
"""Add template_suggestions table for operator template proposals.
|
||||||
|
|
||||||
|
Leads can already create a TestTemplate directly (POST /test-templates).
|
||||||
|
Operators (red_tech/blue_tech) get the same "create template" action, but
|
||||||
|
their proposal lands here pending a lead's review instead of the live
|
||||||
|
catalog.
|
||||||
|
|
||||||
|
Revision ID: b064
|
||||||
|
Revises: b063
|
||||||
|
Create Date: 2026-07-16
|
||||||
|
"""
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from alembic import op
|
||||||
|
from sqlalchemy.dialects import postgresql
|
||||||
|
|
||||||
|
revision = "b064"
|
||||||
|
down_revision = "b063"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.create_table(
|
||||||
|
"template_suggestions",
|
||||||
|
sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True),
|
||||||
|
sa.Column("mitre_technique_id", sa.String(), nullable=False),
|
||||||
|
sa.Column("name", sa.String(), nullable=False),
|
||||||
|
sa.Column("description", sa.Text(), nullable=True),
|
||||||
|
sa.Column("source", sa.String(), nullable=False, server_default="custom"),
|
||||||
|
sa.Column("source_url", sa.String(), nullable=True),
|
||||||
|
sa.Column("attack_procedure", sa.Text(), nullable=True),
|
||||||
|
sa.Column("expected_detection", sa.Text(), nullable=True),
|
||||||
|
sa.Column("platform", sa.String(), nullable=True),
|
||||||
|
sa.Column("tool_suggested", sa.String(), nullable=True),
|
||||||
|
sa.Column("severity", sa.String(), nullable=True),
|
||||||
|
sa.Column("atomic_test_id", sa.String(), nullable=True),
|
||||||
|
sa.Column("suggested_remediation", sa.Text(), nullable=True),
|
||||||
|
sa.Column("team", sa.String(10), nullable=False),
|
||||||
|
sa.Column("submitted_by", postgresql.UUID(as_uuid=True), sa.ForeignKey("users.id"), nullable=True),
|
||||||
|
sa.Column("status", sa.String(10), nullable=False, server_default="pending"),
|
||||||
|
sa.Column("reviewed_by", postgresql.UUID(as_uuid=True), sa.ForeignKey("users.id"), nullable=True),
|
||||||
|
sa.Column("reviewed_at", sa.DateTime(), nullable=True),
|
||||||
|
sa.Column("created_template_id", postgresql.UUID(as_uuid=True), sa.ForeignKey("test_templates.id"), nullable=True),
|
||||||
|
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
|
||||||
|
)
|
||||||
|
op.create_index("ix_template_suggestions_status", "template_suggestions", ["status"])
|
||||||
|
op.create_index("ix_template_suggestions_team", "template_suggestions", ["team"])
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_index("ix_template_suggestions_team", table_name="template_suggestions")
|
||||||
|
op.drop_index("ix_template_suggestions_status", table_name="template_suggestions")
|
||||||
|
op.drop_table("template_suggestions")
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
"""Add users.full_name and a password_setup_tokens table.
|
||||||
|
|
||||||
|
Users are now created with a name + email instead of a directly-set
|
||||||
|
password — the admin's "Send Email" action issues a one-time token (this
|
||||||
|
table) for the user to set their own password via a webhook-delivered
|
||||||
|
link. The same mechanism is reused for password resets.
|
||||||
|
|
||||||
|
Revision ID: b065
|
||||||
|
Revises: b064
|
||||||
|
Create Date: 2026-07-17
|
||||||
|
"""
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from alembic import op
|
||||||
|
from sqlalchemy.dialects import postgresql
|
||||||
|
|
||||||
|
revision = "b065"
|
||||||
|
down_revision = "b064"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.add_column("users", sa.Column("full_name", sa.String(), nullable=True))
|
||||||
|
|
||||||
|
op.create_table(
|
||||||
|
"password_setup_tokens",
|
||||||
|
sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True),
|
||||||
|
sa.Column("user_id", postgresql.UUID(as_uuid=True), sa.ForeignKey("users.id"), nullable=False),
|
||||||
|
sa.Column("token", sa.String(128), nullable=False),
|
||||||
|
sa.Column("expires_at", sa.DateTime(), nullable=False),
|
||||||
|
sa.Column("used_at", sa.DateTime(), nullable=True),
|
||||||
|
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
|
||||||
|
)
|
||||||
|
op.create_index("ix_password_setup_tokens_token", "password_setup_tokens", ["token"], unique=True)
|
||||||
|
op.create_index("ix_password_setup_tokens_user_id", "password_setup_tokens", ["user_id"])
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_index("ix_password_setup_tokens_user_id", table_name="password_setup_tokens")
|
||||||
|
op.drop_index("ix_password_setup_tokens_token", table_name="password_setup_tokens")
|
||||||
|
op.drop_table("password_setup_tokens")
|
||||||
|
op.drop_column("users", "full_name")
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
"""Add users.extra_roles for multi-role support.
|
||||||
|
|
||||||
|
A user can be granted more than one role, but only ever acts under a
|
||||||
|
single "active" role at a time (``users.role``, unchanged — every
|
||||||
|
existing permission check keeps reading it as-is). ``extra_roles`` holds
|
||||||
|
the *other* roles a user can switch into via the top-bar role switcher;
|
||||||
|
switching swaps the active role with one from this list.
|
||||||
|
|
||||||
|
Revision ID: b066
|
||||||
|
Revises: b065
|
||||||
|
Create Date: 2026-07-20
|
||||||
|
"""
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from alembic import op
|
||||||
|
from sqlalchemy.dialects import postgresql
|
||||||
|
|
||||||
|
revision = "b066"
|
||||||
|
down_revision = "b065"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.add_column(
|
||||||
|
"users",
|
||||||
|
sa.Column("extra_roles", postgresql.JSONB(), nullable=False, server_default="[]"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_column("users", "extra_roles")
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
"""Make users.email the unique login identifier (NOT NULL + unique index).
|
||||||
|
|
||||||
|
Backfills any missing/blank email from username first, so existing rows
|
||||||
|
(e.g. the seeded admin, which historically had no email) never violate
|
||||||
|
the new constraint.
|
||||||
|
|
||||||
|
Revision ID: b067
|
||||||
|
Revises: b066
|
||||||
|
Create Date: 2026-07-23
|
||||||
|
"""
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
revision = "b067"
|
||||||
|
down_revision = "b066"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.execute(
|
||||||
|
"UPDATE users SET email = username WHERE email IS NULL OR email = ''"
|
||||||
|
)
|
||||||
|
op.alter_column("users", "email", existing_type=sa.String(), nullable=False)
|
||||||
|
op.create_unique_constraint("uq_users_email", "users", ["email"])
|
||||||
|
op.create_index("ix_users_email", "users", ["email"])
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_index("ix_users_email", table_name="users")
|
||||||
|
op.drop_constraint("uq_users_email", "users", type_="unique")
|
||||||
|
op.alter_column("users", "email", existing_type=sa.String(), nullable=True)
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 22 KiB |
@@ -107,6 +107,11 @@ class Settings(BaseSettings):
|
|||||||
STALE_THRESHOLD_DAYS: int = 365 # days before coverage is considered stale
|
STALE_THRESHOLD_DAYS: int = 365 # days before coverage is considered stale
|
||||||
|
|
||||||
# ── Reporting ─────────────────────────────────────────────────────
|
# ── Reporting ─────────────────────────────────────────────────────
|
||||||
|
# PDF/DOCX/HTML report generation (professional_reports router) is
|
||||||
|
# unfinished — the frontend hides the Reports UI entirely. Defaults
|
||||||
|
# off so a fresh deploy returns a clean 503 instead of a raw 500 from
|
||||||
|
# a half-working render pipeline (e.g. missing weasyprint system deps).
|
||||||
|
REPORTS_ENABLED: bool = False
|
||||||
REPORT_TEMPLATES_DIR: str = "app/templates/reports"
|
REPORT_TEMPLATES_DIR: str = "app/templates/reports"
|
||||||
# Assign REPORT_OUTPUT_DIR = "/tmp/aegis_reports"
|
# Assign REPORT_OUTPUT_DIR = "/tmp/aegis_reports"
|
||||||
REPORT_OUTPUT_DIR: str = "/app/reports"
|
REPORT_OUTPUT_DIR: str = "/app/reports"
|
||||||
@@ -211,3 +216,14 @@ if _is_production:
|
|||||||
f"Set a strong value via the {name} environment variable "
|
f"Set a strong value via the {name} environment variable "
|
||||||
f"before running in production."
|
f"before running in production."
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# PLATFORM_URL has no safe production default — it's baked into every
|
||||||
|
# emailed link (set-password, notifications). Falling back silently to
|
||||||
|
# the dev value (or to some other deployment's hardcoded domain) would
|
||||||
|
# ship broken/wrong links without anyone noticing.
|
||||||
|
if settings.PLATFORM_URL == "http://localhost:5173":
|
||||||
|
raise RuntimeError(
|
||||||
|
"CRITICAL: PLATFORM_URL is not configured. Set it to this "
|
||||||
|
"deployment's real public frontend URL via the PLATFORM_URL "
|
||||||
|
"environment variable before running in production."
|
||||||
|
)
|
||||||
|
|||||||
@@ -253,6 +253,30 @@ def require_any_role(*roles: str) -> Callable[..., object]:
|
|||||||
return role_checker
|
return role_checker
|
||||||
|
|
||||||
|
|
||||||
|
def require_any_role_strict(*roles: str) -> Callable[..., object]:
|
||||||
|
"""Return a FastAPI dependency that enforces **any** of the given *roles*.
|
||||||
|
|
||||||
|
Unlike ``require_any_role``, admins do **not** automatically pass — use
|
||||||
|
this for actions that belong exclusively to Red/Blue operators, leads,
|
||||||
|
or managers (e.g. executing, reviewing, validating, or resolving a
|
||||||
|
disputed test), where "admin" must mean site administration only, not
|
||||||
|
a backdoor into the test workflow itself.
|
||||||
|
"""
|
||||||
|
|
||||||
|
async def role_checker(
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
) -> User:
|
||||||
|
if current_user.role not in roles:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
|
detail="Not enough permissions",
|
||||||
|
)
|
||||||
|
_check_api_key_scope(current_user, "write")
|
||||||
|
return current_user
|
||||||
|
|
||||||
|
return role_checker
|
||||||
|
|
||||||
|
|
||||||
def require_scope(scope: str):
|
def require_scope(scope: str):
|
||||||
"""Return a dependency that enforces the API key carries *scope*.
|
"""Return a dependency that enforces the API key carries *scope*.
|
||||||
|
|
||||||
|
|||||||
@@ -33,6 +33,8 @@ class CampaignStatus(str, enum.Enum):
|
|||||||
|
|
||||||
# Assign draft = "draft"
|
# Assign draft = "draft"
|
||||||
draft = "draft"
|
draft = "draft"
|
||||||
|
# Assign pending_approval = "pending_approval"
|
||||||
|
pending_approval = "pending_approval"
|
||||||
# Assign active = "active"
|
# Assign active = "active"
|
||||||
active = "active"
|
active = "active"
|
||||||
# Assign completed = "completed"
|
# Assign completed = "completed"
|
||||||
@@ -57,7 +59,8 @@ class CampaignType(str, enum.Enum):
|
|||||||
|
|
||||||
# Assign VALID_TRANSITIONS = {
|
# Assign VALID_TRANSITIONS = {
|
||||||
VALID_TRANSITIONS: dict[CampaignStatus, list[CampaignStatus]] = {
|
VALID_TRANSITIONS: dict[CampaignStatus, list[CampaignStatus]] = {
|
||||||
CampaignStatus.draft: [CampaignStatus.active],
|
CampaignStatus.draft: [CampaignStatus.pending_approval, CampaignStatus.active],
|
||||||
|
CampaignStatus.pending_approval: [CampaignStatus.active, CampaignStatus.draft],
|
||||||
CampaignStatus.active: [CampaignStatus.completed],
|
CampaignStatus.active: [CampaignStatus.completed],
|
||||||
CampaignStatus.completed: [CampaignStatus.archived],
|
CampaignStatus.completed: [CampaignStatus.archived],
|
||||||
CampaignStatus.archived: [],
|
CampaignStatus.archived: [],
|
||||||
@@ -132,6 +135,49 @@ class CampaignEntity:
|
|||||||
# Assign self.status = CampaignStatus.active
|
# Assign self.status = CampaignStatus.active
|
||||||
self.status = CampaignStatus.active
|
self.status = CampaignStatus.active
|
||||||
|
|
||||||
|
def submit_for_approval(self) -> None:
|
||||||
|
"""Transition the campaign from ``draft`` to ``pending_approval``.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
None
|
||||||
|
"""
|
||||||
|
if not self.can_transition_to(CampaignStatus.pending_approval):
|
||||||
|
raise InvalidStateTransition(
|
||||||
|
self.status.value, CampaignStatus.pending_approval.value,
|
||||||
|
[s.value for s in VALID_TRANSITIONS[self.status]],
|
||||||
|
)
|
||||||
|
if self.test_count == 0:
|
||||||
|
raise BusinessRuleViolation(
|
||||||
|
"Campaign must have at least one test to submit for approval"
|
||||||
|
)
|
||||||
|
self.status = CampaignStatus.pending_approval
|
||||||
|
|
||||||
|
def approve(self) -> None:
|
||||||
|
"""Transition the campaign from ``pending_approval`` to ``active``.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
None
|
||||||
|
"""
|
||||||
|
if self.status != CampaignStatus.pending_approval:
|
||||||
|
raise InvalidStateTransition(
|
||||||
|
self.status.value, CampaignStatus.active.value,
|
||||||
|
[CampaignStatus.pending_approval.value],
|
||||||
|
)
|
||||||
|
self.status = CampaignStatus.active
|
||||||
|
|
||||||
|
def reject(self) -> None:
|
||||||
|
"""Transition the campaign from ``pending_approval`` back to ``draft``.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
None
|
||||||
|
"""
|
||||||
|
if not self.can_transition_to(CampaignStatus.draft):
|
||||||
|
raise InvalidStateTransition(
|
||||||
|
self.status.value, CampaignStatus.draft.value,
|
||||||
|
[s.value for s in VALID_TRANSITIONS[self.status]],
|
||||||
|
)
|
||||||
|
self.status = CampaignStatus.draft
|
||||||
|
|
||||||
# Define function complete
|
# Define function complete
|
||||||
def complete(self) -> None:
|
def complete(self) -> None:
|
||||||
"""Transition the campaign from ``active`` to ``completed``.
|
"""Transition the campaign from ``active`` to ``completed``.
|
||||||
|
|||||||
@@ -35,8 +35,12 @@ class TestState(str, enum.Enum):
|
|||||||
draft = "draft"
|
draft = "draft"
|
||||||
# Assign red_executing = "red_executing"
|
# Assign red_executing = "red_executing"
|
||||||
red_executing = "red_executing"
|
red_executing = "red_executing"
|
||||||
|
# Red Lead reviews the operator's work before it queues for Blue Team
|
||||||
|
red_review = "red_review"
|
||||||
# Assign blue_evaluating = "blue_evaluating"
|
# Assign blue_evaluating = "blue_evaluating"
|
||||||
blue_evaluating = "blue_evaluating"
|
blue_evaluating = "blue_evaluating"
|
||||||
|
# Blue Lead reviews the operator's work before cross-validation
|
||||||
|
blue_review = "blue_review"
|
||||||
# Assign in_review = "in_review"
|
# Assign in_review = "in_review"
|
||||||
in_review = "in_review"
|
in_review = "in_review"
|
||||||
# Assign validated = "validated"
|
# Assign validated = "validated"
|
||||||
@@ -77,15 +81,31 @@ class ContainmentResult(str, enum.Enum):
|
|||||||
not_contained = "not_contained"
|
not_contained = "not_contained"
|
||||||
|
|
||||||
|
|
||||||
|
class AttackSuccessResult(str, enum.Enum):
|
||||||
|
"""Outcome of a red-team attack from a success perspective."""
|
||||||
|
|
||||||
|
successful = "successful"
|
||||||
|
not_successful = "not_successful"
|
||||||
|
partially_successful = "partially_successful"
|
||||||
|
|
||||||
|
|
||||||
# Define class DataClassification
|
# Define class DataClassification
|
||||||
class DataClassification(str, enum.Enum):
|
class DataClassification(str, enum.Enum):
|
||||||
"""Data sensitivity classification levels for compliance and retention policies."""
|
"""Data sensitivity classification levels for compliance and retention policies.
|
||||||
|
|
||||||
|
Matches Jira's "Data Sensitivity" field (customfield_11814) exactly, since
|
||||||
|
that is the organization's actual data protection scheme:
|
||||||
|
- public: approved for external/public release
|
||||||
|
- general_use: general internal information, no special restriction
|
||||||
|
- internal_use_only: internal use only
|
||||||
|
- trusted_people: shared only with a limited/trusted audience
|
||||||
|
- customer_content: contains customer data
|
||||||
|
- pii: contains personally identifiable information
|
||||||
|
"""
|
||||||
|
|
||||||
# Assign public = "public"
|
|
||||||
public = "public"
|
public = "public"
|
||||||
# Assign internal = "internal"
|
general_use = "general_use"
|
||||||
internal = "internal"
|
internal_use_only = "internal_use_only"
|
||||||
# Assign sensitive = "sensitive"
|
trusted_people = "trusted_people"
|
||||||
sensitive = "sensitive"
|
customer_content = "customer_content"
|
||||||
# Assign restricted = "restricted"
|
pii = "pii"
|
||||||
restricted = "restricted"
|
|
||||||
|
|||||||
@@ -60,8 +60,12 @@ class TestState(str, enum.Enum):
|
|||||||
draft = "draft"
|
draft = "draft"
|
||||||
# Assign red_executing = "red_executing"
|
# Assign red_executing = "red_executing"
|
||||||
red_executing = "red_executing"
|
red_executing = "red_executing"
|
||||||
|
# Red Lead reviews the operator's work before it queues for Blue Team
|
||||||
|
red_review = "red_review"
|
||||||
# Assign blue_evaluating = "blue_evaluating"
|
# Assign blue_evaluating = "blue_evaluating"
|
||||||
blue_evaluating = "blue_evaluating"
|
blue_evaluating = "blue_evaluating"
|
||||||
|
# Blue Lead reviews the operator's work before cross-validation
|
||||||
|
blue_review = "blue_review"
|
||||||
# Assign in_review = "in_review"
|
# Assign in_review = "in_review"
|
||||||
in_review = "in_review"
|
in_review = "in_review"
|
||||||
# Assign validated = "validated"
|
# Assign validated = "validated"
|
||||||
@@ -74,10 +78,15 @@ class TestState(str, enum.Enum):
|
|||||||
# Assign VALID_TRANSITIONS = {
|
# Assign VALID_TRANSITIONS = {
|
||||||
VALID_TRANSITIONS: dict[TestState, list[TestState]] = {
|
VALID_TRANSITIONS: dict[TestState, list[TestState]] = {
|
||||||
TestState.draft: [TestState.red_executing],
|
TestState.draft: [TestState.red_executing],
|
||||||
TestState.red_executing: [TestState.blue_evaluating],
|
TestState.red_executing: [TestState.red_review],
|
||||||
TestState.blue_evaluating: [TestState.in_review],
|
TestState.red_review: [TestState.blue_evaluating, TestState.red_executing],
|
||||||
|
TestState.blue_evaluating: [TestState.blue_review],
|
||||||
|
TestState.blue_review: [TestState.in_review, TestState.blue_evaluating],
|
||||||
TestState.in_review: [TestState.validated, TestState.rejected, TestState.disputed],
|
TestState.in_review: [TestState.validated, TestState.rejected, TestState.disputed],
|
||||||
TestState.disputed: [TestState.validated, TestState.rejected],
|
TestState.disputed: [
|
||||||
|
TestState.validated, TestState.rejected,
|
||||||
|
TestState.red_executing, TestState.blue_evaluating,
|
||||||
|
],
|
||||||
TestState.rejected: [TestState.draft],
|
TestState.rejected: [TestState.draft],
|
||||||
TestState.validated: [],
|
TestState.validated: [],
|
||||||
}
|
}
|
||||||
@@ -363,10 +372,12 @@ class TestEntity:
|
|||||||
|
|
||||||
# Define function submit_red_evidence
|
# Define function submit_red_evidence
|
||||||
def submit_red_evidence(self) -> int:
|
def submit_red_evidence(self) -> int:
|
||||||
"""Transition the test from ``red_executing`` to ``blue_evaluating``.
|
"""Transition the test from ``red_executing`` to ``red_review``.
|
||||||
|
|
||||||
Auto-resumes if paused. Returns paused seconds accumulated
|
Auto-resumes if paused. Returns paused seconds accumulated
|
||||||
during this phase (for worklog calculation).
|
during this phase (for worklog calculation). The Blue Team queue
|
||||||
|
timer does not start yet — that happens in ``approve_red_review``,
|
||||||
|
once the Red Lead actually releases the test to Blue Team.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
int: Total seconds the red phase was paused.
|
int: Total seconds the red phase was paused.
|
||||||
@@ -374,13 +385,9 @@ class TestEntity:
|
|||||||
# Assign paused_extra = self._auto_resume()
|
# Assign paused_extra = self._auto_resume()
|
||||||
paused_extra = self._auto_resume()
|
paused_extra = self._auto_resume()
|
||||||
# Call self._transition()
|
# Call self._transition()
|
||||||
self._transition(TestState.blue_evaluating)
|
self._transition(TestState.red_review)
|
||||||
# Assign total_paused = self.red_paused_seconds + paused_extra
|
# Assign total_paused = self.red_paused_seconds + paused_extra
|
||||||
total_paused = self.red_paused_seconds + paused_extra
|
total_paused = self.red_paused_seconds + paused_extra
|
||||||
# Assign self.blue_started_at = datetime.utcnow()
|
|
||||||
self.blue_started_at = datetime.utcnow()
|
|
||||||
# Assign self.blue_paused_seconds = 0
|
|
||||||
self.blue_paused_seconds = 0
|
|
||||||
# Call self._events.append()
|
# Call self._events.append()
|
||||||
self._events.append(DomainEvent(
|
self._events.append(DomainEvent(
|
||||||
# Literal argument value
|
# Literal argument value
|
||||||
@@ -390,9 +397,36 @@ class TestEntity:
|
|||||||
# Return total_paused
|
# Return total_paused
|
||||||
return total_paused
|
return total_paused
|
||||||
|
|
||||||
|
def approve_red_review(self) -> None:
|
||||||
|
"""Transition the test from ``red_review`` to ``blue_evaluating``.
|
||||||
|
|
||||||
|
Called when the assigned Red Lead approves the operator's work.
|
||||||
|
Starts the Blue Team queue timer.
|
||||||
|
"""
|
||||||
|
self._transition(TestState.blue_evaluating)
|
||||||
|
self.blue_started_at = datetime.utcnow()
|
||||||
|
self.blue_paused_seconds = 0
|
||||||
|
self._events.append(DomainEvent("red_review_approved"))
|
||||||
|
|
||||||
|
def reopen_red_review(self) -> None:
|
||||||
|
"""Transition the test from ``red_review`` back to ``red_executing``.
|
||||||
|
|
||||||
|
Called when the assigned Red Lead sends the work back for rework.
|
||||||
|
Resets the red-phase timer for a fresh attempt (the first attempt's
|
||||||
|
worklog was already recorded at submit time, so this loses nothing).
|
||||||
|
Starts the timer PAUSED — the operator hasn't resumed working yet,
|
||||||
|
just because the lead reopened it. It only starts counting once they
|
||||||
|
explicitly resume it, same as coming back from a hold.
|
||||||
|
"""
|
||||||
|
self._transition(TestState.red_executing)
|
||||||
|
self.red_started_at = datetime.utcnow()
|
||||||
|
self.red_paused_seconds = 0
|
||||||
|
self.paused_at = self.red_started_at
|
||||||
|
self._events.append(DomainEvent("red_review_reopened"))
|
||||||
|
|
||||||
# Define function submit_blue_evidence
|
# Define function submit_blue_evidence
|
||||||
def submit_blue_evidence(self) -> int:
|
def submit_blue_evidence(self) -> int:
|
||||||
"""Transition the test from ``blue_evaluating`` to ``in_review``.
|
"""Transition the test from ``blue_evaluating`` to ``blue_review``.
|
||||||
|
|
||||||
Auto-resumes if paused. Returns paused seconds accumulated
|
Auto-resumes if paused. Returns paused seconds accumulated
|
||||||
during this phase (for worklog calculation).
|
during this phase (for worklog calculation).
|
||||||
@@ -403,7 +437,7 @@ class TestEntity:
|
|||||||
# Assign paused_extra = self._auto_resume()
|
# Assign paused_extra = self._auto_resume()
|
||||||
paused_extra = self._auto_resume()
|
paused_extra = self._auto_resume()
|
||||||
# Call self._transition()
|
# Call self._transition()
|
||||||
self._transition(TestState.in_review)
|
self._transition(TestState.blue_review)
|
||||||
# Assign total_paused = self.blue_paused_seconds + paused_extra
|
# Assign total_paused = self.blue_paused_seconds + paused_extra
|
||||||
total_paused = self.blue_paused_seconds + paused_extra
|
total_paused = self.blue_paused_seconds + paused_extra
|
||||||
# Call self._events.append()
|
# Call self._events.append()
|
||||||
@@ -415,6 +449,39 @@ class TestEntity:
|
|||||||
# Return total_paused
|
# Return total_paused
|
||||||
return total_paused
|
return total_paused
|
||||||
|
|
||||||
|
def approve_blue_review(self) -> None:
|
||||||
|
"""Transition the test from ``blue_review`` to ``in_review``.
|
||||||
|
|
||||||
|
Called when the assigned Blue Lead approves the operator's work.
|
||||||
|
"""
|
||||||
|
self._transition(TestState.in_review)
|
||||||
|
self._events.append(DomainEvent("blue_review_approved"))
|
||||||
|
|
||||||
|
def reopen_blue_review(self) -> None:
|
||||||
|
"""Transition the test from ``blue_review`` back to ``blue_evaluating``.
|
||||||
|
|
||||||
|
Called when the assigned Blue Lead sends the work back for rework.
|
||||||
|
Resets the blue-phase timer for a fresh attempt. Note:
|
||||||
|
``blue_work_started_at`` (the pickup timestamp) lives only on the
|
||||||
|
ORM model, not on this entity — the service layer resets it there.
|
||||||
|
"""
|
||||||
|
self._transition(TestState.blue_evaluating)
|
||||||
|
self.blue_started_at = datetime.utcnow()
|
||||||
|
self.blue_paused_seconds = 0
|
||||||
|
self._events.append(DomainEvent("blue_review_reopened"))
|
||||||
|
|
||||||
|
def flag_blue_review_gap(self) -> None:
|
||||||
|
"""Transition the test from ``blue_review`` to ``in_review``.
|
||||||
|
|
||||||
|
Called when the assigned Blue Lead determines the shortfall is a
|
||||||
|
missing capability, not operator error — the test still proceeds
|
||||||
|
to cross-validation since a retry can't fix a capability gap. The
|
||||||
|
gap description itself is stored on the ORM model
|
||||||
|
(``Test.system_gaps``), not on this entity.
|
||||||
|
"""
|
||||||
|
self._transition(TestState.in_review)
|
||||||
|
self._events.append(DomainEvent("blue_review_gap_flagged"))
|
||||||
|
|
||||||
# Define function pause_timer
|
# Define function pause_timer
|
||||||
def pause_timer(self) -> None:
|
def pause_timer(self) -> None:
|
||||||
"""Pause the active phase timer.
|
"""Pause the active phase timer.
|
||||||
@@ -567,6 +634,65 @@ class TestEntity:
|
|||||||
# Call self._events.append()
|
# Call self._events.append()
|
||||||
self._events.append(DomainEvent("test_reopened"))
|
self._events.append(DomainEvent("test_reopened"))
|
||||||
|
|
||||||
|
def resolve_dispute_reject(self, target: str) -> None:
|
||||||
|
"""Resolve a ``disputed`` test by routing rework to the team at fault.
|
||||||
|
|
||||||
|
Called when the lead who originally approved flips their vote to
|
||||||
|
agree with the rejection. Rather than the generic terminal
|
||||||
|
``rejected`` state (which forces a full draft restart for both
|
||||||
|
teams), the flipping lead identifies WHICH side's work needs
|
||||||
|
redoing — the test goes straight back to that team's active queue
|
||||||
|
so the other team's work is left untouched.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
target (str): ``"red"`` or ``"blue"`` — which team must redo work.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
None
|
||||||
|
"""
|
||||||
|
target_state = TestState.red_executing if target == "red" else TestState.blue_evaluating
|
||||||
|
self._transition(target_state)
|
||||||
|
|
||||||
|
# Both leads must re-vote once the test reaches in_review again —
|
||||||
|
# clear decisions but keep notes (context for the team doing rework).
|
||||||
|
self.red_validation_status = None
|
||||||
|
self.red_validated_by = None
|
||||||
|
self.red_validated_at = None
|
||||||
|
self.blue_validation_status = None
|
||||||
|
self.blue_validated_by = None
|
||||||
|
self.blue_validated_at = None
|
||||||
|
self.paused_at = None
|
||||||
|
|
||||||
|
if target == "red":
|
||||||
|
self.red_started_at = datetime.utcnow()
|
||||||
|
self.red_paused_seconds = 0
|
||||||
|
else:
|
||||||
|
self.blue_started_at = datetime.utcnow()
|
||||||
|
self.blue_paused_seconds = 0
|
||||||
|
|
||||||
|
self._events.append(DomainEvent("dispute_resolved_to_rework", {"target": target}))
|
||||||
|
|
||||||
|
def resolve_dispute_by_manager(self, outcome: str) -> None:
|
||||||
|
"""A manager makes the final call on a disputed test, ending the standoff.
|
||||||
|
|
||||||
|
Unlike ``resolve_dispute_reject`` (a lead flipping their own vote),
|
||||||
|
this bypasses both leads entirely — the manager's decision is final
|
||||||
|
and terminal, going straight to ``validated`` or the normal
|
||||||
|
``rejected`` dead-end (which any lead can later reopen via the
|
||||||
|
standard reopen-to-draft flow, same as any other rejection).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
outcome (str): ``"validated"`` or ``"rejected"``.
|
||||||
|
"""
|
||||||
|
if outcome not in ("validated", "rejected"):
|
||||||
|
raise InvalidOperationError("outcome must be 'validated' or 'rejected'")
|
||||||
|
|
||||||
|
target_state = TestState.validated if outcome == "validated" else TestState.rejected
|
||||||
|
self._transition(target_state)
|
||||||
|
self._events.append(
|
||||||
|
DomainEvent("dual_validation_approved" if outcome == "validated" else "dual_validation_rejected")
|
||||||
|
)
|
||||||
|
|
||||||
# -- Private -------------------------------------------------------
|
# -- Private -------------------------------------------------------
|
||||||
|
|
||||||
def _auto_resume(self) -> int:
|
def _auto_resume(self) -> int:
|
||||||
@@ -634,7 +760,14 @@ class TestEntity:
|
|||||||
|
|
||||||
# Define function _check_dual_validation
|
# Define function _check_dual_validation
|
||||||
def _check_dual_validation(self) -> None:
|
def _check_dual_validation(self) -> None:
|
||||||
"""Advance the test state once both leads have voted."""
|
"""Advance the test state once enough leads have voted.
|
||||||
|
|
||||||
|
A genuine conflict (one lead's *recorded* decision disagreeing with
|
||||||
|
the other's) routes to ``disputed`` — there are two opinions to
|
||||||
|
reconcile. A lone rejection while the other side hasn't voted yet
|
||||||
|
isn't a conflict (nothing to disagree with), so it still vetoes
|
||||||
|
straight to ``rejected`` without waiting for the second vote.
|
||||||
|
"""
|
||||||
r, b = self.red_validation_status, self.blue_validation_status
|
r, b = self.red_validation_status, self.blue_validation_status
|
||||||
|
|
||||||
if r == "approved" and b == "approved":
|
if r == "approved" and b == "approved":
|
||||||
@@ -642,7 +775,16 @@ class TestEntity:
|
|||||||
# Call self._events.append()
|
# Call self._events.append()
|
||||||
self._events.append(DomainEvent("dual_validation_approved"))
|
self._events.append(DomainEvent("dual_validation_approved"))
|
||||||
|
|
||||||
elif r == "rejected" or b == "rejected":
|
elif r == "rejected" and b == "rejected":
|
||||||
# Any rejection is a veto — one lead can reject without waiting for the other
|
self.state = TestState.rejected
|
||||||
|
self._events.append(DomainEvent("dual_validation_rejected"))
|
||||||
|
|
||||||
|
elif (r == "approved" and b == "rejected") or (r == "rejected" and b == "approved"):
|
||||||
|
self.state = TestState.disputed
|
||||||
|
self._events.append(DomainEvent("dual_validation_disputed"))
|
||||||
|
|
||||||
|
elif r == "rejected" or b == "rejected":
|
||||||
|
# One side rejected while the other hasn't voted yet — no
|
||||||
|
# disagreement to dispute yet, just a straightforward veto.
|
||||||
self.state = TestState.rejected
|
self.state = TestState.rejected
|
||||||
self._events.append(DomainEvent("dual_validation_rejected"))
|
self._events.append(DomainEvent("dual_validation_rejected"))
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ from datetime import datetime, timedelta, timezone
|
|||||||
|
|
||||||
# Import BackgroundScheduler from apscheduler.schedulers.background
|
# Import BackgroundScheduler from apscheduler.schedulers.background
|
||||||
from apscheduler.schedulers.background import BackgroundScheduler
|
from apscheduler.schedulers.background import BackgroundScheduler
|
||||||
|
from apscheduler.events import EVENT_JOB_ERROR
|
||||||
|
|
||||||
# Import SessionLocal from app.database
|
# Import SessionLocal from app.database
|
||||||
from app.database import SessionLocal
|
from app.database import SessionLocal
|
||||||
@@ -27,7 +28,10 @@ from app.jobs.jira_sync_job import sync_all_jira_links
|
|||||||
from app.jobs.retention_job import run_retention_job
|
from app.jobs.retention_job import run_retention_job
|
||||||
|
|
||||||
# Import check_and_run_recurring_campaigns from app.services.campaign_scheduler_service
|
# Import check_and_run_recurring_campaigns from app.services.campaign_scheduler_service
|
||||||
from app.services.campaign_scheduler_service import check_and_run_recurring_campaigns
|
from app.services.campaign_scheduler_service import (
|
||||||
|
check_and_run_recurring_campaigns,
|
||||||
|
sync_due_campaign_jira_tickets,
|
||||||
|
)
|
||||||
|
|
||||||
# Import scan_intel from app.services.intel_service
|
# Import scan_intel from app.services.intel_service
|
||||||
from app.services.intel_service import scan_intel
|
from app.services.intel_service import scan_intel
|
||||||
@@ -57,6 +61,27 @@ logger = logging.getLogger(__name__)
|
|||||||
scheduler = BackgroundScheduler()
|
scheduler = BackgroundScheduler()
|
||||||
|
|
||||||
|
|
||||||
|
def _on_job_error(event) -> None:
|
||||||
|
"""Email admins (opted-in) whenever a scheduled background job raises."""
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
from app.services.notification_service import notify_roles_by_email
|
||||||
|
notify_roles_by_email(
|
||||||
|
db, roles=["admin"],
|
||||||
|
preference_key="email_on_system_errors",
|
||||||
|
subject=f"Aegis Background Job Failed: {event.job_id}",
|
||||||
|
message=(
|
||||||
|
f'The scheduled job "{event.job_id}" raised an exception and did '
|
||||||
|
f"not complete:\n\n{event.exception}"
|
||||||
|
),
|
||||||
|
)
|
||||||
|
db.commit()
|
||||||
|
except Exception:
|
||||||
|
logger.exception("Failed to dispatch system-error notification")
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Job functions
|
# Job functions
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -164,92 +189,15 @@ def _run_recurring_campaigns() -> None:
|
|||||||
db.close()
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
def _run_scheduled_campaign_activation() -> None:
|
def _run_due_campaign_jira_sync() -> None:
|
||||||
"""Auto-activate campaigns whose start_date has arrived.
|
"""Create Jira tickets for campaigns whose scheduled start_date has arrived."""
|
||||||
|
logger.info("Scheduled due-campaign Jira sync starting...")
|
||||||
Finds all campaigns in 'draft' state with a start_date <= now,
|
|
||||||
activates them, creates Jira tickets, and notifies the red_tech team.
|
|
||||||
Runs every hour so campaigns activate within ~1 hour of their scheduled time.
|
|
||||||
"""
|
|
||||||
logger.info("Scheduled campaign auto-activation check starting...")
|
|
||||||
db = SessionLocal()
|
db = SessionLocal()
|
||||||
try:
|
try:
|
||||||
from datetime import datetime as _dt
|
processed = sync_due_campaign_jira_tickets(db)
|
||||||
from app.models.campaign import Campaign
|
logger.info("Due-campaign Jira sync finished — processed %d campaigns", processed)
|
||||||
from app.models.user import User
|
|
||||||
from app.services.campaign_crud_service import activate_campaign as _activate
|
|
||||||
from app.services.notification_service import notify_role
|
|
||||||
from app.services.audit_service import log_action
|
|
||||||
|
|
||||||
now = _dt.utcnow()
|
|
||||||
due_campaigns = (
|
|
||||||
db.query(Campaign)
|
|
||||||
.filter(
|
|
||||||
Campaign.status == "draft",
|
|
||||||
Campaign.start_date != None, # noqa: E711
|
|
||||||
Campaign.start_date <= now,
|
|
||||||
)
|
|
||||||
.all()
|
|
||||||
)
|
|
||||||
|
|
||||||
activated = 0
|
|
||||||
for campaign in due_campaigns:
|
|
||||||
try:
|
|
||||||
_activate(db, str(campaign.id))
|
|
||||||
notify_role(
|
|
||||||
db,
|
|
||||||
role="red_tech",
|
|
||||||
type="campaign_activated",
|
|
||||||
title="Campaign auto-activated",
|
|
||||||
message=f'Campaign "{campaign.name}" has been automatically activated on its scheduled start date.',
|
|
||||||
entity_type="campaign",
|
|
||||||
entity_id=campaign.id,
|
|
||||||
)
|
|
||||||
log_action(
|
|
||||||
db,
|
|
||||||
user_id=None,
|
|
||||||
action="auto_activate_campaign",
|
|
||||||
entity_type="campaign",
|
|
||||||
entity_id=campaign.id,
|
|
||||||
details={"name": campaign.name, "start_date": str(campaign.start_date)},
|
|
||||||
)
|
|
||||||
|
|
||||||
# Create Jira tickets non-fatally
|
|
||||||
try:
|
|
||||||
from app.services.jira_service import (
|
|
||||||
auto_create_campaign_issue,
|
|
||||||
auto_create_test_issue,
|
|
||||||
get_campaign_jira_key,
|
|
||||||
get_test_jira_key,
|
|
||||||
)
|
|
||||||
# Use first admin user as actor for Jira auth
|
|
||||||
admin_user = db.query(User).filter(User.role == "admin").first()
|
|
||||||
if admin_user:
|
|
||||||
db.refresh(campaign)
|
|
||||||
campaign_jira_key = get_campaign_jira_key(db, str(campaign.id))
|
|
||||||
if not campaign_jira_key:
|
|
||||||
campaign_jira_key = auto_create_campaign_issue(db, campaign, admin_user)
|
|
||||||
if campaign_jira_key:
|
|
||||||
for ct in campaign.campaign_tests:
|
|
||||||
if ct.test and not get_test_jira_key(db, ct.test.id):
|
|
||||||
auto_create_test_issue(
|
|
||||||
db, ct.test, admin_user,
|
|
||||||
parent_ticket_override=campaign_jira_key,
|
|
||||||
campaign_start_date=campaign.start_date,
|
|
||||||
)
|
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.exception("Jira auto-create failed for auto-activated campaign %s", campaign.id)
|
logger.exception("Due-campaign Jira sync failed")
|
||||||
|
|
||||||
db.commit()
|
|
||||||
activated += 1
|
|
||||||
logger.info("Auto-activated campaign %s (%s)", campaign.id, campaign.name)
|
|
||||||
except Exception:
|
|
||||||
logger.exception("Failed to auto-activate campaign %s", campaign.id)
|
|
||||||
db.rollback()
|
|
||||||
|
|
||||||
logger.info("Campaign auto-activation check finished — activated %d campaigns", activated)
|
|
||||||
except Exception:
|
|
||||||
logger.exception("Campaign auto-activation job failed")
|
|
||||||
finally:
|
finally:
|
||||||
db.close()
|
db.close()
|
||||||
|
|
||||||
@@ -514,6 +462,7 @@ def start_scheduler() -> None:
|
|||||||
|
|
||||||
Neither job fires immediately on startup.
|
Neither job fires immediately on startup.
|
||||||
"""
|
"""
|
||||||
|
scheduler.add_listener(_on_job_error, EVENT_JOB_ERROR)
|
||||||
# Call scheduler.add_job()
|
# Call scheduler.add_job()
|
||||||
scheduler.add_job(
|
scheduler.add_job(
|
||||||
_run_mitre_sync,
|
_run_mitre_sync,
|
||||||
@@ -574,15 +523,6 @@ def start_scheduler() -> None:
|
|||||||
# Keyword argument: replace_existing
|
# Keyword argument: replace_existing
|
||||||
replace_existing=True,
|
replace_existing=True,
|
||||||
)
|
)
|
||||||
# Call scheduler.add_job()
|
|
||||||
scheduler.add_job(
|
|
||||||
_run_scheduled_campaign_activation,
|
|
||||||
trigger="interval",
|
|
||||||
hours=1,
|
|
||||||
id="scheduled_campaign_activation",
|
|
||||||
name="Auto-activate campaigns on start_date (hourly)",
|
|
||||||
replace_existing=True,
|
|
||||||
)
|
|
||||||
scheduler.add_job(
|
scheduler.add_job(
|
||||||
_run_recurring_campaigns,
|
_run_recurring_campaigns,
|
||||||
# Keyword argument: trigger
|
# Keyword argument: trigger
|
||||||
@@ -596,6 +536,14 @@ def start_scheduler() -> None:
|
|||||||
# Keyword argument: replace_existing
|
# Keyword argument: replace_existing
|
||||||
replace_existing=True,
|
replace_existing=True,
|
||||||
)
|
)
|
||||||
|
scheduler.add_job(
|
||||||
|
_run_due_campaign_jira_sync,
|
||||||
|
trigger="interval",
|
||||||
|
minutes=15,
|
||||||
|
id="due_campaign_jira_sync",
|
||||||
|
name="Due campaign Jira ticket sync (every 15 min)",
|
||||||
|
replace_existing=True,
|
||||||
|
)
|
||||||
# Call scheduler.add_job()
|
# Call scheduler.add_job()
|
||||||
scheduler.add_job(
|
scheduler.add_job(
|
||||||
sync_all_jira_links,
|
sync_all_jira_links,
|
||||||
@@ -704,7 +652,7 @@ def start_scheduler() -> None:
|
|||||||
# Literal argument value
|
# Literal argument value
|
||||||
"notification_cleanup (24h), weekly_snapshot (Sundays 00:00), "
|
"notification_cleanup (24h), weekly_snapshot (Sundays 00:00), "
|
||||||
# Literal argument value
|
# Literal argument value
|
||||||
"recurring_campaigns (daily), jira_sync (1h), "
|
"recurring_campaigns (daily), due_campaign_jira_sync (15m), jira_sync (1h), "
|
||||||
# Literal argument value
|
# Literal argument value
|
||||||
"osint_enrichment (weekly), stale_detection (daily), "
|
"osint_enrichment (weekly), stale_detection (daily), "
|
||||||
"retention_policies (daily), data_sources_sync (6h), "
|
"retention_policies (daily), data_sources_sync (6h), "
|
||||||
|
|||||||
@@ -43,6 +43,8 @@ from app.routers import techniques as techniques_router
|
|||||||
from app.routers import tests as tests_router
|
from app.routers import tests as tests_router
|
||||||
from app.routers import evidence as evidence_router
|
from app.routers import evidence as evidence_router
|
||||||
from app.routers import test_templates as test_templates_router
|
from app.routers import test_templates as test_templates_router
|
||||||
|
from app.routers import procedure_suggestions as procedure_suggestions_router
|
||||||
|
from app.routers import template_suggestions as template_suggestions_router
|
||||||
from app.routers import system as system_router
|
from app.routers import system as system_router
|
||||||
from app.routers import metrics as metrics_router
|
from app.routers import metrics as metrics_router
|
||||||
from app.routers import users as users_router
|
from app.routers import users as users_router
|
||||||
@@ -213,6 +215,8 @@ app.include_router(tests_router.router, prefix="/api/v1")
|
|||||||
app.include_router(evidence_router.router, prefix="/api/v1")
|
app.include_router(evidence_router.router, prefix="/api/v1")
|
||||||
# Call app.include_router()
|
# Call app.include_router()
|
||||||
app.include_router(test_templates_router.router, prefix="/api/v1")
|
app.include_router(test_templates_router.router, prefix="/api/v1")
|
||||||
|
app.include_router(procedure_suggestions_router.router, prefix="/api/v1")
|
||||||
|
app.include_router(template_suggestions_router.router, prefix="/api/v1")
|
||||||
# Call app.include_router()
|
# Call app.include_router()
|
||||||
app.include_router(system_router.router, prefix="/api/v1")
|
app.include_router(system_router.router, prefix="/api/v1")
|
||||||
# Call app.include_router()
|
# Call app.include_router()
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ from app.models.threat_actor import ThreatActor, ThreatActorTechnique
|
|||||||
from app.models.defensive_technique import DefensiveTechnique, DefensiveTechniqueMapping
|
from app.models.defensive_technique import DefensiveTechnique, DefensiveTechniqueMapping
|
||||||
from app.models.test_template_detection_rule import TestTemplateDetectionRule
|
from app.models.test_template_detection_rule import TestTemplateDetectionRule
|
||||||
from app.models.test_detection_result import TestDetectionResult
|
from app.models.test_detection_result import TestDetectionResult
|
||||||
from app.models.campaign import Campaign, CampaignTest
|
from app.models.campaign import Campaign, CampaignTest, CampaignModificationRequest
|
||||||
from app.models.compliance import ComplianceFramework, ComplianceControl, ComplianceControlMapping
|
from app.models.compliance import ComplianceFramework, ComplianceControl, ComplianceControlMapping
|
||||||
from app.models.coverage_snapshot import CoverageSnapshot, SnapshotTechniqueState
|
from app.models.coverage_snapshot import CoverageSnapshot, SnapshotTechniqueState
|
||||||
from app.models.jira_link import JiraLink, JiraLinkEntityType, JiraSyncDirection
|
from app.models.jira_link import JiraLink, JiraLinkEntityType, JiraSyncDirection
|
||||||
@@ -43,8 +43,13 @@ from app.models.evidence import Evidence
|
|||||||
from app.models.intel import IntelItem
|
from app.models.intel import IntelItem
|
||||||
from app.models.technique import Technique
|
from app.models.technique import Technique
|
||||||
from app.models.test import Test
|
from app.models.test import Test
|
||||||
|
from app.models.test_round_history import TestRoundHistory
|
||||||
from app.models.test_template import TestTemplate
|
from app.models.test_template import TestTemplate
|
||||||
|
from app.models.procedure_suggestion import ProcedureSuggestion
|
||||||
|
from app.models.template_suggestion import TemplateSuggestion
|
||||||
from app.models.user import User
|
from app.models.user import User
|
||||||
|
from app.models.password_setup_token import PasswordSetupToken
|
||||||
|
from app.models.evaluation_import import EvaluationImport
|
||||||
|
|
||||||
# Assign __all__ = [
|
# Assign __all__ = [
|
||||||
__all__ = [
|
__all__ = [
|
||||||
@@ -59,7 +64,7 @@ __all__ = [
|
|||||||
# Literal argument value
|
# Literal argument value
|
||||||
"TestTemplateDetectionRule", "TestDetectionResult",
|
"TestTemplateDetectionRule", "TestDetectionResult",
|
||||||
# Literal argument value
|
# Literal argument value
|
||||||
"Campaign", "CampaignTest",
|
"Campaign", "CampaignTest", "CampaignModificationRequest",
|
||||||
# Literal argument value
|
# Literal argument value
|
||||||
"ComplianceFramework", "ComplianceControl", "ComplianceControlMapping",
|
"ComplianceFramework", "ComplianceControl", "ComplianceControlMapping",
|
||||||
# Literal argument value
|
# Literal argument value
|
||||||
@@ -86,4 +91,9 @@ __all__ = [
|
|||||||
"SsoConfig",
|
"SsoConfig",
|
||||||
"AlertRule",
|
"AlertRule",
|
||||||
"AlertInstance",
|
"AlertInstance",
|
||||||
|
"TestRoundHistory",
|
||||||
|
"ProcedureSuggestion",
|
||||||
|
"TemplateSuggestion",
|
||||||
|
"PasswordSetupToken",
|
||||||
|
"EvaluationImport",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -75,6 +75,13 @@ class Campaign(Base):
|
|||||||
)
|
)
|
||||||
start_date = Column(DateTime, nullable=True) # campaign won't activate before this date
|
start_date = Column(DateTime, nullable=True) # campaign won't activate before this date
|
||||||
scheduled_at = Column(DateTime, nullable=True)
|
scheduled_at = Column(DateTime, nullable=True)
|
||||||
|
approved_by = Column(
|
||||||
|
UUID(as_uuid=True),
|
||||||
|
ForeignKey("users.id", ondelete="SET NULL"),
|
||||||
|
nullable=True,
|
||||||
|
)
|
||||||
|
approved_at = Column(DateTime, nullable=True)
|
||||||
|
rejection_reason = Column(Text, nullable=True)
|
||||||
# Assign completed_at = Column(DateTime, nullable=True)
|
# Assign completed_at = Column(DateTime, nullable=True)
|
||||||
completed_at = Column(DateTime, nullable=True)
|
completed_at = Column(DateTime, nullable=True)
|
||||||
# Assign target_platform = Column(String, nullable=True)
|
# Assign target_platform = Column(String, nullable=True)
|
||||||
@@ -83,8 +90,8 @@ class Campaign(Base):
|
|||||||
tags = Column(JSONB, nullable=True, default=[])
|
tags = Column(JSONB, nullable=True, default=[])
|
||||||
# Assign created_at = Column(DateTime(timezone=True), server_default=func.now())
|
# Assign created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||||
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||||
# Assign data_classification = Column(String(20), nullable=False, server_default="internal")
|
# Assign data_classification = Column(String(20), nullable=False, server_default="internal_use_only")
|
||||||
data_classification = Column(String(20), nullable=False, server_default="internal")
|
data_classification = Column(String(20), nullable=False, server_default="internal_use_only")
|
||||||
|
|
||||||
# Recurring scheduling fields
|
# Recurring scheduling fields
|
||||||
is_recurring = Column(Boolean, default=False)
|
is_recurring = Column(Boolean, default=False)
|
||||||
@@ -229,3 +236,57 @@ class CampaignTest(Base):
|
|||||||
Index('ix_campaign_tests_campaign', 'campaign_id'),
|
Index('ix_campaign_tests_campaign', 'campaign_id'),
|
||||||
Index('ix_campaign_tests_test', 'test_id'),
|
Index('ix_campaign_tests_test', 'test_id'),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class CampaignModificationRequest(Base):
|
||||||
|
"""A lead's request to add or remove a test from an already-active campaign.
|
||||||
|
|
||||||
|
The underlying ``CampaignTest`` row is only created/deleted once a
|
||||||
|
manager approves the request — see ``approve_modification_request``
|
||||||
|
in ``campaign_crud_service.py``.
|
||||||
|
"""
|
||||||
|
__tablename__ = "campaign_modification_requests"
|
||||||
|
|
||||||
|
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||||
|
campaign_id = Column(
|
||||||
|
UUID(as_uuid=True),
|
||||||
|
ForeignKey("campaigns.id", ondelete="CASCADE"),
|
||||||
|
nullable=False,
|
||||||
|
)
|
||||||
|
requested_by = Column(
|
||||||
|
UUID(as_uuid=True),
|
||||||
|
ForeignKey("users.id", ondelete="SET NULL"),
|
||||||
|
nullable=True,
|
||||||
|
)
|
||||||
|
action = Column(String, nullable=False) # add_test, remove_test
|
||||||
|
# SET NULL (not CASCADE): approving a "remove_test" request deletes the
|
||||||
|
# underlying Test row (see remove_test_from_campaign), and this request
|
||||||
|
# row is the audit record of that decision — it must survive the test's
|
||||||
|
# deletion, not be wiped out along with it.
|
||||||
|
test_id = Column(
|
||||||
|
UUID(as_uuid=True),
|
||||||
|
ForeignKey("tests.id", ondelete="SET NULL"),
|
||||||
|
nullable=True,
|
||||||
|
)
|
||||||
|
order_index = Column(Integer, nullable=True)
|
||||||
|
phase = Column(String, nullable=True)
|
||||||
|
justification = Column(Text, nullable=False)
|
||||||
|
status = Column(String, nullable=False, default="pending") # pending, approved, rejected
|
||||||
|
reviewed_by = Column(
|
||||||
|
UUID(as_uuid=True),
|
||||||
|
ForeignKey("users.id", ondelete="SET NULL"),
|
||||||
|
nullable=True,
|
||||||
|
)
|
||||||
|
reviewed_at = Column(DateTime, nullable=True)
|
||||||
|
review_notes = Column(Text, nullable=True)
|
||||||
|
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||||
|
|
||||||
|
campaign = relationship("Campaign")
|
||||||
|
test = relationship("Test")
|
||||||
|
requester = relationship("User", foreign_keys=[requested_by])
|
||||||
|
reviewer = relationship("User", foreign_keys=[reviewed_by])
|
||||||
|
|
||||||
|
__table_args__ = (
|
||||||
|
Index("ix_campaign_mod_requests_campaign", "campaign_id"),
|
||||||
|
Index("ix_campaign_mod_requests_status", "status"),
|
||||||
|
)
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ working with ``from app.models.enums import ...``.
|
|||||||
|
|
||||||
# Import # noqa: F401 from app.domain.enums
|
# Import # noqa: F401 from app.domain.enums
|
||||||
from app.domain.enums import ( # noqa: F401
|
from app.domain.enums import ( # noqa: F401
|
||||||
|
AttackSuccessResult,
|
||||||
ContainmentResult,
|
ContainmentResult,
|
||||||
DataClassification,
|
DataClassification,
|
||||||
TeamSide,
|
TeamSide,
|
||||||
|
|||||||
@@ -50,8 +50,8 @@ class Evidence(Base):
|
|||||||
team = Column(Enum(TeamSide, name="teamside"), nullable=False, default=TeamSide.red)
|
team = Column(Enum(TeamSide, name="teamside"), nullable=False, default=TeamSide.red)
|
||||||
# Assign notes = Column(Text, nullable=True)
|
# Assign notes = Column(Text, nullable=True)
|
||||||
notes = Column(Text, nullable=True)
|
notes = Column(Text, nullable=True)
|
||||||
# Assign data_classification = Column(String(20), nullable=False, server_default="internal")
|
# Assign data_classification = Column(String(20), nullable=False, server_default="internal_use_only")
|
||||||
data_classification = Column(String(20), nullable=False, server_default="internal")
|
data_classification = Column(String(20), nullable=False, server_default="internal_use_only")
|
||||||
|
|
||||||
# Relationships
|
# Relationships
|
||||||
test = relationship("Test", back_populates="evidences")
|
test = relationship("Test", back_populates="evidences")
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
"""SQLAlchemy model for one-time password setup/reset tokens.
|
||||||
|
|
||||||
|
Issued when an admin sends a "set your password" email — a passwordless
|
||||||
|
user (freshly created, or one whose password an admin wants to reset)
|
||||||
|
uses the token to pick their own password without ever having a temporary
|
||||||
|
one shared with them.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
from sqlalchemy import Column, DateTime, ForeignKey, String, func
|
||||||
|
from sqlalchemy.dialects.postgresql import UUID
|
||||||
|
from sqlalchemy.orm import relationship
|
||||||
|
|
||||||
|
from app.database import Base
|
||||||
|
|
||||||
|
|
||||||
|
class PasswordSetupToken(Base):
|
||||||
|
"""A one-time token letting its owning user set their own password."""
|
||||||
|
|
||||||
|
__tablename__ = "password_setup_tokens"
|
||||||
|
|
||||||
|
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||||
|
user_id = Column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=False)
|
||||||
|
token = Column(String(128), unique=True, nullable=False, index=True)
|
||||||
|
expires_at = Column(DateTime, nullable=False)
|
||||||
|
used_at = Column(DateTime, nullable=True)
|
||||||
|
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||||
|
|
||||||
|
user = relationship("User", foreign_keys=[user_id])
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
"""SQLAlchemy model for procedure-improvement suggestions.
|
||||||
|
|
||||||
|
When an operator submits a round with a filled-in procedure field
|
||||||
|
(``procedure_text`` for Red, ``detect_procedure`` for Blue), the command(s)
|
||||||
|
in it are extracted heuristically and proposed as an update to the
|
||||||
|
originating template's suggested-procedure field. Nothing is written to
|
||||||
|
the template automatically — a lead reviews and approves or rejects each
|
||||||
|
suggestion, so a junior who later picks up the same template only ever
|
||||||
|
sees vetted guidance.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
from sqlalchemy import Column, DateTime, ForeignKey, String, Text, func
|
||||||
|
from sqlalchemy.dialects.postgresql import UUID
|
||||||
|
from sqlalchemy.orm import relationship
|
||||||
|
|
||||||
|
from app.database import Base
|
||||||
|
|
||||||
|
|
||||||
|
class ProcedureSuggestion(Base):
|
||||||
|
"""A proposed update to a TestTemplate's suggested procedure, pending lead review."""
|
||||||
|
|
||||||
|
__tablename__ = "procedure_suggestions"
|
||||||
|
|
||||||
|
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||||
|
template_id = Column(UUID(as_uuid=True), ForeignKey("test_templates.id"), nullable=False)
|
||||||
|
team = Column(String(10), nullable=False) # "red" or "blue"
|
||||||
|
suggested_text = Column(Text, nullable=False)
|
||||||
|
source_test_id = Column(UUID(as_uuid=True), ForeignKey("tests.id"), nullable=True)
|
||||||
|
submitted_by = Column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=True)
|
||||||
|
|
||||||
|
status = Column(String(10), nullable=False, default="pending", server_default="pending") # pending/approved/rejected
|
||||||
|
reviewed_by = Column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=True)
|
||||||
|
reviewed_at = Column(DateTime, nullable=True)
|
||||||
|
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||||
|
|
||||||
|
template = relationship("TestTemplate", foreign_keys=[template_id])
|
||||||
|
source_test = relationship("Test", foreign_keys=[source_test_id])
|
||||||
|
submitter = relationship("User", foreign_keys=[submitted_by])
|
||||||
|
reviewer = relationship("User", foreign_keys=[reviewed_by])
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
"""SQLAlchemy model for operator-proposed test templates, pending lead review.
|
||||||
|
|
||||||
|
Leads create templates directly (see TestTemplateCreate router). An
|
||||||
|
operator (red_tech/blue_tech) gets the same "create template" action, but
|
||||||
|
their submission lands here instead of the live catalog — a lead on their
|
||||||
|
team reviews it, optionally edits any field, and either approves it (which
|
||||||
|
creates the real TestTemplate) or discards it.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
from sqlalchemy import Column, DateTime, ForeignKey, String, Text, func
|
||||||
|
from sqlalchemy.dialects.postgresql import UUID
|
||||||
|
from sqlalchemy.orm import relationship
|
||||||
|
|
||||||
|
from app.database import Base
|
||||||
|
|
||||||
|
|
||||||
|
class TemplateSuggestion(Base):
|
||||||
|
"""A proposed new TestTemplate submitted by an operator, pending lead review."""
|
||||||
|
|
||||||
|
__tablename__ = "template_suggestions"
|
||||||
|
|
||||||
|
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||||
|
|
||||||
|
# Proposed TestTemplate fields — mirrors TestTemplateCreate.
|
||||||
|
mitre_technique_id = Column(String, nullable=False)
|
||||||
|
name = Column(String, nullable=False)
|
||||||
|
description = Column(Text, nullable=True)
|
||||||
|
source = Column(String, nullable=False, default="custom", server_default="custom")
|
||||||
|
source_url = Column(String, nullable=True)
|
||||||
|
attack_procedure = Column(Text, nullable=True)
|
||||||
|
expected_detection = Column(Text, nullable=True)
|
||||||
|
platform = Column(String, nullable=True)
|
||||||
|
tool_suggested = Column(String, nullable=True)
|
||||||
|
severity = Column(String, nullable=True)
|
||||||
|
atomic_test_id = Column(String, nullable=True)
|
||||||
|
suggested_remediation = Column(Text, nullable=True)
|
||||||
|
|
||||||
|
# "red" or "blue" — derived from the submitter's role, determines which
|
||||||
|
# lead reviews it (admins can review both).
|
||||||
|
team = Column(String(10), nullable=False)
|
||||||
|
submitted_by = Column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=True)
|
||||||
|
|
||||||
|
status = Column(String(10), nullable=False, default="pending", server_default="pending") # pending/approved/rejected
|
||||||
|
reviewed_by = Column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=True)
|
||||||
|
reviewed_at = Column(DateTime, nullable=True)
|
||||||
|
created_template_id = Column(UUID(as_uuid=True), ForeignKey("test_templates.id"), nullable=True)
|
||||||
|
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||||
|
|
||||||
|
submitter = relationship("User", foreign_keys=[submitted_by])
|
||||||
|
reviewer = relationship("User", foreign_keys=[reviewed_by])
|
||||||
|
created_template = relationship("TestTemplate", foreign_keys=[created_template_id])
|
||||||
@@ -27,7 +27,7 @@ from sqlalchemy.orm import relationship
|
|||||||
from app.database import Base
|
from app.database import Base
|
||||||
|
|
||||||
# Import ContainmentResult, TestResult, TestState from app.models.enums
|
# Import ContainmentResult, TestResult, TestState from app.models.enums
|
||||||
from app.models.enums import ContainmentResult, TestResult, TestState
|
from app.models.enums import AttackSuccessResult, ContainmentResult, TestResult, TestState
|
||||||
|
|
||||||
|
|
||||||
# Define class Test
|
# Define class Test
|
||||||
@@ -59,6 +59,12 @@ class Test(Base):
|
|||||||
execution_date = Column(DateTime, nullable=True)
|
execution_date = Column(DateTime, nullable=True)
|
||||||
# Assign created_by = Column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=True)
|
# Assign created_by = Column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=True)
|
||||||
created_by = Column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=True)
|
created_by = Column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=True)
|
||||||
|
# The template this test was instantiated from, if any (null for
|
||||||
|
# standalone/manually-created/RT-imported tests). Lets a procedure
|
||||||
|
# suggestion know exactly which template to propose improving —
|
||||||
|
# matching purely by technique would be ambiguous when a technique has
|
||||||
|
# multiple templates.
|
||||||
|
source_template_id = Column(UUID(as_uuid=True), ForeignKey("test_templates.id"), nullable=True)
|
||||||
# Assign result = Column(Enum(TestResult, name="testresult"), nullable=True)
|
# Assign result = Column(Enum(TestResult, name="testresult"), nullable=True)
|
||||||
result = Column(Enum(TestResult, name="testresult"), nullable=True)
|
result = Column(Enum(TestResult, name="testresult"), nullable=True)
|
||||||
# Assign state = Column(Enum(TestState, name="teststate"), default=TestState.draft)
|
# Assign state = Column(Enum(TestState, name="teststate"), default=TestState.draft)
|
||||||
@@ -69,7 +75,7 @@ class Test(Base):
|
|||||||
# ── Red Team fields ─────────────────────────────────────────────
|
# ── Red Team fields ─────────────────────────────────────────────
|
||||||
red_summary = Column(Text, nullable=True)
|
red_summary = Column(Text, nullable=True)
|
||||||
# Assign attack_success = Column(Boolean, nullable=True)
|
# Assign attack_success = Column(Boolean, nullable=True)
|
||||||
attack_success = Column(Boolean, nullable=True)
|
attack_success = Column(Enum(AttackSuccessResult, name="attacksuccessresult"), nullable=True)
|
||||||
execution_start_time = Column(DateTime, nullable=True)
|
execution_start_time = Column(DateTime, nullable=True)
|
||||||
execution_end_time = Column(DateTime, nullable=True)
|
execution_end_time = Column(DateTime, nullable=True)
|
||||||
# Assign red_validated_by = Column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=True)
|
# Assign red_validated_by = Column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=True)
|
||||||
@@ -83,6 +89,9 @@ class Test(Base):
|
|||||||
|
|
||||||
# ── Blue Team fields ────────────────────────────────────────────
|
# ── Blue Team fields ────────────────────────────────────────────
|
||||||
blue_summary = Column(Text, nullable=True)
|
blue_summary = Column(Text, nullable=True)
|
||||||
|
# What Blue actually did to detect the attack — Blue's counterpart to
|
||||||
|
# procedure_text. Free text; may be parsed for a procedure suggestion.
|
||||||
|
detect_procedure = Column(Text, nullable=True)
|
||||||
# Assign detection_result = Column(Enum(TestResult, name="testresult"), nullable=True)
|
# Assign detection_result = Column(Enum(TestResult, name="testresult"), nullable=True)
|
||||||
detection_result = Column(Enum(TestResult, name="testresult"), nullable=True)
|
detection_result = Column(Enum(TestResult, name="testresult"), nullable=True)
|
||||||
containment_result = Column(Enum(ContainmentResult, name="containmentresult"), nullable=True)
|
containment_result = Column(Enum(ContainmentResult, name="containmentresult"), nullable=True)
|
||||||
@@ -108,6 +117,10 @@ class Test(Base):
|
|||||||
# Assign blue_paused_seconds = Column(Integer, default=0)
|
# Assign blue_paused_seconds = Column(Integer, default=0)
|
||||||
blue_paused_seconds = Column(Integer, default=0)
|
blue_paused_seconds = Column(Integer, default=0)
|
||||||
|
|
||||||
|
# ── Round tracking (bumped on each reopen-for-rework) ────────────
|
||||||
|
red_round_number = Column(Integer, nullable=False, default=1, server_default="1")
|
||||||
|
blue_round_number = Column(Integer, nullable=False, default=1, server_default="1")
|
||||||
|
|
||||||
# ── Remediation fields ───────────────────────────────────────────
|
# ── Remediation fields ───────────────────────────────────────────
|
||||||
remediation_steps = Column(Text, nullable=True)
|
remediation_steps = Column(Text, nullable=True)
|
||||||
# Assign remediation_status = Column(String, nullable=True) # pending / in_progress / completed ...
|
# Assign remediation_status = Column(String, nullable=True) # pending / in_progress / completed ...
|
||||||
@@ -119,13 +132,17 @@ class Test(Base):
|
|||||||
retest_of = Column(UUID(as_uuid=True), ForeignKey("tests.id"), nullable=True)
|
retest_of = Column(UUID(as_uuid=True), ForeignKey("tests.id"), nullable=True)
|
||||||
# Assign retest_count = Column(Integer, default=0)
|
# Assign retest_count = Column(Integer, default=0)
|
||||||
retest_count = Column(Integer, default=0)
|
retest_count = Column(Integer, default=0)
|
||||||
# Assign data_classification = Column(String(20), nullable=False, server_default="internal")
|
# Assign data_classification = Column(String(20), nullable=False, server_default="internal_use_only")
|
||||||
data_classification = Column(String(20), nullable=False, server_default="internal")
|
data_classification = Column(String(20), nullable=False, server_default="internal_use_only")
|
||||||
|
|
||||||
# ── Relationships ───────────────────────────────────────────────
|
# ── Relationships ───────────────────────────────────────────────
|
||||||
technique = relationship("Technique", back_populates="tests")
|
technique = relationship("Technique", back_populates="tests")
|
||||||
# Assign evidences = relationship("Evidence", back_populates="test")
|
# Assign evidences = relationship("Evidence", back_populates="test")
|
||||||
evidences = relationship("Evidence", back_populates="test")
|
evidences = relationship("Evidence", back_populates="test")
|
||||||
|
round_history = relationship(
|
||||||
|
"TestRoundHistory", back_populates="test",
|
||||||
|
order_by="TestRoundHistory.round_number", cascade="all, delete-orphan",
|
||||||
|
)
|
||||||
# Assign creator = relationship("User", foreign_keys=[created_by])
|
# Assign creator = relationship("User", foreign_keys=[created_by])
|
||||||
creator = relationship("User", foreign_keys=[created_by])
|
creator = relationship("User", foreign_keys=[created_by])
|
||||||
# Assign red_validator = relationship("User", foreign_keys=[red_validated_by])
|
# Assign red_validator = relationship("User", foreign_keys=[red_validated_by])
|
||||||
@@ -139,6 +156,18 @@ class Test(Base):
|
|||||||
red_tech_assignee = Column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=True)
|
red_tech_assignee = Column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=True)
|
||||||
blue_tech_assignee = Column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=True)
|
blue_tech_assignee = Column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=True)
|
||||||
|
|
||||||
|
# ── Review assignment fields ────────────────────────────────────
|
||||||
|
red_reviewer_assignee = Column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=True)
|
||||||
|
red_review_by = Column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=True)
|
||||||
|
red_review_at = Column(DateTime, nullable=True)
|
||||||
|
red_review_notes = Column(Text, nullable=True)
|
||||||
|
|
||||||
|
blue_reviewer_assignee = Column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=True)
|
||||||
|
blue_review_by = Column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=True)
|
||||||
|
blue_review_at = Column(DateTime, nullable=True)
|
||||||
|
blue_review_notes = Column(Text, nullable=True)
|
||||||
|
system_gaps = Column(Text, nullable=True)
|
||||||
|
|
||||||
# ── On-hold fields ─────────────────────────────────────────────
|
# ── On-hold fields ─────────────────────────────────────────────
|
||||||
is_on_hold = Column(Boolean, default=False, nullable=False, server_default="false")
|
is_on_hold = Column(Boolean, default=False, nullable=False, server_default="false")
|
||||||
hold_reason = Column(Text, nullable=True)
|
hold_reason = Column(Text, nullable=True)
|
||||||
@@ -147,6 +176,11 @@ class Test(Base):
|
|||||||
red_tech_assigned_user = relationship("User", foreign_keys=[red_tech_assignee])
|
red_tech_assigned_user = relationship("User", foreign_keys=[red_tech_assignee])
|
||||||
blue_tech_assigned_user = relationship("User", foreign_keys=[blue_tech_assignee])
|
blue_tech_assigned_user = relationship("User", foreign_keys=[blue_tech_assignee])
|
||||||
|
|
||||||
|
red_reviewer = relationship("User", foreign_keys=[red_reviewer_assignee])
|
||||||
|
red_review_actor = relationship("User", foreign_keys=[red_review_by])
|
||||||
|
blue_reviewer = relationship("User", foreign_keys=[blue_reviewer_assignee])
|
||||||
|
blue_review_actor = relationship("User", foreign_keys=[blue_review_by])
|
||||||
|
|
||||||
# Assign original_test = relationship("Test", remote_side="Test.id", foreign_keys=[retest_of])
|
# Assign original_test = relationship("Test", remote_side="Test.id", foreign_keys=[retest_of])
|
||||||
original_test = relationship("Test", remote_side="Test.id", foreign_keys=[retest_of])
|
original_test = relationship("Test", remote_side="Test.id", foreign_keys=[retest_of])
|
||||||
# Assign retests = relationship("Test", foreign_keys=[retest_of], back_populates="orig...
|
# Assign retests = relationship("Test", foreign_keys=[retest_of], back_populates="orig...
|
||||||
|
|||||||
@@ -0,0 +1,61 @@
|
|||||||
|
"""SQLAlchemy model for archived test rounds.
|
||||||
|
|
||||||
|
Each row is a snapshot of one team's round of work (procedure/results for
|
||||||
|
Red, detection/containment for Blue) taken right before a lead reopens the
|
||||||
|
test for rework. Reopening resets the live fields on ``Test`` for a fresh
|
||||||
|
attempt, but the prior attempt's data must not be lost — it's archived here
|
||||||
|
so the full history stays visible (e.g. to Blue Team, who need to see what
|
||||||
|
Red actually did on earlier rounds, not just the latest one).
|
||||||
|
"""
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
from sqlalchemy import Column, DateTime, Enum, ForeignKey, Integer, String, Text, func
|
||||||
|
from sqlalchemy.dialects.postgresql import UUID
|
||||||
|
from sqlalchemy.orm import relationship
|
||||||
|
|
||||||
|
from app.database import Base
|
||||||
|
from app.models.enums import AttackSuccessResult, ContainmentResult, TestResult
|
||||||
|
|
||||||
|
|
||||||
|
class TestRoundHistory(Base):
|
||||||
|
"""Archived snapshot of one red/blue round of a test, taken on reopen."""
|
||||||
|
|
||||||
|
__tablename__ = "test_round_history"
|
||||||
|
|
||||||
|
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||||
|
test_id = Column(UUID(as_uuid=True), ForeignKey("tests.id"), nullable=False)
|
||||||
|
team = Column(String(10), nullable=False) # "red" or "blue"
|
||||||
|
round_number = Column(Integer, nullable=False)
|
||||||
|
|
||||||
|
started_at = Column(DateTime, nullable=True)
|
||||||
|
ended_at = Column(DateTime, nullable=True)
|
||||||
|
paused_seconds = Column(Integer, default=0)
|
||||||
|
|
||||||
|
# Red-side round output
|
||||||
|
procedure_text = Column(Text, nullable=True)
|
||||||
|
tool_used = Column(String, nullable=True)
|
||||||
|
# native_enum=False — stored as plain VARCHAR, not the shared Postgres
|
||||||
|
# enum type used by tests.attack_success. This is an archive table with
|
||||||
|
# no DB-level constraint needs, and reusing the native type name here
|
||||||
|
# would make Alembic try to (re)create it.
|
||||||
|
attack_success = Column(Enum(AttackSuccessResult, name="test_round_history_attack_success", native_enum=False), nullable=True)
|
||||||
|
red_summary = Column(Text, nullable=True)
|
||||||
|
execution_start_time = Column(DateTime, nullable=True)
|
||||||
|
execution_end_time = Column(DateTime, nullable=True)
|
||||||
|
|
||||||
|
# Blue-side round output
|
||||||
|
detection_result = Column(Enum(TestResult, name="test_round_history_detection_result", native_enum=False), nullable=True)
|
||||||
|
containment_result = Column(Enum(ContainmentResult, name="test_round_history_containment_result", native_enum=False), nullable=True)
|
||||||
|
detection_time = Column(DateTime, nullable=True)
|
||||||
|
containment_time = Column(DateTime, nullable=True)
|
||||||
|
blue_summary = Column(Text, nullable=True)
|
||||||
|
detect_procedure = Column(Text, nullable=True)
|
||||||
|
|
||||||
|
# Why the round was closed
|
||||||
|
review_notes = Column(Text, nullable=True)
|
||||||
|
reviewed_by = Column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=True)
|
||||||
|
archived_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||||
|
|
||||||
|
test = relationship("Test", back_populates="round_history")
|
||||||
|
reviewer = relationship("User", foreign_keys=[reviewed_by])
|
||||||
@@ -41,8 +41,12 @@ class TestTemplate(Base):
|
|||||||
source_url = Column(String, nullable=True)
|
source_url = Column(String, nullable=True)
|
||||||
# Assign attack_procedure = Column(Text, nullable=True) # Suggested attack procedure
|
# Assign attack_procedure = Column(Text, nullable=True) # Suggested attack procedure
|
||||||
attack_procedure = Column(Text, nullable=True) # Suggested attack procedure
|
attack_procedure = Column(Text, nullable=True) # Suggested attack procedure
|
||||||
# Assign expected_detection = Column(Text, nullable=True) # What blue team should detect
|
# What blue team should detect — narrative guidance from imports/leads,
|
||||||
expected_detection = Column(Text, nullable=True) # What blue team should detect
|
# plus concrete commands appended via approved procedure suggestions
|
||||||
|
# (Blue's counterpart to attack_procedure). External syncs never touch
|
||||||
|
# an existing row (they only insert brand-new ones), so anything added
|
||||||
|
# here is safe across re-syncs.
|
||||||
|
expected_detection = Column(Text, nullable=True)
|
||||||
# Assign platform = Column(String, nullable=True) # windows / linux...
|
# Assign platform = Column(String, nullable=True) # windows / linux...
|
||||||
platform = Column(String, nullable=True) # windows / linux / macos
|
platform = Column(String, nullable=True) # windows / linux / macos
|
||||||
# Assign tool_suggested = Column(String, nullable=True)
|
# Assign tool_suggested = Column(String, nullable=True)
|
||||||
|
|||||||
@@ -26,14 +26,26 @@ class User(Base):
|
|||||||
|
|
||||||
# Assign id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
# Assign id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||||
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||||
# Assign username = Column(String, unique=True, nullable=False)
|
# Internal login identifier — always kept equal to `email` (see below).
|
||||||
|
# Kept as a separate column (rather than removed) since the JWT `sub`
|
||||||
|
# claim, audit logs, Jira actor attribution, and SSO provisioning all
|
||||||
|
# still key off it; changing all of those to read `email` directly
|
||||||
|
# would be a much larger, riskier refactor for no behavioral gain now
|
||||||
|
# that the two are always identical.
|
||||||
username = Column(String, unique=True, nullable=False)
|
username = Column(String, unique=True, nullable=False)
|
||||||
# Assign email = Column(String, nullable=True)
|
# The unique identifier a user logs in with. Every user must have one.
|
||||||
email = Column(String, nullable=True)
|
email = Column(String, unique=True, nullable=False, index=True)
|
||||||
|
# Display name shown everywhere in the UI instead of username (which is
|
||||||
|
# now just an internal login identifier, auto-set to the user's email).
|
||||||
|
full_name = Column(String, nullable=True)
|
||||||
# Assign hashed_password = Column(String, nullable=False)
|
# Assign hashed_password = Column(String, nullable=False)
|
||||||
hashed_password = Column(String, nullable=False)
|
hashed_password = Column(String, nullable=False)
|
||||||
# Assign role = Column(String, nullable=False, default="viewer")
|
# Assign role = Column(String, nullable=False, default="viewer")
|
||||||
role = Column(String, nullable=False, default="viewer")
|
role = Column(String, nullable=False, default="viewer")
|
||||||
|
# Other roles this user can switch into (the currently-active role
|
||||||
|
# lives in `role` above and is what every permission check reads —
|
||||||
|
# switching just swaps which one is active, never grants both at once).
|
||||||
|
extra_roles = Column(JSONB, nullable=False, default=list, server_default="[]")
|
||||||
# Assign is_active = Column(Boolean, default=True)
|
# Assign is_active = Column(Boolean, default=True)
|
||||||
is_active = Column(Boolean, default=True)
|
is_active = Column(Boolean, default=True)
|
||||||
# Assign must_change_password = Column(Boolean, default=True)
|
# Assign must_change_password = Column(Boolean, default=True)
|
||||||
|
|||||||
@@ -32,9 +32,18 @@ from app.models.webhook_config import WebhookConfig
|
|||||||
|
|
||||||
router = APIRouter(prefix="/admin", tags=["admin"])
|
router = APIRouter(prefix="/admin", tags=["admin"])
|
||||||
|
|
||||||
# Keys whose values contain secrets and must be redacted in the export
|
# Keys whose values contain secrets and must be redacted in the export.
|
||||||
|
# Must be kept in sync with the actual SystemConfig keys written by
|
||||||
|
# routers/system.py's _JIRA_KEYS map and the SMTP settings section —
|
||||||
|
# a stale/mismatched name here silently exports a live credential in
|
||||||
|
# plaintext instead of redacting it.
|
||||||
_REDACTED_KEYS = {
|
_REDACTED_KEYS = {
|
||||||
"smtp.password",
|
"smtp.password",
|
||||||
|
"jira.admin_api_token",
|
||||||
|
"tempo.admin_token",
|
||||||
|
"email_webhook.api_key",
|
||||||
|
# Older/alternate key names kept defensively in case a prior schema
|
||||||
|
# version wrote under these instead.
|
||||||
"jira.api_token",
|
"jira.api_token",
|
||||||
"jira.password",
|
"jira.password",
|
||||||
"tempo.api_token",
|
"tempo.api_token",
|
||||||
@@ -213,6 +222,7 @@ async def import_config(
|
|||||||
"custom_templates": 0,
|
"custom_templates": 0,
|
||||||
"users_created": 0,
|
"users_created": 0,
|
||||||
"users_updated": 0,
|
"users_updated": 0,
|
||||||
|
"users_skipped_no_email": 0,
|
||||||
}
|
}
|
||||||
|
|
||||||
# ── 1. system_configs ────────────────────────────────────────────
|
# ── 1. system_configs ────────────────────────────────────────────
|
||||||
@@ -300,12 +310,16 @@ async def import_config(
|
|||||||
summary["custom_templates"] += 1
|
summary["custom_templates"] += 1
|
||||||
|
|
||||||
# ── 6. Users ─────────────────────────────────────────────────────
|
# ── 6. Users ─────────────────────────────────────────────────────
|
||||||
|
# Email is the unique login identifier — a bundle entry with no email
|
||||||
|
# (e.g. exported from an older instance, before this requirement) can't
|
||||||
|
# be created; it's skipped rather than crashing the whole import.
|
||||||
import secrets as _secrets
|
import secrets as _secrets
|
||||||
for item in bundle.get("users", []):
|
for item in bundle.get("users", []):
|
||||||
username = item.get("username")
|
email = item.get("email")
|
||||||
if not username:
|
if not email:
|
||||||
|
summary["users_skipped_no_email"] += 1
|
||||||
continue
|
continue
|
||||||
existing = db.query(User).filter(User.username == username).first()
|
existing = db.query(User).filter(User.email == email).first()
|
||||||
if existing:
|
if existing:
|
||||||
existing.role = item.get("role", existing.role)
|
existing.role = item.get("role", existing.role)
|
||||||
existing.is_active = item.get("is_active", existing.is_active)
|
existing.is_active = item.get("is_active", existing.is_active)
|
||||||
@@ -314,14 +328,13 @@ async def import_config(
|
|||||||
# Create with random temp password — user must reset on login
|
# Create with random temp password — user must reset on login
|
||||||
temp_pw = _secrets.token_urlsafe(16) + "Aa1!"
|
temp_pw = _secrets.token_urlsafe(16) + "Aa1!"
|
||||||
new_user = User(
|
new_user = User(
|
||||||
username=username,
|
username=email,
|
||||||
|
email=email,
|
||||||
hashed_password=hash_password(temp_pw),
|
hashed_password=hash_password(temp_pw),
|
||||||
role=item.get("role", "viewer"),
|
role=item.get("role", "viewer"),
|
||||||
is_active=item.get("is_active", True),
|
is_active=item.get("is_active", True),
|
||||||
must_change_password=True,
|
must_change_password=True,
|
||||||
)
|
)
|
||||||
if item.get("email") and hasattr(User, "email"):
|
|
||||||
new_user.email = item["email"]
|
|
||||||
db.add(new_user)
|
db.add(new_user)
|
||||||
summary["users_created"] += 1
|
summary["users_created"] += 1
|
||||||
|
|
||||||
|
|||||||
+76
-12
@@ -16,6 +16,9 @@ from fastapi import APIRouter, Cookie, Depends, Request, Response
|
|||||||
# Import OAuth2PasswordRequestForm from fastapi.security
|
# Import OAuth2PasswordRequestForm from fastapi.security
|
||||||
from fastapi.security import OAuth2PasswordRequestForm
|
from fastapi.security import OAuth2PasswordRequestForm
|
||||||
|
|
||||||
|
# Import datetime, timezone from datetime
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
# Import jwt (PyJWT)
|
# Import jwt (PyJWT)
|
||||||
import jwt
|
import jwt
|
||||||
from jwt.exceptions import PyJWTError as JWTError
|
from jwt.exceptions import PyJWTError as JWTError
|
||||||
@@ -24,7 +27,7 @@ from jwt.exceptions import PyJWTError as JWTError
|
|||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
# Import blacklist_token, create_access_token, verify_pa... from app.auth
|
# Import blacklist_token, create_access_token, verify_pa... from app.auth
|
||||||
from app.auth import blacklist_token, create_access_token, verify_password
|
from app.auth import blacklist_token, create_access_token, is_token_blacklisted, verify_password
|
||||||
|
|
||||||
# Import settings from app.config
|
# Import settings from app.config
|
||||||
from app.config import settings
|
from app.config import settings
|
||||||
@@ -54,7 +57,7 @@ from app.models.user import User
|
|||||||
from app.schemas.auth import TokenResponse, UserOut
|
from app.schemas.auth import TokenResponse, UserOut
|
||||||
|
|
||||||
# Import PasswordChange from app.schemas.user
|
# Import PasswordChange from app.schemas.user
|
||||||
from app.schemas.user import PasswordChange
|
from app.schemas.user import PasswordChange, SetPasswordRequest, SetPasswordTokenValidateOut
|
||||||
|
|
||||||
# Import log_action from app.services.audit_service
|
# Import log_action from app.services.audit_service
|
||||||
from app.services.audit_service import log_action
|
from app.services.audit_service import log_action
|
||||||
@@ -69,6 +72,12 @@ from app.services.auth_service import (
|
|||||||
change_password as auth_change_password,
|
change_password as auth_change_password,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
from app.domain.errors import EntityNotFoundError
|
||||||
|
from app.services.password_setup_service import (
|
||||||
|
consume_token_and_set_password,
|
||||||
|
validate_token as validate_password_setup_token,
|
||||||
|
)
|
||||||
|
|
||||||
# Assign router = APIRouter(prefix="/auth", tags=["auth"])
|
# Assign router = APIRouter(prefix="/auth", tags=["auth"])
|
||||||
router = APIRouter(prefix="/auth", tags=["auth"])
|
router = APIRouter(prefix="/auth", tags=["auth"])
|
||||||
|
|
||||||
@@ -105,8 +114,10 @@ def login(
|
|||||||
Rate-limited to **5 attempts per minute per IP**. Failed and successful
|
Rate-limited to **5 attempts per minute per IP**. Failed and successful
|
||||||
logins are recorded in the audit log (SEC-009).
|
logins are recorded in the audit log (SEC-009).
|
||||||
"""
|
"""
|
||||||
# Assign user = db.query(User).filter(User.username == form_data.username).first()
|
# OAuth2PasswordRequestForm's field is spec-named "username" but the
|
||||||
user = db.query(User).filter(User.username == form_data.username).first()
|
# value a user actually types in is their email — email is the unique
|
||||||
|
# login identifier (username is an internal id, always kept == email).
|
||||||
|
user = db.query(User).filter(User.email == form_data.username).first()
|
||||||
# Assign target_hash = user.hashed_password if user else _DUMMY_HASH
|
# Assign target_hash = user.hashed_password if user else _DUMMY_HASH
|
||||||
target_hash = user.hashed_password if user else _DUMMY_HASH
|
target_hash = user.hashed_password if user else _DUMMY_HASH
|
||||||
# Assign password_valid = verify_password(form_data.password, target_hash)
|
# Assign password_valid = verify_password(form_data.password, target_hash)
|
||||||
@@ -131,7 +142,7 @@ def login(
|
|||||||
# Keyword argument: details
|
# Keyword argument: details
|
||||||
details={
|
details={
|
||||||
# Literal argument value
|
# Literal argument value
|
||||||
"username": form_data.username,
|
"email": form_data.username,
|
||||||
# Literal argument value
|
# Literal argument value
|
||||||
"ip": ip,
|
"ip": ip,
|
||||||
# Literal argument value
|
# Literal argument value
|
||||||
@@ -143,7 +154,7 @@ def login(
|
|||||||
# Call uow.commit()
|
# Call uow.commit()
|
||||||
uow.commit()
|
uow.commit()
|
||||||
# Raise BusinessRuleViolation
|
# Raise BusinessRuleViolation
|
||||||
raise BusinessRuleViolation("Incorrect username or password")
|
raise BusinessRuleViolation("Incorrect email or password")
|
||||||
|
|
||||||
# Check: not user.is_active
|
# Check: not user.is_active
|
||||||
if not user.is_active:
|
if not user.is_active:
|
||||||
@@ -165,7 +176,7 @@ def login(
|
|||||||
"auth",
|
"auth",
|
||||||
str(user.id),
|
str(user.id),
|
||||||
# Keyword argument: details
|
# Keyword argument: details
|
||||||
details={"username": user.username, "ip": ip},
|
details={"email": user.email, "ip": ip},
|
||||||
# Keyword argument: ip_address
|
# Keyword argument: ip_address
|
||||||
ip_address=ip,
|
ip_address=ip,
|
||||||
)
|
)
|
||||||
@@ -272,19 +283,28 @@ def logout(
|
|||||||
return {"detail": "Logged out"}
|
return {"detail": "Logged out"}
|
||||||
|
|
||||||
|
|
||||||
|
# A token that has *just* expired can still be refreshed within this window.
|
||||||
|
# Without this grace period, `/auth/refresh` decodes the same token with the
|
||||||
|
# same strict expiry check as every other endpoint — so by the time a 401
|
||||||
|
# triggers a refresh attempt, the refresh call itself has also expired and
|
||||||
|
# the session is unrecoverable. The grace window only widens the refresh
|
||||||
|
# check; a brand-new token is always issued with the full expiry.
|
||||||
|
_REFRESH_GRACE_SECONDS = 15 * 60
|
||||||
|
|
||||||
|
|
||||||
@router.post("/refresh", response_model=TokenResponse)
|
@router.post("/refresh", response_model=TokenResponse)
|
||||||
def refresh_token(
|
def refresh_token(
|
||||||
response: Response,
|
response: Response,
|
||||||
aegis_token: str | None = Cookie(None),
|
aegis_token: str | None = Cookie(None),
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
):
|
):
|
||||||
"""Issue a new access token if the current one is valid.
|
"""Issue a new access token if the current one is valid or recently expired.
|
||||||
|
|
||||||
Called automatically by the frontend when it detects an expired
|
Called automatically by the frontend when it detects an expired
|
||||||
session while the user is actively using the app. If the current
|
session while the user is actively using the app. Expiry is checked
|
||||||
cookie token is still valid (not blacklisted, not expired), a fresh
|
manually (with `_REFRESH_GRACE_SECONDS` of leeway) instead of relying on
|
||||||
token is issued and the cookie is renewed — keeping the session alive
|
PyJWT's built-in check, so a token that expired moments ago can still
|
||||||
without requiring re-authentication.
|
be refreshed. Revoked (blacklisted) tokens are never refreshable.
|
||||||
"""
|
"""
|
||||||
if not aegis_token:
|
if not aegis_token:
|
||||||
raise PermissionViolation("No active session")
|
raise PermissionViolation("No active session")
|
||||||
@@ -294,10 +314,20 @@ def refresh_token(
|
|||||||
aegis_token,
|
aegis_token,
|
||||||
settings.SECRET_KEY,
|
settings.SECRET_KEY,
|
||||||
algorithms=[settings.ALGORITHM],
|
algorithms=[settings.ALGORITHM],
|
||||||
|
options={"verify_exp": False},
|
||||||
)
|
)
|
||||||
except JWTError:
|
except JWTError:
|
||||||
raise PermissionViolation("Session expired — please log in again")
|
raise PermissionViolation("Session expired — please log in again")
|
||||||
|
|
||||||
|
jti: str | None = payload.get("jti")
|
||||||
|
if jti and is_token_blacklisted(jti):
|
||||||
|
raise PermissionViolation("Session expired — please log in again")
|
||||||
|
|
||||||
|
exp = payload.get("exp")
|
||||||
|
now = datetime.now(timezone.utc).timestamp()
|
||||||
|
if exp is None or now - exp > _REFRESH_GRACE_SECONDS:
|
||||||
|
raise PermissionViolation("Session expired — please log in again")
|
||||||
|
|
||||||
username: str | None = payload.get("sub")
|
username: str | None = payload.get("sub")
|
||||||
if not username:
|
if not username:
|
||||||
raise PermissionViolation("Invalid session")
|
raise PermissionViolation("Invalid session")
|
||||||
@@ -359,3 +389,37 @@ def change_password(
|
|||||||
|
|
||||||
# Return {"detail": "Password changed successfully"}
|
# Return {"detail": "Password changed successfully"}
|
||||||
return {"detail": "Password changed successfully"}
|
return {"detail": "Password changed successfully"}
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Set password via a one-time emailed link — public (no auth), rate-limited
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/set-password/validate", response_model=SetPasswordTokenValidateOut)
|
||||||
|
@limiter.limit("20/minute")
|
||||||
|
def validate_set_password_token(request: Request, token: str, db: Session = Depends(get_db)) -> SetPasswordTokenValidateOut:
|
||||||
|
"""Check a set-password token before rendering the form. Public endpoint."""
|
||||||
|
try:
|
||||||
|
user = validate_password_setup_token(db, token)
|
||||||
|
except (EntityNotFoundError, BusinessRuleViolation):
|
||||||
|
return SetPasswordTokenValidateOut(valid=False)
|
||||||
|
return SetPasswordTokenValidateOut(valid=True, full_name=user.full_name)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/set-password")
|
||||||
|
@limiter.limit("10/minute")
|
||||||
|
def set_password(request: Request, body: SetPasswordRequest, db: Session = Depends(get_db)) -> dict:
|
||||||
|
"""Complete a password setup/reset using a one-time token. Public endpoint."""
|
||||||
|
with UnitOfWork(db) as uow:
|
||||||
|
user = consume_token_and_set_password(db, body.token, body.new_password)
|
||||||
|
log_action(
|
||||||
|
db,
|
||||||
|
user.id,
|
||||||
|
"SET_PASSWORD_VIA_TOKEN",
|
||||||
|
"auth",
|
||||||
|
str(user.id),
|
||||||
|
details={},
|
||||||
|
)
|
||||||
|
uow.commit()
|
||||||
|
return {"detail": "Password set successfully — you can now log in"}
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ from sqlalchemy.orm import Session
|
|||||||
from app.database import get_db
|
from app.database import get_db
|
||||||
|
|
||||||
# Import get_current_user, require_any_role from app.dependencies.auth
|
# Import get_current_user, require_any_role from app.dependencies.auth
|
||||||
from app.dependencies.auth import get_current_user, require_any_role
|
from app.dependencies.auth import get_current_user, require_any_role, require_any_role_strict
|
||||||
|
|
||||||
# Import UnitOfWork from app.domain.unit_of_work
|
# Import UnitOfWork from app.domain.unit_of_work
|
||||||
from app.domain.unit_of_work import UnitOfWork
|
from app.domain.unit_of_work import UnitOfWork
|
||||||
@@ -56,6 +56,10 @@ from app.services.campaign_crud_service import (
|
|||||||
get_campaign_history as crud_get_history,
|
get_campaign_history as crud_get_history,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
from app.services.campaign_crud_service import (
|
||||||
|
get_campaign_timeline as crud_get_timeline,
|
||||||
|
)
|
||||||
|
|
||||||
# Import from app.services.campaign_crud_service
|
# Import from app.services.campaign_crud_service
|
||||||
from app.services.campaign_crud_service import (
|
from app.services.campaign_crud_service import (
|
||||||
get_campaign_progress_data as crud_get_progress,
|
get_campaign_progress_data as crud_get_progress,
|
||||||
@@ -91,11 +95,36 @@ from app.services.campaign_crud_service import (
|
|||||||
activate_campaign as crud_activate,
|
activate_campaign as crud_activate,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
from app.services.campaign_crud_service import (
|
||||||
|
submit_campaign_for_approval as crud_submit,
|
||||||
|
)
|
||||||
|
from app.services.campaign_crud_service import (
|
||||||
|
approve_campaign as crud_approve,
|
||||||
|
)
|
||||||
|
from app.services.campaign_crud_service import (
|
||||||
|
reject_campaign as crud_reject,
|
||||||
|
)
|
||||||
|
from app.services.campaign_crud_service import (
|
||||||
|
create_modification_request as crud_create_mod_request,
|
||||||
|
)
|
||||||
|
from app.services.campaign_crud_service import (
|
||||||
|
approve_modification_request as crud_approve_mod_request,
|
||||||
|
)
|
||||||
|
from app.services.campaign_crud_service import (
|
||||||
|
reject_modification_request as crud_reject_mod_request,
|
||||||
|
)
|
||||||
|
from app.services.campaign_crud_service import (
|
||||||
|
list_modification_requests as crud_list_mod_requests,
|
||||||
|
)
|
||||||
|
from app.services.campaign_crud_service import (
|
||||||
|
serialize_modification_request as crud_serialize_mod_request,
|
||||||
|
)
|
||||||
|
|
||||||
# Import log_action from app.services.audit_service
|
# Import log_action from app.services.audit_service
|
||||||
from app.services.audit_service import log_action
|
from app.services.audit_service import log_action
|
||||||
|
|
||||||
# Import notify_role from app.services.notification_service
|
# Import notify_role from app.services.notification_service
|
||||||
from app.services.notification_service import notify_role
|
from app.services.notification_service import notify_role, notify_roles_by_email
|
||||||
from app.services.webhook_service import dispatch_webhook
|
from app.services.webhook_service import dispatch_webhook
|
||||||
|
|
||||||
# Assign logger = logging.getLogger(__name__)
|
# Assign logger = logging.getLogger(__name__)
|
||||||
@@ -105,6 +134,24 @@ logger = logging.getLogger(__name__)
|
|||||||
router = APIRouter(prefix="/campaigns", tags=["campaigns"])
|
router = APIRouter(prefix="/campaigns", tags=["campaigns"])
|
||||||
|
|
||||||
|
|
||||||
|
def _create_jira_tickets_for_campaign(db: Session, campaign: Campaign, campaign_id: str, user: User) -> None:
|
||||||
|
"""Create Jira tickets for *campaign* now, if its start_date has arrived.
|
||||||
|
|
||||||
|
Shared by both paths that bring a campaign to ``active``: the admin-only
|
||||||
|
emergency `/activate` override and the normal manager `/approve` flow.
|
||||||
|
If ``start_date`` is still in the future, ticket creation is skipped
|
||||||
|
here and left to the periodic ``sync_due_campaign_jira_tickets`` job,
|
||||||
|
which creates them once that date actually arrives — this is what makes
|
||||||
|
the campaign's real scheduled date/time authoritative for when tickets
|
||||||
|
(and their Jira start-date field) appear, instead of always at approval
|
||||||
|
time.
|
||||||
|
"""
|
||||||
|
if campaign.start_date and campaign.start_date > datetime.utcnow():
|
||||||
|
return
|
||||||
|
from app.services.jira_service import ensure_campaign_jira_tickets
|
||||||
|
ensure_campaign_jira_tickets(db, campaign, user)
|
||||||
|
|
||||||
|
|
||||||
# ── Pydantic schemas ─────────────────────────────────────────────────
|
# ── Pydantic schemas ─────────────────────────────────────────────────
|
||||||
|
|
||||||
class CampaignCreate(BaseModel):
|
class CampaignCreate(BaseModel):
|
||||||
@@ -124,7 +171,11 @@ class CampaignCreate(BaseModel):
|
|||||||
tags: Optional[list[str]] = Field(default_factory=list)
|
tags: Optional[list[str]] = Field(default_factory=list)
|
||||||
# Assign scheduled_at = None
|
# Assign scheduled_at = None
|
||||||
scheduled_at: Optional[str] = None
|
scheduled_at: Optional[str] = None
|
||||||
start_date: Optional[str] = None # ISO date — campaign won't activate before this
|
# Only honored when the creator is a manager — see create_campaign():
|
||||||
|
# a manager's own campaign is auto-approved on creation instead of
|
||||||
|
# going through the draft -> submit -> approve queue, so they need to
|
||||||
|
# supply the start_date up front instead of via a later /approve call.
|
||||||
|
start_date: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
# Define class CampaignUpdate
|
# Define class CampaignUpdate
|
||||||
@@ -143,7 +194,6 @@ class CampaignUpdate(BaseModel):
|
|||||||
tags: Optional[list[str]] = None
|
tags: Optional[list[str]] = None
|
||||||
# Assign scheduled_at = None
|
# Assign scheduled_at = None
|
||||||
scheduled_at: Optional[str] = None
|
scheduled_at: Optional[str] = None
|
||||||
start_date: Optional[str] = None # ISO date — can be updated while still in draft
|
|
||||||
|
|
||||||
|
|
||||||
# Define class AddTestPayload
|
# Define class AddTestPayload
|
||||||
@@ -172,6 +222,34 @@ class SchedulePayload(BaseModel):
|
|||||||
next_run_at: Optional[str] = None
|
next_run_at: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
class ApprovePayload(BaseModel):
|
||||||
|
"""Payload for a manager approving a pending campaign."""
|
||||||
|
|
||||||
|
start_date: str # ISO date/datetime — required, campaign won't activate without it
|
||||||
|
|
||||||
|
|
||||||
|
class RejectPayload(BaseModel):
|
||||||
|
"""Payload for a manager rejecting a pending campaign."""
|
||||||
|
|
||||||
|
reason: str
|
||||||
|
|
||||||
|
|
||||||
|
class ModificationRequestPayload(BaseModel):
|
||||||
|
"""Payload for a lead requesting to add/remove a test on an active campaign."""
|
||||||
|
|
||||||
|
action: str # add_test | remove_test
|
||||||
|
test_id: str
|
||||||
|
justification: str
|
||||||
|
order_index: Optional[int] = None
|
||||||
|
phase: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
class RejectModificationPayload(BaseModel):
|
||||||
|
"""Payload for a manager rejecting a modification request."""
|
||||||
|
|
||||||
|
review_notes: str
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# GET /campaigns — List campaigns with filters
|
# GET /campaigns — List campaigns with filters
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -241,18 +319,28 @@ def create_campaign(
|
|||||||
# Entry: db
|
# Entry: db
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
# Entry: current_user
|
# Entry: current_user
|
||||||
current_user: User = Depends(require_any_role("red_lead", "blue_lead")),
|
# Strict variant — admin must NOT get a free pass here. Admin
|
||||||
|
# administers the site, not campaign content; the only admin path to
|
||||||
|
# an active campaign is the emergency /activate override below.
|
||||||
|
current_user: User = Depends(require_any_role_strict("red_lead", "blue_lead", "manager")),
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""Create a new campaign.
|
"""Create a new campaign.
|
||||||
|
|
||||||
|
A manager's campaign is auto-approved on creation — a manager is the
|
||||||
|
same role that would otherwise approve it, so routing it through the
|
||||||
|
draft -> submit -> pending_approval queue would just mean approving
|
||||||
|
their own submission. red_lead/blue_lead campaigns still go through
|
||||||
|
that queue as before.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
payload (CampaignCreate): Fields for the new campaign (name, type, threat actor, etc.).
|
payload (CampaignCreate): Fields for the new campaign (name, type, threat actor, etc.).
|
||||||
db (Session): SQLAlchemy database session.
|
db (Session): SQLAlchemy database session.
|
||||||
current_user (User): Authenticated red_lead or blue_lead creating the campaign.
|
current_user (User): Authenticated red_lead, blue_lead, or manager creating the campaign.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
dict: Serialised representation of the newly created campaign.
|
dict: Serialised representation of the newly created campaign.
|
||||||
"""
|
"""
|
||||||
|
is_manager_create = current_user.role == "manager"
|
||||||
# Open context manager
|
# Open context manager
|
||||||
with UnitOfWork(db) as uow:
|
with UnitOfWork(db) as uow:
|
||||||
# Assign result = crud_create(
|
# Assign result = crud_create(
|
||||||
@@ -274,7 +362,9 @@ def create_campaign(
|
|||||||
tags=payload.tags,
|
tags=payload.tags,
|
||||||
# Keyword argument: scheduled_at
|
# Keyword argument: scheduled_at
|
||||||
scheduled_at=payload.scheduled_at,
|
scheduled_at=payload.scheduled_at,
|
||||||
start_date=payload.start_date,
|
auto_approve=is_manager_create,
|
||||||
|
start_date=payload.start_date if is_manager_create else None,
|
||||||
|
approver_id=current_user.id if is_manager_create else None,
|
||||||
)
|
)
|
||||||
campaign_id = result["id"]
|
campaign_id = result["id"]
|
||||||
log_action(
|
log_action(
|
||||||
@@ -282,7 +372,7 @@ def create_campaign(
|
|||||||
# Keyword argument: user_id
|
# Keyword argument: user_id
|
||||||
user_id=current_user.id,
|
user_id=current_user.id,
|
||||||
# Keyword argument: action
|
# Keyword argument: action
|
||||||
action="create_campaign",
|
action="create_campaign_auto_approved" if is_manager_create else "create_campaign",
|
||||||
# Keyword argument: entity_type
|
# Keyword argument: entity_type
|
||||||
entity_type="campaign",
|
entity_type="campaign",
|
||||||
entity_id=campaign_id,
|
entity_id=campaign_id,
|
||||||
@@ -291,6 +381,13 @@ def create_campaign(
|
|||||||
# Call uow.commit()
|
# Call uow.commit()
|
||||||
uow.commit()
|
uow.commit()
|
||||||
|
|
||||||
|
if is_manager_create:
|
||||||
|
campaign = db.query(Campaign).filter(Campaign.id == uuid.UUID(campaign_id)).first()
|
||||||
|
db.refresh(campaign)
|
||||||
|
# Create Jira tickets now if the manager's chosen start_date is
|
||||||
|
# already due — mirrors the normal manager /approve path.
|
||||||
|
_create_jira_tickets_for_campaign(db, campaign, campaign_id, current_user)
|
||||||
|
|
||||||
# Return result
|
# Return result
|
||||||
return result
|
return result
|
||||||
|
|
||||||
@@ -337,15 +434,19 @@ def update_campaign(
|
|||||||
# Entry: db
|
# Entry: db
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
# Entry: current_user
|
# Entry: current_user
|
||||||
current_user: User = Depends(require_any_role("red_lead", "blue_lead")),
|
current_user: User = Depends(require_any_role("red_lead", "blue_lead", "manager")),
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""Update a campaign. Only allowed in draft or active state.
|
"""Update a campaign. Only allowed in draft or active state.
|
||||||
|
|
||||||
|
A manager may only edit a campaign that's sitting in draft with a
|
||||||
|
rejection_reason set (i.e. one they previously rejected) — see
|
||||||
|
``update_campaign()``'s ownership check.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
campaign_id (str): UUID string of the campaign to update.
|
campaign_id (str): UUID string of the campaign to update.
|
||||||
payload (CampaignUpdate): Partial update payload; only set fields are applied.
|
payload (CampaignUpdate): Partial update payload; only set fields are applied.
|
||||||
db (Session): SQLAlchemy database session.
|
db (Session): SQLAlchemy database session.
|
||||||
current_user (User): Authenticated red_lead or blue_lead performing the update.
|
current_user (User): Authenticated red_lead, blue_lead, or manager performing the update.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
dict: Serialised representation of the updated campaign.
|
dict: Serialised representation of the updated campaign.
|
||||||
@@ -385,6 +486,238 @@ def update_campaign(
|
|||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# POST /campaigns/{id}/submit — Submit draft for manager approval
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@router.post("/{campaign_id}/submit")
|
||||||
|
def submit_campaign(
|
||||||
|
campaign_id: str,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: User = Depends(require_any_role("red_lead", "blue_lead")),
|
||||||
|
) -> dict:
|
||||||
|
"""Submit a draft campaign into the manager's approval queue."""
|
||||||
|
with UnitOfWork(db) as uow:
|
||||||
|
campaign = crud_submit(
|
||||||
|
db, campaign_id,
|
||||||
|
submitter_id=current_user.id,
|
||||||
|
submitter_role=current_user.role,
|
||||||
|
)
|
||||||
|
log_action(
|
||||||
|
db,
|
||||||
|
user_id=current_user.id,
|
||||||
|
action="submit_campaign_for_approval",
|
||||||
|
entity_type="campaign",
|
||||||
|
entity_id=campaign.id,
|
||||||
|
details={"name": campaign.name},
|
||||||
|
)
|
||||||
|
uow.commit()
|
||||||
|
db.refresh(campaign)
|
||||||
|
return serialize_campaign(db, campaign)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# POST /campaigns/{id}/approve — Manager approves a pending campaign
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@router.post("/{campaign_id}/approve")
|
||||||
|
def approve_campaign_endpoint(
|
||||||
|
campaign_id: str,
|
||||||
|
payload: ApprovePayload,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
# admin passes automatically via require_any_role's built-in bypass — do not add "admin" here
|
||||||
|
current_user: User = Depends(require_any_role("manager")),
|
||||||
|
) -> dict:
|
||||||
|
"""Manager approves a pending campaign, fixing its start date and activating it."""
|
||||||
|
with UnitOfWork(db) as uow:
|
||||||
|
campaign = crud_approve(
|
||||||
|
db, campaign_id,
|
||||||
|
approver_id=current_user.id,
|
||||||
|
start_date=payload.start_date,
|
||||||
|
)
|
||||||
|
log_action(
|
||||||
|
db,
|
||||||
|
user_id=current_user.id,
|
||||||
|
action="approve_campaign",
|
||||||
|
entity_type="campaign",
|
||||||
|
entity_id=campaign.id,
|
||||||
|
details={"start_date": payload.start_date},
|
||||||
|
)
|
||||||
|
notify_roles_by_email(
|
||||||
|
db, roles=["red_tech"],
|
||||||
|
preference_key="email_on_assigned_to_campaign",
|
||||||
|
subject=f"Campaign Activated: {campaign.name}",
|
||||||
|
message=f'Campaign "{campaign.name}" has been approved and activated. You may have tests assigned.',
|
||||||
|
)
|
||||||
|
uow.commit()
|
||||||
|
db.refresh(campaign)
|
||||||
|
|
||||||
|
# Create Jira tickets for campaign and its already-linked tests (non-fatal).
|
||||||
|
# Mirrors the admin-only /activate override — this is the normal path.
|
||||||
|
_create_jira_tickets_for_campaign(db, campaign, campaign_id, current_user)
|
||||||
|
|
||||||
|
return serialize_campaign(db, campaign)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# POST /campaigns/{id}/reject — Manager rejects a pending campaign
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@router.post("/{campaign_id}/reject")
|
||||||
|
def reject_campaign_endpoint(
|
||||||
|
campaign_id: str,
|
||||||
|
payload: RejectPayload,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
# admin passes automatically via require_any_role's built-in bypass — do not add "admin" here
|
||||||
|
current_user: User = Depends(require_any_role("manager")),
|
||||||
|
) -> dict:
|
||||||
|
"""Manager rejects a pending campaign, returning it to draft with a reason."""
|
||||||
|
with UnitOfWork(db) as uow:
|
||||||
|
campaign = crud_reject(
|
||||||
|
db, campaign_id,
|
||||||
|
rejecter_id=current_user.id,
|
||||||
|
reason=payload.reason,
|
||||||
|
)
|
||||||
|
log_action(
|
||||||
|
db,
|
||||||
|
user_id=current_user.id,
|
||||||
|
action="reject_campaign",
|
||||||
|
entity_type="campaign",
|
||||||
|
entity_id=campaign.id,
|
||||||
|
details={"reason": payload.reason},
|
||||||
|
)
|
||||||
|
uow.commit()
|
||||||
|
db.refresh(campaign)
|
||||||
|
return serialize_campaign(db, campaign)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# POST /campaigns/{id}/modification-requests — Request a test add/remove
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@router.post("/{campaign_id}/modification-requests", status_code=201)
|
||||||
|
def create_modification_request_endpoint(
|
||||||
|
campaign_id: str,
|
||||||
|
payload: ModificationRequestPayload,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: User = Depends(require_any_role("red_lead", "blue_lead")),
|
||||||
|
) -> dict:
|
||||||
|
"""File a request to add/remove a test on an active campaign — needs manager approval."""
|
||||||
|
with UnitOfWork(db) as uow:
|
||||||
|
request = crud_create_mod_request(
|
||||||
|
db, campaign_id,
|
||||||
|
requester_id=current_user.id,
|
||||||
|
action=payload.action,
|
||||||
|
test_id=payload.test_id,
|
||||||
|
justification=payload.justification,
|
||||||
|
order_index=payload.order_index,
|
||||||
|
phase=payload.phase,
|
||||||
|
)
|
||||||
|
log_action(
|
||||||
|
db,
|
||||||
|
user_id=current_user.id,
|
||||||
|
action="request_campaign_modification",
|
||||||
|
entity_type="campaign",
|
||||||
|
entity_id=campaign_id,
|
||||||
|
details={
|
||||||
|
"action": payload.action,
|
||||||
|
"test_id": payload.test_id,
|
||||||
|
"justification": payload.justification,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
uow.commit()
|
||||||
|
return crud_serialize_mod_request(db, request)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# GET /campaigns/{id}/modification-requests — List requests for one campaign
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@router.get("/{campaign_id}/modification-requests")
|
||||||
|
def list_campaign_modification_requests_endpoint(
|
||||||
|
campaign_id: str,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
) -> list:
|
||||||
|
"""List modification requests filed against a specific campaign."""
|
||||||
|
return crud_list_mod_requests(db, campaign_id=campaign_id)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# GET /campaigns/modification-requests/pending — Manager's global queue
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@router.get("/modification-requests/pending")
|
||||||
|
def list_pending_modification_requests_endpoint(
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
# admin passes automatically via require_any_role's built-in bypass — do not add "admin" here
|
||||||
|
current_user: User = Depends(require_any_role("manager")),
|
||||||
|
) -> list:
|
||||||
|
"""List all modification requests awaiting manager review, across all campaigns."""
|
||||||
|
return crud_list_mod_requests(db, status="pending")
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# POST /campaigns/modification-requests/{id}/approve — Apply the requested change
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@router.post("/modification-requests/{request_id}/approve")
|
||||||
|
def approve_modification_request_endpoint(
|
||||||
|
request_id: str,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
# admin passes automatically via require_any_role's built-in bypass — do not add "admin" here
|
||||||
|
current_user: User = Depends(require_any_role("manager")),
|
||||||
|
) -> dict:
|
||||||
|
"""Manager approves a modification request — the test change is applied now."""
|
||||||
|
with UnitOfWork(db) as uow:
|
||||||
|
request = crud_approve_mod_request(db, request_id, reviewer_id=current_user.id)
|
||||||
|
log_action(
|
||||||
|
db,
|
||||||
|
user_id=current_user.id,
|
||||||
|
action="approve_campaign_modification",
|
||||||
|
entity_type="campaign",
|
||||||
|
entity_id=str(request.campaign_id),
|
||||||
|
details={
|
||||||
|
"request_id": request_id,
|
||||||
|
"action": request.action,
|
||||||
|
"test_id": str(request.test_id) if request.test_id else None,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
uow.commit()
|
||||||
|
return crud_serialize_mod_request(db, request)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# POST /campaigns/modification-requests/{id}/reject — Deny the requested change
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@router.post("/modification-requests/{request_id}/reject")
|
||||||
|
def reject_modification_request_endpoint(
|
||||||
|
request_id: str,
|
||||||
|
payload: RejectModificationPayload,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
# admin passes automatically via require_any_role's built-in bypass — do not add "admin" here
|
||||||
|
current_user: User = Depends(require_any_role("manager")),
|
||||||
|
) -> dict:
|
||||||
|
"""Manager rejects a modification request. No change is applied."""
|
||||||
|
with UnitOfWork(db) as uow:
|
||||||
|
request = crud_reject_mod_request(
|
||||||
|
db, request_id,
|
||||||
|
reviewer_id=current_user.id,
|
||||||
|
review_notes=payload.review_notes,
|
||||||
|
)
|
||||||
|
log_action(
|
||||||
|
db,
|
||||||
|
user_id=current_user.id,
|
||||||
|
action="reject_campaign_modification",
|
||||||
|
entity_type="campaign",
|
||||||
|
entity_id=str(request.campaign_id),
|
||||||
|
details={"request_id": request_id, "review_notes": payload.review_notes},
|
||||||
|
)
|
||||||
|
uow.commit()
|
||||||
|
return crud_serialize_mod_request(db, request)
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# DELETE /campaigns/{id} — Delete campaign
|
# DELETE /campaigns/{id} — Delete campaign
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -510,36 +843,18 @@ def remove_test_from_campaign(
|
|||||||
def activate_campaign(
|
def activate_campaign(
|
||||||
# Entry: campaign_id
|
# Entry: campaign_id
|
||||||
campaign_id: str,
|
campaign_id: str,
|
||||||
force: bool = Query(False, description="Activate even if start_date is in the future"),
|
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
# Entry: current_user
|
# admin passes automatically via require_any_role's built-in bypass — do not add "admin" here
|
||||||
current_user: User = Depends(require_any_role("red_lead", "blue_lead")),
|
current_user: User = Depends(require_any_role("admin")),
|
||||||
):
|
):
|
||||||
"""Activate a campaign, moving it from draft to active.
|
"""Admin-only emergency override: activate a draft campaign directly, bypassing the approval queue.
|
||||||
|
|
||||||
If the campaign has a start_date in the future and force=False, returns a 409
|
Every other path to 'active' goes through /submit -> /approve, where the
|
||||||
with a warning so the frontend can show a confirmation modal. If force=True,
|
manager sets start_date. A draft campaign never has a start_date set
|
||||||
activates immediately regardless of start_date.
|
(only /approve sets it, in the same call that moves the campaign to
|
||||||
|
'active'), so there is no "scheduled for the future" case to guard
|
||||||
|
against here anymore.
|
||||||
"""
|
"""
|
||||||
from fastapi import HTTPException
|
|
||||||
campaign_obj = db.query(Campaign).filter(Campaign.id == campaign_id).first()
|
|
||||||
if campaign_obj and campaign_obj.start_date and not force:
|
|
||||||
now = datetime.utcnow()
|
|
||||||
if campaign_obj.start_date > now:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=409,
|
|
||||||
detail={
|
|
||||||
"code": "start_date_in_future",
|
|
||||||
"start_date": campaign_obj.start_date.strftime("%Y-%m-%d"),
|
|
||||||
"message": (
|
|
||||||
f"This campaign is scheduled to start on "
|
|
||||||
f"{campaign_obj.start_date.strftime('%d %b %Y')}. "
|
|
||||||
f"It will activate automatically on that date. "
|
|
||||||
f"Do you want to activate it now anyway?"
|
|
||||||
),
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
with UnitOfWork(db) as uow:
|
with UnitOfWork(db) as uow:
|
||||||
# Assign campaign = crud_activate(db, campaign_id)
|
# Assign campaign = crud_activate(db, campaign_id)
|
||||||
campaign = crud_activate(db, campaign_id)
|
campaign = crud_activate(db, campaign_id)
|
||||||
@@ -559,6 +874,12 @@ def activate_campaign(
|
|||||||
# Keyword argument: entity_id
|
# Keyword argument: entity_id
|
||||||
entity_id=campaign.id,
|
entity_id=campaign.id,
|
||||||
)
|
)
|
||||||
|
notify_roles_by_email(
|
||||||
|
db, roles=["red_tech"],
|
||||||
|
preference_key="email_on_assigned_to_campaign",
|
||||||
|
subject=f"Campaign Activated: {campaign.name}",
|
||||||
|
message=f'Campaign "{campaign.name}" has been activated. You may have tests assigned.',
|
||||||
|
)
|
||||||
# Call log_action()
|
# Call log_action()
|
||||||
log_action(
|
log_action(
|
||||||
db,
|
db,
|
||||||
@@ -580,30 +901,7 @@ def activate_campaign(
|
|||||||
|
|
||||||
# Create Jira tickets for campaign and tests at activation time (non-fatal).
|
# Create Jira tickets for campaign and tests at activation time (non-fatal).
|
||||||
# Campaign ticket is created here if it doesn't already exist (deferred from creation).
|
# Campaign ticket is created here if it doesn't already exist (deferred from creation).
|
||||||
try:
|
_create_jira_tickets_for_campaign(db, campaign, campaign_id, current_user)
|
||||||
from app.services.jira_service import (
|
|
||||||
auto_create_campaign_issue,
|
|
||||||
auto_create_test_issue,
|
|
||||||
get_campaign_jira_key,
|
|
||||||
get_test_jira_key,
|
|
||||||
)
|
|
||||||
campaign_jira_key = get_campaign_jira_key(db, campaign_id)
|
|
||||||
if not campaign_jira_key:
|
|
||||||
campaign_jira_key = auto_create_campaign_issue(db, campaign, current_user)
|
|
||||||
if campaign_jira_key:
|
|
||||||
for ct in campaign.campaign_tests:
|
|
||||||
if ct.test and not get_test_jira_key(db, ct.test.id):
|
|
||||||
auto_create_test_issue(
|
|
||||||
db, ct.test, current_user,
|
|
||||||
parent_ticket_override=campaign_jira_key,
|
|
||||||
campaign_start_date=campaign.start_date,
|
|
||||||
)
|
|
||||||
db.commit()
|
|
||||||
except Exception:
|
|
||||||
logger.exception(
|
|
||||||
"Jira ticket creation failed during activation of campaign %s",
|
|
||||||
campaign_id,
|
|
||||||
)
|
|
||||||
|
|
||||||
return serialize_campaign(db, campaign)
|
return serialize_campaign(db, campaign)
|
||||||
|
|
||||||
@@ -693,7 +991,7 @@ def get_campaign_progress_endpoint(
|
|||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
class GenerateFromActorPayload(BaseModel):
|
class GenerateFromActorPayload(BaseModel):
|
||||||
start_date: Optional[str] = None # ISO date YYYY-MM-DD
|
start_date: Optional[datetime] = None
|
||||||
|
|
||||||
|
|
||||||
@router.post("/from-threat-actor/{actor_id}", status_code=201)
|
@router.post("/from-threat-actor/{actor_id}", status_code=201)
|
||||||
@@ -704,7 +1002,9 @@ def generate_campaign_from_actor(
|
|||||||
payload: GenerateFromActorPayload = GenerateFromActorPayload(),
|
payload: GenerateFromActorPayload = GenerateFromActorPayload(),
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
# Entry: current_user
|
# Entry: current_user
|
||||||
current_user: User = Depends(require_any_role("red_lead", "blue_lead")),
|
# Strict variant — admin must NOT get a free pass here, same as the
|
||||||
|
# plain create_campaign endpoint above.
|
||||||
|
current_user: User = Depends(require_any_role_strict("red_lead", "blue_lead")),
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""Auto-generate a campaign from a threat actor's uncovered techniques.
|
"""Auto-generate a campaign from a threat actor's uncovered techniques.
|
||||||
|
|
||||||
@@ -719,14 +1019,11 @@ def generate_campaign_from_actor(
|
|||||||
Returns:
|
Returns:
|
||||||
dict: Serialised representation of the newly generated campaign.
|
dict: Serialised representation of the newly generated campaign.
|
||||||
"""
|
"""
|
||||||
start_date_parsed = (
|
|
||||||
datetime.fromisoformat(payload.start_date) if payload.start_date else None
|
|
||||||
)
|
|
||||||
campaign = generate_campaign_from_threat_actor(
|
campaign = generate_campaign_from_threat_actor(
|
||||||
db,
|
db,
|
||||||
uuid.UUID(actor_id),
|
uuid.UUID(actor_id),
|
||||||
current_user,
|
current_user,
|
||||||
start_date=start_date_parsed,
|
start_date=payload.start_date,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Open context manager
|
# Open context manager
|
||||||
@@ -856,6 +1153,20 @@ def get_campaign_history(
|
|||||||
return crud_get_history(db, campaign_id)
|
return crud_get_history(db, campaign_id)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# GET /campaigns/{id}/timeline — Audit-log history for this campaign
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@router.get("/{campaign_id}/timeline")
|
||||||
|
def get_campaign_timeline_endpoint(
|
||||||
|
campaign_id: str,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
) -> list:
|
||||||
|
"""Return the chronological audit-log history for a campaign."""
|
||||||
|
return crud_get_timeline(db, campaign_id)
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# GET /campaigns/{id}/timing-summary — Aggregated timing across campaign tests
|
# GET /campaigns/{id}/timing-summary — Aggregated timing across campaign tests
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -0,0 +1,158 @@
|
|||||||
|
"""Router for procedure-improvement suggestion review.
|
||||||
|
|
||||||
|
Endpoints
|
||||||
|
---------
|
||||||
|
GET /procedure-suggestions — list pending suggestions (lead/admin)
|
||||||
|
POST /procedure-suggestions/{id}/approve — write suggestion into template (lead/admin)
|
||||||
|
POST /procedure-suggestions/{id}/reject — dismiss suggestion (lead/admin)
|
||||||
|
|
||||||
|
A red_lead only ever sees/acts on "red" team suggestions, a blue_lead only
|
||||||
|
"blue" — admins can see and act on both.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
|
|
||||||
|
from app.database import get_db
|
||||||
|
from app.dependencies.auth import require_any_role_strict
|
||||||
|
from app.domain.errors import EntityNotFoundError, InvalidOperationError
|
||||||
|
from app.domain.unit_of_work import UnitOfWork
|
||||||
|
from app.models.procedure_suggestion import ProcedureSuggestion
|
||||||
|
from app.models.test_template import TestTemplate
|
||||||
|
from app.models.user import User
|
||||||
|
from app.schemas.procedure_suggestion import ProcedureSuggestionOut
|
||||||
|
from app.services.audit_service import log_action
|
||||||
|
from app.services.procedure_suggestion_service import (
|
||||||
|
approve_suggestion as approve_suggestion_svc,
|
||||||
|
)
|
||||||
|
from app.services.procedure_suggestion_service import (
|
||||||
|
list_pending_suggestions,
|
||||||
|
)
|
||||||
|
from app.services.procedure_suggestion_service import (
|
||||||
|
reject_suggestion as reject_suggestion_svc,
|
||||||
|
)
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/procedure-suggestions", tags=["procedure-suggestions"])
|
||||||
|
|
||||||
|
|
||||||
|
def _team_for_role(user: User) -> str | None:
|
||||||
|
"""Return the single team a non-admin lead is scoped to, or None for admin (both)."""
|
||||||
|
if user.role == "red_lead":
|
||||||
|
return "red"
|
||||||
|
if user.role == "blue_lead":
|
||||||
|
return "blue"
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _to_out(suggestion, template_by_id: dict) -> ProcedureSuggestionOut:
|
||||||
|
out = ProcedureSuggestionOut.model_validate(suggestion)
|
||||||
|
template = template_by_id.get(suggestion.template_id)
|
||||||
|
if template is not None:
|
||||||
|
out.template_name = template.name
|
||||||
|
out.template_current_text = (
|
||||||
|
template.attack_procedure if suggestion.team == "red" else template.expected_detection
|
||||||
|
)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("", response_model=list[ProcedureSuggestionOut])
|
||||||
|
def list_suggestions(
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: User = Depends(require_any_role_strict("red_lead", "blue_lead", "admin")),
|
||||||
|
) -> list[ProcedureSuggestionOut]:
|
||||||
|
"""List pending procedure suggestions, scoped to the caller's team (admins see both)."""
|
||||||
|
team = _team_for_role(current_user)
|
||||||
|
suggestions = list_pending_suggestions(db, team=team)
|
||||||
|
template_ids = {s.template_id for s in suggestions}
|
||||||
|
templates = (
|
||||||
|
db.query(TestTemplate).filter(TestTemplate.id.in_(template_ids)).all() if template_ids else []
|
||||||
|
)
|
||||||
|
template_by_id = {t.id: t for t in templates}
|
||||||
|
return [_to_out(s, template_by_id) for s in suggestions]
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/for-test/{test_id}", response_model=list[ProcedureSuggestionOut])
|
||||||
|
def list_suggestions_for_test(
|
||||||
|
test_id: uuid.UUID,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: User = Depends(require_any_role_strict("red_lead", "blue_lead", "admin")),
|
||||||
|
) -> list[ProcedureSuggestionOut]:
|
||||||
|
"""List pending suggestions tied to a specific test, scoped to the
|
||||||
|
caller's team (admins see both) — powers the blocking review popup a
|
||||||
|
lead sees when opening a test that has one awaiting them."""
|
||||||
|
team = _team_for_role(current_user)
|
||||||
|
suggestions = list_pending_suggestions(db, team=team, source_test_id=test_id)
|
||||||
|
template_ids = {s.template_id for s in suggestions}
|
||||||
|
templates = (
|
||||||
|
db.query(TestTemplate).filter(TestTemplate.id.in_(template_ids)).all() if template_ids else []
|
||||||
|
)
|
||||||
|
template_by_id = {t.id: t for t in templates}
|
||||||
|
return [_to_out(s, template_by_id) for s in suggestions]
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{suggestion_id}/approve", response_model=ProcedureSuggestionOut)
|
||||||
|
def approve_suggestion(
|
||||||
|
suggestion_id: uuid.UUID,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: User = Depends(require_any_role_strict("red_lead", "blue_lead", "admin")),
|
||||||
|
) -> ProcedureSuggestionOut:
|
||||||
|
"""Approve a suggestion, writing it into its template's suggested-procedure field."""
|
||||||
|
_check_team_permission(current_user, _team_or_404(db, suggestion_id))
|
||||||
|
try:
|
||||||
|
with UnitOfWork(db) as uow:
|
||||||
|
suggestion = approve_suggestion_svc(db, suggestion_id, current_user)
|
||||||
|
log_action(
|
||||||
|
db, user_id=current_user.id, action="approve_procedure_suggestion",
|
||||||
|
entity_type="procedure_suggestion", entity_id=suggestion.id,
|
||||||
|
details={"template_id": str(suggestion.template_id), "team": suggestion.team},
|
||||||
|
)
|
||||||
|
uow.commit()
|
||||||
|
except EntityNotFoundError as e:
|
||||||
|
raise HTTPException(status_code=404, detail=str(e)) from e
|
||||||
|
except InvalidOperationError as e:
|
||||||
|
raise HTTPException(status_code=400, detail=str(e)) from e
|
||||||
|
db.refresh(suggestion)
|
||||||
|
template = db.query(TestTemplate).filter(TestTemplate.id == suggestion.template_id).first()
|
||||||
|
return _to_out(suggestion, {template.id: template} if template else {})
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{suggestion_id}/reject", response_model=ProcedureSuggestionOut)
|
||||||
|
def reject_suggestion(
|
||||||
|
suggestion_id: uuid.UUID,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: User = Depends(require_any_role_strict("red_lead", "blue_lead", "admin")),
|
||||||
|
) -> ProcedureSuggestionOut:
|
||||||
|
"""Reject a suggestion without touching the template."""
|
||||||
|
_check_team_permission(current_user, _team_or_404(db, suggestion_id))
|
||||||
|
try:
|
||||||
|
with UnitOfWork(db) as uow:
|
||||||
|
suggestion = reject_suggestion_svc(db, suggestion_id, current_user)
|
||||||
|
log_action(
|
||||||
|
db, user_id=current_user.id, action="reject_procedure_suggestion",
|
||||||
|
entity_type="procedure_suggestion", entity_id=suggestion.id,
|
||||||
|
details={"template_id": str(suggestion.template_id), "team": suggestion.team},
|
||||||
|
)
|
||||||
|
uow.commit()
|
||||||
|
except EntityNotFoundError as e:
|
||||||
|
raise HTTPException(status_code=404, detail=str(e)) from e
|
||||||
|
except InvalidOperationError as e:
|
||||||
|
raise HTTPException(status_code=400, detail=str(e)) from e
|
||||||
|
db.refresh(suggestion)
|
||||||
|
return _to_out(suggestion, {})
|
||||||
|
|
||||||
|
|
||||||
|
def _team_or_404(db: Session, suggestion_id: uuid.UUID) -> str:
|
||||||
|
"""Look up just the team of a suggestion, for a permission check before mutating it."""
|
||||||
|
suggestion = db.query(ProcedureSuggestion).filter(ProcedureSuggestion.id == suggestion_id).first()
|
||||||
|
if suggestion is None:
|
||||||
|
raise HTTPException(status_code=404, detail="Procedure suggestion not found")
|
||||||
|
return suggestion.team
|
||||||
|
|
||||||
|
|
||||||
|
def _check_team_permission(user: User, team: str) -> None:
|
||||||
|
"""Raise 403 if a non-admin lead tries to act on the other team's suggestion."""
|
||||||
|
scoped_team = _team_for_role(user)
|
||||||
|
if scoped_team is not None and scoped_team != team:
|
||||||
|
raise HTTPException(status_code=403, detail=f"Not authorized to review {team} team suggestions")
|
||||||
@@ -40,6 +40,18 @@ def _assert_safe_report_path(filepath: str) -> str:
|
|||||||
raise HTTPException(status_code=500, detail="Report generation path error")
|
raise HTTPException(status_code=500, detail="Report generation path error")
|
||||||
return filepath
|
return filepath
|
||||||
|
|
||||||
|
|
||||||
|
def _assert_reports_enabled() -> None:
|
||||||
|
"""Raise a clean 503 while report generation is unfinished/disabled.
|
||||||
|
|
||||||
|
The frontend hides the Reports UI entirely; without this guard, hitting
|
||||||
|
these endpoints directly falls through to whatever the underlying
|
||||||
|
render pipeline raises (e.g. missing weasyprint system deps) as a raw,
|
||||||
|
unhelpful 500.
|
||||||
|
"""
|
||||||
|
if not settings.REPORTS_ENABLED:
|
||||||
|
raise HTTPException(status_code=503, detail="Report generation is not yet available on this platform")
|
||||||
|
|
||||||
# Assign router = APIRouter(prefix="/reports/generate", tags=["professional-reports"])
|
# Assign router = APIRouter(prefix="/reports/generate", tags=["professional-reports"])
|
||||||
router = APIRouter(prefix="/reports/generate", tags=["professional-reports"])
|
router = APIRouter(prefix="/reports/generate", tags=["professional-reports"])
|
||||||
|
|
||||||
@@ -72,6 +84,7 @@ def generate_purple_report(
|
|||||||
user: User = Depends(require_any_role("red_lead", "blue_lead", "viewer")),
|
user: User = Depends(require_any_role("red_lead", "blue_lead", "viewer")),
|
||||||
) -> FileResponse:
|
) -> FileResponse:
|
||||||
"""Generate a Purple Team campaign assessment report."""
|
"""Generate a Purple Team campaign assessment report."""
|
||||||
|
_assert_reports_enabled()
|
||||||
# Assign filepath = report_generation_service.generate_purple_campaign_report(
|
# Assign filepath = report_generation_service.generate_purple_campaign_report(
|
||||||
filepath = report_generation_service.generate_purple_campaign_report(
|
filepath = report_generation_service.generate_purple_campaign_report(
|
||||||
db, str(campaign_id), output_format=format,
|
db, str(campaign_id), output_format=format,
|
||||||
@@ -102,6 +115,7 @@ def generate_coverage_report(
|
|||||||
user: User = Depends(require_any_role("red_lead", "blue_lead", "viewer")),
|
user: User = Depends(require_any_role("red_lead", "blue_lead", "viewer")),
|
||||||
) -> FileResponse:
|
) -> FileResponse:
|
||||||
"""Generate an organization-wide MITRE ATT&CK coverage report."""
|
"""Generate an organization-wide MITRE ATT&CK coverage report."""
|
||||||
|
_assert_reports_enabled()
|
||||||
# Assign filepath = report_generation_service.generate_coverage_report(
|
# Assign filepath = report_generation_service.generate_coverage_report(
|
||||||
filepath = report_generation_service.generate_coverage_report(
|
filepath = report_generation_service.generate_coverage_report(
|
||||||
db, output_format=format,
|
db, output_format=format,
|
||||||
@@ -132,6 +146,7 @@ def generate_executive_report(
|
|||||||
user: User = Depends(require_any_role("red_lead", "blue_lead", "viewer")),
|
user: User = Depends(require_any_role("red_lead", "blue_lead", "viewer")),
|
||||||
) -> FileResponse:
|
) -> FileResponse:
|
||||||
"""Generate an executive security summary report."""
|
"""Generate an executive security summary report."""
|
||||||
|
_assert_reports_enabled()
|
||||||
# Assign filepath = report_generation_service.generate_executive_summary(
|
# Assign filepath = report_generation_service.generate_executive_summary(
|
||||||
filepath = report_generation_service.generate_executive_summary(
|
filepath = report_generation_service.generate_executive_summary(
|
||||||
db, output_format=format,
|
db, output_format=format,
|
||||||
@@ -162,6 +177,7 @@ def generate_quarterly_report(
|
|||||||
user: User = Depends(require_any_role("red_lead", "blue_lead", "viewer")),
|
user: User = Depends(require_any_role("red_lead", "blue_lead", "viewer")),
|
||||||
) -> FileResponse:
|
) -> FileResponse:
|
||||||
"""Generate a quarterly security summary report."""
|
"""Generate a quarterly security summary report."""
|
||||||
|
_assert_reports_enabled()
|
||||||
# Assign filepath = report_generation_service.generate_quarterly_summary(
|
# Assign filepath = report_generation_service.generate_quarterly_summary(
|
||||||
filepath = report_generation_service.generate_quarterly_summary(
|
filepath = report_generation_service.generate_quarterly_summary(
|
||||||
db, output_format=format,
|
db, output_format=format,
|
||||||
@@ -194,6 +210,7 @@ def generate_technique_report(
|
|||||||
user: User = Depends(get_current_user),
|
user: User = Depends(get_current_user),
|
||||||
) -> FileResponse:
|
) -> FileResponse:
|
||||||
"""Generate a detailed report for one MITRE technique."""
|
"""Generate a detailed report for one MITRE technique."""
|
||||||
|
_assert_reports_enabled()
|
||||||
# Assign filepath = report_generation_service.generate_technique_detail_report(
|
# Assign filepath = report_generation_service.generate_technique_detail_report(
|
||||||
filepath = report_generation_service.generate_technique_detail_report(
|
filepath = report_generation_service.generate_technique_detail_report(
|
||||||
db, str(technique_id), output_format=format,
|
db, str(technique_id), output_format=format,
|
||||||
|
|||||||
@@ -165,7 +165,7 @@ def score_history(
|
|||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
# Entry: current_user
|
# Entry: current_user
|
||||||
current_user: User = Depends(get_current_user),
|
current_user: User = Depends(get_current_user),
|
||||||
) -> dict:
|
) -> list:
|
||||||
"""Get historical score data points (weekly).
|
"""Get historical score data points (weekly).
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
@@ -174,7 +174,7 @@ def score_history(
|
|||||||
current_user (User): Authenticated user making the request.
|
current_user (User): Authenticated user making the request.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
dict: Weekly score data points for the requested period.
|
list: Weekly score data points for the requested period.
|
||||||
"""
|
"""
|
||||||
# Return get_score_history(db, period)
|
# Return get_score_history(db, period)
|
||||||
return get_score_history(db, period)
|
return get_score_history(db, period)
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ from pydantic import BaseModel
|
|||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app.database import SessionLocal, get_db
|
from app.database import SessionLocal, get_db
|
||||||
from app.dependencies.auth import require_role
|
from app.dependencies.auth import require_role, get_current_user
|
||||||
from app.models.user import User
|
from app.models.user import User
|
||||||
from app.services.mitre_sync_service import sync_mitre
|
from app.services.mitre_sync_service import sync_mitre
|
||||||
from app.services.intel_service import scan_intel
|
from app.services.intel_service import scan_intel
|
||||||
@@ -285,11 +285,13 @@ _JIRA_KEYS = {
|
|||||||
@router.get("/jira-config", response_model=JiraConfigOut)
|
@router.get("/jira-config", response_model=JiraConfigOut)
|
||||||
def get_jira_config(
|
def get_jira_config(
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
current_user: User = Depends(require_role("admin")),
|
current_user: User = Depends(get_current_user),
|
||||||
):
|
):
|
||||||
"""Return current Jira configuration (merged DB + env).
|
"""Return current Jira configuration (merged DB + env).
|
||||||
|
|
||||||
**Requires** the ``admin`` role. Credential values are never returned.
|
Open to any authenticated user — the frontend needs ``url`` to build
|
||||||
|
correct ``/browse/{key}`` links for non-admin users too. Credential
|
||||||
|
values (tokens, admin email) are never returned, only booleans.
|
||||||
"""
|
"""
|
||||||
from app.services.jira_service import (
|
from app.services.jira_service import (
|
||||||
get_jira_url, get_jira_project_key, is_jira_enabled,
|
get_jira_url, get_jira_project_key, is_jira_enabled,
|
||||||
@@ -342,6 +344,95 @@ def update_jira_config(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class EmailWebhookConfigOut(BaseModel):
|
||||||
|
configured: bool
|
||||||
|
url: str
|
||||||
|
api_key_set: bool
|
||||||
|
|
||||||
|
|
||||||
|
class EmailWebhookConfigUpdate(BaseModel):
|
||||||
|
url: str
|
||||||
|
# Optional — omit or send "" to leave the currently-stored key unchanged.
|
||||||
|
api_key: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
class EmailWebhookTestRequest(BaseModel):
|
||||||
|
to: str
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/email-webhook-config", response_model=EmailWebhookConfigOut)
|
||||||
|
def get_email_webhook_config(
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: User = Depends(require_role("admin")),
|
||||||
|
):
|
||||||
|
"""Return the configured webhook used to send every platform notification
|
||||||
|
email (password setup/reset, test validated, campaign completed, etc.).
|
||||||
|
|
||||||
|
The API key itself is never returned, only whether one is set.
|
||||||
|
|
||||||
|
**Requires** the ``admin`` role.
|
||||||
|
"""
|
||||||
|
from app.services.webhook_email_service import get_webhook_api_key, get_webhook_url
|
||||||
|
|
||||||
|
url = get_webhook_url(db) or ""
|
||||||
|
api_key_set = bool(get_webhook_api_key(db))
|
||||||
|
return EmailWebhookConfigOut(configured=bool(url), url=url, api_key_set=api_key_set)
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/email-webhook-config", response_model=EmailWebhookConfigOut)
|
||||||
|
def update_email_webhook_config(
|
||||||
|
payload: EmailWebhookConfigUpdate,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: User = Depends(require_role("admin")),
|
||||||
|
):
|
||||||
|
"""Set the webhook URL (and optionally API key) used to send all
|
||||||
|
platform notification emails.
|
||||||
|
|
||||||
|
**Requires** the ``admin`` role.
|
||||||
|
"""
|
||||||
|
from app.services.webhook_email_service import (
|
||||||
|
get_webhook_api_key,
|
||||||
|
get_webhook_url,
|
||||||
|
set_webhook_api_key,
|
||||||
|
set_webhook_url,
|
||||||
|
)
|
||||||
|
|
||||||
|
set_webhook_url(db, payload.url)
|
||||||
|
if payload.api_key:
|
||||||
|
set_webhook_api_key(db, payload.api_key)
|
||||||
|
db.commit()
|
||||||
|
url = get_webhook_url(db) or ""
|
||||||
|
api_key_set = bool(get_webhook_api_key(db))
|
||||||
|
return EmailWebhookConfigOut(configured=bool(url), url=url, api_key_set=api_key_set)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/email-webhook-test")
|
||||||
|
def test_email_webhook(
|
||||||
|
payload: EmailWebhookTestRequest,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: User = Depends(require_role("admin")),
|
||||||
|
) -> dict:
|
||||||
|
"""Send a real test notification through the configured email webhook.
|
||||||
|
|
||||||
|
**Requires** the ``admin`` role.
|
||||||
|
"""
|
||||||
|
from app.services.webhook_email_service import send_webhook_email
|
||||||
|
|
||||||
|
sent = send_webhook_email(
|
||||||
|
db,
|
||||||
|
to=payload.to,
|
||||||
|
subject="Aegis Email Webhook Test",
|
||||||
|
message=(
|
||||||
|
"This is a test notification confirming the email webhook is "
|
||||||
|
"configured and working correctly."
|
||||||
|
),
|
||||||
|
full_name=current_user.full_name,
|
||||||
|
)
|
||||||
|
if not sent:
|
||||||
|
raise HTTPException(status_code=502, detail="Failed to send — check the webhook URL/API key and logs.")
|
||||||
|
return {"detail": f"Test email sent to {payload.to}"}
|
||||||
|
|
||||||
|
|
||||||
@router.post("/jira-test")
|
@router.post("/jira-test")
|
||||||
def test_jira_connection(
|
def test_jira_connection(
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
@@ -354,7 +445,7 @@ def test_jira_connection(
|
|||||||
"""
|
"""
|
||||||
from app.services.jira_service import (
|
from app.services.jira_service import (
|
||||||
get_admin_jira_client, get_jira_url, has_admin_jira_configured,
|
get_admin_jira_client, get_jira_url, has_admin_jira_configured,
|
||||||
get_admin_jira_email,
|
get_admin_jira_email, get_admin_jira_api_token, is_jira_enabled,
|
||||||
)
|
)
|
||||||
|
|
||||||
jira_url = get_jira_url(db)
|
jira_url = get_jira_url(db)
|
||||||
@@ -362,12 +453,21 @@ def test_jira_connection(
|
|||||||
return {"status": "error", "message": "Jira URL is not configured.", "jira_url": ""}
|
return {"status": "error", "message": "Jira URL is not configured.", "jira_url": ""}
|
||||||
|
|
||||||
if not has_admin_jira_configured(db):
|
if not has_admin_jira_configured(db):
|
||||||
|
# has_admin_jira_configured() bundles 4 independent checks into one
|
||||||
|
# bool — report exactly which one(s) are actually missing instead of
|
||||||
|
# always blaming "Admin Email and Admin API Token" regardless of
|
||||||
|
# which check failed (e.g. URL/email/token can all be set while the
|
||||||
|
# "Enable Jira Integration" toggle itself is still off).
|
||||||
|
missing = []
|
||||||
|
if not is_jira_enabled(db):
|
||||||
|
missing.append("Jira integration is not enabled (toggle 'Enable Jira Integration' above)")
|
||||||
|
if not get_admin_jira_email(db):
|
||||||
|
missing.append("Admin Email is not set")
|
||||||
|
if not get_admin_jira_api_token(db):
|
||||||
|
missing.append("Admin API Token is not set")
|
||||||
return {
|
return {
|
||||||
"status": "error",
|
"status": "error",
|
||||||
"message": (
|
"message": "Cannot connect: " + "; ".join(missing) + ".",
|
||||||
"Admin Jira credentials not configured. "
|
|
||||||
"Set the Admin Email and Admin API Token in the fields above."
|
|
||||||
),
|
|
||||||
"jira_url": jira_url,
|
"jira_url": jira_url,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -254,7 +254,7 @@ def review_technique(
|
|||||||
# Entry: repo
|
# Entry: repo
|
||||||
repo: SATechniqueRepository = Depends(get_technique_repository),
|
repo: SATechniqueRepository = Depends(get_technique_repository),
|
||||||
# Entry: current_user
|
# Entry: current_user
|
||||||
current_user: User = Depends(require_any_role("red_lead", "blue_lead")),
|
current_user: User = Depends(require_any_role("red_lead", "blue_lead", "manager")),
|
||||||
) -> TechniqueOut:
|
) -> TechniqueOut:
|
||||||
"""Mark a technique as reviewed.
|
"""Mark a technique as reviewed.
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,166 @@
|
|||||||
|
"""Router for operator template-proposal review.
|
||||||
|
|
||||||
|
Endpoints
|
||||||
|
---------
|
||||||
|
POST /template-suggestions — propose a new template (red_tech/blue_tech)
|
||||||
|
GET /template-suggestions — list pending suggestions (lead/admin)
|
||||||
|
POST /template-suggestions/{id}/approve — create the template, with optional edits (lead/admin)
|
||||||
|
POST /template-suggestions/{id}/reject — discard the suggestion (lead/admin)
|
||||||
|
|
||||||
|
A red_lead only ever sees/acts on "red" team suggestions, a blue_lead only
|
||||||
|
"blue" — admins can see and act on both.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.database import get_db
|
||||||
|
from app.dependencies.auth import require_any_role_strict
|
||||||
|
from app.domain.errors import EntityNotFoundError, InvalidOperationError
|
||||||
|
from app.domain.unit_of_work import UnitOfWork
|
||||||
|
from app.models.technique import Technique
|
||||||
|
from app.models.template_suggestion import TemplateSuggestion
|
||||||
|
from app.models.user import User
|
||||||
|
from app.schemas.template_suggestion import (
|
||||||
|
TemplateSuggestionApprove,
|
||||||
|
TemplateSuggestionCreate,
|
||||||
|
TemplateSuggestionOut,
|
||||||
|
)
|
||||||
|
from app.services.audit_service import log_action
|
||||||
|
from app.services.template_suggestion_service import (
|
||||||
|
approve_template_suggestion as approve_suggestion_svc,
|
||||||
|
)
|
||||||
|
from app.services.template_suggestion_service import (
|
||||||
|
create_template_suggestion as create_suggestion_svc,
|
||||||
|
)
|
||||||
|
from app.services.template_suggestion_service import (
|
||||||
|
list_pending_template_suggestions,
|
||||||
|
)
|
||||||
|
from app.services.template_suggestion_service import (
|
||||||
|
reject_template_suggestion as reject_suggestion_svc,
|
||||||
|
)
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/template-suggestions", tags=["template-suggestions"])
|
||||||
|
|
||||||
|
|
||||||
|
def _team_for_lead(user: User) -> str | None:
|
||||||
|
"""Return the single team a non-admin lead is scoped to, or None for admin (both)."""
|
||||||
|
if user.role == "red_lead":
|
||||||
|
return "red"
|
||||||
|
if user.role == "blue_lead":
|
||||||
|
return "blue"
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _to_out(suggestion: TemplateSuggestion) -> TemplateSuggestionOut:
|
||||||
|
out = TemplateSuggestionOut.model_validate(suggestion)
|
||||||
|
if suggestion.submitter:
|
||||||
|
out.submitter_name = suggestion.submitter.username or suggestion.submitter.email
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("", response_model=TemplateSuggestionOut, status_code=201)
|
||||||
|
def propose_template(
|
||||||
|
payload: TemplateSuggestionCreate,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: User = Depends(require_any_role_strict("red_tech", "blue_tech")),
|
||||||
|
) -> TemplateSuggestionOut:
|
||||||
|
"""Propose a new test template. Lands in the review queue, not the live catalog."""
|
||||||
|
with UnitOfWork(db) as uow:
|
||||||
|
suggestion = create_suggestion_svc(db, current_user, **payload.model_dump())
|
||||||
|
log_action(
|
||||||
|
db, user_id=current_user.id, action="propose_test_template",
|
||||||
|
entity_type="template_suggestion", entity_id=suggestion.id,
|
||||||
|
details={"name": suggestion.name, "mitre_technique_id": suggestion.mitre_technique_id},
|
||||||
|
)
|
||||||
|
uow.commit()
|
||||||
|
db.refresh(suggestion)
|
||||||
|
return _to_out(suggestion)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("", response_model=list[TemplateSuggestionOut])
|
||||||
|
def list_suggestions(
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: User = Depends(require_any_role_strict("red_lead", "blue_lead", "admin")),
|
||||||
|
) -> list[TemplateSuggestionOut]:
|
||||||
|
"""List pending template suggestions, scoped to the caller's team (admins see both)."""
|
||||||
|
team = _team_for_lead(current_user)
|
||||||
|
suggestions = list_pending_template_suggestions(db, team=team)
|
||||||
|
return [_to_out(s) for s in suggestions]
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{suggestion_id}/approve", response_model=TemplateSuggestionOut)
|
||||||
|
def approve_suggestion(
|
||||||
|
suggestion_id: uuid.UUID,
|
||||||
|
payload: TemplateSuggestionApprove | None = None,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: User = Depends(require_any_role_strict("red_lead", "blue_lead", "admin")),
|
||||||
|
) -> TemplateSuggestionOut:
|
||||||
|
"""Approve a suggestion, optionally editing fields, creating the real template."""
|
||||||
|
_check_team_permission(current_user, _team_or_404(db, suggestion_id))
|
||||||
|
overrides = payload.model_dump(exclude_unset=True) if payload else {}
|
||||||
|
try:
|
||||||
|
with UnitOfWork(db) as uow:
|
||||||
|
suggestion, template = approve_suggestion_svc(db, suggestion_id, current_user, overrides)
|
||||||
|
if template.mitre_technique_id:
|
||||||
|
technique = (
|
||||||
|
db.query(Technique)
|
||||||
|
.filter(Technique.mitre_id == template.mitre_technique_id)
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
if technique:
|
||||||
|
technique.review_required = True
|
||||||
|
log_action(
|
||||||
|
db, user_id=current_user.id, action="approve_template_suggestion",
|
||||||
|
entity_type="template_suggestion", entity_id=suggestion.id,
|
||||||
|
details={"created_template_id": str(template.id), "team": suggestion.team},
|
||||||
|
)
|
||||||
|
uow.commit()
|
||||||
|
except EntityNotFoundError as e:
|
||||||
|
raise HTTPException(status_code=404, detail=str(e)) from e
|
||||||
|
except InvalidOperationError as e:
|
||||||
|
raise HTTPException(status_code=400, detail=str(e)) from e
|
||||||
|
db.refresh(suggestion)
|
||||||
|
return _to_out(suggestion)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{suggestion_id}/reject", response_model=TemplateSuggestionOut)
|
||||||
|
def reject_suggestion(
|
||||||
|
suggestion_id: uuid.UUID,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: User = Depends(require_any_role_strict("red_lead", "blue_lead", "admin")),
|
||||||
|
) -> TemplateSuggestionOut:
|
||||||
|
"""Discard a suggestion without creating a template."""
|
||||||
|
_check_team_permission(current_user, _team_or_404(db, suggestion_id))
|
||||||
|
try:
|
||||||
|
with UnitOfWork(db) as uow:
|
||||||
|
suggestion = reject_suggestion_svc(db, suggestion_id, current_user)
|
||||||
|
log_action(
|
||||||
|
db, user_id=current_user.id, action="reject_template_suggestion",
|
||||||
|
entity_type="template_suggestion", entity_id=suggestion.id,
|
||||||
|
details={"team": suggestion.team},
|
||||||
|
)
|
||||||
|
uow.commit()
|
||||||
|
except EntityNotFoundError as e:
|
||||||
|
raise HTTPException(status_code=404, detail=str(e)) from e
|
||||||
|
except InvalidOperationError as e:
|
||||||
|
raise HTTPException(status_code=400, detail=str(e)) from e
|
||||||
|
db.refresh(suggestion)
|
||||||
|
return _to_out(suggestion)
|
||||||
|
|
||||||
|
|
||||||
|
def _team_or_404(db: Session, suggestion_id: uuid.UUID) -> str:
|
||||||
|
"""Look up just the team of a suggestion, for a permission check before mutating it."""
|
||||||
|
suggestion = db.query(TemplateSuggestion).filter(TemplateSuggestion.id == suggestion_id).first()
|
||||||
|
if suggestion is None:
|
||||||
|
raise HTTPException(status_code=404, detail="Template suggestion not found")
|
||||||
|
return suggestion.team
|
||||||
|
|
||||||
|
|
||||||
|
def _check_team_permission(user: User, team: str) -> None:
|
||||||
|
"""Raise 403 if a non-admin lead tries to act on the other team's suggestion."""
|
||||||
|
scoped_team = _team_for_lead(user)
|
||||||
|
if scoped_team is not None and scoped_team != team:
|
||||||
|
raise HTTPException(status_code=403, detail=f"Not authorized to review {team} team suggestions")
|
||||||
@@ -37,8 +37,8 @@ from sqlalchemy.orm import Session
|
|||||||
# Import get_db from app.database
|
# Import get_db from app.database
|
||||||
from app.database import get_db
|
from app.database import get_db
|
||||||
|
|
||||||
# Import get_current_user, require_any_role from app.dependencies.auth
|
# Import get_current_user, require_any_role_strict from app.dependencies.auth
|
||||||
from app.dependencies.auth import get_current_user, require_any_role
|
from app.dependencies.auth import get_current_user, require_any_role_strict
|
||||||
|
|
||||||
# Import UnitOfWork from app.domain.unit_of_work
|
# Import UnitOfWork from app.domain.unit_of_work
|
||||||
from app.domain.unit_of_work import UnitOfWork
|
from app.domain.unit_of_work import UnitOfWork
|
||||||
@@ -58,6 +58,7 @@ from app.services.audit_service import log_action
|
|||||||
# Import from app.services.test_template_service
|
# Import from app.services.test_template_service
|
||||||
from app.services.test_template_service import (
|
from app.services.test_template_service import (
|
||||||
bulk_activate,
|
bulk_activate,
|
||||||
|
count_templates,
|
||||||
get_template_or_raise,
|
get_template_or_raise,
|
||||||
get_template_stats,
|
get_template_stats,
|
||||||
list_templates,
|
list_templates,
|
||||||
@@ -156,6 +157,35 @@ def _list_templates_handler(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# GET /test-templates/count — total matching a filter, for pagination
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/count")
|
||||||
|
def _count_templates_handler(
|
||||||
|
source: Optional[str] = Query(None),
|
||||||
|
platform: Optional[str] = Query(None),
|
||||||
|
severity: Optional[str] = Query(None),
|
||||||
|
mitre_technique_id: Optional[str] = Query(None),
|
||||||
|
search: Optional[str] = Query(None),
|
||||||
|
is_active: Optional[bool] = Query(None),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
) -> dict:
|
||||||
|
"""Return the total count of templates matching the same filters as the
|
||||||
|
list endpoint, so the catalog UI can show a true page count."""
|
||||||
|
return {"total": count_templates(
|
||||||
|
db,
|
||||||
|
source=source,
|
||||||
|
platform=platform,
|
||||||
|
severity=severity,
|
||||||
|
mitre_technique_id=mitre_technique_id,
|
||||||
|
search=search,
|
||||||
|
is_active=is_active,
|
||||||
|
)}
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# GET /test-templates/stats — catalog statistics (admin)
|
# GET /test-templates/stats — catalog statistics (admin)
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -167,7 +197,7 @@ def template_stats(
|
|||||||
# Entry: db
|
# Entry: db
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
# Entry: current_user
|
# Entry: current_user
|
||||||
current_user: User = Depends(require_any_role("red_lead", "blue_lead")),
|
current_user: User = Depends(require_any_role_strict("red_lead", "blue_lead")),
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""Return catalog statistics: active, by_source, by_platform.
|
"""Return catalog statistics: active, by_source, by_platform.
|
||||||
|
|
||||||
@@ -195,7 +225,7 @@ def bulk_activate_templates(
|
|||||||
# Entry: db
|
# Entry: db
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
# Entry: current_user
|
# Entry: current_user
|
||||||
current_user: User = Depends(require_any_role("red_lead", "blue_lead")),
|
current_user: User = Depends(require_any_role_strict("red_lead", "blue_lead")),
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""Set all templates to active or inactive.
|
"""Set all templates to active or inactive.
|
||||||
|
|
||||||
@@ -317,7 +347,7 @@ def create_template(
|
|||||||
# Entry: db
|
# Entry: db
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
# Entry: current_user
|
# Entry: current_user
|
||||||
current_user: User = Depends(require_any_role("red_lead", "blue_lead")),
|
current_user: User = Depends(require_any_role_strict("red_lead", "blue_lead")),
|
||||||
) -> TestTemplateOut:
|
) -> TestTemplateOut:
|
||||||
"""Create a custom test template.
|
"""Create a custom test template.
|
||||||
|
|
||||||
@@ -386,7 +416,7 @@ def update_template(
|
|||||||
# Entry: db
|
# Entry: db
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
# Entry: current_user
|
# Entry: current_user
|
||||||
current_user: User = Depends(require_any_role("red_lead", "blue_lead")),
|
current_user: User = Depends(require_any_role_strict("red_lead", "blue_lead")),
|
||||||
) -> TestTemplateOut:
|
) -> TestTemplateOut:
|
||||||
"""Update fields of an existing test template.
|
"""Update fields of an existing test template.
|
||||||
|
|
||||||
@@ -439,7 +469,7 @@ def toggle_template_active(
|
|||||||
# Entry: db
|
# Entry: db
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
# Entry: current_user
|
# Entry: current_user
|
||||||
current_user: User = Depends(require_any_role("red_lead", "blue_lead")),
|
current_user: User = Depends(require_any_role_strict("red_lead", "blue_lead")),
|
||||||
) -> TestTemplateOut:
|
) -> TestTemplateOut:
|
||||||
"""Toggle a template between active and inactive (is_active = not is_active).
|
"""Toggle a template between active and inactive (is_active = not is_active).
|
||||||
|
|
||||||
@@ -491,7 +521,7 @@ def delete_template(
|
|||||||
# Entry: db
|
# Entry: db
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
# Entry: current_user
|
# Entry: current_user
|
||||||
current_user: User = Depends(require_any_role("red_lead", "blue_lead")),
|
current_user: User = Depends(require_any_role_strict("red_lead", "blue_lead")),
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""Soft-delete a test template by setting ``is_active=False``.
|
"""Soft-delete a test template by setting ``is_active=False``.
|
||||||
|
|
||||||
|
|||||||
+395
-62
@@ -10,13 +10,20 @@ PATCH /tests/{id} — general update (draft/rejected only)
|
|||||||
PATCH /tests/{id}/red — Red Team updates (draft, red_executing)
|
PATCH /tests/{id}/red — Red Team updates (draft, red_executing)
|
||||||
PATCH /tests/{id}/blue — Blue Team updates (blue_evaluating)
|
PATCH /tests/{id}/blue — Blue Team updates (blue_evaluating)
|
||||||
POST /tests/{id}/start-execution — draft → red_executing
|
POST /tests/{id}/start-execution — draft → red_executing
|
||||||
POST /tests/{id}/submit-red — red_executing → blue_evaluating
|
POST /tests/{id}/submit-red — red_executing → red_review
|
||||||
|
POST /tests/{id}/review-red — assigned Red Lead approves/reopens
|
||||||
POST /tests/{id}/start-blue-work — blue tech picks up (sets Tempo timer)
|
POST /tests/{id}/start-blue-work — blue tech picks up (sets Tempo timer)
|
||||||
POST /tests/{id}/submit-blue — blue_evaluating → in_review
|
POST /tests/{id}/submit-blue — blue_evaluating → blue_review
|
||||||
|
POST /tests/{id}/review-blue — assigned Blue Lead approves/reopens/flags gap
|
||||||
POST /tests/{id}/validate-red — Red Lead validates
|
POST /tests/{id}/validate-red — Red Lead validates
|
||||||
POST /tests/{id}/validate-blue — Blue Lead validates
|
POST /tests/{id}/validate-blue — Blue Lead validates
|
||||||
|
POST /tests/{id}/resolve-dispute — approver flips to reject, routes to red/blue queue
|
||||||
POST /tests/{id}/reopen — rejected → draft
|
POST /tests/{id}/reopen — rejected → draft
|
||||||
GET /tests/{id}/timeline — audit-log history for this test
|
GET /tests/{id}/timeline — audit-log history for this test
|
||||||
|
|
||||||
|
GET /tests/{id} hides the other team's fields while a test is blind
|
||||||
|
(draft through blue_review) for red_tech/red_lead/blue_tech/blue_lead
|
||||||
|
viewers — admin and viewer always see everything.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import base64
|
import base64
|
||||||
@@ -34,15 +41,15 @@ from sqlalchemy.orm import Session
|
|||||||
# Import get_db from app.database
|
# Import get_db from app.database
|
||||||
from app.database import get_db
|
from app.database import get_db
|
||||||
|
|
||||||
# Import get_current_user, require_any_role, require_role from app.dependencies.auth
|
# Import get_current_user, require_any_role from app.dependencies.auth
|
||||||
from app.dependencies.auth import get_current_user, require_any_role, require_role
|
from app.dependencies.auth import get_current_user, require_any_role_strict
|
||||||
|
|
||||||
# Import UnitOfWork from app.domain.unit_of_work
|
# Import UnitOfWork from app.domain.unit_of_work
|
||||||
from app.domain.unit_of_work import UnitOfWork
|
from app.domain.unit_of_work import UnitOfWork
|
||||||
|
|
||||||
# Import limiter from app.limiter
|
# Import limiter from app.limiter
|
||||||
from app.limiter import limiter
|
from app.limiter import limiter
|
||||||
from app.models.enums import TestState, TestResult, TeamSide
|
from app.models.enums import AttackSuccessResult, TestState, TestResult, TeamSide
|
||||||
from app.models.evidence import Evidence
|
from app.models.evidence import Evidence
|
||||||
from app.storage import upload_file
|
from app.storage import upload_file
|
||||||
from app.models.technique import Technique
|
from app.models.technique import Technique
|
||||||
@@ -52,15 +59,19 @@ from app.models.user import User
|
|||||||
# Import from app.schemas.test
|
# Import from app.schemas.test
|
||||||
from app.schemas.test import (
|
from app.schemas.test import (
|
||||||
TestAssign,
|
TestAssign,
|
||||||
|
TestBlueReview,
|
||||||
TestBlueUpdate,
|
TestBlueUpdate,
|
||||||
TestBlueValidate,
|
TestBlueValidate,
|
||||||
TestClassificationUpdate,
|
TestClassificationUpdate,
|
||||||
TestCreate,
|
TestCreate,
|
||||||
TestHold,
|
TestHold,
|
||||||
|
TestManagerResolveDispute,
|
||||||
TestOut,
|
TestOut,
|
||||||
|
TestRedReview,
|
||||||
TestRedUpdate,
|
TestRedUpdate,
|
||||||
TestRedValidate,
|
TestRedValidate,
|
||||||
TestRemediationUpdate,
|
TestRemediationUpdate,
|
||||||
|
TestResolveDispute,
|
||||||
TestUpdate,
|
TestUpdate,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -76,12 +87,18 @@ from app.services.webhook_service import dispatch_webhook
|
|||||||
from app.services.test_crud_service import (
|
from app.services.test_crud_service import (
|
||||||
create_test as crud_create_test,
|
create_test as crud_create_test,
|
||||||
)
|
)
|
||||||
|
from app.services.test_crud_service import determine_initial_classification
|
||||||
|
|
||||||
# Import from app.services.test_crud_service
|
# Import from app.services.test_crud_service
|
||||||
from app.services.test_crud_service import (
|
from app.services.test_crud_service import (
|
||||||
create_test_from_template as crud_create_from_template,
|
create_test_from_template as crud_create_from_template,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Import from app.services.test_crud_service
|
||||||
|
from app.services.test_crud_service import (
|
||||||
|
delete_test as crud_delete_test,
|
||||||
|
)
|
||||||
|
|
||||||
# Import from app.services.test_crud_service
|
# Import from app.services.test_crud_service
|
||||||
from app.services.test_crud_service import (
|
from app.services.test_crud_service import (
|
||||||
get_test_detail as crud_get_test_detail,
|
get_test_detail as crud_get_test_detail,
|
||||||
@@ -107,6 +124,11 @@ from app.services.test_crud_service import (
|
|||||||
list_tests as crud_list_tests,
|
list_tests as crud_list_tests,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Import from app.services.test_crud_service
|
||||||
|
from app.services.test_crud_service import (
|
||||||
|
count_tests as crud_count_tests,
|
||||||
|
)
|
||||||
|
|
||||||
# Import from app.services.test_crud_service
|
# Import from app.services.test_crud_service
|
||||||
from app.services.test_crud_service import (
|
from app.services.test_crud_service import (
|
||||||
update_test as crud_update_test,
|
update_test as crud_update_test,
|
||||||
@@ -126,10 +148,17 @@ from app.services.test_crud_service import (
|
|||||||
from app.services.test_workflow_service import (
|
from app.services.test_workflow_service import (
|
||||||
start_execution as wf_start_execution,
|
start_execution as wf_start_execution,
|
||||||
submit_red_evidence as wf_submit_red,
|
submit_red_evidence as wf_submit_red,
|
||||||
|
approve_red_review as wf_approve_red_review,
|
||||||
|
reopen_red_review as wf_reopen_red_review,
|
||||||
submit_blue_evidence as wf_submit_blue,
|
submit_blue_evidence as wf_submit_blue,
|
||||||
|
approve_blue_review as wf_approve_blue_review,
|
||||||
|
reopen_blue_review as wf_reopen_blue_review,
|
||||||
|
flag_blue_review_gap as wf_flag_blue_review_gap,
|
||||||
start_blue_work as wf_start_blue_work,
|
start_blue_work as wf_start_blue_work,
|
||||||
validate_as_red_lead as wf_validate_red,
|
validate_as_red_lead as wf_validate_red,
|
||||||
validate_as_blue_lead as wf_validate_blue,
|
validate_as_blue_lead as wf_validate_blue,
|
||||||
|
resolve_dispute as wf_resolve_dispute,
|
||||||
|
resolve_dispute_by_manager as wf_resolve_dispute_by_manager,
|
||||||
reopen_test as wf_reopen,
|
reopen_test as wf_reopen,
|
||||||
handle_remediation_completed as wf_handle_remediation,
|
handle_remediation_completed as wf_handle_remediation,
|
||||||
get_retest_chain as wf_get_retest_chain,
|
get_retest_chain as wf_get_retest_chain,
|
||||||
@@ -146,13 +175,14 @@ router = APIRouter(prefix="/tests", tags=["tests"])
|
|||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
@router.get("", response_model=list[TestOut])
|
def _test_list_filter_params(
|
||||||
# Define function list_tests
|
|
||||||
def list_tests(
|
|
||||||
# Entry: state
|
# Entry: state
|
||||||
state: Optional[str] = Query(None, description="Filter by test state"),
|
state: Optional[str] = Query(None, description="Filter by test state"),
|
||||||
# Entry: technique_id
|
# Entry: technique_id
|
||||||
technique_id: Optional[uuid.UUID] = Query(None, description="Filter by technique"),
|
technique_id: Optional[uuid.UUID] = Query(None, description="Filter by technique"),
|
||||||
|
technique_search: Optional[str] = Query(
|
||||||
|
None, description="Free-text filter on technique MITRE ID or name"
|
||||||
|
),
|
||||||
# Entry: platform
|
# Entry: platform
|
||||||
platform: Optional[str] = Query(None, description="Filter by platform"),
|
platform: Optional[str] = Query(None, description="Filter by platform"),
|
||||||
# Entry: created_by
|
# Entry: created_by
|
||||||
@@ -161,9 +191,59 @@ def list_tests(
|
|||||||
pending_validation_side: Optional[str] = Query(
|
pending_validation_side: Optional[str] = Query(
|
||||||
None, description="Filter in_review tests pending validation on 'red' or 'blue' side"
|
None, description="Filter in_review tests pending validation on 'red' or 'blue' side"
|
||||||
),
|
),
|
||||||
|
reviewer_id: Optional[uuid.UUID] = Query(
|
||||||
|
None,
|
||||||
|
description="\"My reviews\" queue — filter red_review/blue_review tests assigned to this reviewer",
|
||||||
|
),
|
||||||
not_in_any_campaign: bool = Query(
|
not_in_any_campaign: bool = Query(
|
||||||
False, description="Only return tests not linked to any campaign"
|
False, description="Only return tests not linked to any campaign"
|
||||||
),
|
),
|
||||||
|
attack_success: Optional[str] = Query(None, description="Filter by attack success outcome"),
|
||||||
|
detection_result: Optional[str] = Query(None, description="Filter by detection outcome"),
|
||||||
|
validated_from: Optional[datetime] = Query(
|
||||||
|
None, description="Only tests validated on/after this date"
|
||||||
|
),
|
||||||
|
validated_to: Optional[datetime] = Query(
|
||||||
|
None, description="Only tests validated on/before this date"
|
||||||
|
),
|
||||||
|
) -> dict:
|
||||||
|
"""Shared filter params for GET /tests and GET /tests/count, so the two
|
||||||
|
endpoints can never silently drift out of sync with each other."""
|
||||||
|
return {
|
||||||
|
"state": state,
|
||||||
|
"technique_id": technique_id,
|
||||||
|
"technique_search": technique_search,
|
||||||
|
"platform": platform,
|
||||||
|
"created_by": created_by,
|
||||||
|
"pending_validation_side": pending_validation_side,
|
||||||
|
"reviewer_id": reviewer_id,
|
||||||
|
"not_in_any_campaign": not_in_any_campaign,
|
||||||
|
"attack_success": attack_success,
|
||||||
|
"detection_result": detection_result,
|
||||||
|
"validated_from": validated_from,
|
||||||
|
"validated_to": validated_to,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/count")
|
||||||
|
def count_tests(
|
||||||
|
filters: dict = Depends(_test_list_filter_params),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
) -> dict:
|
||||||
|
"""Return the true total of tests matching the given filters.
|
||||||
|
|
||||||
|
GET /tests caps `limit` at 200, so pages that show "N results" need
|
||||||
|
this to report the real total rather than len(page) — which silently
|
||||||
|
under-reports whenever there are more matches than the page size.
|
||||||
|
"""
|
||||||
|
return {"total": crud_count_tests(db, **filters)}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("", response_model=list[TestOut])
|
||||||
|
# Define function list_tests
|
||||||
|
def list_tests(
|
||||||
|
filters: dict = Depends(_test_list_filter_params),
|
||||||
offset: int = Query(0, ge=0),
|
offset: int = Query(0, ge=0),
|
||||||
# Entry: limit
|
# Entry: limit
|
||||||
limit: int = Query(50, ge=1, le=200),
|
limit: int = Query(50, ge=1, le=200),
|
||||||
@@ -175,12 +255,7 @@ def list_tests(
|
|||||||
"""Return a paginated list of tests, optionally filtered by state, technique, platform or creator.
|
"""Return a paginated list of tests, optionally filtered by state, technique, platform or creator.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
state (Optional[str]): Filter by test state (e.g. ``draft``, ``validated``).
|
filters (dict): Shared filter params — see ``_test_list_filter_params``.
|
||||||
technique_id (Optional[uuid.UUID]): Filter tests belonging to a specific technique.
|
|
||||||
platform (Optional[str]): Filter by target platform (e.g. ``windows``, ``linux``).
|
|
||||||
created_by (Optional[uuid.UUID]): Filter by the UUID of the creator.
|
|
||||||
pending_validation_side (Optional[str]): Filter ``in_review`` tests pending validation
|
|
||||||
on ``'red'`` or ``'blue'`` side.
|
|
||||||
offset (int): Number of records to skip for pagination.
|
offset (int): Number of records to skip for pagination.
|
||||||
limit (int): Maximum number of records to return.
|
limit (int): Maximum number of records to return.
|
||||||
db (Session): SQLAlchemy database session.
|
db (Session): SQLAlchemy database session.
|
||||||
@@ -192,17 +267,7 @@ def list_tests(
|
|||||||
# Return crud_list_tests(
|
# Return crud_list_tests(
|
||||||
return crud_list_tests(
|
return crud_list_tests(
|
||||||
db,
|
db,
|
||||||
# Keyword argument: state
|
**filters,
|
||||||
state=state,
|
|
||||||
# Keyword argument: technique_id
|
|
||||||
technique_id=technique_id,
|
|
||||||
# Keyword argument: platform
|
|
||||||
platform=platform,
|
|
||||||
# Keyword argument: created_by
|
|
||||||
created_by=created_by,
|
|
||||||
# Keyword argument: pending_validation_side
|
|
||||||
pending_validation_side=pending_validation_side,
|
|
||||||
not_in_any_campaign=not_in_any_campaign,
|
|
||||||
offset=offset,
|
offset=offset,
|
||||||
# Keyword argument: limit
|
# Keyword argument: limit
|
||||||
limit=limit,
|
limit=limit,
|
||||||
@@ -233,7 +298,7 @@ def create_test(
|
|||||||
# Entry: db
|
# Entry: db
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
# Entry: current_user
|
# Entry: current_user
|
||||||
current_user: User = Depends(require_any_role("red_lead", "blue_lead")),
|
current_user: User = Depends(require_any_role_strict("red_lead", "blue_lead")),
|
||||||
) -> TestOut:
|
) -> TestOut:
|
||||||
"""Create a new test linked to an existing technique.
|
"""Create a new test linked to an existing technique.
|
||||||
|
|
||||||
@@ -313,7 +378,7 @@ def create_test_from_template(
|
|||||||
# Entry: db
|
# Entry: db
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
# Entry: current_user
|
# Entry: current_user
|
||||||
current_user: User = Depends(require_any_role("red_lead", "blue_lead")),
|
current_user: User = Depends(require_any_role_strict("red_lead", "blue_lead", "manager")),
|
||||||
) -> TestOut:
|
) -> TestOut:
|
||||||
"""Instantiate a real Test from an existing TestTemplate.
|
"""Instantiate a real Test from an existing TestTemplate.
|
||||||
|
|
||||||
@@ -344,6 +409,7 @@ def create_test_from_template(
|
|||||||
platform_override=payload.platform,
|
platform_override=payload.platform,
|
||||||
procedure_text_override=payload.procedure_text,
|
procedure_text_override=payload.procedure_text,
|
||||||
tool_used_override=payload.tool_used,
|
tool_used_override=payload.tool_used,
|
||||||
|
detect_procedure_override=payload.detect_procedure,
|
||||||
)
|
)
|
||||||
# Call log_action()
|
# Call log_action()
|
||||||
log_action(
|
log_action(
|
||||||
@@ -406,9 +472,11 @@ def get_test(
|
|||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
TestOut: Full test detail including split red/blue evidence lists.
|
TestOut: Full test detail including split red/blue evidence lists.
|
||||||
|
Both teams see each other's fields regardless of state — Red
|
||||||
|
and Blue are meant to see each other's work, not test blind.
|
||||||
"""
|
"""
|
||||||
# Return crud_get_test_detail(db, test_id)
|
test = crud_get_test_detail(db, test_id)
|
||||||
return crud_get_test_detail(db, test_id)
|
return TestOut.model_validate(test)
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -426,11 +494,12 @@ def update_test(
|
|||||||
# Entry: db
|
# Entry: db
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
# Entry: current_user
|
# Entry: current_user
|
||||||
current_user: User = Depends(require_any_role("red_lead", "blue_lead")),
|
current_user: User = Depends(require_any_role_strict("red_lead", "blue_lead")),
|
||||||
) -> TestOut:
|
) -> TestOut:
|
||||||
"""Update one or more fields of an existing test.
|
"""Update one or more fields of an existing test.
|
||||||
|
|
||||||
Only leads or admins can update general test fields.
|
Only leads can update general test fields — admin administers the
|
||||||
|
site, not test content.
|
||||||
The test must be in ``draft`` or ``rejected`` state.
|
The test must be in ``draft`` or ``rejected`` state.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
@@ -479,6 +548,38 @@ def update_test(
|
|||||||
return test
|
return test
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/{test_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||||
|
def delete_test(
|
||||||
|
test_id: uuid.UUID,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
# manager-only, and admin does NOT get a free pass — this is a
|
||||||
|
# queue-cleanup action for the manager role specifically, not a site
|
||||||
|
# administration task.
|
||||||
|
current_user: User = Depends(require_any_role_strict("manager")),
|
||||||
|
) -> None:
|
||||||
|
"""Delete a standalone test that hasn't started yet.
|
||||||
|
|
||||||
|
Only tests still in ``draft`` state and not linked to any campaign can
|
||||||
|
be deleted this way.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
test_id (uuid.UUID): Primary key of the test to delete.
|
||||||
|
db (Session): SQLAlchemy database session.
|
||||||
|
current_user (User): Authenticated manager performing the deletion.
|
||||||
|
"""
|
||||||
|
with UnitOfWork(db) as uow:
|
||||||
|
crud_delete_test(db, test_id)
|
||||||
|
log_action(
|
||||||
|
db,
|
||||||
|
user_id=current_user.id,
|
||||||
|
action="delete_test",
|
||||||
|
entity_type="test",
|
||||||
|
entity_id=test_id,
|
||||||
|
details={},
|
||||||
|
)
|
||||||
|
uow.commit()
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# PATCH /tests/{id}/classification — admin data classification
|
# PATCH /tests/{id}/classification — admin data classification
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -494,9 +595,14 @@ def update_test_classification(
|
|||||||
# Entry: db
|
# Entry: db
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
# Entry: current_user
|
# Entry: current_user
|
||||||
current_user: User = Depends(require_role("admin")),
|
current_user: User = Depends(require_any_role_strict(
|
||||||
|
"manager", "red_tech", "red_lead", "blue_tech", "blue_lead",
|
||||||
|
)),
|
||||||
) -> TestOut:
|
) -> TestOut:
|
||||||
"""Update the data classification label for a test (admin only).
|
"""Update the data classification label for a test.
|
||||||
|
|
||||||
|
The initial classification is a best-effort default based on the
|
||||||
|
technique's tactic — any test participant or admin can correct it.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
test_id (uuid.UUID): Primary key of the test to classify.
|
test_id (uuid.UUID): Primary key of the test to classify.
|
||||||
@@ -552,7 +658,7 @@ def update_test_red(
|
|||||||
# Entry: db
|
# Entry: db
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
# Entry: current_user
|
# Entry: current_user
|
||||||
current_user: User = Depends(require_any_role("red_tech", "red_lead")),
|
current_user: User = Depends(require_any_role_strict("red_tech", "red_lead")),
|
||||||
) -> TestOut:
|
) -> TestOut:
|
||||||
"""Red Team updates their fields (allowed in ``draft`` and ``red_executing``).
|
"""Red Team updates their fields (allowed in ``draft`` and ``red_executing``).
|
||||||
|
|
||||||
@@ -565,11 +671,13 @@ def update_test_red(
|
|||||||
Returns:
|
Returns:
|
||||||
TestOut: The updated test with refreshed red-team field values.
|
TestOut: The updated test with refreshed red-team field values.
|
||||||
"""
|
"""
|
||||||
# Assignee lock: red_tech cannot work a test assigned to someone else
|
# Assignee lock: only the operator actually assigned to this test may
|
||||||
|
# edit it — applies to red_lead too, not just red_tech, since a lead
|
||||||
|
# editing another operator's in-progress test is exactly as wrong as
|
||||||
|
# a different tech doing it.
|
||||||
_pre_test = crud_get_test_or_raise(db, test_id)
|
_pre_test = crud_get_test_or_raise(db, test_id)
|
||||||
if (
|
if (
|
||||||
_pre_test.red_tech_assignee is not None
|
_pre_test.red_tech_assignee is not None
|
||||||
and current_user.role == "red_tech"
|
|
||||||
and _pre_test.red_tech_assignee != current_user.id
|
and _pre_test.red_tech_assignee != current_user.id
|
||||||
):
|
):
|
||||||
raise HTTPException(status_code=403, detail="Test is assigned to another operator")
|
raise HTTPException(status_code=403, detail="Test is assigned to another operator")
|
||||||
@@ -618,7 +726,7 @@ def update_test_blue(
|
|||||||
# Entry: db
|
# Entry: db
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
# Entry: current_user
|
# Entry: current_user
|
||||||
current_user: User = Depends(require_any_role("blue_tech", "blue_lead")),
|
current_user: User = Depends(require_any_role_strict("blue_tech", "blue_lead")),
|
||||||
) -> TestOut:
|
) -> TestOut:
|
||||||
"""Blue Team updates their fields (allowed only in ``blue_evaluating``).
|
"""Blue Team updates their fields (allowed only in ``blue_evaluating``).
|
||||||
|
|
||||||
@@ -631,11 +739,11 @@ def update_test_blue(
|
|||||||
Returns:
|
Returns:
|
||||||
TestOut: The updated test with refreshed blue-team field values.
|
TestOut: The updated test with refreshed blue-team field values.
|
||||||
"""
|
"""
|
||||||
# Assignee lock: blue_tech cannot work a test assigned to someone else
|
# Assignee lock: only the operator actually assigned to this test may
|
||||||
|
# edit it — applies to blue_lead too, not just blue_tech.
|
||||||
_pre_test = crud_get_test_or_raise(db, test_id)
|
_pre_test = crud_get_test_or_raise(db, test_id)
|
||||||
if (
|
if (
|
||||||
_pre_test.blue_tech_assignee is not None
|
_pre_test.blue_tech_assignee is not None
|
||||||
and current_user.role == "blue_tech"
|
|
||||||
and _pre_test.blue_tech_assignee != current_user.id
|
and _pre_test.blue_tech_assignee != current_user.id
|
||||||
):
|
):
|
||||||
raise HTTPException(status_code=403, detail="Test is assigned to another operator")
|
raise HTTPException(status_code=403, detail="Test is assigned to another operator")
|
||||||
@@ -682,7 +790,7 @@ def start_execution(
|
|||||||
# Entry: db
|
# Entry: db
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
# Entry: current_user
|
# Entry: current_user
|
||||||
current_user: User = Depends(require_any_role("red_tech", "red_lead")),
|
current_user: User = Depends(require_any_role_strict("red_tech", "red_lead")),
|
||||||
) -> TestOut:
|
) -> TestOut:
|
||||||
"""Move a test from ``draft`` to ``red_executing``.
|
"""Move a test from ``draft`` to ``red_executing``.
|
||||||
|
|
||||||
@@ -733,7 +841,7 @@ def submit_red(
|
|||||||
# Entry: db
|
# Entry: db
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
# Entry: current_user
|
# Entry: current_user
|
||||||
current_user: User = Depends(require_any_role("red_tech", "red_lead")),
|
current_user: User = Depends(require_any_role_strict("red_tech", "red_lead")),
|
||||||
) -> TestOut:
|
) -> TestOut:
|
||||||
"""Red Team finalises — move from ``red_executing`` to ``blue_evaluating``.
|
"""Red Team finalises — move from ``red_executing`` to ``blue_evaluating``.
|
||||||
|
|
||||||
@@ -781,7 +889,7 @@ def submit_blue(
|
|||||||
# Entry: db
|
# Entry: db
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
# Entry: current_user
|
# Entry: current_user
|
||||||
current_user: User = Depends(require_any_role("blue_tech", "blue_lead")),
|
current_user: User = Depends(require_any_role_strict("blue_tech", "blue_lead")),
|
||||||
) -> TestOut:
|
) -> TestOut:
|
||||||
"""Blue Team finalises — move from ``blue_evaluating`` to ``in_review``.
|
"""Blue Team finalises — move from ``blue_evaluating`` to ``in_review``.
|
||||||
|
|
||||||
@@ -825,7 +933,7 @@ def submit_blue(
|
|||||||
def start_blue_work(
|
def start_blue_work(
|
||||||
test_id: uuid.UUID,
|
test_id: uuid.UUID,
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
current_user: User = Depends(require_any_role("blue_tech", "blue_lead")),
|
current_user: User = Depends(require_any_role_strict("blue_tech", "blue_lead")),
|
||||||
):
|
):
|
||||||
"""Blue tech picks up the test to start evaluating. Sets the Tempo timer start."""
|
"""Blue tech picks up the test to start evaluating. Sets the Tempo timer start."""
|
||||||
test = crud_get_test_or_raise(db, test_id)
|
test = crud_get_test_or_raise(db, test_id)
|
||||||
@@ -848,6 +956,68 @@ def start_blue_work(
|
|||||||
return test
|
return test
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# POST /tests/{id}/review-red — Red Lead reviews the operator's submission
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{test_id}/review-red", response_model=TestOut)
|
||||||
|
def review_red(
|
||||||
|
test_id: uuid.UUID,
|
||||||
|
payload: TestRedReview,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: User = Depends(require_any_role_strict("red_lead")),
|
||||||
|
) -> TestOut:
|
||||||
|
"""Assigned Red Lead approves or reopens a test sitting in red_review."""
|
||||||
|
test = crud_get_test_or_raise(db, test_id)
|
||||||
|
|
||||||
|
if test.red_reviewer_assignee != current_user.id:
|
||||||
|
raise HTTPException(status_code=403, detail="You are not the assigned reviewer for this test")
|
||||||
|
|
||||||
|
with UnitOfWork(db) as uow:
|
||||||
|
if payload.decision == "approve":
|
||||||
|
test = wf_approve_red_review(db, test, current_user, notes=payload.notes)
|
||||||
|
elif payload.decision == "reopen":
|
||||||
|
test = wf_reopen_red_review(db, test, current_user, notes=payload.notes or "")
|
||||||
|
else:
|
||||||
|
raise HTTPException(status_code=400, detail="decision must be 'approve' or 'reopen'")
|
||||||
|
uow.commit()
|
||||||
|
db.refresh(test)
|
||||||
|
return test
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# POST /tests/{id}/review-blue — Blue Lead reviews the operator's submission
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{test_id}/review-blue", response_model=TestOut)
|
||||||
|
def review_blue(
|
||||||
|
test_id: uuid.UUID,
|
||||||
|
payload: TestBlueReview,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: User = Depends(require_any_role_strict("blue_lead")),
|
||||||
|
) -> TestOut:
|
||||||
|
"""Assigned Blue Lead approves, reopens, or flags a capability gap on a test in blue_review."""
|
||||||
|
test = crud_get_test_or_raise(db, test_id)
|
||||||
|
|
||||||
|
if test.blue_reviewer_assignee != current_user.id:
|
||||||
|
raise HTTPException(status_code=403, detail="You are not the assigned reviewer for this test")
|
||||||
|
|
||||||
|
with UnitOfWork(db) as uow:
|
||||||
|
if payload.decision == "approve":
|
||||||
|
test = wf_approve_blue_review(db, test, current_user, notes=payload.notes)
|
||||||
|
elif payload.decision == "reopen":
|
||||||
|
test = wf_reopen_blue_review(db, test, current_user, notes=payload.notes or "")
|
||||||
|
elif payload.decision == "gap":
|
||||||
|
test = wf_flag_blue_review_gap(db, test, current_user, system_gaps=payload.system_gaps or "", notes=payload.notes)
|
||||||
|
else:
|
||||||
|
raise HTTPException(status_code=400, detail="decision must be 'approve', 'reopen', or 'gap'")
|
||||||
|
uow.commit()
|
||||||
|
db.refresh(test)
|
||||||
|
return test
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# POST /tests/{id}/pause-timer — pause the active phase timer
|
# POST /tests/{id}/pause-timer — pause the active phase timer
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -861,7 +1031,7 @@ def pause_timer(
|
|||||||
# Entry: db
|
# Entry: db
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
# Entry: current_user
|
# Entry: current_user
|
||||||
current_user: User = Depends(require_any_role("red_tech", "blue_tech", "red_lead", "blue_lead")),
|
current_user: User = Depends(require_any_role_strict("red_tech", "blue_tech", "red_lead", "blue_lead")),
|
||||||
) -> TestOut:
|
) -> TestOut:
|
||||||
"""Pause the running timer for the current phase (red_executing or blue_evaluating).
|
"""Pause the running timer for the current phase (red_executing or blue_evaluating).
|
||||||
|
|
||||||
@@ -900,7 +1070,7 @@ def resume_timer(
|
|||||||
# Entry: db
|
# Entry: db
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
# Entry: current_user
|
# Entry: current_user
|
||||||
current_user: User = Depends(require_any_role("red_tech", "blue_tech", "red_lead", "blue_lead")),
|
current_user: User = Depends(require_any_role_strict("red_tech", "blue_tech", "red_lead", "blue_lead")),
|
||||||
) -> TestOut:
|
) -> TestOut:
|
||||||
"""Resume the paused timer for the current phase.
|
"""Resume the paused timer for the current phase.
|
||||||
|
|
||||||
@@ -941,7 +1111,7 @@ def validate_red(
|
|||||||
# Entry: db
|
# Entry: db
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
# Entry: current_user
|
# Entry: current_user
|
||||||
current_user: User = Depends(require_any_role("red_lead")),
|
current_user: User = Depends(require_any_role_strict("red_lead")),
|
||||||
) -> TestOut:
|
) -> TestOut:
|
||||||
"""Red Lead approves or rejects the red side of a test.
|
"""Red Lead approves or rejects the red side of a test.
|
||||||
|
|
||||||
@@ -998,7 +1168,7 @@ def validate_blue(
|
|||||||
# Entry: db
|
# Entry: db
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
# Entry: current_user
|
# Entry: current_user
|
||||||
current_user: User = Depends(require_any_role("blue_lead")),
|
current_user: User = Depends(require_any_role_strict("blue_lead")),
|
||||||
) -> TestOut:
|
) -> TestOut:
|
||||||
"""Blue Lead approves or rejects the blue side of a test.
|
"""Blue Lead approves or rejects the blue side of a test.
|
||||||
|
|
||||||
@@ -1040,6 +1210,99 @@ def validate_blue(
|
|||||||
return test
|
return test
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# POST /tests/{id}/resolve-dispute — approver flips to reject, picks a queue
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{test_id}/resolve-dispute", response_model=TestOut)
|
||||||
|
def resolve_dispute(
|
||||||
|
test_id: uuid.UUID,
|
||||||
|
payload: TestResolveDispute,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: User = Depends(require_any_role_strict("red_lead", "blue_lead")),
|
||||||
|
) -> TestOut:
|
||||||
|
"""The lead who approved flips their vote to reject, choosing which team must redo the work."""
|
||||||
|
test = crud_get_test_with_technique(db, test_id)
|
||||||
|
with UnitOfWork(db) as uow:
|
||||||
|
test = wf_resolve_dispute(db, test, current_user, payload.target_team, notes=payload.notes)
|
||||||
|
uow.commit()
|
||||||
|
db.refresh(test)
|
||||||
|
return test
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# POST /tests/{id}/escalate-to-manager — disputed: notify managers to step in
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{test_id}/escalate-to-manager")
|
||||||
|
def escalate_to_manager(
|
||||||
|
test_id: uuid.UUID,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: User = Depends(require_any_role_strict("red_lead", "blue_lead")),
|
||||||
|
):
|
||||||
|
"""Either lead on a disputed test can escalate it for a manager to decide.
|
||||||
|
|
||||||
|
Unlike the automatic manager notification sent when a test first becomes
|
||||||
|
disputed, this is an explicit, repeatable nudge — a lead pulls this when
|
||||||
|
peer discussion (Request Discussion) isn't going anywhere.
|
||||||
|
"""
|
||||||
|
from app.services.notification_service import notify_role_with_email
|
||||||
|
|
||||||
|
test = crud_get_test_or_raise(db, test_id)
|
||||||
|
|
||||||
|
if test.state.value != "disputed":
|
||||||
|
from app.domain.errors import BusinessRuleViolation
|
||||||
|
raise BusinessRuleViolation("Test is not in disputed state")
|
||||||
|
|
||||||
|
try:
|
||||||
|
notify_role_with_email(
|
||||||
|
db,
|
||||||
|
role="manager",
|
||||||
|
type="validation_disputed",
|
||||||
|
title="Dispute escalated — needs your decision",
|
||||||
|
message=(
|
||||||
|
f'{current_user.username} escalated test "{test.name}" — the leads could not '
|
||||||
|
f'resolve their disagreement. Please review and decide the final outcome.'
|
||||||
|
),
|
||||||
|
entity_type="test",
|
||||||
|
entity_id=test.id,
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
import logging
|
||||||
|
logging.getLogger(__name__).warning("Failed to notify managers of escalation: %s", e)
|
||||||
|
|
||||||
|
log_action(
|
||||||
|
db, user_id=current_user.id, action="escalate_dispute_to_manager",
|
||||||
|
entity_type="test", entity_id=test.id, details={"test_name": test.name},
|
||||||
|
)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
return {"status": "escalated", "message": "Managers have been notified"}
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# POST /tests/{id}/manager-resolve-dispute — manager makes the final call
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{test_id}/manager-resolve-dispute", response_model=TestOut)
|
||||||
|
def manager_resolve_dispute(
|
||||||
|
test_id: uuid.UUID,
|
||||||
|
payload: TestManagerResolveDispute,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: User = Depends(require_any_role_strict("manager")),
|
||||||
|
) -> TestOut:
|
||||||
|
"""A manager decides the final outcome of a disputed test directly."""
|
||||||
|
test = crud_get_test_with_technique(db, test_id)
|
||||||
|
with UnitOfWork(db) as uow:
|
||||||
|
test = wf_resolve_dispute_by_manager(db, test, current_user, payload.outcome, notes=payload.notes)
|
||||||
|
uow.commit()
|
||||||
|
db.refresh(test)
|
||||||
|
return test
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# POST /tests/{id}/reopen — rejected → draft
|
# POST /tests/{id}/reopen — rejected → draft
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -1053,7 +1316,7 @@ def reopen(
|
|||||||
# Entry: db
|
# Entry: db
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
# Entry: current_user
|
# Entry: current_user
|
||||||
current_user: User = Depends(require_any_role("red_lead", "blue_lead")),
|
current_user: User = Depends(require_any_role_strict("red_lead", "blue_lead")),
|
||||||
) -> TestOut:
|
) -> TestOut:
|
||||||
"""Reopen a rejected test, moving it back to ``draft``.
|
"""Reopen a rejected test, moving it back to ``draft``.
|
||||||
|
|
||||||
@@ -1080,7 +1343,7 @@ def reopen(
|
|||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# POST /tests/{id}/assign — assign red_tech / blue_tech operators (leads + admin)
|
# POST /tests/{id}/assign — assign red_tech / blue_tech operators (leads + managers)
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
@@ -1089,35 +1352,68 @@ def assign_test_operators(
|
|||||||
test_id: uuid.UUID,
|
test_id: uuid.UUID,
|
||||||
payload: TestAssign,
|
payload: TestAssign,
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
current_user: User = Depends(require_any_role("admin", "red_lead", "blue_lead")),
|
current_user: User = Depends(require_any_role_strict("manager", "red_lead", "blue_lead")),
|
||||||
):
|
):
|
||||||
"""Assign red_tech and/or blue_tech operators to a test. Admin/leads only."""
|
"""Assign red/blue tech operators and/or reviewers to a test. Leads/managers only — not admin, who administers the site rather than coordinating people."""
|
||||||
test = crud_get_test_or_raise(db, test_id)
|
test = crud_get_test_or_raise(db, test_id)
|
||||||
|
newly_assigned: list[User] = []
|
||||||
|
|
||||||
if payload.red_tech_assignee is not None:
|
if payload.red_tech_assignee is not None:
|
||||||
u = db.query(User).filter(User.id == payload.red_tech_assignee).first()
|
u = db.query(User).filter(User.id == payload.red_tech_assignee).first()
|
||||||
if not u or u.role not in ("red_tech", "red_lead", "admin"):
|
if not u or u.role not in ("red_tech", "red_lead"):
|
||||||
raise HTTPException(status_code=400, detail="Invalid red tech assignee")
|
raise HTTPException(status_code=400, detail="Invalid red tech assignee")
|
||||||
test.red_tech_assignee = payload.red_tech_assignee
|
test.red_tech_assignee = payload.red_tech_assignee
|
||||||
|
newly_assigned.append(u)
|
||||||
|
|
||||||
if payload.blue_tech_assignee is not None:
|
if payload.blue_tech_assignee is not None:
|
||||||
u = db.query(User).filter(User.id == payload.blue_tech_assignee).first()
|
u = db.query(User).filter(User.id == payload.blue_tech_assignee).first()
|
||||||
if not u or u.role not in ("blue_tech", "blue_lead", "admin"):
|
if not u or u.role not in ("blue_tech", "blue_lead"):
|
||||||
raise HTTPException(status_code=400, detail="Invalid blue tech assignee")
|
raise HTTPException(status_code=400, detail="Invalid blue tech assignee")
|
||||||
test.blue_tech_assignee = payload.blue_tech_assignee
|
test.blue_tech_assignee = payload.blue_tech_assignee
|
||||||
|
newly_assigned.append(u)
|
||||||
|
|
||||||
|
if payload.red_reviewer_assignee is not None:
|
||||||
|
u = db.query(User).filter(User.id == payload.red_reviewer_assignee).first()
|
||||||
|
if not u or u.role != "red_lead":
|
||||||
|
raise HTTPException(status_code=400, detail="Invalid red reviewer — must be a red_lead")
|
||||||
|
test.red_reviewer_assignee = payload.red_reviewer_assignee
|
||||||
|
newly_assigned.append(u)
|
||||||
|
|
||||||
|
if payload.blue_reviewer_assignee is not None:
|
||||||
|
u = db.query(User).filter(User.id == payload.blue_reviewer_assignee).first()
|
||||||
|
if not u or u.role != "blue_lead":
|
||||||
|
raise HTTPException(status_code=400, detail="Invalid blue reviewer — must be a blue_lead")
|
||||||
|
test.blue_reviewer_assignee = payload.blue_reviewer_assignee
|
||||||
|
newly_assigned.append(u)
|
||||||
|
|
||||||
# Handle intentional null (clearing) — model_fields_set tracks which keys were sent
|
# Handle intentional null (clearing) — model_fields_set tracks which keys were sent
|
||||||
if "red_tech_assignee" in payload.model_fields_set and payload.red_tech_assignee is None:
|
if "red_tech_assignee" in payload.model_fields_set and payload.red_tech_assignee is None:
|
||||||
test.red_tech_assignee = None
|
test.red_tech_assignee = None
|
||||||
if "blue_tech_assignee" in payload.model_fields_set and payload.blue_tech_assignee is None:
|
if "blue_tech_assignee" in payload.model_fields_set and payload.blue_tech_assignee is None:
|
||||||
test.blue_tech_assignee = None
|
test.blue_tech_assignee = None
|
||||||
|
if "red_reviewer_assignee" in payload.model_fields_set and payload.red_reviewer_assignee is None:
|
||||||
|
test.red_reviewer_assignee = None
|
||||||
|
if "blue_reviewer_assignee" in payload.model_fields_set and payload.blue_reviewer_assignee is None:
|
||||||
|
test.blue_reviewer_assignee = None
|
||||||
|
|
||||||
log_action(db, current_user.id, "assign_test", str(test_id), {
|
log_action(db, current_user.id, "assign_test", str(test_id), {
|
||||||
"red_tech_assignee": str(payload.red_tech_assignee) if payload.red_tech_assignee else None,
|
"red_tech_assignee": str(payload.red_tech_assignee) if payload.red_tech_assignee else None,
|
||||||
"blue_tech_assignee": str(payload.blue_tech_assignee) if payload.blue_tech_assignee else None,
|
"blue_tech_assignee": str(payload.blue_tech_assignee) if payload.blue_tech_assignee else None,
|
||||||
|
"red_reviewer_assignee": str(payload.red_reviewer_assignee) if payload.red_reviewer_assignee else None,
|
||||||
|
"blue_reviewer_assignee": str(payload.blue_reviewer_assignee) if payload.blue_reviewer_assignee else None,
|
||||||
})
|
})
|
||||||
db.commit()
|
db.commit()
|
||||||
db.refresh(test)
|
db.refresh(test)
|
||||||
|
|
||||||
|
if newly_assigned:
|
||||||
|
from app.services.jira_service import push_assignee_update
|
||||||
|
for assignee in newly_assigned:
|
||||||
|
try:
|
||||||
|
push_assignee_update(db, test, assignee)
|
||||||
|
db.commit()
|
||||||
|
except Exception: # nosec B110
|
||||||
|
pass # jira_service already logs warnings internally
|
||||||
|
|
||||||
return test
|
return test
|
||||||
|
|
||||||
|
|
||||||
@@ -1126,12 +1422,27 @@ def assign_test_operators(
|
|||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _check_hold_permission(current_user: User, test) -> None:
|
||||||
|
"""Only whichever team currently owns the test may hold/resume it.
|
||||||
|
|
||||||
|
``require_any_role_strict("red_tech", "blue_tech")`` alone lets either
|
||||||
|
role through regardless of phase — a red_tech could otherwise hold (or
|
||||||
|
resume) a test that has already moved into blue_evaluating.
|
||||||
|
"""
|
||||||
|
expected_role = "blue_tech" if test.state == TestState.blue_evaluating else "red_tech"
|
||||||
|
if current_user.role != expected_role:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=403,
|
||||||
|
detail=f"Only {expected_role.replace('_', ' ')} can do this while the test is in '{test.state}'.",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.post("/{test_id}/hold", response_model=TestOut)
|
@router.post("/{test_id}/hold", response_model=TestOut)
|
||||||
def hold_test(
|
def hold_test(
|
||||||
test_id: uuid.UUID,
|
test_id: uuid.UUID,
|
||||||
payload: TestHold,
|
payload: TestHold,
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
current_user: User = Depends(require_any_role("red_tech", "blue_tech", "admin")),
|
current_user: User = Depends(require_any_role_strict("red_tech", "blue_tech")),
|
||||||
):
|
):
|
||||||
"""Place a test on hold with a mandatory reason. Posts comment + transitions Jira."""
|
"""Place a test on hold with a mandatory reason. Posts comment + transitions Jira."""
|
||||||
from datetime import datetime as _dt
|
from datetime import datetime as _dt
|
||||||
@@ -1146,6 +1457,8 @@ def hold_test(
|
|||||||
detail=f"Cannot hold a test in state '{test.state}'. Only pre-validation states can be held.",
|
detail=f"Cannot hold a test in state '{test.state}'. Only pre-validation states can be held.",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
_check_hold_permission(current_user, test)
|
||||||
|
|
||||||
if test.is_on_hold:
|
if test.is_on_hold:
|
||||||
raise HTTPException(status_code=400, detail="Test is already on hold")
|
raise HTTPException(status_code=400, detail="Test is already on hold")
|
||||||
|
|
||||||
@@ -1153,6 +1466,10 @@ def hold_test(
|
|||||||
test.hold_reason = payload.reason
|
test.hold_reason = payload.reason
|
||||||
test.held_at = _dt.utcnow()
|
test.held_at = _dt.utcnow()
|
||||||
|
|
||||||
|
# A running phase timer must stop counting while on hold.
|
||||||
|
if test.state in (TestState.red_executing, TestState.blue_evaluating) and test.paused_at is None:
|
||||||
|
test.paused_at = test.held_at
|
||||||
|
|
||||||
log_action(db, current_user.id, "hold_test", str(test_id), {"reason": payload.reason})
|
log_action(db, current_user.id, "hold_test", str(test_id), {"reason": payload.reason})
|
||||||
db.commit()
|
db.commit()
|
||||||
db.refresh(test)
|
db.refresh(test)
|
||||||
@@ -1171,9 +1488,10 @@ def hold_test(
|
|||||||
def resume_test(
|
def resume_test(
|
||||||
test_id: uuid.UUID,
|
test_id: uuid.UUID,
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
current_user: User = Depends(require_any_role("red_tech", "blue_tech", "admin")),
|
current_user: User = Depends(require_any_role_strict("red_tech", "blue_tech")),
|
||||||
):
|
):
|
||||||
"""Resume a test that was placed on hold."""
|
"""Resume a test that was placed on hold."""
|
||||||
|
from datetime import datetime as _dt
|
||||||
from app.services.jira_service import push_hold_event
|
from app.services.jira_service import push_hold_event
|
||||||
|
|
||||||
test = crud_get_test_or_raise(db, test_id)
|
test = crud_get_test_or_raise(db, test_id)
|
||||||
@@ -1181,10 +1499,22 @@ def resume_test(
|
|||||||
if not test.is_on_hold:
|
if not test.is_on_hold:
|
||||||
raise HTTPException(status_code=400, detail="Test is not on hold")
|
raise HTTPException(status_code=400, detail="Test is not on hold")
|
||||||
|
|
||||||
|
_check_hold_permission(current_user, test)
|
||||||
|
|
||||||
test.is_on_hold = False
|
test.is_on_hold = False
|
||||||
test.hold_reason = None
|
test.hold_reason = None
|
||||||
test.held_at = None
|
test.held_at = None
|
||||||
|
|
||||||
|
# Resume the phase timer that hold_test paused, accumulating the held
|
||||||
|
# duration the same way pause/resume-timer does.
|
||||||
|
if test.paused_at is not None:
|
||||||
|
held_seconds = max(int((_dt.utcnow() - test.paused_at).total_seconds()), 0)
|
||||||
|
if test.state == TestState.red_executing:
|
||||||
|
test.red_paused_seconds = (test.red_paused_seconds or 0) + held_seconds
|
||||||
|
elif test.state == TestState.blue_evaluating:
|
||||||
|
test.blue_paused_seconds = (test.blue_paused_seconds or 0) + held_seconds
|
||||||
|
test.paused_at = None
|
||||||
|
|
||||||
log_action(db, current_user.id, "resume_test", str(test_id), {})
|
log_action(db, current_user.id, "resume_test", str(test_id), {})
|
||||||
db.commit()
|
db.commit()
|
||||||
db.refresh(test)
|
db.refresh(test)
|
||||||
@@ -1209,7 +1539,7 @@ def update_remediation(
|
|||||||
# Entry: db
|
# Entry: db
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
# Entry: current_user
|
# Entry: current_user
|
||||||
current_user: User = Depends(require_any_role("red_lead", "blue_lead")),
|
current_user: User = Depends(require_any_role_strict("red_lead", "blue_lead")),
|
||||||
) -> TestOut:
|
) -> TestOut:
|
||||||
"""Update remediation fields on a test.
|
"""Update remediation fields on a test.
|
||||||
|
|
||||||
@@ -1443,7 +1773,7 @@ def sync_tempo(
|
|||||||
def request_discussion(
|
def request_discussion(
|
||||||
test_id: uuid.UUID,
|
test_id: uuid.UUID,
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
current_user: User = Depends(require_any_role("red_lead", "blue_lead", "admin")),
|
current_user: User = Depends(require_any_role_strict("red_lead", "blue_lead")),
|
||||||
):
|
):
|
||||||
"""Called when the approving lead confirms their vote in a disputed test.
|
"""Called when the approving lead confirms their vote in a disputed test.
|
||||||
|
|
||||||
@@ -1462,12 +1792,12 @@ def request_discussion(
|
|||||||
role = current_user.role
|
role = current_user.role
|
||||||
|
|
||||||
# Identify who the "other lead" is (the one who rejected)
|
# Identify who the "other lead" is (the one who rejected)
|
||||||
if (role in ("red_lead", "admin")) and test.red_validation_status == "approved":
|
if role == "red_lead" and test.red_validation_status == "approved":
|
||||||
# Red approved, Blue rejected → notify Blue Lead who rejected
|
# Red approved, Blue rejected → notify Blue Lead who rejected
|
||||||
rejector_id = test.blue_validated_by
|
rejector_id = test.blue_validated_by
|
||||||
rejector_label = "Blue Lead"
|
rejector_label = "Blue Lead"
|
||||||
requester_label = "Red Lead"
|
requester_label = "Red Lead"
|
||||||
elif (role in ("blue_lead", "admin")) and test.blue_validation_status == "approved":
|
elif role == "blue_lead" and test.blue_validation_status == "approved":
|
||||||
# Blue approved, Red rejected → notify Red Lead who rejected
|
# Blue approved, Red rejected → notify Red Lead who rejected
|
||||||
rejector_id = test.red_validated_by
|
rejector_id = test.red_validated_by
|
||||||
rejector_label = "Red Lead"
|
rejector_label = "Red Lead"
|
||||||
@@ -1484,6 +1814,7 @@ def request_discussion(
|
|||||||
if rejector_id else None
|
if rejector_id else None
|
||||||
)
|
)
|
||||||
rejector_name = rejector.username if rejector else rejector_label
|
rejector_name = rejector.username if rejector else rejector_label
|
||||||
|
rejector_full_name = getattr(rejector, "full_name", None) if rejector else None
|
||||||
rejector_email = getattr(rejector, "email", None) if rejector else None
|
rejector_email = getattr(rejector, "email", None) if rejector else None
|
||||||
|
|
||||||
# Notify the rejecting lead
|
# Notify the rejecting lead
|
||||||
@@ -1522,6 +1853,7 @@ def request_discussion(
|
|||||||
"status": "notification_sent",
|
"status": "notification_sent",
|
||||||
"message": f"Discussion request sent to {rejector_name}",
|
"message": f"Discussion request sent to {rejector_name}",
|
||||||
"rejector_username": rejector_name,
|
"rejector_username": rejector_name,
|
||||||
|
"rejector_full_name": rejector_full_name,
|
||||||
"rejector_email": rejector_email,
|
"rejector_email": rejector_email,
|
||||||
"rejector_role": rejector_label,
|
"rejector_role": rejector_label,
|
||||||
}
|
}
|
||||||
@@ -1545,7 +1877,7 @@ class RTEvidenceEntry(BaseModel):
|
|||||||
class RTTechniqueEntry(BaseModel):
|
class RTTechniqueEntry(BaseModel):
|
||||||
mitre_id: str
|
mitre_id: str
|
||||||
result: str # "detected" | "not_detected" | "partially_detected"
|
result: str # "detected" | "not_detected" | "partially_detected"
|
||||||
attack_success: bool = True
|
attack_success: AttackSuccessResult = AttackSuccessResult.successful
|
||||||
platform: Optional[str] = None
|
platform: Optional[str] = None
|
||||||
notes: Optional[str] = None
|
notes: Optional[str] = None
|
||||||
evidence: list[RTEvidenceEntry] # REQUIRED — at least one image per technique
|
evidence: list[RTEvidenceEntry] # REQUIRED — at least one image per technique
|
||||||
@@ -1563,13 +1895,13 @@ class RTImportPayload(BaseModel):
|
|||||||
def import_rt(
|
def import_rt(
|
||||||
payload: RTImportPayload,
|
payload: RTImportPayload,
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
current_user: User = Depends(require_any_role("red_lead")),
|
current_user: User = Depends(require_any_role_strict("red_lead")),
|
||||||
):
|
):
|
||||||
"""Import results from a real Red Team engagement.
|
"""Import results from a real Red Team engagement.
|
||||||
|
|
||||||
Creates one Test record per technique in ``validated`` state (bypassing
|
Creates one Test record per technique in ``validated`` state (bypassing
|
||||||
the normal Red/Blue workflow) and immediately recalculates coverage metrics.
|
the normal Red/Blue workflow) and immediately recalculates coverage metrics.
|
||||||
Requires ``red_lead`` or ``admin`` role.
|
Requires ``red_lead`` — admin administers the site, not test content.
|
||||||
"""
|
"""
|
||||||
# Pre-validate: every technique must include at least one evidence image
|
# Pre-validate: every technique must include at least one evidence image
|
||||||
for entry in payload.techniques:
|
for entry in payload.techniques:
|
||||||
@@ -1645,6 +1977,7 @@ def import_rt(
|
|||||||
# validation_status pending so Blue Lead must confirm
|
# validation_status pending so Blue Lead must confirm
|
||||||
detection_result=detection_result,
|
detection_result=detection_result,
|
||||||
blue_validation_status=None,
|
blue_validation_status=None,
|
||||||
|
data_classification=determine_initial_classification(technique),
|
||||||
# Timing
|
# Timing
|
||||||
execution_date=exec_date_str,
|
execution_date=exec_date_str,
|
||||||
created_at=datetime.utcnow(),
|
created_at=datetime.utcnow(),
|
||||||
|
|||||||
+183
-13
@@ -1,5 +1,8 @@
|
|||||||
"""User management router (admin only)."""
|
"""User management router (admin only)."""
|
||||||
|
|
||||||
|
# Import logging
|
||||||
|
import logging
|
||||||
|
|
||||||
# Import uuid
|
# Import uuid
|
||||||
import uuid
|
import uuid
|
||||||
|
|
||||||
@@ -13,28 +16,42 @@ from sqlalchemy.orm import Session
|
|||||||
from app.database import get_db
|
from app.database import get_db
|
||||||
|
|
||||||
# Import require_role from app.dependencies.auth
|
# Import require_role from app.dependencies.auth
|
||||||
from app.dependencies.auth import require_role
|
from app.dependencies.auth import require_role, require_any_role_strict
|
||||||
|
|
||||||
# Import UnitOfWork from app.domain.unit_of_work
|
# Import UnitOfWork from app.domain.unit_of_work
|
||||||
from app.domain.unit_of_work import UnitOfWork
|
from app.domain.unit_of_work import UnitOfWork
|
||||||
|
from app.domain.errors import BusinessRuleViolation
|
||||||
|
|
||||||
# Import User from app.models.user
|
# Import User from app.models.user
|
||||||
from app.models.user import User
|
from app.models.user import User
|
||||||
from app.dependencies.auth import get_current_user
|
from app.dependencies.auth import get_current_user
|
||||||
from app.schemas.user import UserCreate, UserUpdate, UserOut, UserPreferencesUpdate
|
from app.schemas.user import (
|
||||||
|
UserCreate,
|
||||||
|
UserUpdate,
|
||||||
|
UserOut,
|
||||||
|
UserPreferencesUpdate,
|
||||||
|
UserOperatorOut,
|
||||||
|
SendPasswordEmailOut,
|
||||||
|
SwitchRoleRequest,
|
||||||
|
)
|
||||||
from app.services.audit_service import log_action
|
from app.services.audit_service import log_action
|
||||||
|
|
||||||
# Import from app.services.user_service
|
# Import from app.services.user_service
|
||||||
from app.services.user_service import (
|
from app.services.user_service import (
|
||||||
create_user,
|
create_user_without_password,
|
||||||
|
delete_user,
|
||||||
get_user_or_raise,
|
get_user_or_raise,
|
||||||
list_users,
|
list_users,
|
||||||
|
switch_active_role,
|
||||||
update_user,
|
update_user,
|
||||||
)
|
)
|
||||||
|
from app.services.password_setup_service import send_password_setup_email
|
||||||
|
|
||||||
# Assign router = APIRouter(prefix="/users", tags=["users"])
|
# Assign router = APIRouter(prefix="/users", tags=["users"])
|
||||||
router = APIRouter(prefix="/users", tags=["users"])
|
router = APIRouter(prefix="/users", tags=["users"])
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# PATCH /users/me/preferences — update current user preferences
|
# PATCH /users/me/preferences — update current user preferences
|
||||||
@@ -77,6 +94,39 @@ def get_me(
|
|||||||
return current_user
|
return current_user
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# POST /users/me/switch-role — swap the caller's active role
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/me/switch-role", response_model=UserOut)
|
||||||
|
def switch_my_role(
|
||||||
|
payload: SwitchRoleRequest,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
) -> UserOut:
|
||||||
|
"""Switch the caller's active role to one of their granted extra_roles.
|
||||||
|
|
||||||
|
Roles never mix — this changes which single role is active, it never
|
||||||
|
grants permissions from more than one role at once. Takes effect
|
||||||
|
immediately since every permission check reads ``role`` fresh from the
|
||||||
|
DB on each request.
|
||||||
|
"""
|
||||||
|
with UnitOfWork(db) as uow:
|
||||||
|
user = switch_active_role(db, current_user, payload.role)
|
||||||
|
log_action(
|
||||||
|
db,
|
||||||
|
user_id=current_user.id,
|
||||||
|
action="switch_role",
|
||||||
|
entity_type="user",
|
||||||
|
entity_id=current_user.id,
|
||||||
|
details={"new_role": payload.role},
|
||||||
|
)
|
||||||
|
uow.commit()
|
||||||
|
db.refresh(user)
|
||||||
|
return user
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# GET /users — list all users
|
# GET /users — list all users
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -110,19 +160,17 @@ def create_user_route(
|
|||||||
# Entry: current_user
|
# Entry: current_user
|
||||||
current_user: User = Depends(require_role("admin")),
|
current_user: User = Depends(require_role("admin")),
|
||||||
) -> UserOut:
|
) -> UserOut:
|
||||||
"""Create a new user. **Requires admin role.**."""
|
"""Create a new user. **Requires admin role.**.
|
||||||
|
|
||||||
|
No password is set — use ``POST /users/{id}/send-password-email``
|
||||||
|
afterward to let the user set their own via a one-time link.
|
||||||
|
"""
|
||||||
# Open context manager
|
# Open context manager
|
||||||
with UnitOfWork(db) as uow:
|
with UnitOfWork(db) as uow:
|
||||||
# Assign user = create_user(
|
user = create_user_without_password(
|
||||||
user = create_user(
|
|
||||||
db,
|
db,
|
||||||
# Keyword argument: username
|
full_name=payload.full_name,
|
||||||
username=payload.username,
|
|
||||||
# Keyword argument: email
|
|
||||||
email=payload.email,
|
email=payload.email,
|
||||||
# Keyword argument: password
|
|
||||||
password=payload.password,
|
|
||||||
# Keyword argument: role
|
|
||||||
role=payload.role,
|
role=payload.role,
|
||||||
)
|
)
|
||||||
# Call log_action()
|
# Call log_action()
|
||||||
@@ -137,17 +185,68 @@ def create_user_route(
|
|||||||
# Keyword argument: entity_id
|
# Keyword argument: entity_id
|
||||||
entity_id=user.id,
|
entity_id=user.id,
|
||||||
# Keyword argument: details
|
# Keyword argument: details
|
||||||
details={"username": user.username, "role": user.role},
|
details={"full_name": user.full_name, "email": user.email, "role": user.role},
|
||||||
)
|
)
|
||||||
# Call uow.commit()
|
# Call uow.commit()
|
||||||
uow.commit()
|
uow.commit()
|
||||||
# Reload ORM object attributes from the database
|
# Reload ORM object attributes from the database
|
||||||
db.refresh(user)
|
db.refresh(user)
|
||||||
|
|
||||||
|
# Send the set-password email right away — best-effort. If the webhook
|
||||||
|
# isn't configured or rejects the request, the user is still created
|
||||||
|
# successfully; the admin can retry via the "Send Email" button, which
|
||||||
|
# surfaces the failure properly (unlike this automatic first attempt).
|
||||||
|
try:
|
||||||
|
with UnitOfWork(db) as uow:
|
||||||
|
send_password_setup_email(db, user)
|
||||||
|
uow.commit()
|
||||||
|
except Exception:
|
||||||
|
logger.warning("Automatic set-password email failed for new user %s", user.id, exc_info=True)
|
||||||
|
|
||||||
|
from app.services.notification_service import notify_roles_by_email
|
||||||
|
notify_roles_by_email(
|
||||||
|
db, roles=["admin"],
|
||||||
|
preference_key="email_on_new_users",
|
||||||
|
subject=f"New User Created: {user.full_name or user.email}",
|
||||||
|
message=(
|
||||||
|
f'A new user account was created: {user.full_name or user.email} '
|
||||||
|
f"({user.email}), role: {user.role}."
|
||||||
|
),
|
||||||
|
exclude_user_id=current_user.id,
|
||||||
|
)
|
||||||
|
|
||||||
# Return user
|
# Return user
|
||||||
return user
|
return user
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# GET /users/operators — minimal operator/lead list for assignment pickers
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/operators", response_model=list[UserOperatorOut])
|
||||||
|
def list_operators_route(
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: User = Depends(require_any_role_strict("manager", "red_lead", "blue_lead")),
|
||||||
|
) -> list[UserOperatorOut]:
|
||||||
|
"""Return active red/blue operators and leads, for lead/manager assignment pickers.
|
||||||
|
|
||||||
|
Not reachable by admin — admin administers the site, leads/managers
|
||||||
|
coordinate operators, and admin cannot itself be assigned as an
|
||||||
|
operator either. Returns only id/username/role — no emails or
|
||||||
|
tokens — since this is reachable by non-admin leads.
|
||||||
|
"""
|
||||||
|
return (
|
||||||
|
db.query(User)
|
||||||
|
.filter(
|
||||||
|
User.role.in_(["red_tech", "red_lead", "blue_tech", "blue_lead"]),
|
||||||
|
User.is_active.is_(True),
|
||||||
|
)
|
||||||
|
.order_by(User.username)
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# GET /users/{id} — get a single user
|
# GET /users/{id} — get a single user
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -188,6 +287,16 @@ def update_user_route(
|
|||||||
"""Update one or more fields of an existing user. **Requires admin role.**."""
|
"""Update one or more fields of an existing user. **Requires admin role.**."""
|
||||||
# Assign update_data = payload.model_dump(exclude_unset=True)
|
# Assign update_data = payload.model_dump(exclude_unset=True)
|
||||||
update_data = payload.model_dump(exclude_unset=True)
|
update_data = payload.model_dump(exclude_unset=True)
|
||||||
|
|
||||||
|
# An admin can never strip their own admin permission — otherwise a
|
||||||
|
# careless self-edit (or a compromised session) could lock every admin
|
||||||
|
# out of the platform with no way back in.
|
||||||
|
if user_id == current_user.id and current_user.role == "admin":
|
||||||
|
final_role = update_data.get("role", current_user.role)
|
||||||
|
final_extra_roles = update_data.get("extra_roles", current_user.extra_roles or [])
|
||||||
|
if "admin" not in {final_role, *(final_extra_roles or [])}:
|
||||||
|
raise BusinessRuleViolation("You cannot remove your own admin permission.")
|
||||||
|
|
||||||
# Open context manager
|
# Open context manager
|
||||||
with UnitOfWork(db) as uow:
|
with UnitOfWork(db) as uow:
|
||||||
# Assign user = update_user(db, user_id, **update_data)
|
# Assign user = update_user(db, user_id, **update_data)
|
||||||
@@ -213,3 +322,64 @@ def update_user_route(
|
|||||||
|
|
||||||
# Return user
|
# Return user
|
||||||
return user
|
return user
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# DELETE /users/{id} — permanently delete a user
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/{user_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||||
|
def delete_user_route(
|
||||||
|
user_id: uuid.UUID,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: User = Depends(require_role("admin")),
|
||||||
|
) -> None:
|
||||||
|
"""Permanently delete a user. **Requires admin role.**.
|
||||||
|
|
||||||
|
Only users with no activity footprint (no tests, evidence, worklogs,
|
||||||
|
audit entries, etc.) can be hard-deleted — anyone else must be
|
||||||
|
deactivated instead, to preserve the audit trail.
|
||||||
|
"""
|
||||||
|
with UnitOfWork(db) as uow:
|
||||||
|
delete_user(db, user_id, current_user.id)
|
||||||
|
log_action(
|
||||||
|
db,
|
||||||
|
user_id=current_user.id,
|
||||||
|
action="delete_user",
|
||||||
|
entity_type="user",
|
||||||
|
entity_id=user_id,
|
||||||
|
details={},
|
||||||
|
)
|
||||||
|
uow.commit()
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# POST /users/{id}/send-password-email — set-password / reset link
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{user_id}/send-password-email", response_model=SendPasswordEmailOut)
|
||||||
|
def send_password_email_route(
|
||||||
|
user_id: uuid.UUID,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: User = Depends(require_role("admin")),
|
||||||
|
) -> SendPasswordEmailOut:
|
||||||
|
"""Email a one-time set-password link to a user. **Requires admin role.**.
|
||||||
|
|
||||||
|
Works for both a freshly-created passwordless user and an existing
|
||||||
|
one whose password needs resetting — same link, same flow either way.
|
||||||
|
"""
|
||||||
|
user = get_user_or_raise(db, user_id)
|
||||||
|
with UnitOfWork(db) as uow:
|
||||||
|
send_password_setup_email(db, user)
|
||||||
|
log_action(
|
||||||
|
db,
|
||||||
|
user_id=current_user.id,
|
||||||
|
action="send_password_setup_email",
|
||||||
|
entity_type="user",
|
||||||
|
entity_id=user.id,
|
||||||
|
details={},
|
||||||
|
)
|
||||||
|
uow.commit()
|
||||||
|
return SendPasswordEmailOut(detail=f"Password setup email sent to {user.email}")
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ class AuditLogOut(BaseModel):
|
|||||||
user_id: uuid.UUID | None = None
|
user_id: uuid.UUID | None = None
|
||||||
# Assign username = None # Populated from user relationship
|
# Assign username = None # Populated from user relationship
|
||||||
username: str | None = None # Populated from user relationship
|
username: str | None = None # Populated from user relationship
|
||||||
|
full_name: str | None = None # Populated from user relationship
|
||||||
# action: str
|
# action: str
|
||||||
action: str
|
action: str
|
||||||
# Assign entity_type = None
|
# Assign entity_type = None
|
||||||
|
|||||||
@@ -39,10 +39,12 @@ class UserOut(BaseModel):
|
|||||||
id: uuid.UUID
|
id: uuid.UUID
|
||||||
# username: str
|
# username: str
|
||||||
username: str
|
username: str
|
||||||
|
full_name: str | None = None
|
||||||
# Assign email = None
|
# Assign email = None
|
||||||
email: str | None = None
|
email: str | None = None
|
||||||
# role: str
|
# role: str
|
||||||
role: str
|
role: str
|
||||||
|
extra_roles: list[str] = []
|
||||||
# is_active: bool
|
# is_active: bool
|
||||||
is_active: bool
|
is_active: bool
|
||||||
# Assign must_change_password = True
|
# Assign must_change_password = True
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
"""Pydantic schemas for procedure-suggestion review endpoints."""
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from pydantic import BaseModel, ConfigDict
|
||||||
|
|
||||||
|
|
||||||
|
class ProcedureSuggestionOut(BaseModel):
|
||||||
|
"""A proposed template procedure update, pending or reviewed."""
|
||||||
|
|
||||||
|
id: uuid.UUID
|
||||||
|
template_id: uuid.UUID
|
||||||
|
team: str
|
||||||
|
suggested_text: str
|
||||||
|
source_test_id: uuid.UUID | None = None
|
||||||
|
submitted_by: uuid.UUID | None = None
|
||||||
|
status: str
|
||||||
|
reviewed_by: uuid.UUID | None = None
|
||||||
|
reviewed_at: datetime | None = None
|
||||||
|
created_at: datetime | None = None
|
||||||
|
|
||||||
|
# Populated for display — the template name and its current suggested
|
||||||
|
# value, so a lead can compare without a second lookup.
|
||||||
|
template_name: str | None = None
|
||||||
|
template_current_text: str | None = None
|
||||||
|
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
"""Pydantic schemas for the TemplateSuggestion (operator template proposal) workflow."""
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from pydantic import BaseModel, ConfigDict
|
||||||
|
|
||||||
|
|
||||||
|
class TemplateSuggestionCreate(BaseModel):
|
||||||
|
"""Payload for an operator proposing a new test template."""
|
||||||
|
|
||||||
|
mitre_technique_id: str
|
||||||
|
name: str
|
||||||
|
description: str | None = None
|
||||||
|
source: str = "custom"
|
||||||
|
source_url: str | None = None
|
||||||
|
attack_procedure: str | None = None
|
||||||
|
expected_detection: str | None = None
|
||||||
|
platform: str | None = None
|
||||||
|
tool_suggested: str | None = None
|
||||||
|
severity: str | None = None
|
||||||
|
atomic_test_id: str | None = None
|
||||||
|
suggested_remediation: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class TemplateSuggestionApprove(BaseModel):
|
||||||
|
"""Optional field overrides a lead can apply while approving a suggestion.
|
||||||
|
|
||||||
|
Any field left unset falls back to what the operator originally
|
||||||
|
submitted — the lead only needs to pass the fields they want to change.
|
||||||
|
"""
|
||||||
|
|
||||||
|
mitre_technique_id: str | None = None
|
||||||
|
name: str | None = None
|
||||||
|
description: str | None = None
|
||||||
|
source: str | None = None
|
||||||
|
source_url: str | None = None
|
||||||
|
attack_procedure: str | None = None
|
||||||
|
expected_detection: str | None = None
|
||||||
|
platform: str | None = None
|
||||||
|
tool_suggested: str | None = None
|
||||||
|
severity: str | None = None
|
||||||
|
atomic_test_id: str | None = None
|
||||||
|
suggested_remediation: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class TemplateSuggestionOut(BaseModel):
|
||||||
|
"""Full representation of a pending/reviewed template suggestion."""
|
||||||
|
|
||||||
|
id: uuid.UUID
|
||||||
|
mitre_technique_id: str
|
||||||
|
name: str
|
||||||
|
description: str | None = None
|
||||||
|
source: str
|
||||||
|
source_url: str | None = None
|
||||||
|
attack_procedure: str | None = None
|
||||||
|
expected_detection: str | None = None
|
||||||
|
platform: str | None = None
|
||||||
|
tool_suggested: str | None = None
|
||||||
|
severity: str | None = None
|
||||||
|
atomic_test_id: str | None = None
|
||||||
|
suggested_remediation: str | None = None
|
||||||
|
team: str
|
||||||
|
submitted_by: uuid.UUID | None = None
|
||||||
|
submitter_name: str | None = None
|
||||||
|
status: str
|
||||||
|
reviewed_by: uuid.UUID | None = None
|
||||||
|
reviewed_at: datetime | None = None
|
||||||
|
created_template_id: uuid.UUID | None = None
|
||||||
|
created_at: datetime | None = None
|
||||||
|
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
+149
-9
@@ -4,15 +4,29 @@
|
|||||||
import uuid
|
import uuid
|
||||||
|
|
||||||
# Import datetime from datetime
|
# Import datetime from datetime
|
||||||
from datetime import datetime
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
from pydantic import BaseModel, ConfigDict, model_validator
|
from pydantic import BaseModel, ConfigDict, field_validator, model_validator
|
||||||
|
|
||||||
# Import DataClassification from app.domain.enums
|
# Import DataClassification from app.domain.enums
|
||||||
from app.domain.enums import ContainmentResult, DataClassification
|
from app.domain.enums import AttackSuccessResult, ContainmentResult, DataClassification
|
||||||
from app.models.enums import TestResult, TestState
|
from app.models.enums import TestResult, TestState
|
||||||
from app.schemas.evidence import EvidenceOut
|
from app.schemas.evidence import EvidenceOut
|
||||||
|
|
||||||
|
|
||||||
|
def _to_naive_utc(v: datetime | None) -> datetime | None:
|
||||||
|
"""Normalize an incoming datetime to naive UTC.
|
||||||
|
|
||||||
|
Every timestamp column in this app is naive-UTC (set via
|
||||||
|
``datetime.utcnow()`` server-side). Clients are expected to send a
|
||||||
|
proper UTC ISO string (with a ``Z``/offset), but if one slips through
|
||||||
|
tz-aware, convert it rather than let it get silently mis-stored as if
|
||||||
|
its clock digits were already UTC.
|
||||||
|
"""
|
||||||
|
if v is not None and v.tzinfo is not None:
|
||||||
|
return v.astimezone(timezone.utc).replace(tzinfo=None)
|
||||||
|
return v
|
||||||
|
|
||||||
# ── Create ──────────────────────────────────────────────────────────
|
# ── Create ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
@@ -37,7 +51,7 @@ class TestCreate(BaseModel):
|
|||||||
|
|
||||||
|
|
||||||
class TestClassificationUpdate(BaseModel):
|
class TestClassificationUpdate(BaseModel):
|
||||||
"""Admin-only payload for changing data classification."""
|
"""Payload for changing a test's data classification (any test participant or admin)."""
|
||||||
|
|
||||||
# data_classification: DataClassification
|
# data_classification: DataClassification
|
||||||
data_classification: DataClassification
|
data_classification: DataClassification
|
||||||
@@ -79,12 +93,14 @@ class TestRedUpdate(BaseModel):
|
|||||||
# Assign tool_used = None
|
# Assign tool_used = None
|
||||||
tool_used: str | None = None
|
tool_used: str | None = None
|
||||||
# Assign attack_success = None
|
# Assign attack_success = None
|
||||||
attack_success: bool | None = None
|
attack_success: AttackSuccessResult | None = None
|
||||||
# Assign red_summary = None
|
# Assign red_summary = None
|
||||||
red_summary: str | None = None
|
red_summary: str | None = None
|
||||||
execution_start_time: datetime | None = None
|
execution_start_time: datetime | None = None
|
||||||
execution_end_time: datetime | None = None
|
execution_end_time: datetime | None = None
|
||||||
|
|
||||||
|
_normalize_datetimes = field_validator("execution_start_time", "execution_end_time")(_to_naive_utc)
|
||||||
|
|
||||||
|
|
||||||
# ── Blue Team update ───────────────────────────────────────────────
|
# ── Blue Team update ───────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -99,6 +115,40 @@ class TestBlueUpdate(BaseModel):
|
|||||||
containment_time: datetime | None = None
|
containment_time: datetime | None = None
|
||||||
# Assign blue_summary = None
|
# Assign blue_summary = None
|
||||||
blue_summary: str | None = None
|
blue_summary: str | None = None
|
||||||
|
detect_procedure: str | None = None
|
||||||
|
|
||||||
|
_normalize_datetimes = field_validator("detection_time", "containment_time")(_to_naive_utc)
|
||||||
|
|
||||||
|
|
||||||
|
# ── Round history (archived on reopen) ─────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
class TestRoundHistoryOut(BaseModel):
|
||||||
|
"""One archived round snapshot, taken right before a reopen resets the live fields."""
|
||||||
|
|
||||||
|
id: uuid.UUID
|
||||||
|
team: str
|
||||||
|
round_number: int
|
||||||
|
started_at: datetime | None = None
|
||||||
|
ended_at: datetime | None = None
|
||||||
|
paused_seconds: int = 0
|
||||||
|
procedure_text: str | None = None
|
||||||
|
tool_used: str | None = None
|
||||||
|
attack_success: AttackSuccessResult | None = None
|
||||||
|
red_summary: str | None = None
|
||||||
|
execution_start_time: datetime | None = None
|
||||||
|
execution_end_time: datetime | None = None
|
||||||
|
detection_result: TestResult | None = None
|
||||||
|
containment_result: ContainmentResult | None = None
|
||||||
|
detection_time: datetime | None = None
|
||||||
|
containment_time: datetime | None = None
|
||||||
|
blue_summary: str | None = None
|
||||||
|
detect_procedure: str | None = None
|
||||||
|
review_notes: str | None = None
|
||||||
|
reviewed_by: uuid.UUID | None = None
|
||||||
|
archived_at: datetime | None = None
|
||||||
|
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
|
|
||||||
# ── Red Lead validation ────────────────────────────────────────────
|
# ── Red Lead validation ────────────────────────────────────────────
|
||||||
@@ -125,6 +175,44 @@ class TestBlueValidate(BaseModel):
|
|||||||
blue_validation_notes: str | None = None
|
blue_validation_notes: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
# ── Dispute resolution ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
class TestResolveDispute(BaseModel):
|
||||||
|
"""Payload sent by the approving lead flipping their vote to reject."""
|
||||||
|
|
||||||
|
target_team: str # "red" | "blue" — which team must redo the work
|
||||||
|
notes: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class TestManagerResolveDispute(BaseModel):
|
||||||
|
"""Payload sent by a manager making the final call on a disputed test."""
|
||||||
|
|
||||||
|
outcome: str # "validated" | "rejected"
|
||||||
|
notes: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
# ── Red Lead review gate (pre-Blue-Team) ────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
class TestRedReview(BaseModel):
|
||||||
|
"""Payload sent by the assigned Red Lead reviewer."""
|
||||||
|
|
||||||
|
decision: str # "approve" | "reopen"
|
||||||
|
notes: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
# ── Blue Lead review gate (pre-cross-validation) ────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
class TestBlueReview(BaseModel):
|
||||||
|
"""Payload sent by the assigned Blue Lead reviewer."""
|
||||||
|
|
||||||
|
decision: str # "approve" | "reopen" | "gap"
|
||||||
|
notes: str | None = None
|
||||||
|
system_gaps: str | None = None
|
||||||
|
|
||||||
|
|
||||||
# ── Remediation update ────────────────────────────────────────────
|
# ── Remediation update ────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
@@ -140,10 +228,12 @@ class TestRemediationUpdate(BaseModel):
|
|||||||
|
|
||||||
|
|
||||||
class TestAssign(BaseModel):
|
class TestAssign(BaseModel):
|
||||||
"""Payload for assigning operators to a test."""
|
"""Payload for assigning operators or reviewers to a test."""
|
||||||
|
|
||||||
red_tech_assignee: uuid.UUID | None = None
|
red_tech_assignee: uuid.UUID | None = None
|
||||||
blue_tech_assignee: uuid.UUID | None = None
|
blue_tech_assignee: uuid.UUID | None = None
|
||||||
|
red_reviewer_assignee: uuid.UUID | None = None
|
||||||
|
blue_reviewer_assignee: uuid.UUID | None = None
|
||||||
|
|
||||||
|
|
||||||
class TestHold(BaseModel):
|
class TestHold(BaseModel):
|
||||||
@@ -188,6 +278,7 @@ class TestOut(BaseModel):
|
|||||||
execution_date: datetime | None = None
|
execution_date: datetime | None = None
|
||||||
# Assign created_by = None
|
# Assign created_by = None
|
||||||
created_by: uuid.UUID | None = None
|
created_by: uuid.UUID | None = None
|
||||||
|
source_template_id: uuid.UUID | None = None
|
||||||
# Assign result = None
|
# Assign result = None
|
||||||
result: TestResult | None = None
|
result: TestResult | None = None
|
||||||
# Assign state = TestState.draft
|
# Assign state = TestState.draft
|
||||||
@@ -198,7 +289,7 @@ class TestOut(BaseModel):
|
|||||||
# Red Team fields
|
# Red Team fields
|
||||||
red_summary: str | None = None
|
red_summary: str | None = None
|
||||||
# Assign attack_success = None
|
# Assign attack_success = None
|
||||||
attack_success: bool | None = None
|
attack_success: AttackSuccessResult | None = None
|
||||||
execution_start_time: datetime | None = None
|
execution_start_time: datetime | None = None
|
||||||
execution_end_time: datetime | None = None
|
execution_end_time: datetime | None = None
|
||||||
# Assign red_validated_by = None
|
# Assign red_validated_by = None
|
||||||
@@ -212,6 +303,7 @@ class TestOut(BaseModel):
|
|||||||
|
|
||||||
# Blue Team fields
|
# Blue Team fields
|
||||||
blue_summary: str | None = None
|
blue_summary: str | None = None
|
||||||
|
detect_procedure: str | None = None
|
||||||
# Assign detection_result = None
|
# Assign detection_result = None
|
||||||
detection_result: TestResult | None = None
|
detection_result: TestResult | None = None
|
||||||
containment_result: ContainmentResult | None = None
|
containment_result: ContainmentResult | None = None
|
||||||
@@ -236,6 +328,8 @@ class TestOut(BaseModel):
|
|||||||
red_paused_seconds: int = 0
|
red_paused_seconds: int = 0
|
||||||
# Assign blue_paused_seconds = 0
|
# Assign blue_paused_seconds = 0
|
||||||
blue_paused_seconds: int = 0
|
blue_paused_seconds: int = 0
|
||||||
|
red_round_number: int = 1
|
||||||
|
blue_round_number: int = 1
|
||||||
|
|
||||||
# Remediation fields
|
# Remediation fields
|
||||||
remediation_steps: str | None = None
|
remediation_steps: str | None = None
|
||||||
@@ -247,6 +341,25 @@ class TestOut(BaseModel):
|
|||||||
# Assignment fields
|
# Assignment fields
|
||||||
red_tech_assignee: uuid.UUID | None = None
|
red_tech_assignee: uuid.UUID | None = None
|
||||||
blue_tech_assignee: uuid.UUID | None = None
|
blue_tech_assignee: uuid.UUID | None = None
|
||||||
|
# Resolved usernames — the operator (or lead) viewing a test they're not
|
||||||
|
# a lead/manager on can't call GET /users/operators (403), so they have
|
||||||
|
# no other way to resolve who an assignee ID actually is. Populated
|
||||||
|
# from the ORM relationship, same pattern as technique_name below.
|
||||||
|
red_tech_assignee_username: str | None = None
|
||||||
|
blue_tech_assignee_username: str | None = None
|
||||||
|
|
||||||
|
# Review assignment fields
|
||||||
|
red_reviewer_assignee: uuid.UUID | None = None
|
||||||
|
red_review_by: uuid.UUID | None = None
|
||||||
|
red_review_at: datetime | None = None
|
||||||
|
red_review_notes: str | None = None
|
||||||
|
blue_reviewer_assignee: uuid.UUID | None = None
|
||||||
|
blue_review_by: uuid.UUID | None = None
|
||||||
|
blue_review_at: datetime | None = None
|
||||||
|
blue_review_notes: str | None = None
|
||||||
|
red_reviewer_assignee_username: str | None = None
|
||||||
|
blue_reviewer_assignee_username: str | None = None
|
||||||
|
system_gaps: str | None = None
|
||||||
|
|
||||||
# On-hold fields
|
# On-hold fields
|
||||||
is_on_hold: bool = False
|
is_on_hold: bool = False
|
||||||
@@ -257,8 +370,8 @@ class TestOut(BaseModel):
|
|||||||
retest_of: uuid.UUID | None = None
|
retest_of: uuid.UUID | None = None
|
||||||
# Assign retest_count = 0
|
# Assign retest_count = 0
|
||||||
retest_count: int = 0
|
retest_count: int = 0
|
||||||
# Assign data_classification = "internal"
|
# Assign data_classification = "internal_use_only"
|
||||||
data_classification: str = "internal"
|
data_classification: str = "internal_use_only"
|
||||||
|
|
||||||
# Technique info (populated when joined)
|
# Technique info (populated when joined)
|
||||||
technique_mitre_id: str | None = None
|
technique_mitre_id: str | None = None
|
||||||
@@ -269,6 +382,9 @@ class TestOut(BaseModel):
|
|||||||
red_evidences: list[EvidenceOut] = []
|
red_evidences: list[EvidenceOut] = []
|
||||||
blue_evidences: list[EvidenceOut] = []
|
blue_evidences: list[EvidenceOut] = []
|
||||||
|
|
||||||
|
# Archived rounds — full history, oldest first (populated from the ORM relationship)
|
||||||
|
round_history: list[TestRoundHistoryOut] = []
|
||||||
|
|
||||||
model_config = ConfigDict(from_attributes=True)
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
@model_validator(mode="before")
|
@model_validator(mode="before")
|
||||||
@@ -301,6 +417,23 @@ class TestOut(BaseModel):
|
|||||||
except Exception: # nosec B110
|
except Exception: # nosec B110
|
||||||
pass # DetachedInstanceError or similar — leave technique fields None
|
pass # DetachedInstanceError or similar — leave technique fields None
|
||||||
|
|
||||||
|
# Resolved assignee usernames (lazy-load, same as technique above).
|
||||||
|
# A plain operator/tech viewing their own test can't call
|
||||||
|
# GET /users/operators (lead/manager-only) to resolve an assignee ID
|
||||||
|
# into a name, so the API needs to hand it over pre-resolved.
|
||||||
|
for attr, field in (
|
||||||
|
("red_tech_assigned_user", "red_tech_assignee_username"),
|
||||||
|
("blue_tech_assigned_user", "blue_tech_assignee_username"),
|
||||||
|
("red_reviewer", "red_reviewer_assignee_username"),
|
||||||
|
("blue_reviewer", "blue_reviewer_assignee_username"),
|
||||||
|
):
|
||||||
|
try:
|
||||||
|
user = getattr(obj, attr, None)
|
||||||
|
if user is not None:
|
||||||
|
obj.__dict__[field] = user.username
|
||||||
|
except Exception: # nosec B110
|
||||||
|
pass # DetachedInstanceError or similar — leave username None
|
||||||
|
|
||||||
# Only split evidences when they are already in memory (loaded via joinedload)
|
# Only split evidences when they are already in memory (loaded via joinedload)
|
||||||
raw_evs = obj.__dict__.get("evidences")
|
raw_evs = obj.__dict__.get("evidences")
|
||||||
if raw_evs is not None:
|
if raw_evs is not None:
|
||||||
@@ -325,4 +458,11 @@ class TestOut(BaseModel):
|
|||||||
obj.__dict__["red_evidences"] = red_evs
|
obj.__dict__["red_evidences"] = red_evs
|
||||||
obj.__dict__["blue_evidences"] = blue_evs
|
obj.__dict__["blue_evidences"] = blue_evs
|
||||||
|
|
||||||
|
# Only populate round history when already loaded (via joinedload)
|
||||||
|
raw_rounds = obj.__dict__.get("round_history")
|
||||||
|
if raw_rounds is not None:
|
||||||
|
obj.__dict__["round_history"] = [
|
||||||
|
TestRoundHistoryOut.model_validate(r) for r in raw_rounds
|
||||||
|
]
|
||||||
|
|
||||||
return obj
|
return obj
|
||||||
|
|||||||
@@ -102,6 +102,9 @@ class TestTemplateSummary(BaseModel):
|
|||||||
severity: str | None = None
|
severity: str | None = None
|
||||||
# Assign is_active = True
|
# Assign is_active = True
|
||||||
is_active: bool = True
|
is_active: bool = True
|
||||||
|
# Number of existing Test rows for this template's technique — lets the
|
||||||
|
# catalog UI warn before creating a likely-duplicate test.
|
||||||
|
existing_test_count: int = 0
|
||||||
|
|
||||||
# Assign model_config = ConfigDict(from_attributes=True)
|
# Assign model_config = ConfigDict(from_attributes=True)
|
||||||
model_config = ConfigDict(from_attributes=True)
|
model_config = ConfigDict(from_attributes=True)
|
||||||
@@ -127,3 +130,4 @@ class TestTemplateInstantiate(BaseModel):
|
|||||||
platform: str | None = None
|
platform: str | None = None
|
||||||
procedure_text: str | None = None
|
procedure_text: str | None = None
|
||||||
tool_used: str | None = None
|
tool_used: str | None = None
|
||||||
|
detect_procedure: str | None = None
|
||||||
|
|||||||
+95
-33
@@ -112,49 +112,57 @@ def _validate_password_strength(password: str) -> str:
|
|||||||
# ── Create ──────────────────────────────────────────────────────────
|
# ── Create ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
class UserCreate(BaseModel):
|
class UserCreate(BaseModel):
|
||||||
"""Payload for creating a new user."""
|
"""Payload for creating a new user.
|
||||||
|
|
||||||
# username: str
|
No password is set here — the admin creates the user with just a
|
||||||
username: str
|
name/email/role, then uses "Send Email" to let the user set their own
|
||||||
# Assign email = None
|
password via a one-time link. ``username`` is no longer admin-supplied;
|
||||||
email: str | None = None
|
the service layer derives it from ``email`` since it's still needed
|
||||||
# password: str
|
internally as the login identifier, but it is never shown in the UI.
|
||||||
password: str
|
"""
|
||||||
|
|
||||||
|
full_name: str
|
||||||
|
email: str
|
||||||
# Assign role = "viewer"
|
# Assign role = "viewer"
|
||||||
role: str = "viewer"
|
role: str = "viewer"
|
||||||
|
|
||||||
# Apply the @field_validator decorator
|
@field_validator("full_name")
|
||||||
@field_validator("username")
|
@classmethod
|
||||||
# Apply the @classmethod decorator
|
def full_name_not_blank(cls, v: str) -> str:
|
||||||
|
v = v.strip()
|
||||||
|
if not v:
|
||||||
|
raise ValueError("Full name is required")
|
||||||
|
return v
|
||||||
|
|
||||||
|
@field_validator("email")
|
||||||
|
@classmethod
|
||||||
|
def email_format(cls, v: str) -> str:
|
||||||
|
v = v.strip()
|
||||||
|
if "@" not in v or v.startswith("@") or v.endswith("@"):
|
||||||
|
raise ValueError("A valid email address is required")
|
||||||
|
return v
|
||||||
|
|
||||||
|
|
||||||
|
# ── Legacy direct-password creation (hidden from the UI, kept for a
|
||||||
|
# possible future revert — see UserCreate above for the active path) ──
|
||||||
|
|
||||||
|
class LegacyUserCreateWithPassword(BaseModel):
|
||||||
|
"""The old admin-sets-the-password creation payload. Not wired to any
|
||||||
|
active endpoint; retained only so this path can be restored quickly."""
|
||||||
|
|
||||||
|
username: str
|
||||||
|
email: str | None = None
|
||||||
|
password: str
|
||||||
|
role: str = "viewer"
|
||||||
|
|
||||||
|
@field_validator("username")
|
||||||
@classmethod
|
@classmethod
|
||||||
# Define function username_format
|
|
||||||
def username_format(cls, v: str) -> str:
|
def username_format(cls, v: str) -> str:
|
||||||
"""Validate the username field against the platform policy.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
v (str): Raw username value from the request body.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
str: The validated username.
|
|
||||||
"""
|
|
||||||
# Return _validate_username(v)
|
|
||||||
return _validate_username(v)
|
return _validate_username(v)
|
||||||
|
|
||||||
# Apply the @field_validator decorator
|
|
||||||
@field_validator("password")
|
@field_validator("password")
|
||||||
# Apply the @classmethod decorator
|
|
||||||
@classmethod
|
@classmethod
|
||||||
# Define function password_strength
|
|
||||||
def password_strength(cls, v: str) -> str:
|
def password_strength(cls, v: str) -> str:
|
||||||
"""Validate the password field against the complexity policy.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
v (str): Raw password value from the request body.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
str: The validated password.
|
|
||||||
"""
|
|
||||||
# Return _validate_password_strength(v)
|
|
||||||
return _validate_password_strength(v)
|
return _validate_password_strength(v)
|
||||||
|
|
||||||
|
|
||||||
@@ -163,13 +171,19 @@ class UserCreate(BaseModel):
|
|||||||
class UserUpdate(BaseModel):
|
class UserUpdate(BaseModel):
|
||||||
"""Payload for partially updating an existing user.
|
"""Payload for partially updating an existing user.
|
||||||
|
|
||||||
Every field is optional so callers send only what changed.
|
Every field is optional so callers send only what changed. ``password``
|
||||||
|
is intentionally still supported server-side (see
|
||||||
|
LegacyUserCreateWithPassword) but the current UI never sends it —
|
||||||
|
password changes go through the email-a-set-password-link flow instead.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
# Assign email = None
|
# Assign email = None
|
||||||
email: str | None = None
|
email: str | None = None
|
||||||
|
full_name: str | None = None
|
||||||
# Assign role = None
|
# Assign role = None
|
||||||
role: str | None = None
|
role: str | None = None
|
||||||
|
# Other roles this user can switch into via the top-bar switcher.
|
||||||
|
extra_roles: list[str] | None = None
|
||||||
# Assign is_active = None
|
# Assign is_active = None
|
||||||
is_active: bool | None = None
|
is_active: bool | None = None
|
||||||
# Assign password = None
|
# Assign password = None
|
||||||
@@ -248,10 +262,12 @@ class UserOut(BaseModel):
|
|||||||
id: uuid.UUID
|
id: uuid.UUID
|
||||||
# username: str
|
# username: str
|
||||||
username: str
|
username: str
|
||||||
|
full_name: str | None = None
|
||||||
# Assign email = None
|
# Assign email = None
|
||||||
email: str | None = None
|
email: str | None = None
|
||||||
# role: str
|
# role: str
|
||||||
role: str
|
role: str
|
||||||
|
extra_roles: list[str] = Field(default_factory=list)
|
||||||
# is_active: bool
|
# is_active: bool
|
||||||
is_active: bool
|
is_active: bool
|
||||||
# Assign must_change_password = True
|
# Assign must_change_password = True
|
||||||
@@ -284,3 +300,49 @@ class UserOut(BaseModel):
|
|||||||
self.jira_token_set = bool(self.jira_api_token)
|
self.jira_token_set = bool(self.jira_api_token)
|
||||||
self.tempo_token_set = bool(self.tempo_api_token)
|
self.tempo_token_set = bool(self.tempo_api_token)
|
||||||
return self
|
return self
|
||||||
|
|
||||||
|
|
||||||
|
class UserOperatorOut(BaseModel):
|
||||||
|
"""Minimal user representation for lead/admin operator-picker dropdowns."""
|
||||||
|
|
||||||
|
id: uuid.UUID
|
||||||
|
username: str
|
||||||
|
full_name: str | None = None
|
||||||
|
role: str
|
||||||
|
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
|
|
||||||
|
# ── Password setup / reset (via emailed one-time token) ─────────────
|
||||||
|
|
||||||
|
class SendPasswordEmailOut(BaseModel):
|
||||||
|
"""Response after an admin triggers a set-password email for a user."""
|
||||||
|
|
||||||
|
detail: str
|
||||||
|
|
||||||
|
|
||||||
|
class SetPasswordTokenValidateOut(BaseModel):
|
||||||
|
"""Response for checking a set-password token before rendering the form."""
|
||||||
|
|
||||||
|
valid: bool
|
||||||
|
full_name: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class SetPasswordRequest(BaseModel):
|
||||||
|
"""Payload for completing a password setup/reset via a one-time token."""
|
||||||
|
|
||||||
|
token: str
|
||||||
|
new_password: str
|
||||||
|
|
||||||
|
@field_validator("new_password")
|
||||||
|
@classmethod
|
||||||
|
def new_password_strength(cls, v: str) -> str:
|
||||||
|
return _validate_password_strength(v)
|
||||||
|
|
||||||
|
|
||||||
|
# ── Multi-role switching ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
class SwitchRoleRequest(BaseModel):
|
||||||
|
"""Payload for switching the caller's currently-active role."""
|
||||||
|
|
||||||
|
role: str
|
||||||
|
|||||||
+27
-6
@@ -1,7 +1,10 @@
|
|||||||
"""Seed script — creates the initial admin user if it does not already exist.
|
"""Seed script — creates the initial admin user if it does not already exist.
|
||||||
|
|
||||||
On first run the admin credentials are generated securely:
|
On first run the admin credentials are generated securely:
|
||||||
- Username is read from ``ADMIN_USERNAME`` env var (default: ``admin``).
|
- Email (the unique login identifier) is read from ``ADMIN_EMAIL`` env var.
|
||||||
|
Falls back to ``ADMIN_USERNAME`` if it's already email-shaped, otherwise
|
||||||
|
to a placeholder that MUST be changed before this account can receive
|
||||||
|
any webhook email (password reset, notifications, etc).
|
||||||
- Password is read from ``ADMIN_PASSWORD`` env var. When the variable is
|
- Password is read from ``ADMIN_PASSWORD`` env var. When the variable is
|
||||||
**not set**, a cryptographically random 16-character password is generated
|
**not set**, a cryptographically random 16-character password is generated
|
||||||
automatically and printed to the startup logs so the operator can copy it.
|
automatically and printed to the startup logs so the operator can copy it.
|
||||||
@@ -54,12 +57,25 @@ def seed_admin() -> None:
|
|||||||
# Assign admin_username = os.environ.get("ADMIN_USERNAME", "admin").strip() or "admin"
|
# Assign admin_username = os.environ.get("ADMIN_USERNAME", "admin").strip() or "admin"
|
||||||
admin_username = os.environ.get("ADMIN_USERNAME", "admin").strip() or "admin"
|
admin_username = os.environ.get("ADMIN_USERNAME", "admin").strip() or "admin"
|
||||||
|
|
||||||
# Assign existing = db.query(User).filter(User.username == admin_username).first()
|
# Email is the unique login identifier — prefer ADMIN_EMAIL, then an
|
||||||
existing = db.query(User).filter(User.username == admin_username).first()
|
# already email-shaped ADMIN_USERNAME, then a placeholder that needs
|
||||||
|
# fixing later (Users page) before this account can receive email.
|
||||||
|
admin_email = os.environ.get("ADMIN_EMAIL", "").strip()
|
||||||
|
if not admin_email:
|
||||||
|
admin_email = admin_username if "@" in admin_username else f"{admin_username}@localhost"
|
||||||
|
|
||||||
|
# Skip if ANY admin already exists — not just one matching this
|
||||||
|
# specific username/email. Matching only on the computed identifier
|
||||||
|
# is fragile: an install predating ADMIN_EMAIL has a user whose
|
||||||
|
# email was migration-backfilled to its bare username (e.g.
|
||||||
|
# "administrator"), which won't match a freshly-computed
|
||||||
|
# "...@localhost" placeholder, so every restart would otherwise
|
||||||
|
# create a new duplicate admin account.
|
||||||
|
existing = db.query(User).filter(User.role == "admin").first()
|
||||||
# Check: existing
|
# Check: existing
|
||||||
if existing:
|
if existing:
|
||||||
# Call print()
|
# Call print()
|
||||||
print(f"Admin user '{admin_username}' already exists — skipping.")
|
print(f"An admin user already exists ('{existing.email}') — skipping.")
|
||||||
# Return control to caller
|
# Return control to caller
|
||||||
return
|
return
|
||||||
|
|
||||||
@@ -78,7 +94,9 @@ def seed_admin() -> None:
|
|||||||
# Assign admin = User(
|
# Assign admin = User(
|
||||||
admin = User(
|
admin = User(
|
||||||
# Keyword argument: username
|
# Keyword argument: username
|
||||||
username=admin_username,
|
username=admin_email,
|
||||||
|
# Keyword argument: email
|
||||||
|
email=admin_email,
|
||||||
# Keyword argument: hashed_password
|
# Keyword argument: hashed_password
|
||||||
hashed_password=hash_password(admin_password),
|
hashed_password=hash_password(admin_password),
|
||||||
# Keyword argument: role
|
# Keyword argument: role
|
||||||
@@ -98,7 +116,10 @@ def seed_admin() -> None:
|
|||||||
# Call print()
|
# Call print()
|
||||||
print("=" * 60)
|
print("=" * 60)
|
||||||
# Call print()
|
# Call print()
|
||||||
print(f" Username : {admin_username}")
|
print(f" Login (email) : {admin_email}")
|
||||||
|
if "@localhost" in admin_email:
|
||||||
|
print(" ** No ADMIN_EMAIL was set — using a placeholder. **")
|
||||||
|
print(" ** Update it in Users before relying on emailed links. **")
|
||||||
# Check: password_was_generated
|
# Check: password_was_generated
|
||||||
if password_was_generated:
|
if password_was_generated:
|
||||||
# Call print()
|
# Call print()
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ from app.database import SessionLocal
|
|||||||
from app.models.audit import AuditLog
|
from app.models.audit import AuditLog
|
||||||
|
|
||||||
# Import TeamSide, TechniqueStatus, TestResult, TestState from app.models.enums
|
# Import TeamSide, TechniqueStatus, TestResult, TestState from app.models.enums
|
||||||
from app.models.enums import TeamSide, TechniqueStatus, TestResult, TestState
|
from app.models.enums import AttackSuccessResult, TeamSide, TechniqueStatus, TestResult, TestState
|
||||||
|
|
||||||
# Import Evidence from app.models.evidence
|
# Import Evidence from app.models.evidence
|
||||||
from app.models.evidence import Evidence
|
from app.models.evidence import Evidence
|
||||||
@@ -54,6 +54,7 @@ from app.models.test_template import TestTemplate
|
|||||||
|
|
||||||
# Import User from app.models.user
|
# Import User from app.models.user
|
||||||
from app.models.user import User
|
from app.models.user import User
|
||||||
|
from app.services.test_crud_service import determine_initial_classification
|
||||||
|
|
||||||
# Assign logger = logging.getLogger(__name__)
|
# Assign logger = logging.getLogger(__name__)
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -348,14 +349,20 @@ def _seed_tests(db: Session, users: list[User], techniques: list[Technique], cou
|
|||||||
state=state,
|
state=state,
|
||||||
# Keyword argument: created_at
|
# Keyword argument: created_at
|
||||||
created_at=datetime.utcnow() - timedelta(days=random.randint(0, 90)),
|
created_at=datetime.utcnow() - timedelta(days=random.randint(0, 90)),
|
||||||
|
data_classification=determine_initial_classification(technique),
|
||||||
)
|
)
|
||||||
|
|
||||||
# Populate team fields based on state
|
# Populate team fields based on state
|
||||||
if state in (TestState.blue_evaluating, TestState.in_review, TestState.validated, TestState.rejected):
|
if state in (TestState.blue_evaluating, TestState.in_review, TestState.validated, TestState.rejected):
|
||||||
# Assign test.red_summary = f"Attack executed successfully using {test.tool_used}."
|
# Assign test.red_summary = f"Attack executed successfully using {test.tool_used}."
|
||||||
test.red_summary = f"Attack executed successfully using {test.tool_used}."
|
test.red_summary = f"Attack executed successfully using {test.tool_used}."
|
||||||
# Assign test.attack_success = random.choice([True, True, True, False])
|
test.attack_success = random.choice([
|
||||||
test.attack_success = random.choice([True, True, True, False])
|
AttackSuccessResult.successful,
|
||||||
|
AttackSuccessResult.successful,
|
||||||
|
AttackSuccessResult.successful,
|
||||||
|
AttackSuccessResult.partially_successful,
|
||||||
|
AttackSuccessResult.not_successful,
|
||||||
|
])
|
||||||
|
|
||||||
# Check: state in (TestState.in_review, TestState.validated, TestState.rejec...
|
# Check: state in (TestState.in_review, TestState.validated, TestState.rejec...
|
||||||
if state in (TestState.in_review, TestState.validated, TestState.rejected):
|
if state in (TestState.in_review, TestState.validated, TestState.rejected):
|
||||||
|
|||||||
@@ -105,7 +105,7 @@ def get_tests_analytics(
|
|||||||
# Literal argument value
|
# Literal argument value
|
||||||
"tool_used": t.tool_used,
|
"tool_used": t.tool_used,
|
||||||
# Literal argument value
|
# Literal argument value
|
||||||
"attack_success": t.attack_success,
|
"attack_success": t.attack_success.value if t.attack_success else None,
|
||||||
# Literal argument value
|
# Literal argument value
|
||||||
"remediation_status": t.remediation_status,
|
"remediation_status": t.remediation_status,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -35,13 +35,14 @@ from typing import Any
|
|||||||
import requests
|
import requests
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app.models.enums import TestState, TestResult
|
from app.models.enums import AttackSuccessResult, TestState, TestResult
|
||||||
from app.models.evaluation_import import EvaluationImport
|
from app.models.evaluation_import import EvaluationImport
|
||||||
from app.models.technique import Technique
|
from app.models.technique import Technique
|
||||||
from app.models.test import Test
|
from app.models.test import Test
|
||||||
from app.models.user import User
|
from app.models.user import User
|
||||||
from app.services.audit_service import log_action
|
from app.services.audit_service import log_action
|
||||||
from app.services.status_service import recalculate_technique_status
|
from app.services.status_service import recalculate_technique_status
|
||||||
|
from app.services.test_crud_service import determine_initial_classification
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -625,12 +626,13 @@ def import_evaluation_round(
|
|||||||
procedure_text=procedure_text,
|
procedure_text=procedure_text,
|
||||||
created_by=current_user.id,
|
created_by=current_user.id,
|
||||||
state=TestState.in_review,
|
state=TestState.in_review,
|
||||||
attack_success=True,
|
attack_success=AttackSuccessResult.successful,
|
||||||
red_summary=red_summary,
|
red_summary=red_summary,
|
||||||
red_validation_status="approved",
|
red_validation_status="approved",
|
||||||
red_validated_by=current_user.id,
|
red_validated_by=current_user.id,
|
||||||
red_validated_at=datetime.utcnow(),
|
red_validated_at=datetime.utcnow(),
|
||||||
detection_result=detection_result,
|
detection_result=detection_result,
|
||||||
|
data_classification=determine_initial_classification(technique),
|
||||||
blue_validation_status=None,
|
blue_validation_status=None,
|
||||||
execution_date=datetime.utcnow(),
|
execution_date=datetime.utcnow(),
|
||||||
created_at=datetime.utcnow(),
|
created_at=datetime.utcnow(),
|
||||||
|
|||||||
@@ -92,6 +92,7 @@ def list_logs(
|
|||||||
"user_id": log.user_id,
|
"user_id": log.user_id,
|
||||||
# Literal argument value
|
# Literal argument value
|
||||||
"username": log.user.username if log.user else None,
|
"username": log.user.username if log.user else None,
|
||||||
|
"full_name": log.user.full_name if log.user else None,
|
||||||
# Literal argument value
|
# Literal argument value
|
||||||
"action": log.action,
|
"action": log.action,
|
||||||
# Literal argument value
|
# Literal argument value
|
||||||
|
|||||||
@@ -19,15 +19,15 @@ _DUMMY_HASH = "$2b$12$LJ3m4ys3Lg3dMO/NpNmOaeVwFpWJMxlB2FLmEAo9fZr.S8H1vC4Wy"
|
|||||||
|
|
||||||
|
|
||||||
# Define function authenticate_user
|
# Define function authenticate_user
|
||||||
def authenticate_user(db: Session, *, username: str, password: str) -> User:
|
def authenticate_user(db: Session, *, email: str, password: str) -> User:
|
||||||
"""Validate credentials and return the User.
|
"""Validate credentials and return the User.
|
||||||
|
|
||||||
Raises BusinessRuleViolation for invalid credentials.
|
Raises BusinessRuleViolation for invalid credentials.
|
||||||
Raises PermissionViolation for disabled account.
|
Raises PermissionViolation for disabled account.
|
||||||
Uses constant-time comparison to prevent timing attacks.
|
Uses constant-time comparison to prevent timing attacks.
|
||||||
"""
|
"""
|
||||||
# Assign user = db.query(User).filter(User.username == username).first()
|
# Assign user = db.query(User).filter(User.email == email).first()
|
||||||
user = db.query(User).filter(User.username == username).first()
|
user = db.query(User).filter(User.email == email).first()
|
||||||
# Assign hashed = user.hashed_password if user else _DUMMY_HASH
|
# Assign hashed = user.hashed_password if user else _DUMMY_HASH
|
||||||
hashed = user.hashed_password if user else _DUMMY_HASH
|
hashed = user.hashed_password if user else _DUMMY_HASH
|
||||||
# Assign password_valid = verify_password(password, hashed)
|
# Assign password_valid = verify_password(password, hashed)
|
||||||
@@ -36,7 +36,7 @@ def authenticate_user(db: Session, *, username: str, password: str) -> User:
|
|||||||
# Check: user is None or not password_valid
|
# Check: user is None or not password_valid
|
||||||
if user is None or not password_valid:
|
if user is None or not password_valid:
|
||||||
# Raise BusinessRuleViolation
|
# Raise BusinessRuleViolation
|
||||||
raise BusinessRuleViolation("Incorrect username or password")
|
raise BusinessRuleViolation("Incorrect email or password")
|
||||||
# Check: not user.is_active
|
# Check: not user.is_active
|
||||||
if not user.is_active:
|
if not user.is_active:
|
||||||
# Raise PermissionViolation
|
# Raise PermissionViolation
|
||||||
|
|||||||
@@ -23,8 +23,11 @@ from app.domain.errors import (
|
|||||||
PermissionViolation,
|
PermissionViolation,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Import Campaign, CampaignTest from app.models.campaign
|
# Import Campaign, CampaignModificationRequest, CampaignTest from app.models.campaign
|
||||||
from app.models.campaign import Campaign, CampaignTest
|
from app.models.campaign import Campaign, CampaignModificationRequest, CampaignTest
|
||||||
|
|
||||||
|
# Import AuditLog from app.models.audit
|
||||||
|
from app.models.audit import AuditLog
|
||||||
|
|
||||||
# Import Technique from app.models.technique
|
# Import Technique from app.models.technique
|
||||||
from app.models.technique import Technique
|
from app.models.technique import Technique
|
||||||
@@ -125,6 +128,9 @@ def serialize_campaign(db: Session, campaign: Campaign) -> dict:
|
|||||||
"scheduled_at": campaign.scheduled_at.isoformat() if campaign.scheduled_at else None,
|
"scheduled_at": campaign.scheduled_at.isoformat() if campaign.scheduled_at else None,
|
||||||
# Literal argument value
|
# Literal argument value
|
||||||
"completed_at": campaign.completed_at.isoformat() if campaign.completed_at else None,
|
"completed_at": campaign.completed_at.isoformat() if campaign.completed_at else None,
|
||||||
|
"approved_by": str(campaign.approved_by) if campaign.approved_by else None,
|
||||||
|
"approved_at": campaign.approved_at.isoformat() if campaign.approved_at else None,
|
||||||
|
"rejection_reason": campaign.rejection_reason,
|
||||||
# Literal argument value
|
# Literal argument value
|
||||||
"target_platform": campaign.target_platform,
|
"target_platform": campaign.target_platform,
|
||||||
# Literal argument value
|
# Literal argument value
|
||||||
@@ -275,9 +281,20 @@ def create_campaign(
|
|||||||
tags: Optional[list[str]] = None,
|
tags: Optional[list[str]] = None,
|
||||||
# Entry: scheduled_at
|
# Entry: scheduled_at
|
||||||
scheduled_at: Optional[str] = None,
|
scheduled_at: Optional[str] = None,
|
||||||
|
# A manager's own campaign is auto-approved on creation instead of
|
||||||
|
# going through the draft -> submit -> pending_approval queue (a
|
||||||
|
# manager is the same role that would otherwise approve it).
|
||||||
|
auto_approve: bool = False,
|
||||||
start_date: Optional[str] = None,
|
start_date: Optional[str] = None,
|
||||||
|
approver_id: Optional[uuid.UUID] = None,
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""Create a new campaign. Does not commit; caller commits."""
|
"""Create a new campaign. Does not commit; caller commits.
|
||||||
|
|
||||||
|
Raises BusinessRuleViolation if auto_approve is set without a start_date.
|
||||||
|
"""
|
||||||
|
if auto_approve and not start_date:
|
||||||
|
raise BusinessRuleViolation("start_date is required to auto-approve a campaign")
|
||||||
|
|
||||||
# Assign campaign = Campaign(
|
# Assign campaign = Campaign(
|
||||||
campaign = Campaign(
|
campaign = Campaign(
|
||||||
# Keyword argument: name
|
# Keyword argument: name
|
||||||
@@ -296,8 +313,12 @@ def create_campaign(
|
|||||||
created_by=creator_id,
|
created_by=creator_id,
|
||||||
# Keyword argument: scheduled_at
|
# Keyword argument: scheduled_at
|
||||||
scheduled_at=datetime.fromisoformat(scheduled_at) if scheduled_at else None,
|
scheduled_at=datetime.fromisoformat(scheduled_at) if scheduled_at else None,
|
||||||
start_date=datetime.fromisoformat(start_date) if start_date else None,
|
|
||||||
)
|
)
|
||||||
|
if auto_approve:
|
||||||
|
campaign.start_date = datetime.fromisoformat(start_date)
|
||||||
|
campaign.status = "active"
|
||||||
|
campaign.approved_by = approver_id
|
||||||
|
campaign.approved_at = datetime.utcnow()
|
||||||
# Stage new record(s) for database insertion
|
# Stage new record(s) for database insertion
|
||||||
db.add(campaign)
|
db.add(campaign)
|
||||||
# Flush changes to DB without committing the transaction
|
# Flush changes to DB without committing the transaction
|
||||||
@@ -306,6 +327,104 @@ def create_campaign(
|
|||||||
return serialize_campaign(db, campaign)
|
return serialize_campaign(db, campaign)
|
||||||
|
|
||||||
|
|
||||||
|
def submit_campaign_for_approval(
|
||||||
|
db: Session,
|
||||||
|
campaign_id: str,
|
||||||
|
*,
|
||||||
|
submitter_id: uuid.UUID,
|
||||||
|
submitter_role: str,
|
||||||
|
) -> Campaign:
|
||||||
|
"""Submit a draft campaign into the manager's approval queue.
|
||||||
|
|
||||||
|
Raises EntityNotFoundError, PermissionViolation, BusinessRuleViolation.
|
||||||
|
Does not commit; caller commits.
|
||||||
|
"""
|
||||||
|
campaign = db.query(Campaign).filter(Campaign.id == uuid.UUID(campaign_id)).first()
|
||||||
|
if not campaign:
|
||||||
|
raise EntityNotFoundError("Campaign", campaign_id)
|
||||||
|
|
||||||
|
if str(campaign.created_by) != str(submitter_id) and submitter_role != "admin":
|
||||||
|
raise PermissionViolation("Only the creator or admin can submit this campaign for approval")
|
||||||
|
|
||||||
|
if campaign.status != "draft":
|
||||||
|
raise BusinessRuleViolation("Only draft campaigns can be submitted for approval")
|
||||||
|
|
||||||
|
test_count = db.query(CampaignTest).filter(CampaignTest.campaign_id == campaign.id).count()
|
||||||
|
if test_count == 0:
|
||||||
|
raise BusinessRuleViolation("Campaign must have at least one test to submit for approval")
|
||||||
|
|
||||||
|
campaign.status = "pending_approval"
|
||||||
|
db.flush()
|
||||||
|
return campaign
|
||||||
|
|
||||||
|
|
||||||
|
def approve_campaign(
|
||||||
|
db: Session,
|
||||||
|
campaign_id: str,
|
||||||
|
*,
|
||||||
|
approver_id: uuid.UUID,
|
||||||
|
start_date: str,
|
||||||
|
) -> Campaign:
|
||||||
|
"""Approve a pending campaign: fix its start date and activate it.
|
||||||
|
|
||||||
|
Also allows approving a previously-rejected campaign directly (status
|
||||||
|
``draft`` with a ``rejection_reason`` set) — a manager can edit it via
|
||||||
|
``update_campaign()`` and then approve it themselves, without needing
|
||||||
|
the original lead to resubmit it through the normal queue.
|
||||||
|
|
||||||
|
Raises EntityNotFoundError, BusinessRuleViolation.
|
||||||
|
Does not commit; caller commits.
|
||||||
|
"""
|
||||||
|
campaign = db.query(Campaign).filter(Campaign.id == uuid.UUID(campaign_id)).first()
|
||||||
|
if not campaign:
|
||||||
|
raise EntityNotFoundError("Campaign", campaign_id)
|
||||||
|
|
||||||
|
is_previously_rejected_draft = campaign.status == "draft" and campaign.rejection_reason
|
||||||
|
if campaign.status != "pending_approval" and not is_previously_rejected_draft:
|
||||||
|
raise BusinessRuleViolation("Only campaigns pending approval (or previously rejected) can be approved")
|
||||||
|
|
||||||
|
if not start_date:
|
||||||
|
raise BusinessRuleViolation("start_date is required to approve a campaign")
|
||||||
|
|
||||||
|
campaign.start_date = datetime.fromisoformat(start_date)
|
||||||
|
campaign.status = "active"
|
||||||
|
campaign.approved_by = approver_id
|
||||||
|
campaign.approved_at = datetime.utcnow()
|
||||||
|
campaign.rejection_reason = None
|
||||||
|
db.flush()
|
||||||
|
return campaign
|
||||||
|
|
||||||
|
|
||||||
|
def reject_campaign(
|
||||||
|
db: Session,
|
||||||
|
campaign_id: str,
|
||||||
|
*,
|
||||||
|
rejecter_id: uuid.UUID,
|
||||||
|
reason: str,
|
||||||
|
) -> Campaign:
|
||||||
|
"""Reject a pending campaign, returning it to draft with a mandatory reason.
|
||||||
|
|
||||||
|
Raises EntityNotFoundError, BusinessRuleViolation.
|
||||||
|
Does not commit; caller commits.
|
||||||
|
"""
|
||||||
|
campaign = db.query(Campaign).filter(Campaign.id == uuid.UUID(campaign_id)).first()
|
||||||
|
if not campaign:
|
||||||
|
raise EntityNotFoundError("Campaign", campaign_id)
|
||||||
|
|
||||||
|
if campaign.status != "pending_approval":
|
||||||
|
raise BusinessRuleViolation("Only campaigns pending approval can be rejected")
|
||||||
|
|
||||||
|
if not reason or not reason.strip():
|
||||||
|
raise BusinessRuleViolation("A rejection reason is required")
|
||||||
|
|
||||||
|
campaign.status = "draft"
|
||||||
|
campaign.rejection_reason = reason.strip()
|
||||||
|
campaign.approved_by = None
|
||||||
|
campaign.approved_at = None
|
||||||
|
db.flush()
|
||||||
|
return campaign
|
||||||
|
|
||||||
|
|
||||||
# Define function get_campaign_detail
|
# Define function get_campaign_detail
|
||||||
def get_campaign_detail(db: Session, campaign_id: str) -> dict:
|
def get_campaign_detail(db: Session, campaign_id: str) -> dict:
|
||||||
"""Get detailed campaign info including tests and progress.
|
"""Get detailed campaign info including tests and progress.
|
||||||
@@ -341,7 +460,7 @@ def update_campaign(
|
|||||||
Does not commit; caller commits.
|
Does not commit; caller commits.
|
||||||
"""
|
"""
|
||||||
# Assign campaign = db.query(Campaign).filter(Campaign.id == campaign_id).first()
|
# Assign campaign = db.query(Campaign).filter(Campaign.id == campaign_id).first()
|
||||||
campaign = db.query(Campaign).filter(Campaign.id == campaign_id).first()
|
campaign = db.query(Campaign).filter(Campaign.id == uuid.UUID(campaign_id)).first()
|
||||||
# Check: not campaign
|
# Check: not campaign
|
||||||
if not campaign:
|
if not campaign:
|
||||||
# Raise EntityNotFoundError
|
# Raise EntityNotFoundError
|
||||||
@@ -352,17 +471,21 @@ def update_campaign(
|
|||||||
# Raise BusinessRuleViolation
|
# Raise BusinessRuleViolation
|
||||||
raise BusinessRuleViolation("Can only update draft or active campaigns")
|
raise BusinessRuleViolation("Can only update draft or active campaigns")
|
||||||
|
|
||||||
# Check: str(campaign.created_by) != str(updater_id) and updater_role != "ad...
|
# A manager may edit a campaign they (or another manager) previously
|
||||||
if str(campaign.created_by) != str(updater_id) and updater_role != "admin":
|
# rejected — this is what lets them fix it up and self-approve it,
|
||||||
|
# instead of bouncing it back to the original lead to resubmit.
|
||||||
|
is_manager_editing_own_rejection = (
|
||||||
|
updater_role == "manager" and campaign.status == "draft" and campaign.rejection_reason
|
||||||
|
)
|
||||||
|
is_owner_or_admin = str(campaign.created_by) == str(updater_id) or updater_role == "admin"
|
||||||
|
if not is_owner_or_admin and not is_manager_editing_own_rejection:
|
||||||
# Raise PermissionViolation
|
# Raise PermissionViolation
|
||||||
raise PermissionViolation("Only the creator or admin can update this campaign")
|
raise PermissionViolation("Only the creator, admin, or a manager reviewing a rejected campaign can update this campaign")
|
||||||
|
|
||||||
# Check: "scheduled_at" in fields and fields["scheduled_at"]
|
# Check: "scheduled_at" in fields and fields["scheduled_at"]
|
||||||
if "scheduled_at" in fields and fields["scheduled_at"]:
|
if "scheduled_at" in fields and fields["scheduled_at"]:
|
||||||
# Assign fields["scheduled_at"] = datetime.fromisoformat(fields["scheduled_at"])
|
# Assign fields["scheduled_at"] = datetime.fromisoformat(fields["scheduled_at"])
|
||||||
fields["scheduled_at"] = datetime.fromisoformat(fields["scheduled_at"])
|
fields["scheduled_at"] = datetime.fromisoformat(fields["scheduled_at"])
|
||||||
if "start_date" in fields and fields["start_date"]:
|
|
||||||
fields["start_date"] = datetime.fromisoformat(fields["start_date"])
|
|
||||||
|
|
||||||
# Iterate over fields.items()
|
# Iterate over fields.items()
|
||||||
for field, value in fields.items():
|
for field, value in fields.items():
|
||||||
@@ -390,6 +513,8 @@ def add_test_to_campaign(
|
|||||||
depends_on: Optional[str] = None,
|
depends_on: Optional[str] = None,
|
||||||
# Entry: phase
|
# Entry: phase
|
||||||
phase: Optional[str] = None,
|
phase: Optional[str] = None,
|
||||||
|
# Entry: allowed_statuses
|
||||||
|
allowed_statuses: tuple[str, ...] = ("draft",),
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""Add a test to a campaign with optional ordering and dependency.
|
"""Add a test to a campaign with optional ordering and dependency.
|
||||||
|
|
||||||
@@ -397,20 +522,22 @@ def add_test_to_campaign(
|
|||||||
Raises BusinessRuleViolation for invalid state or circular dependency.
|
Raises BusinessRuleViolation for invalid state or circular dependency.
|
||||||
Does not commit; caller commits.
|
Does not commit; caller commits.
|
||||||
"""
|
"""
|
||||||
# Assign campaign = db.query(Campaign).filter(Campaign.id == campaign_id).first()
|
# Assign campaign = db.query(Campaign).filter(Campaign.id == uuid.UUID(campaign_id)).first()
|
||||||
campaign = db.query(Campaign).filter(Campaign.id == campaign_id).first()
|
campaign = db.query(Campaign).filter(Campaign.id == uuid.UUID(campaign_id)).first()
|
||||||
# Check: not campaign
|
# Check: not campaign
|
||||||
if not campaign:
|
if not campaign:
|
||||||
# Raise EntityNotFoundError
|
# Raise EntityNotFoundError
|
||||||
raise EntityNotFoundError("Campaign", campaign_id)
|
raise EntityNotFoundError("Campaign", campaign_id)
|
||||||
|
|
||||||
# Check: campaign.status not in ("draft", "active")
|
# Check: campaign.status not in allowed_statuses
|
||||||
if campaign.status not in ("draft", "active"):
|
if campaign.status not in allowed_statuses:
|
||||||
# Raise BusinessRuleViolation
|
# Raise BusinessRuleViolation
|
||||||
raise BusinessRuleViolation("Can only add tests to draft or active campaigns")
|
raise BusinessRuleViolation(
|
||||||
|
f"Can only add tests to a campaign in one of: {', '.join(allowed_statuses)}"
|
||||||
|
)
|
||||||
|
|
||||||
# Assign test = db.query(Test).filter(Test.id == test_id).first()
|
# Assign test = db.query(Test).filter(Test.id == uuid.UUID(test_id)).first()
|
||||||
test = db.query(Test).filter(Test.id == test_id).first()
|
test = db.query(Test).filter(Test.id == uuid.UUID(test_id)).first()
|
||||||
# Check: not test
|
# Check: not test
|
||||||
if not test:
|
if not test:
|
||||||
# Raise EntityNotFoundError
|
# Raise EntityNotFoundError
|
||||||
@@ -426,7 +553,7 @@ def add_test_to_campaign(
|
|||||||
max_order = (
|
max_order = (
|
||||||
db.query(CampaignTest.order_index)
|
db.query(CampaignTest.order_index)
|
||||||
# Chain .filter() call
|
# Chain .filter() call
|
||||||
.filter(CampaignTest.campaign_id == campaign_id)
|
.filter(CampaignTest.campaign_id == uuid.UUID(campaign_id))
|
||||||
# Chain .order_by() call
|
# Chain .order_by() call
|
||||||
.order_by(CampaignTest.order_index.desc())
|
.order_by(CampaignTest.order_index.desc())
|
||||||
# Chain .first() call
|
# Chain .first() call
|
||||||
@@ -459,9 +586,9 @@ def add_test_to_campaign(
|
|||||||
# Keyword argument: id
|
# Keyword argument: id
|
||||||
id=ct_id,
|
id=ct_id,
|
||||||
# Keyword argument: campaign_id
|
# Keyword argument: campaign_id
|
||||||
campaign_id=campaign_id,
|
campaign_id=uuid.UUID(campaign_id),
|
||||||
# Keyword argument: test_id
|
# Keyword argument: test_id
|
||||||
test_id=test_id,
|
test_id=uuid.UUID(test_id),
|
||||||
# Keyword argument: order_index
|
# Keyword argument: order_index
|
||||||
order_index=final_order_index,
|
order_index=final_order_index,
|
||||||
# Keyword argument: depends_on
|
# Keyword argument: depends_on
|
||||||
@@ -492,32 +619,41 @@ def add_test_to_campaign(
|
|||||||
|
|
||||||
|
|
||||||
# Define function remove_test_from_campaign
|
# Define function remove_test_from_campaign
|
||||||
def remove_test_from_campaign(db: Session, campaign_id: str, campaign_test_id: str) -> None:
|
def remove_test_from_campaign(
|
||||||
|
db: Session,
|
||||||
|
campaign_id: str,
|
||||||
|
campaign_test_id: str,
|
||||||
|
*,
|
||||||
|
# Entry: allowed_statuses
|
||||||
|
allowed_statuses: tuple[str, ...] = ("draft",),
|
||||||
|
) -> None:
|
||||||
"""Remove a test from a campaign.
|
"""Remove a test from a campaign.
|
||||||
|
|
||||||
Raises EntityNotFoundError for missing campaign or campaign test.
|
Raises EntityNotFoundError for missing campaign or campaign test.
|
||||||
Raises BusinessRuleViolation for invalid state.
|
Raises BusinessRuleViolation for invalid state.
|
||||||
Does not commit; caller commits.
|
Does not commit; caller commits.
|
||||||
"""
|
"""
|
||||||
# Assign campaign = db.query(Campaign).filter(Campaign.id == campaign_id).first()
|
# Assign campaign = db.query(Campaign).filter(Campaign.id == uuid.UUID(campaign_id)).first()
|
||||||
campaign = db.query(Campaign).filter(Campaign.id == campaign_id).first()
|
campaign = db.query(Campaign).filter(Campaign.id == uuid.UUID(campaign_id)).first()
|
||||||
# Check: not campaign
|
# Check: not campaign
|
||||||
if not campaign:
|
if not campaign:
|
||||||
# Raise EntityNotFoundError
|
# Raise EntityNotFoundError
|
||||||
raise EntityNotFoundError("Campaign", campaign_id)
|
raise EntityNotFoundError("Campaign", campaign_id)
|
||||||
|
|
||||||
# Check: campaign.status not in ("draft", "active")
|
# Check: campaign.status not in allowed_statuses
|
||||||
if campaign.status not in ("draft", "active"):
|
if campaign.status not in allowed_statuses:
|
||||||
# Raise BusinessRuleViolation
|
# Raise BusinessRuleViolation
|
||||||
raise BusinessRuleViolation("Can only modify draft or active campaigns")
|
raise BusinessRuleViolation(
|
||||||
|
f"Can only modify tests on a campaign in one of: {', '.join(allowed_statuses)}"
|
||||||
|
)
|
||||||
|
|
||||||
# Assign ct = (
|
# Assign ct = (
|
||||||
ct = (
|
ct = (
|
||||||
db.query(CampaignTest)
|
db.query(CampaignTest)
|
||||||
# Chain .filter() call
|
# Chain .filter() call
|
||||||
.filter(
|
.filter(
|
||||||
CampaignTest.id == campaign_test_id,
|
CampaignTest.id == uuid.UUID(campaign_test_id),
|
||||||
CampaignTest.campaign_id == campaign_id,
|
CampaignTest.campaign_id == uuid.UUID(campaign_id),
|
||||||
)
|
)
|
||||||
# Chain .first() call
|
# Chain .first() call
|
||||||
.first()
|
.first()
|
||||||
@@ -568,7 +704,7 @@ def activate_campaign(db: Session, campaign_id: str) -> Campaign:
|
|||||||
Does not commit; caller commits.
|
Does not commit; caller commits.
|
||||||
"""
|
"""
|
||||||
# Assign campaign = db.query(Campaign).filter(Campaign.id == campaign_id).first()
|
# Assign campaign = db.query(Campaign).filter(Campaign.id == campaign_id).first()
|
||||||
campaign = db.query(Campaign).filter(Campaign.id == campaign_id).first()
|
campaign = db.query(Campaign).filter(Campaign.id == uuid.UUID(campaign_id)).first()
|
||||||
# Check: not campaign
|
# Check: not campaign
|
||||||
if not campaign:
|
if not campaign:
|
||||||
# Raise EntityNotFoundError
|
# Raise EntityNotFoundError
|
||||||
@@ -580,7 +716,7 @@ def activate_campaign(db: Session, campaign_id: str) -> Campaign:
|
|||||||
raise BusinessRuleViolation("Only draft campaigns can be activated")
|
raise BusinessRuleViolation("Only draft campaigns can be activated")
|
||||||
|
|
||||||
# Assign test_count = db.query(CampaignTest).filter(CampaignTest.campaign_id == campaign_...
|
# Assign test_count = db.query(CampaignTest).filter(CampaignTest.campaign_id == campaign_...
|
||||||
test_count = db.query(CampaignTest).filter(CampaignTest.campaign_id == campaign_id).count()
|
test_count = db.query(CampaignTest).filter(CampaignTest.campaign_id == campaign.id).count()
|
||||||
# Check: test_count == 0
|
# Check: test_count == 0
|
||||||
if test_count == 0:
|
if test_count == 0:
|
||||||
# Raise BusinessRuleViolation
|
# Raise BusinessRuleViolation
|
||||||
@@ -619,6 +755,15 @@ def complete_campaign(db: Session, campaign_id: str) -> Campaign:
|
|||||||
campaign.completed_at = datetime.utcnow()
|
campaign.completed_at = datetime.utcnow()
|
||||||
# Flush changes to DB without committing the transaction
|
# Flush changes to DB without committing the transaction
|
||||||
db.flush()
|
db.flush()
|
||||||
|
|
||||||
|
from app.services.notification_service import notify_user_by_email
|
||||||
|
notify_user_by_email(
|
||||||
|
db, campaign.created_by,
|
||||||
|
preference_key="email_on_campaign_completed",
|
||||||
|
subject=f"Campaign Completed: {campaign.name}",
|
||||||
|
message=f'Your campaign "{campaign.name}" has been completed.',
|
||||||
|
)
|
||||||
|
|
||||||
# Return campaign
|
# Return campaign
|
||||||
return campaign
|
return campaign
|
||||||
|
|
||||||
@@ -838,3 +983,221 @@ def get_campaign_history(db: Session, campaign_id: str) -> dict:
|
|||||||
for child in children
|
for child in children
|
||||||
],
|
],
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ── Campaign Modification Requests ──────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def serialize_modification_request(db: Session, request: CampaignModificationRequest) -> dict:
|
||||||
|
"""Serialize a modification request, including the test's display name."""
|
||||||
|
test = request.test
|
||||||
|
return {
|
||||||
|
"id": str(request.id),
|
||||||
|
"campaign_id": str(request.campaign_id),
|
||||||
|
"campaign_name": request.campaign.name if request.campaign else None,
|
||||||
|
"requested_by": str(request.requested_by) if request.requested_by else None,
|
||||||
|
"action": request.action,
|
||||||
|
"test_id": str(request.test_id) if request.test_id else None,
|
||||||
|
"test_name": test.name if test else None,
|
||||||
|
"order_index": request.order_index,
|
||||||
|
"phase": request.phase,
|
||||||
|
"justification": request.justification,
|
||||||
|
"status": request.status,
|
||||||
|
"reviewed_by": str(request.reviewed_by) if request.reviewed_by else None,
|
||||||
|
"reviewed_at": request.reviewed_at.isoformat() if request.reviewed_at else None,
|
||||||
|
"review_notes": request.review_notes,
|
||||||
|
"created_at": request.created_at.isoformat() if request.created_at else None,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def create_modification_request(
|
||||||
|
db: Session,
|
||||||
|
campaign_id: str,
|
||||||
|
*,
|
||||||
|
requester_id: uuid.UUID,
|
||||||
|
action: str,
|
||||||
|
test_id: str,
|
||||||
|
justification: str,
|
||||||
|
order_index: Optional[int] = None,
|
||||||
|
phase: Optional[str] = None,
|
||||||
|
) -> CampaignModificationRequest:
|
||||||
|
"""File a request to add/remove a test on an active campaign.
|
||||||
|
|
||||||
|
Raises EntityNotFoundError, BusinessRuleViolation.
|
||||||
|
Does not commit; caller commits.
|
||||||
|
"""
|
||||||
|
campaign = db.query(Campaign).filter(Campaign.id == uuid.UUID(campaign_id)).first()
|
||||||
|
if not campaign:
|
||||||
|
raise EntityNotFoundError("Campaign", campaign_id)
|
||||||
|
|
||||||
|
if campaign.status != "active":
|
||||||
|
raise BusinessRuleViolation("Modification requests can only be created for active campaigns")
|
||||||
|
|
||||||
|
if action not in ("add_test", "remove_test"):
|
||||||
|
raise BusinessRuleViolation("action must be 'add_test' or 'remove_test'")
|
||||||
|
|
||||||
|
if not justification or not justification.strip():
|
||||||
|
raise BusinessRuleViolation("A justification is required")
|
||||||
|
|
||||||
|
test = db.query(Test).filter(Test.id == uuid.UUID(test_id)).first()
|
||||||
|
if not test:
|
||||||
|
raise EntityNotFoundError("Test", test_id)
|
||||||
|
|
||||||
|
if action == "remove_test":
|
||||||
|
ct = (
|
||||||
|
db.query(CampaignTest)
|
||||||
|
.filter(CampaignTest.campaign_id == campaign.id, CampaignTest.test_id == test.id)
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
if not ct:
|
||||||
|
raise EntityNotFoundError("CampaignTest", test_id)
|
||||||
|
|
||||||
|
request = CampaignModificationRequest(
|
||||||
|
campaign_id=campaign.id,
|
||||||
|
requested_by=requester_id,
|
||||||
|
action=action,
|
||||||
|
test_id=test.id,
|
||||||
|
order_index=order_index,
|
||||||
|
phase=phase,
|
||||||
|
justification=justification.strip(),
|
||||||
|
status="pending",
|
||||||
|
)
|
||||||
|
db.add(request)
|
||||||
|
db.flush()
|
||||||
|
return request
|
||||||
|
|
||||||
|
|
||||||
|
def approve_modification_request(
|
||||||
|
db: Session,
|
||||||
|
request_id: str,
|
||||||
|
*,
|
||||||
|
reviewer_id: uuid.UUID,
|
||||||
|
) -> CampaignModificationRequest:
|
||||||
|
"""Approve a pending modification request and apply the underlying test change.
|
||||||
|
|
||||||
|
Raises EntityNotFoundError, BusinessRuleViolation.
|
||||||
|
Does not commit; caller commits.
|
||||||
|
"""
|
||||||
|
request = (
|
||||||
|
db.query(CampaignModificationRequest)
|
||||||
|
.filter(CampaignModificationRequest.id == uuid.UUID(request_id))
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
if not request:
|
||||||
|
raise EntityNotFoundError("CampaignModificationRequest", request_id)
|
||||||
|
|
||||||
|
if request.status != "pending":
|
||||||
|
raise BusinessRuleViolation("Only pending modification requests can be approved")
|
||||||
|
|
||||||
|
# A "remove_test" approval deletes the underlying Test row below, which
|
||||||
|
# nulls this row's test_id (test_id uses ON DELETE SET NULL specifically
|
||||||
|
# so this audit record survives that delete instead of being cascaded
|
||||||
|
# away with it — see CampaignModificationRequest.test_id).
|
||||||
|
request.status = "approved"
|
||||||
|
request.reviewed_by = reviewer_id
|
||||||
|
request.reviewed_at = datetime.utcnow()
|
||||||
|
db.flush()
|
||||||
|
|
||||||
|
if request.action == "add_test":
|
||||||
|
add_test_to_campaign(
|
||||||
|
db, str(request.campaign_id),
|
||||||
|
test_id=str(request.test_id),
|
||||||
|
order_index=request.order_index,
|
||||||
|
phase=request.phase,
|
||||||
|
allowed_statuses=("active",),
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
ct = (
|
||||||
|
db.query(CampaignTest)
|
||||||
|
.filter(
|
||||||
|
CampaignTest.campaign_id == request.campaign_id,
|
||||||
|
CampaignTest.test_id == request.test_id,
|
||||||
|
)
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
if not ct:
|
||||||
|
raise EntityNotFoundError("CampaignTest", str(request.test_id))
|
||||||
|
remove_test_from_campaign(
|
||||||
|
db, str(request.campaign_id), str(ct.id), allowed_statuses=("active",),
|
||||||
|
)
|
||||||
|
|
||||||
|
db.flush()
|
||||||
|
return request
|
||||||
|
|
||||||
|
|
||||||
|
def reject_modification_request(
|
||||||
|
db: Session,
|
||||||
|
request_id: str,
|
||||||
|
*,
|
||||||
|
reviewer_id: uuid.UUID,
|
||||||
|
review_notes: str,
|
||||||
|
) -> CampaignModificationRequest:
|
||||||
|
"""Reject a pending modification request. No change is applied to the campaign.
|
||||||
|
|
||||||
|
Raises EntityNotFoundError, BusinessRuleViolation.
|
||||||
|
Does not commit; caller commits.
|
||||||
|
"""
|
||||||
|
request = (
|
||||||
|
db.query(CampaignModificationRequest)
|
||||||
|
.filter(CampaignModificationRequest.id == uuid.UUID(request_id))
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
if not request:
|
||||||
|
raise EntityNotFoundError("CampaignModificationRequest", request_id)
|
||||||
|
|
||||||
|
if request.status != "pending":
|
||||||
|
raise BusinessRuleViolation("Only pending modification requests can be rejected")
|
||||||
|
|
||||||
|
if not review_notes or not review_notes.strip():
|
||||||
|
raise BusinessRuleViolation("Review notes are required to reject a modification request")
|
||||||
|
|
||||||
|
request.status = "rejected"
|
||||||
|
request.reviewed_by = reviewer_id
|
||||||
|
request.reviewed_at = datetime.utcnow()
|
||||||
|
request.review_notes = review_notes.strip()
|
||||||
|
db.flush()
|
||||||
|
return request
|
||||||
|
|
||||||
|
|
||||||
|
def list_modification_requests(
|
||||||
|
db: Session,
|
||||||
|
*,
|
||||||
|
campaign_id: Optional[str] = None,
|
||||||
|
status: Optional[str] = None,
|
||||||
|
) -> list[dict]:
|
||||||
|
"""List modification requests, optionally filtered by campaign and/or status."""
|
||||||
|
query = db.query(CampaignModificationRequest)
|
||||||
|
if campaign_id:
|
||||||
|
query = query.filter(CampaignModificationRequest.campaign_id == uuid.UUID(campaign_id))
|
||||||
|
if status:
|
||||||
|
query = query.filter(CampaignModificationRequest.status == status)
|
||||||
|
requests = query.order_by(CampaignModificationRequest.created_at.desc()).all()
|
||||||
|
return [serialize_modification_request(db, r) for r in requests]
|
||||||
|
|
||||||
|
|
||||||
|
def get_campaign_timeline(db: Session, campaign_id: str) -> list[dict]:
|
||||||
|
"""Return the chronological audit-log history for a campaign.
|
||||||
|
|
||||||
|
Raises EntityNotFoundError if the campaign does not exist.
|
||||||
|
"""
|
||||||
|
campaign = db.query(Campaign).filter(Campaign.id == uuid.UUID(campaign_id)).first()
|
||||||
|
if not campaign:
|
||||||
|
raise EntityNotFoundError("Campaign", campaign_id)
|
||||||
|
|
||||||
|
logs = (
|
||||||
|
db.query(AuditLog)
|
||||||
|
.filter(AuditLog.entity_type == "campaign", AuditLog.entity_id == str(campaign_id))
|
||||||
|
.order_by(AuditLog.timestamp.asc())
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"id": str(log.id),
|
||||||
|
"action": log.action,
|
||||||
|
"user_id": str(log.user_id) if log.user_id else None,
|
||||||
|
"timestamp": log.timestamp.isoformat() if log.timestamp else None,
|
||||||
|
"details": log.details or {},
|
||||||
|
}
|
||||||
|
for log in logs
|
||||||
|
]
|
||||||
|
|||||||
@@ -72,32 +72,24 @@ def _clone_campaign(db: Session, original: Campaign) -> Campaign:
|
|||||||
1. Clone the campaign with a date-stamped name.
|
1. Clone the campaign with a date-stamped name.
|
||||||
2. For each ``CampaignTest`` in the original, create a new ``Test``
|
2. For each ``CampaignTest`` in the original, create a new ``Test``
|
||||||
with the same base data (in ``draft`` state) and link it.
|
with the same base data (in ``draft`` state) and link it.
|
||||||
3. Activate the new campaign.
|
3. Queue the new campaign for manager approval — recurrence only
|
||||||
|
automates *creating* the run, not skipping the same approval gate
|
||||||
|
every other campaign goes through. A manager still picks the real
|
||||||
|
start date (and that's what triggers Jira ticket creation) via the
|
||||||
|
normal /approve endpoint.
|
||||||
"""
|
"""
|
||||||
# Assign now = datetime.utcnow()
|
|
||||||
now = datetime.utcnow()
|
now = datetime.utcnow()
|
||||||
# Assign run_label = now.strftime("%Y-%m-%d")
|
|
||||||
run_label = now.strftime("%Y-%m-%d")
|
run_label = now.strftime("%Y-%m-%d")
|
||||||
|
|
||||||
# Assign child = Campaign(
|
|
||||||
child = Campaign(
|
child = Campaign(
|
||||||
# Keyword argument: name
|
|
||||||
name=f"{original.name} (Run {run_label})",
|
name=f"{original.name} (Run {run_label})",
|
||||||
# Keyword argument: description
|
|
||||||
description=original.description,
|
description=original.description,
|
||||||
# Keyword argument: type
|
|
||||||
type=original.type,
|
type=original.type,
|
||||||
# Keyword argument: threat_actor_id
|
|
||||||
threat_actor_id=original.threat_actor_id,
|
threat_actor_id=original.threat_actor_id,
|
||||||
# Keyword argument: status
|
status="pending_approval",
|
||||||
status="active",
|
|
||||||
# Keyword argument: created_by
|
|
||||||
created_by=original.created_by,
|
created_by=original.created_by,
|
||||||
# Keyword argument: target_platform
|
|
||||||
target_platform=original.target_platform,
|
target_platform=original.target_platform,
|
||||||
# Keyword argument: tags
|
|
||||||
tags=original.tags or [],
|
tags=original.tags or [],
|
||||||
# Keyword argument: parent_campaign_id
|
|
||||||
parent_campaign_id=original.id,
|
parent_campaign_id=original.id,
|
||||||
)
|
)
|
||||||
# Stage new record(s) for database insertion
|
# Stage new record(s) for database insertion
|
||||||
@@ -239,46 +231,34 @@ def check_and_run_recurring_campaigns(db: Session) -> int:
|
|||||||
# Commit all pending changes to the database
|
# Commit all pending changes to the database
|
||||||
db.commit()
|
db.commit()
|
||||||
|
|
||||||
# Notify
|
# Notify the creator — the run happened, but it still needs a
|
||||||
|
# manager's approval before it goes anywhere.
|
||||||
if campaign.created_by:
|
if campaign.created_by:
|
||||||
# Call create_notification()
|
|
||||||
create_notification(
|
create_notification(
|
||||||
db,
|
db,
|
||||||
# Keyword argument: user_id
|
|
||||||
user_id=campaign.created_by,
|
user_id=campaign.created_by,
|
||||||
# Keyword argument: type
|
|
||||||
type="recurring_campaign_run",
|
type="recurring_campaign_run",
|
||||||
# Keyword argument: title
|
title="Recurring campaign created — awaiting approval",
|
||||||
title="Recurring campaign executed",
|
|
||||||
# Keyword argument: message
|
|
||||||
message=(
|
message=(
|
||||||
f'Campaign "{child.name}" was automatically created '
|
f'Campaign "{child.name}" was automatically created '
|
||||||
f'from recurring template "{campaign.name}".'
|
f'from recurring template "{campaign.name}" and is now '
|
||||||
|
f'queued for manager approval.'
|
||||||
),
|
),
|
||||||
# Keyword argument: entity_type
|
|
||||||
entity_type="campaign",
|
entity_type="campaign",
|
||||||
# Keyword argument: entity_id
|
|
||||||
entity_id=child.id,
|
entity_id=child.id,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Notify red_tech users
|
# Notify managers — same approval gate as any other campaign,
|
||||||
red_techs = db.query(User).filter(User.role == "red_tech", User.is_active == True).all() # noqa: E712
|
# recurrence only automates spawning the run, not skipping review.
|
||||||
# Iterate over red_techs
|
managers = db.query(User).filter(User.role == "manager", User.is_active == True).all() # noqa: E712
|
||||||
for user in red_techs:
|
for user in managers:
|
||||||
# Call create_notification()
|
|
||||||
create_notification(
|
create_notification(
|
||||||
db,
|
db,
|
||||||
# Keyword argument: user_id
|
|
||||||
user_id=user.id,
|
user_id=user.id,
|
||||||
# Keyword argument: type
|
type="campaign_pending_approval",
|
||||||
type="campaign_activated",
|
title="Recurring campaign needs approval",
|
||||||
# Keyword argument: title
|
message=f'Campaign "{child.name}" was auto-created from a recurring template and needs your approval.',
|
||||||
title="New recurring campaign active",
|
|
||||||
# Keyword argument: message
|
|
||||||
message=f'Campaign "{child.name}" is now active and ready for execution.',
|
|
||||||
# Keyword argument: entity_type
|
|
||||||
entity_type="campaign",
|
entity_type="campaign",
|
||||||
# Keyword argument: entity_id
|
|
||||||
entity_id=child.id,
|
entity_id=child.id,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -296,3 +276,61 @@ def check_and_run_recurring_campaigns(db: Session) -> int:
|
|||||||
|
|
||||||
# Return spawned
|
# Return spawned
|
||||||
return spawned
|
return spawned
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Catch up on due campaigns' Jira tickets (periodic job)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def sync_due_campaign_jira_tickets(db: Session) -> int:
|
||||||
|
"""Create Jira tickets for active campaigns whose start_date has arrived.
|
||||||
|
|
||||||
|
A campaign approved with a future ``start_date`` intentionally skips
|
||||||
|
Jira ticket creation at approval time (see
|
||||||
|
``app.routers.campaigns._create_jira_tickets_for_campaign``). This job
|
||||||
|
is what actually creates those tickets once that date arrives — it
|
||||||
|
finds active campaigns lacking a Jira link and, for any whose
|
||||||
|
start_date is now due, calls the same idempotent creation helper.
|
||||||
|
|
||||||
|
Returns the number of campaigns processed.
|
||||||
|
"""
|
||||||
|
from app.models.jira_link import JiraLink, JiraLinkEntityType
|
||||||
|
from app.services.jira_service import ensure_campaign_jira_tickets
|
||||||
|
|
||||||
|
now = datetime.utcnow()
|
||||||
|
|
||||||
|
linked_campaign_ids = {
|
||||||
|
row[0]
|
||||||
|
for row in db.query(JiraLink.entity_id).filter(
|
||||||
|
JiraLink.entity_type == JiraLinkEntityType.campaign
|
||||||
|
).all()
|
||||||
|
}
|
||||||
|
|
||||||
|
due_campaigns = (
|
||||||
|
db.query(Campaign)
|
||||||
|
.filter(
|
||||||
|
Campaign.status == "active",
|
||||||
|
Campaign.start_date.isnot(None),
|
||||||
|
Campaign.start_date <= now,
|
||||||
|
)
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
|
||||||
|
processed = 0
|
||||||
|
for campaign in due_campaigns:
|
||||||
|
if campaign.id in linked_campaign_ids:
|
||||||
|
continue
|
||||||
|
actor = db.query(User).filter(User.id == campaign.approved_by).first()
|
||||||
|
if not actor:
|
||||||
|
actor = db.query(User).filter(User.id == campaign.created_by).first()
|
||||||
|
if not actor:
|
||||||
|
logger.warning(
|
||||||
|
"Cannot sync Jira tickets for campaign %s: no valid actor found",
|
||||||
|
campaign.id,
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
ensure_campaign_jira_tickets(db, campaign, actor)
|
||||||
|
processed += 1
|
||||||
|
|
||||||
|
return processed
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ threat actors, and progress calculation.
|
|||||||
import logging
|
import logging
|
||||||
import uuid
|
import uuid
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from typing import Optional
|
|
||||||
|
|
||||||
# Import Session from sqlalchemy.orm
|
# Import Session from sqlalchemy.orm
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
@@ -179,8 +178,8 @@ def generate_campaign_from_threat_actor(
|
|||||||
actor_id: uuid.UUID,
|
actor_id: uuid.UUID,
|
||||||
# Entry: user
|
# Entry: user
|
||||||
user: User,
|
user: User,
|
||||||
*,
|
# Entry: start_date
|
||||||
start_date: Optional[datetime] = None,
|
start_date: datetime | None = None,
|
||||||
) -> Campaign:
|
) -> Campaign:
|
||||||
"""Auto-generate a campaign from a threat actor's uncovered techniques.
|
"""Auto-generate a campaign from a threat actor's uncovered techniques.
|
||||||
|
|
||||||
@@ -238,6 +237,7 @@ def generate_campaign_from_threat_actor(
|
|||||||
created_by=user.id,
|
created_by=user.id,
|
||||||
# Keyword argument: tags
|
# Keyword argument: tags
|
||||||
tags=[actor.name, "auto-generated"],
|
tags=[actor.name, "auto-generated"],
|
||||||
|
# Keyword argument: start_date
|
||||||
start_date=start_date,
|
start_date=start_date,
|
||||||
)
|
)
|
||||||
# Stage new record(s) for database insertion
|
# Stage new record(s) for database insertion
|
||||||
|
|||||||
@@ -305,7 +305,10 @@ def build_test_results_report(
|
|||||||
# Literal argument value
|
# Literal argument value
|
||||||
"platform": t.platform,
|
"platform": t.platform,
|
||||||
# Literal argument value
|
# Literal argument value
|
||||||
"attack_success": t.attack_success,
|
"attack_success": (
|
||||||
|
t.attack_success.value if t.attack_success and hasattr(t.attack_success, "value")
|
||||||
|
else str(t.attack_success) if t.attack_success else None
|
||||||
|
),
|
||||||
# Literal argument value
|
# Literal argument value
|
||||||
"detection_result": (
|
"detection_result": (
|
||||||
t.detection_result.value if t.detection_result and hasattr(t.detection_result, "value")
|
t.detection_result.value if t.detection_result and hasattr(t.detection_result, "value")
|
||||||
|
|||||||
@@ -583,6 +583,8 @@ def import_d3fend_mappings(db: Session) -> dict[str, int]:
|
|||||||
created = 0
|
created = 0
|
||||||
# Assign skipped = 0
|
# Assign skipped = 0
|
||||||
skipped = 0
|
skipped = 0
|
||||||
|
# Assign new_technique_ids = set()
|
||||||
|
new_technique_ids: set[str] = set()
|
||||||
|
|
||||||
# Get all ATT&CK techniques from the DB
|
# Get all ATT&CK techniques from the DB
|
||||||
attack_techniques = db.query(Technique).all()
|
attack_techniques = db.query(Technique).all()
|
||||||
@@ -649,6 +651,12 @@ def import_d3fend_mappings(db: Session) -> dict[str, int]:
|
|||||||
db.add(mapping)
|
db.add(mapping)
|
||||||
# Assign created = 1
|
# Assign created = 1
|
||||||
created += 1
|
created += 1
|
||||||
|
new_technique_ids.add(mitre_id)
|
||||||
|
|
||||||
|
if new_technique_ids:
|
||||||
|
db.query(Technique).filter(
|
||||||
|
Technique.mitre_id.in_(new_technique_ids)
|
||||||
|
).update({"review_required": True}, synchronize_session=False)
|
||||||
|
|
||||||
# Commit all pending changes to the database
|
# Commit all pending changes to the database
|
||||||
db.commit()
|
db.commit()
|
||||||
|
|||||||
@@ -57,6 +57,8 @@ ALLOWED_EXTENSIONS: frozenset[str] = frozenset({
|
|||||||
".zip", ".tar", ".gz", ".7z",
|
".zip", ".tar", ".gz", ".7z",
|
||||||
# Literal argument value
|
# Literal argument value
|
||||||
".har", ".eml", ".msg",
|
".har", ".eml", ".msg",
|
||||||
|
# Red team artifacts shared with Blue for detection analysis.
|
||||||
|
".exe",
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -353,6 +353,17 @@ def build_coverage_layer(
|
|||||||
layer["techniques"].append({
|
layer["techniques"].append({
|
||||||
# Literal argument value
|
# Literal argument value
|
||||||
"techniqueID": tech.mitre_id,
|
"techniqueID": tech.mitre_id,
|
||||||
|
# Technique display name — not part of the official ATT&CK
|
||||||
|
# Navigator schema, but harmless extra data for Navigator
|
||||||
|
# imports and lets Aegis's own heatmap UI show names inline
|
||||||
|
# instead of only ID + hover tooltip.
|
||||||
|
"name": tech.name,
|
||||||
|
# Only the coverage layer has a real TechniqueStatus concept —
|
||||||
|
# exposed so the frontend can color cells by the exact same
|
||||||
|
# discrete status palette the Techniques page uses, instead of
|
||||||
|
# a continuous score-derived hex. The other 3 layer types don't
|
||||||
|
# set this (their score doesn't correspond to a TechniqueStatus).
|
||||||
|
"status": tech.status_global.value,
|
||||||
# Literal argument value
|
# Literal argument value
|
||||||
"tactic": _format_tactic(tech.tactic),
|
"tactic": _format_tactic(tech.tactic),
|
||||||
# Literal argument value
|
# Literal argument value
|
||||||
@@ -491,6 +502,11 @@ def build_threat_actor_layer(
|
|||||||
)
|
)
|
||||||
layer["techniques"].append({
|
layer["techniques"].append({
|
||||||
"techniqueID": tech.mitre_id,
|
"techniqueID": tech.mitre_id,
|
||||||
|
"name": tech.name,
|
||||||
|
# Every appended row here is an actor-used technique (non-actor
|
||||||
|
# ones are skipped above), so status_global is meaningful here
|
||||||
|
# too — see the coverage layer's "status" field for why.
|
||||||
|
"status": tech.status_global.value,
|
||||||
"tactic": _format_tactic(tech.tactic),
|
"tactic": _format_tactic(tech.tactic),
|
||||||
"color": _score_to_color(score),
|
"color": _score_to_color(score),
|
||||||
"score": score,
|
"score": score,
|
||||||
@@ -586,6 +602,7 @@ def build_detection_rules_layer(
|
|||||||
layer["techniques"].append({
|
layer["techniques"].append({
|
||||||
# Literal argument value
|
# Literal argument value
|
||||||
"techniqueID": tech.mitre_id,
|
"techniqueID": tech.mitre_id,
|
||||||
|
"name": tech.name,
|
||||||
# Literal argument value
|
# Literal argument value
|
||||||
"tactic": _format_tactic(tech.tactic),
|
"tactic": _format_tactic(tech.tactic),
|
||||||
# Literal argument value
|
# Literal argument value
|
||||||
@@ -754,6 +771,7 @@ def build_campaign_layer(
|
|||||||
layer["techniques"].append({
|
layer["techniques"].append({
|
||||||
# Literal argument value
|
# Literal argument value
|
||||||
"techniqueID": mitre_id,
|
"techniqueID": mitre_id,
|
||||||
|
"name": tech.name,
|
||||||
# Literal argument value
|
# Literal argument value
|
||||||
"tactic": _format_tactic(tech.tactic),
|
"tactic": _format_tactic(tech.tactic),
|
||||||
# Literal argument value
|
# Literal argument value
|
||||||
|
|||||||
@@ -31,6 +31,9 @@ from __future__ import annotations
|
|||||||
# Import logging
|
# Import logging
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
|
# Import re
|
||||||
|
import re
|
||||||
|
|
||||||
# Import datetime from datetime
|
# Import datetime from datetime
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
|
||||||
@@ -65,6 +68,50 @@ from app.models.technique import Technique
|
|||||||
from app.models.test import Test
|
from app.models.test import Test
|
||||||
from app.models.user import User
|
from app.models.user import User
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Custom field IDs (project-specific — see System Settings → Jira Configuration)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
JIRA_FIELD_RT_START_DATE = "customfield_11803"
|
||||||
|
JIRA_FIELD_RT_END_DATE = "customfield_11804"
|
||||||
|
JIRA_FIELD_BT_START_DATE = "customfield_11805"
|
||||||
|
JIRA_FIELD_BT_END_DATE = "customfield_11806"
|
||||||
|
JIRA_FIELD_TTP = "customfield_11807"
|
||||||
|
JIRA_FIELD_SEVERITY = "customfield_10098"
|
||||||
|
JIRA_FIELD_ATTACK_SUCCESS = "customfield_11815"
|
||||||
|
JIRA_FIELD_ATTACK_DETECTED = "customfield_11809"
|
||||||
|
JIRA_FIELD_TIME_TO_DETECT = "customfield_11810"
|
||||||
|
JIRA_FIELD_TIME_TO_CONTAIN = "customfield_11811"
|
||||||
|
JIRA_FIELD_DATA_SENSITIVITY = "customfield_11814"
|
||||||
|
JIRA_FIELD_ATTACK_CONTAINED = "customfield_11885"
|
||||||
|
|
||||||
|
_ATTACK_SUCCESS_TO_JIRA = {
|
||||||
|
"successful": "Yes",
|
||||||
|
"not_successful": "No",
|
||||||
|
"partially_successful": "Partial",
|
||||||
|
}
|
||||||
|
|
||||||
|
_DETECTION_TO_JIRA = {
|
||||||
|
"detected": "Yes",
|
||||||
|
"not_detected": "No",
|
||||||
|
"partially_detected": "Partial",
|
||||||
|
}
|
||||||
|
|
||||||
|
_CONTAINMENT_TO_JIRA = {
|
||||||
|
"contained": "Yes",
|
||||||
|
"not_contained": "No",
|
||||||
|
"partially_contained": "Partial",
|
||||||
|
}
|
||||||
|
|
||||||
|
_DATA_CLASSIFICATION_TO_JIRA = {
|
||||||
|
"public": "Public",
|
||||||
|
"general_use": "General Use",
|
||||||
|
"internal_use_only": "Internal Use Only",
|
||||||
|
"trusted_people": "Trusted People",
|
||||||
|
"customer_content": "Customer Content",
|
||||||
|
"pii": "PII",
|
||||||
|
}
|
||||||
|
|
||||||
# Assign logger = logging.getLogger(__name__)
|
# Assign logger = logging.getLogger(__name__)
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -248,6 +295,9 @@ def get_admin_jira_client(db: Session):
|
|||||||
def lookup_user_jira_account_id(db: Session, user: User) -> bool:
|
def lookup_user_jira_account_id(db: Session, user: User) -> bool:
|
||||||
"""Lookup *user*'s Atlassian account ID by email using the admin Jira client.
|
"""Lookup *user*'s Atlassian account ID by email using the admin Jira client.
|
||||||
|
|
||||||
|
Uses ``user.jira_email`` when set (for users whose corporate Atlassian
|
||||||
|
email differs from their Aegis login email), falling back to
|
||||||
|
``user.email`` — same resolution order as the rest of this module.
|
||||||
Updates ``user.jira_account_id`` in-place when found or changed.
|
Updates ``user.jira_account_id`` in-place when found or changed.
|
||||||
Returns ``True`` when the value was updated, ``False`` otherwise.
|
Returns ``True`` when the value was updated, ``False`` otherwise.
|
||||||
Non-fatal — all errors are logged at DEBUG level and swallowed.
|
Non-fatal — all errors are logged at DEBUG level and swallowed.
|
||||||
@@ -255,13 +305,13 @@ def lookup_user_jira_account_id(db: Session, user: User) -> bool:
|
|||||||
if not has_admin_jira_configured(db):
|
if not has_admin_jira_configured(db):
|
||||||
return False
|
return False
|
||||||
|
|
||||||
email = getattr(user, "email", None)
|
email = getattr(user, "jira_email", None) or getattr(user, "email", None)
|
||||||
if not email:
|
if not email:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
try:
|
try:
|
||||||
jira = get_admin_jira_client(db)
|
jira = get_admin_jira_client(db)
|
||||||
results = jira.user_find_by_user_string(query=email, maxResults=10)
|
results = jira.user_find_by_user_string(query=email, limit=10)
|
||||||
|
|
||||||
account_id: Optional[str] = None
|
account_id: Optional[str] = None
|
||||||
for u in results or []:
|
for u in results or []:
|
||||||
@@ -292,6 +342,28 @@ def lookup_user_jira_account_id(db: Session, user: User) -> bool:
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_jira_account_id(db: Session, user: Optional[User]) -> Optional[str]:
|
||||||
|
"""Return *user*'s Atlassian account ID, trying a fresh lookup if it
|
||||||
|
isn't cached yet.
|
||||||
|
|
||||||
|
``jira_account_id`` is normally populated on login, but a user assigned
|
||||||
|
or reviewing before their first login (or before Jira was configured)
|
||||||
|
would otherwise leave every Jira assignment silently no-op'd forever —
|
||||||
|
exactly the gap that caused a blue_lead's reviews to never reassign in
|
||||||
|
Jira while red_lead's did, since only push_assignee_update had this
|
||||||
|
fallback. Every Jira-assignment call site should use this, not read
|
||||||
|
``jira_account_id`` directly.
|
||||||
|
"""
|
||||||
|
if user is None:
|
||||||
|
return None
|
||||||
|
jira_account_id = getattr(user, "jira_account_id", None)
|
||||||
|
if jira_account_id:
|
||||||
|
return jira_account_id
|
||||||
|
lookup_user_jira_account_id(db, user)
|
||||||
|
db.flush()
|
||||||
|
return getattr(user, "jira_account_id", None)
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Ticket content builders (inspired by the pentest-to-Jira script)
|
# Ticket content builders (inspired by the pentest-to-Jira script)
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -307,10 +379,37 @@ _SEVERITY_TO_PRIORITY: dict[str, str] = {
|
|||||||
_STATE_EMOJI: dict[str, str] = {
|
_STATE_EMOJI: dict[str, str] = {
|
||||||
"draft": "📝 Draft",
|
"draft": "📝 Draft",
|
||||||
"red_executing": "🔴 Red Team Executing",
|
"red_executing": "🔴 Red Team Executing",
|
||||||
|
"red_review": "🔎 Red Lead Review",
|
||||||
"blue_evaluating": "🔵 Blue Team Evaluating",
|
"blue_evaluating": "🔵 Blue Team Evaluating",
|
||||||
|
"blue_review": "🔎 Blue Lead Review",
|
||||||
"in_review": "📋 In Review",
|
"in_review": "📋 In Review",
|
||||||
"validated": "✅ Validated",
|
"validated": "✅ Validated",
|
||||||
"rejected": "❌ Rejected",
|
"rejected": "❌ Rejected",
|
||||||
|
"disputed": "⚠️ Disputed",
|
||||||
|
}
|
||||||
|
|
||||||
|
# TestState -> Jira workflow status. These status names must already exist
|
||||||
|
# as valid transition targets in the Purple Team project's Jira workflow
|
||||||
|
# scheme (configured by a Jira admin) — set_issue_status() is a no-op
|
||||||
|
# (logged warning, non-fatal) if the named status isn't a legal transition
|
||||||
|
# from the issue's current status.
|
||||||
|
#
|
||||||
|
# "blue_evaluating" maps to "Queued Blue Team" here because that's the
|
||||||
|
# state's *first-entry* meaning (approved by Red Lead, not yet claimed by
|
||||||
|
# Blue, or re-routed to Blue Team during dispute resolution). When Blue
|
||||||
|
# Lead sends work back to the *same* operator for rework
|
||||||
|
# (reopen_blue_review), that's pushed as "In Progress" directly instead of
|
||||||
|
# through this table — see push_bt_review_reopened().
|
||||||
|
_STATE_TO_JIRA_STATUS: dict[str, str] = {
|
||||||
|
"draft": "To Do",
|
||||||
|
"red_executing": "In Progress",
|
||||||
|
"red_review": "Red Team test review",
|
||||||
|
"blue_evaluating": "Queued Blue Team",
|
||||||
|
"blue_review": "Blue Team test review",
|
||||||
|
"in_review": "Validation",
|
||||||
|
"validated": "Done",
|
||||||
|
"rejected": "Rejected",
|
||||||
|
"disputed": "Dispute",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -370,11 +469,35 @@ def _build_state_comment(
|
|||||||
"Red Team has started the attack execution.",
|
"Red Team has started the attack execution.",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
elif new_state == "red_review":
|
||||||
|
lines += [
|
||||||
|
f"Red Team has submitted evidence for Round {test.red_round_number or 1} and the "
|
||||||
|
"test is awaiting Red Lead review before it queues for Blue Team.",
|
||||||
|
"",
|
||||||
|
f"*Procedure:* {test.procedure_text or 'N/A'}",
|
||||||
|
f"*Tool used:* {test.tool_used or 'N/A'}",
|
||||||
|
f"*Attack Success:* {_enum_value(test.attack_success) or 'N/A'}",
|
||||||
|
]
|
||||||
|
if test.red_summary:
|
||||||
|
lines += ["", "h4. Red Team Summary", test.red_summary]
|
||||||
|
|
||||||
|
elif new_state == "blue_review":
|
||||||
|
lines += [
|
||||||
|
f"Blue Team has submitted evidence for Round {test.blue_round_number or 1} and the "
|
||||||
|
"test is awaiting Blue Lead review before cross-validation.",
|
||||||
|
"",
|
||||||
|
f"*Detect Procedure:* {test.detect_procedure or 'N/A'}",
|
||||||
|
f"*Detection Result:* {_enum_value(test.detection_result) or 'N/A'}",
|
||||||
|
f"*Containment Result:* {_enum_value(test.containment_result) or 'N/A'}",
|
||||||
|
]
|
||||||
|
if test.blue_summary:
|
||||||
|
lines += ["", "h4. Blue Team Summary", test.blue_summary]
|
||||||
|
|
||||||
elif new_state == "blue_evaluating":
|
elif new_state == "blue_evaluating":
|
||||||
lines += [
|
lines += [
|
||||||
"Red Team has finished execution and submitted evidence for Blue Team evaluation.",
|
"Red Team has finished execution and submitted evidence for Blue Team evaluation.",
|
||||||
"",
|
"",
|
||||||
f"*Attack Success:* {test.attack_success if test.attack_success is not None else 'N/A'}",
|
f"*Attack Success:* {_enum_value(test.attack_success) or 'N/A'}",
|
||||||
]
|
]
|
||||||
if test.red_summary:
|
if test.red_summary:
|
||||||
lines += ["", "h4. Red Team Summary", test.red_summary]
|
lines += ["", "h4. Red Team Summary", test.red_summary]
|
||||||
@@ -383,16 +506,27 @@ def _build_state_comment(
|
|||||||
lines += [
|
lines += [
|
||||||
"Blue Team has completed evaluation. Test is awaiting lead validation.",
|
"Blue Team has completed evaluation. Test is awaiting lead validation.",
|
||||||
"",
|
"",
|
||||||
f"*Detection Result:* {test.detection_result or 'N/A'}",
|
f"*Detection Result:* {_enum_value(test.detection_result) or 'N/A'}",
|
||||||
]
|
]
|
||||||
|
if test.system_gaps:
|
||||||
|
lines += ["", "h4. ⚠️ System Gap Flagged", test.system_gaps]
|
||||||
if test.blue_summary:
|
if test.blue_summary:
|
||||||
lines += ["", "h4. Blue Team Summary", test.blue_summary]
|
lines += ["", "h4. Blue Team Summary", test.blue_summary]
|
||||||
if test.remediation_steps:
|
if test.remediation_steps:
|
||||||
lines += ["", "h4. Remediation Steps", test.remediation_steps]
|
lines += ["", "h4. Remediation Steps", test.remediation_steps]
|
||||||
|
|
||||||
elif new_state == "validated":
|
elif new_state == "validated":
|
||||||
|
# A disputed test resolved by a manager keeps the leads' original,
|
||||||
|
# disagreeing votes (one approved, one rejected) — that combination
|
||||||
|
# is otherwise unreachable, since normal dual-validation requires
|
||||||
|
# both leads to agree. Use it to tell the two paths apart.
|
||||||
|
manager_resolved = (
|
||||||
|
test.red_validation_status and test.blue_validation_status
|
||||||
|
and test.red_validation_status != test.blue_validation_status
|
||||||
|
)
|
||||||
lines += [
|
lines += [
|
||||||
"Test has been *validated* by both leads.",
|
"Test has been *validated* by a manager's decision on a disputed test."
|
||||||
|
if manager_resolved else "Test has been *validated* by both leads.",
|
||||||
"",
|
"",
|
||||||
f"*Red Lead Status:* {test.red_validation_status or 'N/A'}",
|
f"*Red Lead Status:* {test.red_validation_status or 'N/A'}",
|
||||||
f"*Blue Lead Status:* {test.blue_validation_status or 'N/A'}",
|
f"*Blue Lead Status:* {test.blue_validation_status or 'N/A'}",
|
||||||
@@ -403,8 +537,13 @@ def _build_state_comment(
|
|||||||
lines += ["", f"*Blue Lead Notes:* {test.blue_validation_notes}"]
|
lines += ["", f"*Blue Lead Notes:* {test.blue_validation_notes}"]
|
||||||
|
|
||||||
elif new_state == "rejected":
|
elif new_state == "rejected":
|
||||||
|
manager_resolved = (
|
||||||
|
test.red_validation_status and test.blue_validation_status
|
||||||
|
and test.red_validation_status != test.blue_validation_status
|
||||||
|
)
|
||||||
lines += [
|
lines += [
|
||||||
"Test has been *rejected* and must be reworked.",
|
"Test has been *rejected* by a manager's decision on a disputed test."
|
||||||
|
if manager_resolved else "Test has been *rejected* and must be reworked.",
|
||||||
"",
|
"",
|
||||||
f"*Red Lead Status:* {test.red_validation_status or 'N/A'}",
|
f"*Red Lead Status:* {test.red_validation_status or 'N/A'}",
|
||||||
f"*Blue Lead Status:* {test.blue_validation_status or 'N/A'}",
|
f"*Blue Lead Status:* {test.blue_validation_status or 'N/A'}",
|
||||||
@@ -566,6 +705,37 @@ def auto_create_campaign_issue(
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def ensure_campaign_jira_tickets(db: Session, campaign, user: User) -> None:
|
||||||
|
"""Create Jira tickets for a campaign and its already-linked tests, if missing.
|
||||||
|
|
||||||
|
Called from two places: immediately at manager-approval time when the
|
||||||
|
campaign's ``start_date`` is now/past, and from the periodic
|
||||||
|
``sync_due_campaign_jira_tickets`` job for campaigns approved with a
|
||||||
|
future ``start_date`` once that date actually arrives. Idempotent —
|
||||||
|
checks for existing links before creating anything — so it is safe to
|
||||||
|
call repeatedly. Best-effort: failures are logged, not raised, since a
|
||||||
|
Jira outage must not block campaign activation.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
campaign_jira_key = get_campaign_jira_key(db, campaign.id)
|
||||||
|
if not campaign_jira_key:
|
||||||
|
campaign_jira_key = auto_create_campaign_issue(db, campaign, user)
|
||||||
|
if campaign_jira_key:
|
||||||
|
for ct in campaign.campaign_tests:
|
||||||
|
if ct.test and not get_test_jira_key(db, ct.test.id):
|
||||||
|
auto_create_test_issue(
|
||||||
|
db, ct.test, user,
|
||||||
|
parent_ticket_override=campaign_jira_key,
|
||||||
|
campaign_start_date=campaign.start_date,
|
||||||
|
)
|
||||||
|
db.commit()
|
||||||
|
except Exception:
|
||||||
|
logger.exception(
|
||||||
|
"Jira ticket creation failed for campaign %s",
|
||||||
|
campaign.id,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def auto_create_test_issue(
|
def auto_create_test_issue(
|
||||||
db: Session,
|
db: Session,
|
||||||
test: Test,
|
test: Test,
|
||||||
@@ -613,16 +783,32 @@ def auto_create_test_issue(
|
|||||||
issue_type = settings.JIRA_ISSUE_TYPE_TEST # always Task
|
issue_type = settings.JIRA_ISSUE_TYPE_TEST # always Task
|
||||||
|
|
||||||
poc = test.procedure_text or "N/A"
|
poc = test.procedure_text or "N/A"
|
||||||
|
severity = _technique_severity(technique).capitalize()
|
||||||
|
labels = ["aegis", mitre_id.replace(".", "-")]
|
||||||
|
if test.platform:
|
||||||
|
# Jira labels can't contain whitespace — normalize to kebab-case.
|
||||||
|
labels.append(re.sub(r"[^a-z0-9_-]+", "-", test.platform.lower()).strip("-"))
|
||||||
|
# No parent_ticket_override means this test isn't nested under a
|
||||||
|
# campaign ticket — i.e. it's standalone. Tag it so standalone tests
|
||||||
|
# are identifiable in Jira without cross-referencing Aegis.
|
||||||
|
if parent_ticket_override is None:
|
||||||
|
labels.append("standalone-test")
|
||||||
fields: dict = {
|
fields: dict = {
|
||||||
"project": {"key": project_key},
|
"project": {"key": project_key},
|
||||||
"summary": f"[Aegis] {mitre_id} — {test.name}",
|
"summary": f"[Aegis] {mitre_id} — {test.name}",
|
||||||
"description": _build_test_description(test, technique),
|
"description": _build_test_description(test, technique),
|
||||||
"issuetype": {"name": issue_type},
|
"issuetype": {"name": issue_type},
|
||||||
"labels": ["aegis", "security-test", mitre_id.replace(".", "-")],
|
"labels": labels,
|
||||||
# customfield_10309 = Proof of Concept field (required by team's Jira config)
|
# customfield_10309 = Proof of Concept field (required by team's Jira config)
|
||||||
"customfield_10309": f"{{code}}{poc}{{code}}",
|
"customfield_10309": f"{{code}}{poc}{{code}}",
|
||||||
|
JIRA_FIELD_TTP: mitre_id,
|
||||||
|
JIRA_FIELD_SEVERITY: _select_field(severity),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
data_sensitivity = _DATA_CLASSIFICATION_TO_JIRA.get(_enum_value(test.data_classification))
|
||||||
|
if data_sensitivity:
|
||||||
|
fields[JIRA_FIELD_DATA_SENSITIVITY] = _select_field(data_sensitivity)
|
||||||
|
|
||||||
# Inherit campaign start date if available, otherwise use today
|
# Inherit campaign start date if available, otherwise use today
|
||||||
from datetime import date as _date
|
from datetime import date as _date
|
||||||
effective_start = campaign_start_date or _date.today()
|
effective_start = campaign_start_date or _date.today()
|
||||||
@@ -632,7 +818,23 @@ def auto_create_test_issue(
|
|||||||
if parent:
|
if parent:
|
||||||
fields["parent"] = {"key": parent}
|
fields["parent"] = {"key": parent}
|
||||||
|
|
||||||
|
try:
|
||||||
result = jira.issue_create(fields=fields)
|
result = jira.issue_create(fields=fields)
|
||||||
|
except Exception as exc:
|
||||||
|
# Jira rejects the whole issue when a custom field isn't on the
|
||||||
|
# project's create screen ("cannot be set ... not on the
|
||||||
|
# appropriate screen"). Don't let a screen-config gap on an
|
||||||
|
# optional field (data sensitivity) block ticket creation
|
||||||
|
# entirely — drop it and retry once.
|
||||||
|
if JIRA_FIELD_DATA_SENSITIVITY in fields and JIRA_FIELD_DATA_SENSITIVITY in str(exc):
|
||||||
|
logger.warning(
|
||||||
|
"Jira rejected %s (not on create screen); retrying without it for test %s",
|
||||||
|
JIRA_FIELD_DATA_SENSITIVITY, test.id,
|
||||||
|
)
|
||||||
|
fields.pop(JIRA_FIELD_DATA_SENSITIVITY)
|
||||||
|
result = jira.issue_create(fields=fields)
|
||||||
|
else:
|
||||||
|
raise
|
||||||
issue_key = result["key"]
|
issue_key = result["key"]
|
||||||
issue_id = result.get("id", "")
|
issue_id = result.get("id", "")
|
||||||
|
|
||||||
@@ -667,6 +869,7 @@ def push_test_event(
|
|||||||
new_state: str,
|
new_state: str,
|
||||||
*,
|
*,
|
||||||
extra: dict | None = None,
|
extra: dict | None = None,
|
||||||
|
assignee: User | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Post a lifecycle comment to the Jira issue linked to *test*.
|
"""Post a lifecycle comment to the Jira issue linked to *test*.
|
||||||
|
|
||||||
@@ -693,20 +896,30 @@ def push_test_event(
|
|||||||
comment = _build_state_comment(test, new_state, actor, extra)
|
comment = _build_state_comment(test, new_state, actor, extra)
|
||||||
jira.issue_add_comment(link.jira_issue_key, comment)
|
jira.issue_add_comment(link.jira_issue_key, comment)
|
||||||
|
|
||||||
# When the operator starts execution: transition to "In Progress"
|
# Transition the Jira issue's status to match the new Aegis state,
|
||||||
# and assign the ticket to that operator.
|
# per _STATE_TO_JIRA_STATUS. Covers the full Purple Team workflow:
|
||||||
if new_state == "red_executing":
|
# To-Do -> In Progress -> RT Test Review -> Queued Blue Team ->
|
||||||
|
# In Progress -> Blue Team Test Review -> Validation -> Done /
|
||||||
|
# Rejected / Dispute.
|
||||||
|
target_status = _STATE_TO_JIRA_STATUS.get(new_state)
|
||||||
|
if target_status:
|
||||||
try:
|
try:
|
||||||
jira.set_issue_status(link.jira_issue_key, "In Progress")
|
jira.set_issue_status(link.jira_issue_key, target_status)
|
||||||
logger.info(
|
logger.info(
|
||||||
"Transitioned Jira ticket %s to In Progress", link.jira_issue_key
|
"Transitioned Jira ticket %s to %s", link.jira_issue_key, target_status
|
||||||
)
|
)
|
||||||
except Exception as exc_t:
|
except Exception as exc_t:
|
||||||
logger.warning(
|
logger.warning(
|
||||||
"Could not transition %s to In Progress: %s",
|
"Could not transition %s to %s: %s",
|
||||||
link.jira_issue_key, exc_t,
|
link.jira_issue_key, target_status, exc_t,
|
||||||
)
|
)
|
||||||
jira_account_id = getattr(actor, "jira_account_id", None)
|
|
||||||
|
# When the operator starts execution: assign the ticket to that
|
||||||
|
# operator. On reopen (rework), *actor* is the lead who reopened
|
||||||
|
# it, not the operator who should keep working it — the caller
|
||||||
|
# passes the original red_tech_assignee as *assignee* in that case.
|
||||||
|
if new_state == "red_executing":
|
||||||
|
jira_account_id = _resolve_jira_account_id(db, assignee or actor)
|
||||||
if jira_account_id:
|
if jira_account_id:
|
||||||
try:
|
try:
|
||||||
jira.assign_issue(link.jira_issue_key, account_id=jira_account_id)
|
jira.assign_issue(link.jira_issue_key, account_id=jira_account_id)
|
||||||
@@ -720,6 +933,56 @@ def push_test_event(
|
|||||||
link.jira_issue_key, jira_account_id, exc_a,
|
link.jira_issue_key, jira_account_id, exc_a,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if new_state in ("red_review", "blue_review") and assignee:
|
||||||
|
jira_account_id = _resolve_jira_account_id(db, assignee)
|
||||||
|
if jira_account_id:
|
||||||
|
try:
|
||||||
|
jira.assign_issue(link.jira_issue_key, account_id=jira_account_id)
|
||||||
|
logger.info(
|
||||||
|
"Assigned Jira ticket %s to reviewer account %s",
|
||||||
|
link.jira_issue_key, jira_account_id,
|
||||||
|
)
|
||||||
|
except Exception as exc_a:
|
||||||
|
logger.warning(
|
||||||
|
"Could not assign %s to reviewer %s: %s",
|
||||||
|
link.jira_issue_key, jira_account_id, exc_a,
|
||||||
|
)
|
||||||
|
|
||||||
|
# blue_evaluating is reached two different ways: a fresh hand-off
|
||||||
|
# from Red (no blue_tech_assignee yet — queued, no owner) or a
|
||||||
|
# reopen/rework of a test some blue_tech already had (assignee is
|
||||||
|
# still set) — that operator should get the ticket back, not have
|
||||||
|
# it clear. in_review is always dual-validation by both leads at
|
||||||
|
# once, so it never has a single owner.
|
||||||
|
if new_state == "blue_evaluating":
|
||||||
|
if test.blue_tech_assignee:
|
||||||
|
reassignee = db.query(User).filter(User.id == test.blue_tech_assignee).first()
|
||||||
|
jira_account_id = _resolve_jira_account_id(db, reassignee)
|
||||||
|
else:
|
||||||
|
jira_account_id = None
|
||||||
|
try:
|
||||||
|
jira.assign_issue(link.jira_issue_key, account_id=jira_account_id)
|
||||||
|
logger.info(
|
||||||
|
"%s Jira ticket %s (state=%s)",
|
||||||
|
"Reassigned" if jira_account_id else "Unassigned", link.jira_issue_key, new_state,
|
||||||
|
)
|
||||||
|
except Exception as exc_a:
|
||||||
|
logger.warning(
|
||||||
|
"Could not update assignee on %s for state %s: %s",
|
||||||
|
link.jira_issue_key, new_state, exc_a,
|
||||||
|
)
|
||||||
|
elif new_state == "in_review":
|
||||||
|
try:
|
||||||
|
jira.assign_issue(link.jira_issue_key, account_id=None)
|
||||||
|
logger.info("Unassigned Jira ticket %s (state=%s)", link.jira_issue_key, new_state)
|
||||||
|
except Exception as exc_a:
|
||||||
|
logger.warning(
|
||||||
|
"Could not unassign %s for state %s: %s",
|
||||||
|
link.jira_issue_key, new_state, exc_a,
|
||||||
|
)
|
||||||
|
if test.system_gaps:
|
||||||
|
_add_jira_label(jira, link.jira_issue_key, "system-gap")
|
||||||
|
|
||||||
link.last_synced_at = datetime.utcnow()
|
link.last_synced_at = datetime.utcnow()
|
||||||
db.flush()
|
db.flush()
|
||||||
logger.info(
|
logger.info(
|
||||||
@@ -733,6 +996,47 @@ def push_test_event(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def push_assignee_update(db: Session, test: Test, assignee: User) -> None:
|
||||||
|
"""Sync a lead's manual operator assignment to the linked Jira ticket.
|
||||||
|
|
||||||
|
Called from ``POST /tests/{id}/assign`` so Jira reflects the assignment
|
||||||
|
immediately, instead of waiting for the operator to start execution
|
||||||
|
(the only other point that pushes a Jira assignee — see
|
||||||
|
``push_test_event``'s ``red_executing`` handling above).
|
||||||
|
"""
|
||||||
|
if not has_admin_jira_configured(db):
|
||||||
|
return
|
||||||
|
|
||||||
|
link = (
|
||||||
|
db.query(JiraLink)
|
||||||
|
.filter(
|
||||||
|
JiraLink.entity_type == JiraLinkEntityType.test,
|
||||||
|
JiraLink.entity_id == test.id,
|
||||||
|
)
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
if not link:
|
||||||
|
return
|
||||||
|
|
||||||
|
jira_account_id = _resolve_jira_account_id(db, assignee)
|
||||||
|
if not jira_account_id:
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
jira = get_admin_jira_client(db)
|
||||||
|
jira.assign_issue(link.jira_issue_key, account_id=jira_account_id)
|
||||||
|
link.last_synced_at = datetime.utcnow()
|
||||||
|
db.flush()
|
||||||
|
logger.info(
|
||||||
|
"Assigned Jira ticket %s to account %s (manual assignment)",
|
||||||
|
link.jira_issue_key, jira_account_id,
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning(
|
||||||
|
"Could not assign %s to %s: %s", link.jira_issue_key, jira_account_id, exc,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# On-hold Jira notification
|
# On-hold Jira notification
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -773,24 +1077,24 @@ def push_hold_event(
|
|||||||
f"h3. ▶ Test Resumed\n\n"
|
f"h3. ▶ Test Resumed\n\n"
|
||||||
f"*Resumed by:* {actor.username}\n"
|
f"*Resumed by:* {actor.username}\n"
|
||||||
f"*At:* {ts}\n\n"
|
f"*At:* {ts}\n\n"
|
||||||
f"_The test has been taken off hold and is active again._"
|
f"_The test has been unblocked and is active again._"
|
||||||
)
|
)
|
||||||
try:
|
try:
|
||||||
jira.set_issue_status(link.jira_issue_key, "In Progress")
|
jira.set_issue_status(link.jira_issue_key, "In Progress")
|
||||||
except Exception as exc_t:
|
except Exception as exc_t:
|
||||||
logger.warning("Could not transition %s off hold: %s", link.jira_issue_key, exc_t)
|
logger.warning("Could not transition %s off blocked: %s", link.jira_issue_key, exc_t)
|
||||||
else:
|
else:
|
||||||
comment = (
|
comment = (
|
||||||
f"h3. ⏸ Test Placed On Hold\n\n"
|
f"h3. 🚫 Test Blocked\n\n"
|
||||||
f"*Placed on hold by:* {actor.username}\n"
|
f"*Blocked by:* {actor.username}\n"
|
||||||
f"*At:* {ts}\n"
|
f"*At:* {ts}\n"
|
||||||
f"*Reason:* {reason or 'No reason provided'}\n\n"
|
f"*Reason:* {reason or 'No reason provided'}\n\n"
|
||||||
f"_This test has been paused. No action required until it is resumed._"
|
f"_This test is blocked. No action required until it is resumed._"
|
||||||
)
|
)
|
||||||
try:
|
try:
|
||||||
jira.set_issue_status(link.jira_issue_key, "On Hold")
|
jira.set_issue_status(link.jira_issue_key, "Blocked")
|
||||||
except Exception as exc_t:
|
except Exception as exc_t:
|
||||||
logger.warning("Could not transition %s to On Hold: %s", link.jira_issue_key, exc_t)
|
logger.warning("Could not transition %s to Blocked: %s", link.jira_issue_key, exc_t)
|
||||||
|
|
||||||
jira.issue_add_comment(link.jira_issue_key, comment)
|
jira.issue_add_comment(link.jira_issue_key, comment)
|
||||||
link.last_synced_at = datetime.utcnow()
|
link.last_synced_at = datetime.utcnow()
|
||||||
@@ -800,6 +1104,316 @@ def push_hold_event(
|
|||||||
logger.warning("Failed to push hold event for test %s: %s", test.id, exc, exc_info=True)
|
logger.warning("Failed to push hold event for test %s: %s", test.id, exc, exc_info=True)
|
||||||
|
|
||||||
|
|
||||||
|
def push_pause_event(db: Session, test, actor, *, resuming: bool = False) -> None:
|
||||||
|
"""Post a timer pause/resume comment to Jira — status is left untouched.
|
||||||
|
|
||||||
|
Distinct from :func:`push_hold_event` — a short timer pause (the timer
|
||||||
|
sitting idle between rework rounds, or the operator briefly stepping
|
||||||
|
away) is not the same thing as the test actually being blocked, so it
|
||||||
|
must not flip the Jira issue to "On Hold". Only a genuine Hold action
|
||||||
|
(:func:`push_hold_event`) does that. This just leaves a record.
|
||||||
|
Non-fatal — any Jira error is logged and swallowed.
|
||||||
|
"""
|
||||||
|
if not has_admin_jira_configured(db):
|
||||||
|
return
|
||||||
|
|
||||||
|
link = (
|
||||||
|
db.query(JiraLink)
|
||||||
|
.filter(
|
||||||
|
JiraLink.entity_type == JiraLinkEntityType.test,
|
||||||
|
JiraLink.entity_id == test.id,
|
||||||
|
)
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
if not link:
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
jira = get_admin_jira_client(db)
|
||||||
|
ts = datetime.utcnow().strftime("%Y-%m-%d %H:%M UTC")
|
||||||
|
|
||||||
|
if resuming:
|
||||||
|
comment = f"h3. ▶ Timer Resumed\n\n*Resumed by:* {actor.username}\n*At:* {ts}"
|
||||||
|
else:
|
||||||
|
comment = f"h3. ⏸ Timer Paused\n\n*Paused by:* {actor.username}\n*At:* {ts}"
|
||||||
|
|
||||||
|
jira.issue_add_comment(link.jira_issue_key, comment)
|
||||||
|
link.last_synced_at = datetime.utcnow()
|
||||||
|
db.flush()
|
||||||
|
logger.info("Posted pause event to Jira %s (resuming=%s)", link.jira_issue_key, resuming)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning("Failed to push pause event for test %s: %s", test.id, exc, exc_info=True)
|
||||||
|
|
||||||
|
|
||||||
|
def push_round_archived(db: Session, test, actor, *, round_data) -> None:
|
||||||
|
"""Post a permanent Jira comment summarizing a round right before it's
|
||||||
|
reset for rework.
|
||||||
|
|
||||||
|
``round_data`` custom fields (attack success, detection result, etc.)
|
||||||
|
only ever show Jira's *latest* value — each new round's submission
|
||||||
|
overwrites the field. This comment is what preserves every prior
|
||||||
|
round's results in Jira, since comments are never overwritten.
|
||||||
|
Non-fatal — any Jira error is logged and swallowed.
|
||||||
|
"""
|
||||||
|
if not has_admin_jira_configured(db):
|
||||||
|
return
|
||||||
|
|
||||||
|
link = (
|
||||||
|
db.query(JiraLink)
|
||||||
|
.filter(
|
||||||
|
JiraLink.entity_type == JiraLinkEntityType.test,
|
||||||
|
JiraLink.entity_id == test.id,
|
||||||
|
)
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
if not link:
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
jira = get_admin_jira_client(db)
|
||||||
|
team = round_data.team
|
||||||
|
lines = [f"h3. \U0001F4CB Round {round_data.round_number} archived ({team} team)\n"]
|
||||||
|
if team == "red":
|
||||||
|
lines.append(f"*Procedure:* {round_data.procedure_text or '_none recorded_'}")
|
||||||
|
lines.append(f"*Tool used:* {round_data.tool_used or '-'}")
|
||||||
|
lines.append(f"*Attack success:* {round_data.attack_success.value if round_data.attack_success else '-'}")
|
||||||
|
lines.append(f"*Summary:* {round_data.red_summary or '-'}")
|
||||||
|
else:
|
||||||
|
lines.append(f"*Detect procedure:* {round_data.detect_procedure or '_none recorded_'}")
|
||||||
|
lines.append(f"*Detection result:* {round_data.detection_result.value if round_data.detection_result else '-'}")
|
||||||
|
lines.append(f"*Containment result:* {round_data.containment_result.value if round_data.containment_result else '-'}")
|
||||||
|
lines.append(f"*Summary:* {round_data.blue_summary or '-'}")
|
||||||
|
lines.append(f"*Sent back by:* {actor.username}")
|
||||||
|
lines.append(f"*Reopen notes:* {round_data.review_notes or '-'}")
|
||||||
|
comment = "\n".join(lines)
|
||||||
|
|
||||||
|
jira.issue_add_comment(link.jira_issue_key, comment)
|
||||||
|
_add_jira_label(jira, link.jira_issue_key, "reopened")
|
||||||
|
link.last_synced_at = datetime.utcnow()
|
||||||
|
db.flush()
|
||||||
|
logger.info(
|
||||||
|
"Posted round-archived comment to Jira %s (team=%s, round=%s)",
|
||||||
|
link.jira_issue_key, team, round_data.round_number,
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning("Failed to push round-archived comment for test %s: %s", test.id, exc, exc_info=True)
|
||||||
|
|
||||||
|
|
||||||
|
def _add_jira_label(jira, issue_key: str, label: str) -> None:
|
||||||
|
"""Add *label* to an issue's labels without clobbering the existing ones.
|
||||||
|
|
||||||
|
Jira's update API takes the whole ``labels`` array, so adding one means
|
||||||
|
read-modify-write: fetch the current list, append if missing, write it
|
||||||
|
back. Failures here are logged and swallowed by the caller.
|
||||||
|
"""
|
||||||
|
issue = jira.issue(issue_key, fields="labels")
|
||||||
|
current = (issue.get("fields") or {}).get("labels") or []
|
||||||
|
if label not in current:
|
||||||
|
jira.update_issue_field(issue_key, fields={"labels": current + [label]})
|
||||||
|
|
||||||
|
|
||||||
|
def push_bt_work_started(db: Session, test, actor) -> None:
|
||||||
|
"""Transition the Jira issue to "In Progress" when a blue tech picks up
|
||||||
|
a queued test to start evaluating.
|
||||||
|
|
||||||
|
The test's TestState stays "blue_evaluating" throughout (queued AND
|
||||||
|
actively worked are the same Aegis state), so this can't be driven by
|
||||||
|
the generic push_test_event()/_STATE_TO_JIRA_STATUS dispatch — that
|
||||||
|
would incorrectly re-push "Queued Blue Team". Non-fatal.
|
||||||
|
"""
|
||||||
|
if not has_admin_jira_configured(db):
|
||||||
|
return
|
||||||
|
|
||||||
|
link = (
|
||||||
|
db.query(JiraLink)
|
||||||
|
.filter(
|
||||||
|
JiraLink.entity_type == JiraLinkEntityType.test,
|
||||||
|
JiraLink.entity_id == test.id,
|
||||||
|
)
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
if not link:
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
jira = get_admin_jira_client(db)
|
||||||
|
ts = datetime.utcnow().strftime("%Y-%m-%d %H:%M UTC")
|
||||||
|
comment = f"h3. ▶ Blue Team Started\n\n*Picked up by:* {actor.username}\n*At:* {ts}"
|
||||||
|
try:
|
||||||
|
jira.set_issue_status(link.jira_issue_key, "In Progress")
|
||||||
|
except Exception as exc_t:
|
||||||
|
logger.warning("Could not transition %s to In Progress: %s", link.jira_issue_key, exc_t)
|
||||||
|
jira.issue_add_comment(link.jira_issue_key, comment)
|
||||||
|
|
||||||
|
jira_account_id = getattr(actor, "jira_account_id", None)
|
||||||
|
if jira_account_id:
|
||||||
|
try:
|
||||||
|
jira.assign_issue(link.jira_issue_key, account_id=jira_account_id)
|
||||||
|
except Exception as exc_a:
|
||||||
|
logger.warning(
|
||||||
|
"Could not assign %s to %s: %s", link.jira_issue_key, jira_account_id, exc_a,
|
||||||
|
)
|
||||||
|
|
||||||
|
link.last_synced_at = datetime.utcnow()
|
||||||
|
db.flush()
|
||||||
|
logger.info("Posted blue-team-started event to Jira %s", link.jira_issue_key)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning("Failed to push blue-team-started event for test %s: %s", test.id, exc, exc_info=True)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# RT/BT date + result custom-field sync
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _enum_value(v) -> Optional[str]:
|
||||||
|
"""Return the plain string value of an enum member, or the value itself."""
|
||||||
|
if v is None:
|
||||||
|
return None
|
||||||
|
return v.value if hasattr(v, "value") else str(v)
|
||||||
|
|
||||||
|
|
||||||
|
def _select_field(value: str) -> dict:
|
||||||
|
"""Wrap a value for a Jira single-select custom field.
|
||||||
|
|
||||||
|
Jira's REST API rejects a bare string for select-type custom fields
|
||||||
|
("Specify a valid 'id' or 'name' for <field>") — it must be posted as
|
||||||
|
``{"value": ...}``.
|
||||||
|
"""
|
||||||
|
return {"value": value}
|
||||||
|
|
||||||
|
|
||||||
|
def _jira_datetime(dt: datetime) -> str:
|
||||||
|
"""Format a naive-UTC datetime for a Jira ``datetime``-type custom field.
|
||||||
|
|
||||||
|
RT/BT Start/End Date are genuine Jira datetime fields (confirmed via
|
||||||
|
issue_editmeta — schema type "datetime"), not plain dates. Sending a
|
||||||
|
bare "YYYY-MM-DD" string makes Jira default the time-of-day to
|
||||||
|
midnight, so RT Start Date and RT End Date ended up showing the same
|
||||||
|
meaningless midnight timestamp regardless of when the operator
|
||||||
|
actually started/finished. Jira's REST API expects
|
||||||
|
``yyyy-MM-dd'T'HH:mm:ss.SSSZ`` for this field type.
|
||||||
|
"""
|
||||||
|
return dt.strftime("%Y-%m-%dT%H:%M:%S.000+0000")
|
||||||
|
|
||||||
|
|
||||||
|
def _update_test_fields(db: Session, test: Test, fields: dict) -> None:
|
||||||
|
"""Best-effort update of custom fields on the Jira issue linked to *test*.
|
||||||
|
|
||||||
|
Non-fatal — any Jira error is logged and swallowed so it never blocks
|
||||||
|
the test workflow.
|
||||||
|
"""
|
||||||
|
if not fields or not has_admin_jira_configured(db):
|
||||||
|
return
|
||||||
|
|
||||||
|
link = (
|
||||||
|
db.query(JiraLink)
|
||||||
|
.filter(
|
||||||
|
JiraLink.entity_type == JiraLinkEntityType.test,
|
||||||
|
JiraLink.entity_id == test.id,
|
||||||
|
)
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
if not link:
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
jira = get_admin_jira_client(db)
|
||||||
|
jira.update_issue_field(link.jira_issue_key, fields=fields)
|
||||||
|
link.last_synced_at = datetime.utcnow()
|
||||||
|
db.flush()
|
||||||
|
logger.info("Updated Jira fields %s on %s", list(fields.keys()), link.jira_issue_key)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning(
|
||||||
|
"Failed to update Jira fields for test %s: %s", test.id, exc, exc_info=True
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def push_rt_submitted(db: Session, test: Test) -> None:
|
||||||
|
"""Set RT Start Date, RT End Date, and Attack Success — called when Red submits for review.
|
||||||
|
|
||||||
|
RT Start Date always reflects the *first* round's execution start (from
|
||||||
|
round_number=1 in round_history if this test has been reopened, else the
|
||||||
|
live field — this is round 1 in that case). RT End Date and Attack
|
||||||
|
Success always reflect the *current* round, since they're read live off
|
||||||
|
the test each time this runs and get pushed again on every resubmit.
|
||||||
|
Previously this pushed ``datetime.utcnow()`` (click time) instead of the
|
||||||
|
operator-entered execution window — fixed to read the real fields.
|
||||||
|
"""
|
||||||
|
from app.models.test_round_history import TestRoundHistory
|
||||||
|
|
||||||
|
fields: dict = {}
|
||||||
|
|
||||||
|
first_round = (
|
||||||
|
db.query(TestRoundHistory)
|
||||||
|
.filter(
|
||||||
|
TestRoundHistory.test_id == test.id,
|
||||||
|
TestRoundHistory.team == "red",
|
||||||
|
TestRoundHistory.round_number == 1,
|
||||||
|
)
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
first_start = (
|
||||||
|
first_round.execution_start_time
|
||||||
|
if first_round and first_round.execution_start_time
|
||||||
|
else test.execution_start_time
|
||||||
|
)
|
||||||
|
if first_start:
|
||||||
|
fields[JIRA_FIELD_RT_START_DATE] = _jira_datetime(first_start)
|
||||||
|
|
||||||
|
if test.execution_end_time:
|
||||||
|
fields[JIRA_FIELD_RT_END_DATE] = _jira_datetime(test.execution_end_time)
|
||||||
|
|
||||||
|
attack_success = _enum_value(test.attack_success)
|
||||||
|
if attack_success in _ATTACK_SUCCESS_TO_JIRA:
|
||||||
|
fields[JIRA_FIELD_ATTACK_SUCCESS] = _select_field(_ATTACK_SUCCESS_TO_JIRA[attack_success])
|
||||||
|
|
||||||
|
_update_test_fields(db, test, fields)
|
||||||
|
|
||||||
|
|
||||||
|
def push_bt_started(db: Session, test: Test) -> None:
|
||||||
|
"""Set the BT Start Date field — called when Blue picks up the test to evaluate.
|
||||||
|
|
||||||
|
Only pushed on round 1. On later rounds (after a reopen), the field
|
||||||
|
already holds round 1's real pickup date and must not be overwritten —
|
||||||
|
same "first round survives every reopen" rule as RT Start Date.
|
||||||
|
"""
|
||||||
|
if (test.blue_round_number or 1) > 1:
|
||||||
|
return
|
||||||
|
_update_test_fields(db, test, {
|
||||||
|
JIRA_FIELD_BT_START_DATE: _jira_datetime(datetime.utcnow()),
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
def push_bt_submitted(db: Session, test: Test) -> None:
|
||||||
|
"""Set BT End Date, Attack Detected/Contained, and Time to Detect/Contain — Blue submits for review.
|
||||||
|
|
||||||
|
Time to Detect / Time to Contain are Jira **datetime** fields (confirmed
|
||||||
|
via issue_editmeta), not numeric duration fields — despite the name.
|
||||||
|
This used to push a computed hour count, which Jira rejected outright;
|
||||||
|
since Jira validates the whole fields payload atomically, that one bad
|
||||||
|
field silently sank BT End Date and Attack Detected too. Now pushes the
|
||||||
|
actual detection_time/containment_time timestamps.
|
||||||
|
"""
|
||||||
|
fields: dict = {JIRA_FIELD_BT_END_DATE: _jira_datetime(datetime.utcnow())}
|
||||||
|
|
||||||
|
detection_result = _enum_value(test.detection_result)
|
||||||
|
if detection_result in _DETECTION_TO_JIRA:
|
||||||
|
fields[JIRA_FIELD_ATTACK_DETECTED] = _select_field(_DETECTION_TO_JIRA[detection_result])
|
||||||
|
|
||||||
|
containment_result = _enum_value(test.containment_result)
|
||||||
|
if containment_result in _CONTAINMENT_TO_JIRA:
|
||||||
|
fields[JIRA_FIELD_ATTACK_CONTAINED] = _select_field(_CONTAINMENT_TO_JIRA[containment_result])
|
||||||
|
|
||||||
|
if test.detection_time:
|
||||||
|
fields[JIRA_FIELD_TIME_TO_DETECT] = _jira_datetime(test.detection_time)
|
||||||
|
|
||||||
|
if test.containment_time:
|
||||||
|
fields[JIRA_FIELD_TIME_TO_CONTAIN] = _jira_datetime(test.containment_time)
|
||||||
|
|
||||||
|
_update_test_fields(db, test, fields)
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Legacy / generic helpers (kept for existing routes)
|
# Legacy / generic helpers (kept for existing routes)
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -38,6 +38,26 @@ from app.schemas.metrics import (
|
|||||||
ValidationRate,
|
ValidationRate,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Canonical MITRE ATT&CK kill-chain order — used to sort "coverage by
|
||||||
|
# tactic" consistently everywhere it's rendered (dashboard table, executive
|
||||||
|
# bar chart), instead of each consumer inventing its own order/subset.
|
||||||
|
MITRE_TACTIC_ORDER: list[str] = [
|
||||||
|
"reconnaissance",
|
||||||
|
"resource-development",
|
||||||
|
"initial-access",
|
||||||
|
"execution",
|
||||||
|
"persistence",
|
||||||
|
"privilege-escalation",
|
||||||
|
"defense-evasion",
|
||||||
|
"credential-access",
|
||||||
|
"discovery",
|
||||||
|
"lateral-movement",
|
||||||
|
"collection",
|
||||||
|
"command-and-control",
|
||||||
|
"exfiltration",
|
||||||
|
"impact",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
# Define function get_coverage_summary
|
# Define function get_coverage_summary
|
||||||
def get_coverage_summary(db: Session) -> CoverageSummary:
|
def get_coverage_summary(db: Session) -> CoverageSummary:
|
||||||
@@ -112,6 +132,12 @@ def get_coverage_by_tactic(db: Session) -> list[TacticCoverage]:
|
|||||||
lambda: {s.value: 0 for s in TechniqueStatus}
|
lambda: {s.value: 0 for s in TechniqueStatus}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Materialize every canonical tactic up front so the response always
|
||||||
|
# includes all 14, zero-filled, rather than only whichever tactics
|
||||||
|
# happen to have techniques seeded.
|
||||||
|
for tactic in MITRE_TACTIC_ORDER:
|
||||||
|
tactic_data[tactic] # noqa: B018 — touch to create the defaultdict entry
|
||||||
|
|
||||||
# Iterate over techniques
|
# Iterate over techniques
|
||||||
for tactic_str, status in techniques:
|
for tactic_str, status in techniques:
|
||||||
# Check: not tactic_str
|
# Check: not tactic_str
|
||||||
@@ -128,10 +154,17 @@ def get_coverage_by_tactic(db: Session) -> list[TacticCoverage]:
|
|||||||
# Assign tactic_data[tactic][status.value] = 1
|
# Assign tactic_data[tactic][status.value] = 1
|
||||||
tactic_data[tactic][status.value] += 1
|
tactic_data[tactic][status.value] += 1
|
||||||
|
|
||||||
|
def _tactic_sort_key(tactic: str) -> tuple[int, str]:
|
||||||
|
try:
|
||||||
|
return (MITRE_TACTIC_ORDER.index(tactic), tactic)
|
||||||
|
except ValueError:
|
||||||
|
# Anything outside the canonical 14 (e.g. "unknown") sorts last.
|
||||||
|
return (len(MITRE_TACTIC_ORDER), tactic)
|
||||||
|
|
||||||
# Assign result = []
|
# Assign result = []
|
||||||
result = []
|
result = []
|
||||||
# Iterate over sorted(tactic_data)
|
# Iterate over sorted(tactic_data)
|
||||||
for tactic in sorted(tactic_data):
|
for tactic in sorted(tactic_data, key=_tactic_sort_key):
|
||||||
# Assign counts = tactic_data[tactic]
|
# Assign counts = tactic_data[tactic]
|
||||||
counts = tactic_data[tactic]
|
counts = tactic_data[tactic]
|
||||||
# Assign total = sum(counts.values())
|
# Assign total = sum(counts.values())
|
||||||
|
|||||||
@@ -391,5 +391,18 @@ def sync_mitre(db: Session) -> dict:
|
|||||||
# Commit all pending changes to the database
|
# Commit all pending changes to the database
|
||||||
db.commit()
|
db.commit()
|
||||||
|
|
||||||
|
if created > 0:
|
||||||
|
from app.services.notification_service import notify_all_users_by_email
|
||||||
|
|
||||||
|
notify_all_users_by_email(
|
||||||
|
db,
|
||||||
|
preference_key="email_on_new_mitre_techniques",
|
||||||
|
subject=f"MITRE ATT&CK Updated: {created} new techniques",
|
||||||
|
message=(
|
||||||
|
f"{created} new techniques were added and {updated} were "
|
||||||
|
"updated in the MITRE ATT&CK catalog."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
# Return summary
|
# Return summary
|
||||||
return summary
|
return summary
|
||||||
|
|||||||
@@ -245,6 +245,76 @@ def cleanup_old_notifications(db: Session, days: int = 90) -> int:
|
|||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _preference_allows(user: User | None, preference_key: str) -> bool:
|
||||||
|
"""True unless the user has explicitly turned *preference_key* off.
|
||||||
|
|
||||||
|
``notification_preferences`` may be ``None`` (older rows predating the
|
||||||
|
column) or missing individual keys (newer preference toggles added
|
||||||
|
after the DB default was set) — both cases default to "on".
|
||||||
|
"""
|
||||||
|
if user is None or not user.email:
|
||||||
|
return False
|
||||||
|
prefs = user.notification_preferences or {}
|
||||||
|
return prefs.get(preference_key, True) is not False
|
||||||
|
|
||||||
|
|
||||||
|
def notify_user_by_email(db: Session, user_id, *, preference_key: str, subject: str, message: str) -> None:
|
||||||
|
"""Email a single user (by id) if their preferences allow it.
|
||||||
|
|
||||||
|
Best-effort — swallows lookup/send failures so a notification email
|
||||||
|
never breaks whatever workflow step triggered it.
|
||||||
|
"""
|
||||||
|
if not user_id:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
user = db.query(User).filter(User.id == user_id).first()
|
||||||
|
if not _preference_allows(user, preference_key):
|
||||||
|
return
|
||||||
|
from app.services.webhook_email_service import send_webhook_email
|
||||||
|
send_webhook_email(db, to=user.email, subject=subject, message=message, full_name=user.full_name)
|
||||||
|
except Exception:
|
||||||
|
pass # nosec B110 — email failures never crash the caller
|
||||||
|
|
||||||
|
|
||||||
|
def notify_all_users_by_email(db: Session, *, preference_key: str, subject: str, message: str) -> None:
|
||||||
|
"""Email every active, opted-in user — for platform-wide events
|
||||||
|
(e.g. a MITRE ATT&CK sync) rather than a single actor's own action.
|
||||||
|
"""
|
||||||
|
users = db.query(User).filter(User.is_active == True, User.email.isnot(None)).all() # noqa: E712
|
||||||
|
for user in users:
|
||||||
|
if not _preference_allows(user, preference_key):
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
from app.services.webhook_email_service import send_webhook_email
|
||||||
|
send_webhook_email(db, to=user.email, subject=subject, message=message, full_name=user.full_name)
|
||||||
|
except Exception:
|
||||||
|
pass # nosec B110 — one user's failure must not skip the rest
|
||||||
|
|
||||||
|
|
||||||
|
def notify_roles_by_email(
|
||||||
|
db: Session, *, roles: list[str], preference_key: str, subject: str, message: str,
|
||||||
|
exclude_user_id=None,
|
||||||
|
) -> None:
|
||||||
|
"""Email every active, opted-in user holding any of *roles*.
|
||||||
|
|
||||||
|
For workflow-wide callouts to a team (e.g. every lead vote) rather than
|
||||||
|
a single actor's own action — no in-app notification is created here,
|
||||||
|
only the email; pair with ``create_notification``/``notify_role_with_email``
|
||||||
|
if an in-app entry is also needed.
|
||||||
|
"""
|
||||||
|
users = db.query(User).filter(User.role.in_(roles), User.is_active == True).all() # noqa: E712
|
||||||
|
for user in users:
|
||||||
|
if exclude_user_id and user.id == exclude_user_id:
|
||||||
|
continue
|
||||||
|
if not _preference_allows(user, preference_key):
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
from app.services.webhook_email_service import send_webhook_email
|
||||||
|
send_webhook_email(db, to=user.email, subject=subject, message=message, full_name=user.full_name)
|
||||||
|
except Exception:
|
||||||
|
pass # nosec B110 — one user's failure must not skip the rest
|
||||||
|
|
||||||
|
|
||||||
def notify_role_with_email(
|
def notify_role_with_email(
|
||||||
db: Session,
|
db: Session,
|
||||||
*,
|
*,
|
||||||
@@ -316,6 +386,12 @@ def notify_test_state_change(db: Session, test, new_state: str) -> None:
|
|||||||
# Keyword argument: entity_id
|
# Keyword argument: entity_id
|
||||||
entity_id=test_id,
|
entity_id=test_id,
|
||||||
)
|
)
|
||||||
|
notify_user_by_email(
|
||||||
|
db, creator_id,
|
||||||
|
preference_key="email_on_test_state_change",
|
||||||
|
subject=f"Test Execution Started: {test_name}",
|
||||||
|
message=f'Your test "{test_name}" has moved to the execution phase.',
|
||||||
|
)
|
||||||
|
|
||||||
# Alternative: new_state == "blue_evaluating"
|
# Alternative: new_state == "blue_evaluating"
|
||||||
elif new_state == "blue_evaluating":
|
elif new_state == "blue_evaluating":
|
||||||
@@ -339,6 +415,12 @@ def notify_test_state_change(db: Session, test, new_state: str) -> None:
|
|||||||
# Keyword argument: entity_id
|
# Keyword argument: entity_id
|
||||||
entity_id=test_id,
|
entity_id=test_id,
|
||||||
)
|
)
|
||||||
|
notify_user_by_email(
|
||||||
|
db, user.id,
|
||||||
|
preference_key="email_on_test_state_change",
|
||||||
|
subject=f"Test Ready for Blue Evaluation: {test_name}",
|
||||||
|
message=f'Test "{test_name}" needs blue team evaluation.',
|
||||||
|
)
|
||||||
|
|
||||||
# Alternative: new_state == "in_review"
|
# Alternative: new_state == "in_review"
|
||||||
elif new_state == "in_review":
|
elif new_state == "in_review":
|
||||||
@@ -368,6 +450,12 @@ def notify_test_state_change(db: Session, test, new_state: str) -> None:
|
|||||||
# Keyword argument: entity_id
|
# Keyword argument: entity_id
|
||||||
entity_id=test_id,
|
entity_id=test_id,
|
||||||
)
|
)
|
||||||
|
notify_user_by_email(
|
||||||
|
db, user.id,
|
||||||
|
preference_key="email_on_test_state_change",
|
||||||
|
subject=f"Test Ready for Validation: {test_name}",
|
||||||
|
message=f'Test "{test_name}" is awaiting your review.',
|
||||||
|
)
|
||||||
|
|
||||||
# Alternative: new_state == "rejected" and creator_id
|
# Alternative: new_state == "rejected" and creator_id
|
||||||
elif new_state == "rejected" and creator_id:
|
elif new_state == "rejected" and creator_id:
|
||||||
@@ -387,6 +475,12 @@ def notify_test_state_change(db: Session, test, new_state: str) -> None:
|
|||||||
# Keyword argument: entity_id
|
# Keyword argument: entity_id
|
||||||
entity_id=test_id,
|
entity_id=test_id,
|
||||||
)
|
)
|
||||||
|
notify_user_by_email(
|
||||||
|
db, creator_id,
|
||||||
|
preference_key="email_on_test_rejected",
|
||||||
|
subject=f"Test Rejected: {test_name}",
|
||||||
|
message=f'Your test "{test_name}" has been rejected. Please review and resubmit.',
|
||||||
|
)
|
||||||
|
|
||||||
# Alternative: new_state == "validated" and creator_id
|
# Alternative: new_state == "validated" and creator_id
|
||||||
elif new_state == "validated" and creator_id:
|
elif new_state == "validated" and creator_id:
|
||||||
@@ -406,3 +500,9 @@ def notify_test_state_change(db: Session, test, new_state: str) -> None:
|
|||||||
# Keyword argument: entity_id
|
# Keyword argument: entity_id
|
||||||
entity_id=test_id,
|
entity_id=test_id,
|
||||||
)
|
)
|
||||||
|
notify_user_by_email(
|
||||||
|
db, creator_id,
|
||||||
|
preference_key="email_on_test_validated",
|
||||||
|
subject=f"Test Validated: {test_name}",
|
||||||
|
message=f'Your test "{test_name}" has been validated successfully.',
|
||||||
|
)
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ log = logging.getLogger(__name__)
|
|||||||
|
|
||||||
def _dispatch_inapp_notifications(db: Session, rule: AlertRule, instance: AlertInstance) -> None:
|
def _dispatch_inapp_notifications(db: Session, rule: AlertRule, instance: AlertInstance) -> None:
|
||||||
"""Create in-app Notification rows for all admins and leads."""
|
"""Create in-app Notification rows for all admins and leads."""
|
||||||
from app.services.notification_service import create_notification
|
from app.services.notification_service import create_notification, notify_roles_by_email
|
||||||
|
|
||||||
admin_roles = {"admin", "red_lead", "blue_lead"}
|
admin_roles = {"admin", "red_lead", "blue_lead"}
|
||||||
users = db.query(User).filter(
|
users = db.query(User).filter(
|
||||||
@@ -47,6 +47,14 @@ def _dispatch_inapp_notifications(db: Session, rule: AlertRule, instance: AlertI
|
|||||||
entity_id = instance.id,
|
entity_id = instance.id,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if rule.rule_type == AlertRuleType.stale_technique.value:
|
||||||
|
notify_roles_by_email(
|
||||||
|
db, roles=list(admin_roles),
|
||||||
|
preference_key="email_on_stale_coverage",
|
||||||
|
subject=instance.title,
|
||||||
|
message=instance.message,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _dispatch_webhooks(rule: AlertRule, instance: AlertInstance) -> None:
|
def _dispatch_webhooks(rule: AlertRule, instance: AlertInstance) -> None:
|
||||||
"""Fire webhook(s) for a triggered alert (all exceptions caught)."""
|
"""Fire webhook(s) for a triggered alert (all exceptions caught)."""
|
||||||
|
|||||||
@@ -0,0 +1,105 @@
|
|||||||
|
"""Passwordless-user onboarding — one-time set-password links sent by webhook.
|
||||||
|
|
||||||
|
An admin creates a user with just a name/email/role (see
|
||||||
|
``user_service.create_user_without_password``). To let that user in, the
|
||||||
|
admin clicks "Send Email": this service issues a one-time token and sends
|
||||||
|
it via the shared ``webhook_email_service`` (POSTs to an admin-configured
|
||||||
|
Power Automate webhook). The same mechanism, and the same button, is
|
||||||
|
reused for password resets on existing users.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import secrets
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.config import settings
|
||||||
|
from app.domain.errors import BusinessRuleViolation, EntityNotFoundError
|
||||||
|
from app.models.password_setup_token import PasswordSetupToken
|
||||||
|
from app.models.user import User
|
||||||
|
from app.services.webhook_email_service import get_webhook_url, send_webhook_email
|
||||||
|
|
||||||
|
_TOKEN_TTL = timedelta(hours=24)
|
||||||
|
|
||||||
|
|
||||||
|
def create_setup_token(db: Session, user: User) -> PasswordSetupToken:
|
||||||
|
"""Issue a fresh one-time token for *user*, invalidating any unused ones.
|
||||||
|
|
||||||
|
Does not commit; caller commits.
|
||||||
|
"""
|
||||||
|
db.query(PasswordSetupToken).filter(
|
||||||
|
PasswordSetupToken.user_id == user.id,
|
||||||
|
PasswordSetupToken.used_at.is_(None),
|
||||||
|
).delete()
|
||||||
|
|
||||||
|
token = PasswordSetupToken(
|
||||||
|
user_id=user.id,
|
||||||
|
token=secrets.token_urlsafe(32),
|
||||||
|
expires_at=datetime.utcnow() + _TOKEN_TTL,
|
||||||
|
)
|
||||||
|
db.add(token)
|
||||||
|
db.flush()
|
||||||
|
return token
|
||||||
|
|
||||||
|
|
||||||
|
def send_password_setup_email(db: Session, user: User) -> None:
|
||||||
|
"""Issue a token and email it via the configured webhook.
|
||||||
|
|
||||||
|
Raises BusinessRuleViolation if no webhook is configured yet.
|
||||||
|
Does not commit; caller commits (the token must be persisted even if
|
||||||
|
the webhook call itself fails, so a fresh "Send Email" retry works).
|
||||||
|
"""
|
||||||
|
if not get_webhook_url(db):
|
||||||
|
raise BusinessRuleViolation(
|
||||||
|
"No email webhook is configured yet — set one in Settings first."
|
||||||
|
)
|
||||||
|
|
||||||
|
token = create_setup_token(db, user)
|
||||||
|
set_password_url = f"{settings.PLATFORM_URL}/set-password?token={token.token}"
|
||||||
|
is_reset = not user.must_change_password
|
||||||
|
subject = "Reset Your Password" if is_reset else "Set Your Password"
|
||||||
|
message = (
|
||||||
|
f'Click the link below to {"reset" if is_reset else "set"} your Aegis password:\n\n'
|
||||||
|
f"{set_password_url}\n\n"
|
||||||
|
"This link expires in 24 hours and can only be used once."
|
||||||
|
)
|
||||||
|
sent = send_webhook_email(db, to=user.email, subject=subject, message=message, full_name=user.full_name)
|
||||||
|
if not sent:
|
||||||
|
raise BusinessRuleViolation(
|
||||||
|
"Failed to send the email — check the webhook URL/API key in Settings and the server logs."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _get_valid_token_or_raise(db: Session, token: str) -> PasswordSetupToken:
|
||||||
|
row = db.query(PasswordSetupToken).filter(PasswordSetupToken.token == token).first()
|
||||||
|
if row is None:
|
||||||
|
raise EntityNotFoundError("Password setup token", token)
|
||||||
|
if row.used_at is not None:
|
||||||
|
raise BusinessRuleViolation("This link has already been used")
|
||||||
|
if row.expires_at < datetime.utcnow():
|
||||||
|
raise BusinessRuleViolation("This link has expired — ask an admin to send a new one")
|
||||||
|
return row
|
||||||
|
|
||||||
|
|
||||||
|
def validate_token(db: Session, token: str) -> User:
|
||||||
|
"""Return the token's owning user if the token is valid, else raise."""
|
||||||
|
row = _get_valid_token_or_raise(db, token)
|
||||||
|
return row.user
|
||||||
|
|
||||||
|
|
||||||
|
def consume_token_and_set_password(db: Session, token: str, new_password: str) -> User:
|
||||||
|
"""Set *new_password* for the token's user and mark the token used.
|
||||||
|
|
||||||
|
Does not commit; caller commits.
|
||||||
|
"""
|
||||||
|
from app.auth import hash_password
|
||||||
|
|
||||||
|
row = _get_valid_token_or_raise(db, token)
|
||||||
|
user = row.user
|
||||||
|
user.hashed_password = hash_password(new_password)
|
||||||
|
user.must_change_password = False
|
||||||
|
row.used_at = datetime.utcnow()
|
||||||
|
db.flush()
|
||||||
|
return user
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
"""Heuristic extraction of the actual command(s)/query out of a free-text
|
||||||
|
procedure field (``procedure_text`` for Red, ``detect_procedure`` for
|
||||||
|
Blue), for proposing as a template's suggested procedure.
|
||||||
|
|
||||||
|
Operators write these fields as free text mixing narrative ("I ran this
|
||||||
|
against the DC and it worked"), the actual command, and sometimes pasted
|
||||||
|
output. We only want the command — not the story around it, not the
|
||||||
|
output. There's no reliable general way to do this with NLP-level
|
||||||
|
accuracy, so this uses a deterministic, conservative regex approach:
|
||||||
|
|
||||||
|
1. Fenced code blocks (```...``` or ~~~...~~~) are the strongest signal —
|
||||||
|
an operator who bothered to fence something almost always fenced the
|
||||||
|
command, not the narrative. If any exist, their contents are returned
|
||||||
|
verbatim (narrative outside the fence is ignored).
|
||||||
|
2. Otherwise, each line is checked against a curated set of patterns that
|
||||||
|
look like real commands or detection queries (shell prompts, known
|
||||||
|
binaries/cmdlets, SQL/KQL/SPL-style query syntax). Only matching lines
|
||||||
|
survive; narrative sentences and plain command output are discarded.
|
||||||
|
|
||||||
|
This is deliberately conservative — false negatives (missing a real
|
||||||
|
command written in an unrecognized style) are far less harmful than false
|
||||||
|
positives (proposing narrative or output as "the command"), especially
|
||||||
|
since a lead reviews every suggestion before it reaches a template.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
|
||||||
|
_FENCE_RE = re.compile(r"```(?:\w*\n)?(.*?)```|~~~(?:\w*\n)?(.*?)~~~", re.DOTALL)
|
||||||
|
|
||||||
|
# Leading shell/PowerShell prompt markers — stripped, not treated as
|
||||||
|
# evidence by themselves (the remainder still has to look like a command).
|
||||||
|
_PROMPT_RE = re.compile(r"^\s*(?:PS(?:\s+\S+)?>|[$#>])\s+")
|
||||||
|
|
||||||
|
# Signatures that indicate "this line is a command or query", not prose.
|
||||||
|
_COMMAND_LINE_RE = re.compile(
|
||||||
|
r"""
|
||||||
|
^\s*(?:PS(?:\s+\S+)?>|[$#>])\s*\S # explicit shell/PowerShell prompt with something after it
|
||||||
|
|
|
||||||
|
(?-i:\b[A-Z][a-zA-Z]+-[A-Z][a-zA-Z]+\b) # PowerShell Verb-Noun cmdlet, e.g. Get-Process
|
||||||
|
# (case-sensitive even though the rest of this
|
||||||
|
# pattern is IGNORECASE — otherwise lowercase
|
||||||
|
# hyphenated prose like "well-known" false-matches)
|
||||||
|
|
|
||||||
|
^\s*(?:sudo|chmod|chown|curl|wget|nmap|ssh|scp|python3?|bash|sh|zsh|
|
||||||
|
powershell(?:\.exe)?|cmd(?:\.exe)?|reg(?:\.exe)?|net(?:\.exe)?|
|
||||||
|
netsh|wmic|schtasks|sc|rundll32|regsvr32|msiexec|certutil|
|
||||||
|
mimikatz\S*|cscript|wscript|osascript|openssl|systemctl|service|
|
||||||
|
reg\s+query|whoami|nslookup|dig|ping|tasklist|ps\s|kill|
|
||||||
|
Invoke-\S+|iex|dsquery|nltest|
|
||||||
|
sysmon\S*|wevtutil|auditpol|tcpdump|tshark|procmon\S*|autorunsc\S*|
|
||||||
|
volatility\S*)\b
|
||||||
|
|
|
||||||
|
\bSELECT\s+.+\s+FROM\b # SQL
|
||||||
|
|
|
||||||
|
\bsearch\s+index\s*= # SPL (explicit "search" keyword)
|
||||||
|
|
|
||||||
|
\bindex\s*=\S # SPL (bare "index=value")
|
||||||
|
|
|
||||||
|
\|\s*(?:where|project|summarize|extend|join|render)\b # KQL pipe syntax
|
||||||
|
""",
|
||||||
|
re.IGNORECASE | re.VERBOSE,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def extract_commands(text: str | None) -> str | None:
|
||||||
|
"""Return the command/query lines found in *text*, or None if none found."""
|
||||||
|
if not text or not text.strip():
|
||||||
|
return None
|
||||||
|
|
||||||
|
fence_matches = _FENCE_RE.findall(text)
|
||||||
|
if fence_matches:
|
||||||
|
blocks = [(a or b).strip() for a, b in fence_matches if (a or b).strip()]
|
||||||
|
if blocks:
|
||||||
|
return "\n\n".join(blocks)
|
||||||
|
|
||||||
|
kept: list[str] = []
|
||||||
|
for line in text.splitlines():
|
||||||
|
stripped = line.strip()
|
||||||
|
if not stripped:
|
||||||
|
continue
|
||||||
|
if _COMMAND_LINE_RE.search(stripped):
|
||||||
|
kept.append(_PROMPT_RE.sub("", stripped))
|
||||||
|
|
||||||
|
return "\n".join(kept) if kept else None
|
||||||
@@ -0,0 +1,180 @@
|
|||||||
|
"""Procedure-improvement suggestion workflow.
|
||||||
|
|
||||||
|
When an operator submits a round with a filled-in procedure field, the
|
||||||
|
command(s) in it are extracted heuristically (see
|
||||||
|
``procedure_extraction_service``) and proposed as an update to the
|
||||||
|
originating template's suggested-procedure field. A suggestion is only
|
||||||
|
ever created when there's a real, novel improvement to propose — nothing
|
||||||
|
is written to the template until a lead reviews and approves it, and
|
||||||
|
approving one only ever appends new commands, never overwrites or drops
|
||||||
|
whatever earlier approved rounds already contributed.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.domain.errors import EntityNotFoundError, InvalidOperationError
|
||||||
|
from app.models.procedure_suggestion import ProcedureSuggestion
|
||||||
|
from app.models.test import Test
|
||||||
|
from app.models.test_template import TestTemplate
|
||||||
|
from app.models.user import User
|
||||||
|
from app.services.notification_service import notify_role
|
||||||
|
from app.services.procedure_extraction_service import extract_commands
|
||||||
|
|
||||||
|
# Which Test field holds the operator's free-text procedure, and which
|
||||||
|
# TestTemplate field it's proposed as an improvement to, per team.
|
||||||
|
_SOURCE_FIELD = {"red": "procedure_text", "blue": "detect_procedure"}
|
||||||
|
_TEMPLATE_FIELD = {"red": "attack_procedure", "blue": "expected_detection"}
|
||||||
|
_LEAD_ROLE = {"red": "red_lead", "blue": "blue_lead"}
|
||||||
|
|
||||||
|
|
||||||
|
def _new_lines(current: str | None, suggested: str) -> list[str]:
|
||||||
|
"""Lines in *suggested* not already present verbatim in *current*.
|
||||||
|
|
||||||
|
Line-level, not whole-text: a later round's extraction is usually a
|
||||||
|
different subset/superset of commands than an earlier one, so an exact
|
||||||
|
string comparison would treat almost every resubmission as "novel" and
|
||||||
|
then, on approval, blow away whatever an earlier approved round already
|
||||||
|
contributed.
|
||||||
|
"""
|
||||||
|
existing = {line.strip() for line in (current or "").splitlines() if line.strip()}
|
||||||
|
seen: set[str] = set()
|
||||||
|
new: list[str] = []
|
||||||
|
for line in suggested.splitlines():
|
||||||
|
stripped = line.strip()
|
||||||
|
if not stripped or stripped in existing or stripped in seen:
|
||||||
|
continue
|
||||||
|
new.append(stripped)
|
||||||
|
seen.add(stripped)
|
||||||
|
return new
|
||||||
|
|
||||||
|
|
||||||
|
def _merge_procedure_text(current: str | None, suggested: str) -> str:
|
||||||
|
"""Append genuinely new lines from *suggested* onto *current*.
|
||||||
|
|
||||||
|
Approving a suggestion must only ever grow a template's procedure,
|
||||||
|
never replace it — otherwise a narrower extraction from a later round
|
||||||
|
would silently erase commands an earlier approved round already added.
|
||||||
|
"""
|
||||||
|
current_stripped = (current or "").strip()
|
||||||
|
new = _new_lines(current, suggested)
|
||||||
|
if not new:
|
||||||
|
return current_stripped
|
||||||
|
if not current_stripped:
|
||||||
|
return "\n".join(new)
|
||||||
|
return current_stripped + "\n" + "\n".join(new)
|
||||||
|
|
||||||
|
|
||||||
|
def create_suggestion_from_submission(db: Session, test: Test, team: str) -> ProcedureSuggestion | None:
|
||||||
|
"""Propose a template procedure improvement from *test*'s just-submitted round.
|
||||||
|
|
||||||
|
Returns the created (unflushed-to-caller-commit) suggestion, or ``None``
|
||||||
|
when there's nothing worth proposing: the test wasn't created from a
|
||||||
|
template, no command could be extracted, the extraction matches what
|
||||||
|
the template already suggests, or an identical suggestion is already
|
||||||
|
pending review. Does not commit; caller uses UnitOfWork.
|
||||||
|
"""
|
||||||
|
if not test.source_template_id:
|
||||||
|
return None
|
||||||
|
|
||||||
|
procedure_text = getattr(test, _SOURCE_FIELD[team])
|
||||||
|
extracted = extract_commands(procedure_text)
|
||||||
|
if not extracted:
|
||||||
|
return None
|
||||||
|
|
||||||
|
template = db.query(TestTemplate).filter(TestTemplate.id == test.source_template_id).first()
|
||||||
|
if template is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
current = getattr(template, _TEMPLATE_FIELD[team])
|
||||||
|
if not _new_lines(current, extracted):
|
||||||
|
return None
|
||||||
|
|
||||||
|
duplicate = (
|
||||||
|
db.query(ProcedureSuggestion)
|
||||||
|
.filter(
|
||||||
|
ProcedureSuggestion.template_id == template.id,
|
||||||
|
ProcedureSuggestion.team == team,
|
||||||
|
ProcedureSuggestion.suggested_text == extracted,
|
||||||
|
ProcedureSuggestion.status == "pending",
|
||||||
|
)
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
if duplicate is not None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
submitter_id = test.red_tech_assignee if team == "red" else test.blue_tech_assignee
|
||||||
|
suggestion = ProcedureSuggestion(
|
||||||
|
template_id=template.id,
|
||||||
|
team=team,
|
||||||
|
suggested_text=extracted,
|
||||||
|
source_test_id=test.id,
|
||||||
|
submitted_by=submitter_id,
|
||||||
|
)
|
||||||
|
db.add(suggestion)
|
||||||
|
db.flush()
|
||||||
|
|
||||||
|
notify_role(
|
||||||
|
db,
|
||||||
|
role=_LEAD_ROLE[team],
|
||||||
|
type="procedure_suggestion",
|
||||||
|
title="New procedure suggestion for review",
|
||||||
|
message=f'A {team} procedure improvement was proposed for template "{template.name}".',
|
||||||
|
entity_type="procedure_suggestion",
|
||||||
|
entity_id=suggestion.id,
|
||||||
|
)
|
||||||
|
|
||||||
|
return suggestion
|
||||||
|
|
||||||
|
|
||||||
|
def list_pending_suggestions(
|
||||||
|
db: Session, *, team: str | None = None, source_test_id: uuid.UUID | None = None,
|
||||||
|
) -> list[ProcedureSuggestion]:
|
||||||
|
"""List pending suggestions, optionally filtered to one team and/or the
|
||||||
|
specific test that originated them (used to block a lead's test-detail
|
||||||
|
view on a suggestion awaiting their review)."""
|
||||||
|
query = db.query(ProcedureSuggestion).filter(ProcedureSuggestion.status == "pending")
|
||||||
|
if team is not None:
|
||||||
|
query = query.filter(ProcedureSuggestion.team == team)
|
||||||
|
if source_test_id is not None:
|
||||||
|
query = query.filter(ProcedureSuggestion.source_test_id == source_test_id)
|
||||||
|
return query.order_by(ProcedureSuggestion.created_at.asc()).all()
|
||||||
|
|
||||||
|
|
||||||
|
def _get_pending_suggestion_or_raise(db: Session, suggestion_id: uuid.UUID) -> ProcedureSuggestion:
|
||||||
|
suggestion = db.query(ProcedureSuggestion).filter(ProcedureSuggestion.id == suggestion_id).first()
|
||||||
|
if suggestion is None:
|
||||||
|
raise EntityNotFoundError("ProcedureSuggestion", str(suggestion_id))
|
||||||
|
if suggestion.status != "pending":
|
||||||
|
raise InvalidOperationError("This suggestion has already been reviewed.")
|
||||||
|
return suggestion
|
||||||
|
|
||||||
|
|
||||||
|
def approve_suggestion(db: Session, suggestion_id: uuid.UUID, user: User) -> ProcedureSuggestion:
|
||||||
|
"""Approve a suggestion: write it into the template and mark reviewed. Does not commit."""
|
||||||
|
suggestion = _get_pending_suggestion_or_raise(db, suggestion_id)
|
||||||
|
template = db.query(TestTemplate).filter(TestTemplate.id == suggestion.template_id).first()
|
||||||
|
if template is None:
|
||||||
|
raise EntityNotFoundError("TestTemplate", str(suggestion.template_id))
|
||||||
|
|
||||||
|
current_text = getattr(template, _TEMPLATE_FIELD[suggestion.team])
|
||||||
|
setattr(template, _TEMPLATE_FIELD[suggestion.team], _merge_procedure_text(current_text, suggestion.suggested_text))
|
||||||
|
suggestion.status = "approved"
|
||||||
|
suggestion.reviewed_by = user.id
|
||||||
|
suggestion.reviewed_at = datetime.utcnow()
|
||||||
|
db.flush()
|
||||||
|
return suggestion
|
||||||
|
|
||||||
|
|
||||||
|
def reject_suggestion(db: Session, suggestion_id: uuid.UUID, user: User) -> ProcedureSuggestion:
|
||||||
|
"""Reject a suggestion without touching the template. Does not commit."""
|
||||||
|
suggestion = _get_pending_suggestion_or_raise(db, suggestion_id)
|
||||||
|
suggestion.status = "rejected"
|
||||||
|
suggestion.reviewed_by = user.id
|
||||||
|
suggestion.reviewed_at = datetime.utcnow()
|
||||||
|
db.flush()
|
||||||
|
return suggestion
|
||||||
@@ -177,8 +177,9 @@ def process_callback(db: Session, request_data: dict) -> User:
|
|||||||
name_id = auth.get_nameid()
|
name_id = auth.get_nameid()
|
||||||
|
|
||||||
# Attribute claim URIs — defaults support both plain names and Azure AD full URIs
|
# Attribute claim URIs — defaults support both plain names and Azure AD full URIs
|
||||||
|
# (attr_username is no longer read: email is the sole login identifier
|
||||||
|
# platform-wide, username always mirrors it — see below.)
|
||||||
email_attr = cfg.attr_email or "email"
|
email_attr = cfg.attr_email or "email"
|
||||||
username_attr = cfg.attr_username or "email" # Azure AD: use email as username
|
|
||||||
role_attr = cfg.attr_role or "http://schemas.microsoft.com/ws/2008/06/identity/claims/role"
|
role_attr = cfg.attr_role or "http://schemas.microsoft.com/ws/2008/06/identity/claims/role"
|
||||||
|
|
||||||
# Resolve email: try configured attr → Azure email claim URI → NameID
|
# Resolve email: try configured attr → Azure email claim URI → NameID
|
||||||
@@ -189,9 +190,9 @@ def process_callback(db: Session, request_data: dict) -> User:
|
|||||||
or ""
|
or ""
|
||||||
)
|
)
|
||||||
|
|
||||||
# Resolve username: keep full email (e.g. user@company.com) to avoid collisions with local accounts
|
# Email is the unique login identifier platform-wide — username always
|
||||||
raw_username = _first_attr(attrs, username_attr) or email or name_id or ""
|
# mirrors it (see User model), never a separately-provisioned IdP value.
|
||||||
username = raw_username.strip() or email.split("@")[0] or name_id
|
username = email
|
||||||
|
|
||||||
# Resolve role: try configured attr → Azure role claim URI → default
|
# Resolve role: try configured attr → Azure role claim URI → default
|
||||||
role = (
|
role = (
|
||||||
@@ -202,7 +203,7 @@ def process_callback(db: Session, request_data: dict) -> User:
|
|||||||
)
|
)
|
||||||
|
|
||||||
# Validate role
|
# Validate role
|
||||||
valid_roles = {"admin", "red_lead", "blue_lead", "red_tech", "blue_tech", "viewer"}
|
valid_roles = {"admin", "red_lead", "blue_lead", "red_tech", "blue_tech", "manager", "viewer"}
|
||||||
if role not in valid_roles:
|
if role not in valid_roles:
|
||||||
log.warning("SSO: unknown role '%s' for user '%s', falling back to default", role, username)
|
log.warning("SSO: unknown role '%s' for user '%s', falling back to default", role, username)
|
||||||
role = cfg.default_role or "viewer"
|
role = cfg.default_role or "viewer"
|
||||||
|
|||||||
@@ -0,0 +1,119 @@
|
|||||||
|
"""Operator template-proposal workflow.
|
||||||
|
|
||||||
|
Leads create test templates directly (see test_template_service.create_template,
|
||||||
|
gated to red_lead/blue_lead in the router). An operator (red_tech/blue_tech)
|
||||||
|
gets the same "propose a template" action, but nothing is written to the
|
||||||
|
live catalog until a lead on their team reviews it — the lead can approve
|
||||||
|
as-is, approve with edits, or discard it outright.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.domain.errors import EntityNotFoundError, InvalidOperationError
|
||||||
|
from app.models.template_suggestion import TemplateSuggestion
|
||||||
|
from app.models.test_template import TestTemplate
|
||||||
|
from app.models.user import User
|
||||||
|
from app.services.notification_service import notify_role
|
||||||
|
from app.services.test_template_service import create_template, validate_mitre_technique_id
|
||||||
|
|
||||||
|
_TEAM_BY_ROLE = {"red_tech": "red", "blue_tech": "blue"}
|
||||||
|
_LEAD_ROLE = {"red": "red_lead", "blue": "blue_lead"}
|
||||||
|
|
||||||
|
_TEMPLATE_FIELDS = (
|
||||||
|
"mitre_technique_id", "name", "description", "source", "source_url",
|
||||||
|
"attack_procedure", "expected_detection", "platform", "tool_suggested",
|
||||||
|
"severity", "atomic_test_id", "suggested_remediation",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def team_for_submitter(user: User) -> str:
|
||||||
|
"""Which team a submission belongs to, based on the submitter's role."""
|
||||||
|
team = _TEAM_BY_ROLE.get(user.role)
|
||||||
|
if team is None:
|
||||||
|
raise InvalidOperationError("Only red_tech/blue_tech operators can propose templates")
|
||||||
|
return team
|
||||||
|
|
||||||
|
|
||||||
|
def create_template_suggestion(db: Session, submitter: User, **fields: object) -> TemplateSuggestion:
|
||||||
|
"""Create a pending template suggestion and notify the submitter's lead.
|
||||||
|
|
||||||
|
Does not commit; caller uses UnitOfWork.
|
||||||
|
"""
|
||||||
|
team = team_for_submitter(submitter)
|
||||||
|
validate_mitre_technique_id(db, fields["mitre_technique_id"])
|
||||||
|
suggestion = TemplateSuggestion(team=team, submitted_by=submitter.id, **fields)
|
||||||
|
db.add(suggestion)
|
||||||
|
db.flush()
|
||||||
|
|
||||||
|
notify_role(
|
||||||
|
db,
|
||||||
|
role=_LEAD_ROLE[team],
|
||||||
|
type="template_suggestion",
|
||||||
|
title="New test template proposed for review",
|
||||||
|
message=f'A new template, "{suggestion.name}", was proposed and needs your review.',
|
||||||
|
entity_type="template_suggestion",
|
||||||
|
entity_id=suggestion.id,
|
||||||
|
)
|
||||||
|
return suggestion
|
||||||
|
|
||||||
|
|
||||||
|
def list_pending_template_suggestions(db: Session, *, team: str | None = None) -> list[TemplateSuggestion]:
|
||||||
|
"""List pending template suggestions, optionally filtered to one team."""
|
||||||
|
query = db.query(TemplateSuggestion).filter(TemplateSuggestion.status == "pending")
|
||||||
|
if team is not None:
|
||||||
|
query = query.filter(TemplateSuggestion.team == team)
|
||||||
|
return query.order_by(TemplateSuggestion.created_at.asc()).all()
|
||||||
|
|
||||||
|
|
||||||
|
def _get_pending_suggestion_or_raise(db: Session, suggestion_id: uuid.UUID) -> TemplateSuggestion:
|
||||||
|
suggestion = db.query(TemplateSuggestion).filter(TemplateSuggestion.id == suggestion_id).first()
|
||||||
|
if suggestion is None:
|
||||||
|
raise EntityNotFoundError("TemplateSuggestion", str(suggestion_id))
|
||||||
|
if suggestion.status != "pending":
|
||||||
|
raise InvalidOperationError("This template suggestion has already been reviewed.")
|
||||||
|
return suggestion
|
||||||
|
|
||||||
|
|
||||||
|
def approve_template_suggestion(
|
||||||
|
db: Session,
|
||||||
|
suggestion_id: uuid.UUID,
|
||||||
|
user: User,
|
||||||
|
overrides: dict | None = None,
|
||||||
|
) -> tuple[TemplateSuggestion, TestTemplate]:
|
||||||
|
"""Approve a suggestion: create the real TestTemplate and mark it reviewed.
|
||||||
|
|
||||||
|
*overrides* lets the reviewing lead adjust any field before it goes
|
||||||
|
live — unset/None fields fall back to what the operator submitted.
|
||||||
|
Does not commit; caller uses UnitOfWork.
|
||||||
|
"""
|
||||||
|
suggestion = _get_pending_suggestion_or_raise(db, suggestion_id)
|
||||||
|
|
||||||
|
fields = {name: getattr(suggestion, name) for name in _TEMPLATE_FIELDS}
|
||||||
|
for key, value in (overrides or {}).items():
|
||||||
|
if value is not None and key in fields:
|
||||||
|
fields[key] = value
|
||||||
|
|
||||||
|
template = create_template(db, **fields)
|
||||||
|
db.flush()
|
||||||
|
|
||||||
|
suggestion.status = "approved"
|
||||||
|
suggestion.reviewed_by = user.id
|
||||||
|
suggestion.reviewed_at = datetime.utcnow()
|
||||||
|
suggestion.created_template_id = template.id
|
||||||
|
db.flush()
|
||||||
|
return suggestion, template
|
||||||
|
|
||||||
|
|
||||||
|
def reject_template_suggestion(db: Session, suggestion_id: uuid.UUID, user: User) -> TemplateSuggestion:
|
||||||
|
"""Discard a suggestion without creating a template. Does not commit."""
|
||||||
|
suggestion = _get_pending_suggestion_or_raise(db, suggestion_id)
|
||||||
|
suggestion.status = "rejected"
|
||||||
|
suggestion.reviewed_by = user.id
|
||||||
|
suggestion.reviewed_at = datetime.utcnow()
|
||||||
|
db.flush()
|
||||||
|
return suggestion
|
||||||
@@ -6,8 +6,11 @@ Each user authenticates to Tempo with their own personal Tempo API token,
|
|||||||
stored in ``user.tempo_api_token``. This is different from the Jira API token.
|
stored in ``user.tempo_api_token``. This is different from the Jira API token.
|
||||||
Obtain a Tempo token at: Jira → Apps → Tempo → Settings → API Integration.
|
Obtain a Tempo token at: Jira → Apps → Tempo → Settings → API Integration.
|
||||||
|
|
||||||
The global ``settings.TEMPO_ENABLED`` flag acts as a kill-switch. When False,
|
The global ``settings.TEMPO_ENABLED`` env var is an optional hard kill-switch
|
||||||
all Tempo calls are silently skipped regardless of whether users have tokens.
|
for ops (e.g. disable entirely in a demo environment). It defaults to False,
|
||||||
|
but that default is bypassed automatically once an admin or personal Tempo
|
||||||
|
token is actually configured — configuring a token via Settings is enough
|
||||||
|
to turn Tempo sync on, same as Jira's DB-backed "enabled" toggle.
|
||||||
|
|
||||||
What goes to Tempo
|
What goes to Tempo
|
||||||
------------------
|
------------------
|
||||||
@@ -223,8 +226,12 @@ def auto_log_test_worklog(
|
|||||||
logger.debug("Skipping Tempo sync for activity_type=%s", activity_type)
|
logger.debug("Skipping Tempo sync for activity_type=%s", activity_type)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
# Global kill-switch
|
# Global kill-switch — bypassed once an admin or personal Tempo token
|
||||||
if not settings.TEMPO_ENABLED:
|
# is actually configured, mirroring how Jira's DB-backed "enabled"
|
||||||
|
# toggle takes priority over its env-var default. Without this, an
|
||||||
|
# admin who configures a Tempo token via Settings still gets silent
|
||||||
|
# no-ops because TEMPO_ENABLED defaults to False and has no UI toggle.
|
||||||
|
if not settings.TEMPO_ENABLED and not has_admin_tempo_configured(db) and not has_tempo_configured(user):
|
||||||
return None
|
return None
|
||||||
|
|
||||||
# Compute duration from test timestamps when not supplied by the caller
|
# Compute duration from test timestamps when not supplied by the caller
|
||||||
|
|||||||
@@ -10,7 +10,8 @@ from datetime import datetime
|
|||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
# Import Session, joinedload from sqlalchemy.orm
|
# Import Session, joinedload from sqlalchemy.orm
|
||||||
from sqlalchemy.orm import Session, joinedload
|
from sqlalchemy import func, or_
|
||||||
|
from sqlalchemy.orm import Query, Session, joinedload
|
||||||
|
|
||||||
# Import from app.domain.errors
|
# Import from app.domain.errors
|
||||||
from app.domain.errors import (
|
from app.domain.errors import (
|
||||||
@@ -18,42 +19,61 @@ from app.domain.errors import (
|
|||||||
EntityNotFoundError,
|
EntityNotFoundError,
|
||||||
PermissionViolation,
|
PermissionViolation,
|
||||||
)
|
)
|
||||||
from app.models.enums import TestState
|
from app.models.enums import DataClassification, TestState
|
||||||
from app.models.technique import Technique
|
from app.models.technique import Technique
|
||||||
from app.models.test import Test
|
from app.models.test import Test
|
||||||
from app.models.test_template import TestTemplate
|
from app.models.test_template import TestTemplate
|
||||||
from app.models.campaign import Campaign, CampaignTest
|
from app.models.campaign import CampaignTest
|
||||||
from app.models.audit import AuditLog
|
from app.models.audit import AuditLog
|
||||||
from app.utils import escape_like
|
from app.utils import escape_like
|
||||||
|
|
||||||
|
# Tactics whose test data is more likely to touch customer content, PII, or
|
||||||
|
# high-impact operational detail — these get an initial classification of
|
||||||
|
# 'pii' rather than the 'internal_use_only' baseline. This is a starting
|
||||||
|
# point only; the assigned operator or lead can always correct it.
|
||||||
|
_RESTRICTED_TACTICS = frozenset({
|
||||||
|
"exfiltration", "collection", "credential-access", "impact",
|
||||||
|
})
|
||||||
|
|
||||||
# Define function list_tests
|
|
||||||
def list_tests(
|
def determine_initial_classification(technique: Technique | None) -> str:
|
||||||
# Entry: db
|
"""Best-effort initial data classification for a new test.
|
||||||
|
|
||||||
|
Every security test reveals internal attack/defense posture, so the
|
||||||
|
baseline is 'internal_use_only' — never public or general use by
|
||||||
|
default. Escalated to 'pii' when the technique's tactic implies
|
||||||
|
customer data, PII, or destructive impact.
|
||||||
|
"""
|
||||||
|
if technique and technique.tactic and technique.tactic.lower() in _RESTRICTED_TACTICS:
|
||||||
|
return DataClassification.pii.value
|
||||||
|
return DataClassification.internal_use_only.value
|
||||||
|
|
||||||
|
|
||||||
|
def _build_test_query(
|
||||||
db: Session,
|
db: Session,
|
||||||
*,
|
*,
|
||||||
# Entry: state
|
|
||||||
state: str | None = None,
|
state: str | None = None,
|
||||||
# Entry: technique_id
|
|
||||||
technique_id: uuid.UUID | None = None,
|
technique_id: uuid.UUID | None = None,
|
||||||
# Entry: platform
|
technique_search: str | None = None,
|
||||||
platform: str | None = None,
|
platform: str | None = None,
|
||||||
# Entry: created_by
|
|
||||||
created_by: uuid.UUID | None = None,
|
created_by: uuid.UUID | None = None,
|
||||||
# Entry: pending_validation_side
|
|
||||||
pending_validation_side: str | None = None,
|
pending_validation_side: str | None = None,
|
||||||
|
reviewer_id: uuid.UUID | None = None,
|
||||||
not_in_any_campaign: bool = False,
|
not_in_any_campaign: bool = False,
|
||||||
offset: int = 0,
|
attack_success: str | None = None,
|
||||||
# Entry: limit
|
detection_result: str | None = None,
|
||||||
limit: int = 50,
|
validated_from: datetime | None = None,
|
||||||
) -> list[Test]:
|
validated_to: datetime | None = None,
|
||||||
"""Return a paginated list of tests with optional filters.
|
) -> Query:
|
||||||
|
"""Build the filtered Test query shared by list_tests() and count_tests().
|
||||||
|
|
||||||
Tests that belong to a campaign still in 'draft' status AND with a
|
Kept as a single source of truth so the displayed page of results and
|
||||||
start_date in the future are always excluded — they should not appear
|
the total count it's paginated against can never drift apart.
|
||||||
in the team's queue until the campaign is activated on its start date.
|
|
||||||
"""
|
"""
|
||||||
query = db.query(Test).options(joinedload(Test.technique))
|
query = db.query(Test).options(
|
||||||
|
joinedload(Test.technique),
|
||||||
|
joinedload(Test.red_tech_assigned_user), joinedload(Test.blue_tech_assigned_user),
|
||||||
|
)
|
||||||
|
|
||||||
# Check: state
|
# Check: state
|
||||||
if state:
|
if state:
|
||||||
@@ -63,6 +83,16 @@ def list_tests(
|
|||||||
if technique_id:
|
if technique_id:
|
||||||
# Assign query = query.filter(Test.technique_id == technique_id)
|
# Assign query = query.filter(Test.technique_id == technique_id)
|
||||||
query = query.filter(Test.technique_id == technique_id)
|
query = query.filter(Test.technique_id == technique_id)
|
||||||
|
# Free-text technique filter (MITRE ID or name) — doesn't require the
|
||||||
|
# caller to already know the technique's UUID like technique_id does.
|
||||||
|
if technique_search:
|
||||||
|
pattern = f"%{escape_like(technique_search)}%"
|
||||||
|
query = query.join(Technique, Test.technique_id == Technique.id).filter(
|
||||||
|
or_(
|
||||||
|
Technique.mitre_id.ilike(pattern),
|
||||||
|
Technique.name.ilike(pattern),
|
||||||
|
)
|
||||||
|
)
|
||||||
# Check: platform
|
# Check: platform
|
||||||
if platform:
|
if platform:
|
||||||
# Assign query = query.filter(Test.platform.ilike(f"%{escape_like(platform)}%"))
|
# Assign query = query.filter(Test.platform.ilike(f"%{escape_like(platform)}%"))
|
||||||
@@ -85,30 +115,123 @@ def list_tests(
|
|||||||
Test.state == TestState.in_review,
|
Test.state == TestState.in_review,
|
||||||
Test.blue_validation_status.in_(["pending", None]),
|
Test.blue_validation_status.in_(["pending", None]),
|
||||||
)
|
)
|
||||||
|
# "My reviews" — tests currently assigned to *reviewer_id* at the
|
||||||
|
# red_review/blue_review lead-review gate (distinct from
|
||||||
|
# pending_validation_side, which covers the later in_review stage).
|
||||||
|
if reviewer_id:
|
||||||
|
if state == TestState.red_review.value:
|
||||||
|
query = query.filter(Test.red_reviewer_assignee == reviewer_id)
|
||||||
|
elif state == TestState.blue_review.value:
|
||||||
|
query = query.filter(Test.blue_reviewer_assignee == reviewer_id)
|
||||||
|
else:
|
||||||
|
query = query.filter(
|
||||||
|
Test.state.in_([TestState.red_review, TestState.blue_review]),
|
||||||
|
or_(
|
||||||
|
Test.red_reviewer_assignee == reviewer_id,
|
||||||
|
Test.blue_reviewer_assignee == reviewer_id,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
if not_in_any_campaign:
|
if not_in_any_campaign:
|
||||||
linked = db.query(CampaignTest.test_id).distinct().subquery()
|
linked = db.query(CampaignTest.test_id).distinct().subquery()
|
||||||
query = query.filter(~Test.id.in_(linked))
|
query = query.filter(~Test.id.in_(linked))
|
||||||
|
|
||||||
# Always hide tests from scheduled campaigns that haven't started yet.
|
if attack_success:
|
||||||
# A "scheduled-but-not-yet-active" campaign = draft status + start_date in future.
|
query = query.filter(Test.attack_success == attack_success)
|
||||||
now = datetime.utcnow()
|
if detection_result:
|
||||||
future_draft_tests = (
|
query = query.filter(Test.detection_result == detection_result)
|
||||||
db.query(CampaignTest.test_id)
|
|
||||||
.join(Campaign, Campaign.id == CampaignTest.campaign_id)
|
|
||||||
.filter(
|
|
||||||
Campaign.status == "draft",
|
|
||||||
Campaign.start_date.isnot(None),
|
|
||||||
Campaign.start_date > now,
|
|
||||||
)
|
|
||||||
.distinct()
|
|
||||||
.subquery()
|
|
||||||
)
|
|
||||||
query = query.filter(~Test.id.in_(future_draft_tests))
|
|
||||||
|
|
||||||
# Return query.order_by(Test.created_at.desc()).offset(offset).limit(limit)....
|
# Validated-date range: whichever lead validated last (mirrors the
|
||||||
|
# "Validated" column shown on the Validated Tests page).
|
||||||
|
if validated_from or validated_to:
|
||||||
|
validated_at = func.coalesce(Test.blue_validated_at, Test.red_validated_at)
|
||||||
|
if validated_from:
|
||||||
|
query = query.filter(validated_at >= validated_from)
|
||||||
|
if validated_to:
|
||||||
|
query = query.filter(validated_at <= validated_to)
|
||||||
|
|
||||||
|
return query
|
||||||
|
|
||||||
|
|
||||||
|
# Define function list_tests
|
||||||
|
def list_tests(
|
||||||
|
db: Session,
|
||||||
|
*,
|
||||||
|
state: str | None = None,
|
||||||
|
technique_id: uuid.UUID | None = None,
|
||||||
|
technique_search: str | None = None,
|
||||||
|
platform: str | None = None,
|
||||||
|
created_by: uuid.UUID | None = None,
|
||||||
|
pending_validation_side: str | None = None,
|
||||||
|
# Entry: reviewer_id — "my reviews" queue for the red_review/blue_review gate stage
|
||||||
|
reviewer_id: uuid.UUID | None = None,
|
||||||
|
not_in_any_campaign: bool = False,
|
||||||
|
attack_success: str | None = None,
|
||||||
|
detection_result: str | None = None,
|
||||||
|
validated_from: datetime | None = None,
|
||||||
|
validated_to: datetime | None = None,
|
||||||
|
offset: int = 0,
|
||||||
|
# Entry: limit
|
||||||
|
limit: int = 50,
|
||||||
|
) -> list[Test]:
|
||||||
|
"""Return a paginated list of tests with optional filters."""
|
||||||
|
query = _build_test_query(
|
||||||
|
db,
|
||||||
|
state=state,
|
||||||
|
technique_id=technique_id,
|
||||||
|
technique_search=technique_search,
|
||||||
|
platform=platform,
|
||||||
|
created_by=created_by,
|
||||||
|
pending_validation_side=pending_validation_side,
|
||||||
|
reviewer_id=reviewer_id,
|
||||||
|
not_in_any_campaign=not_in_any_campaign,
|
||||||
|
attack_success=attack_success,
|
||||||
|
detection_result=detection_result,
|
||||||
|
validated_from=validated_from,
|
||||||
|
validated_to=validated_to,
|
||||||
|
)
|
||||||
return query.order_by(Test.created_at.desc()).offset(offset).limit(limit).all()
|
return query.order_by(Test.created_at.desc()).offset(offset).limit(limit).all()
|
||||||
|
|
||||||
|
|
||||||
|
def count_tests(
|
||||||
|
db: Session,
|
||||||
|
*,
|
||||||
|
state: str | None = None,
|
||||||
|
technique_id: uuid.UUID | None = None,
|
||||||
|
technique_search: str | None = None,
|
||||||
|
platform: str | None = None,
|
||||||
|
created_by: uuid.UUID | None = None,
|
||||||
|
pending_validation_side: str | None = None,
|
||||||
|
reviewer_id: uuid.UUID | None = None,
|
||||||
|
not_in_any_campaign: bool = False,
|
||||||
|
attack_success: str | None = None,
|
||||||
|
detection_result: str | None = None,
|
||||||
|
validated_from: datetime | None = None,
|
||||||
|
validated_to: datetime | None = None,
|
||||||
|
) -> int:
|
||||||
|
"""Return the total count of tests matching the same filters as list_tests().
|
||||||
|
|
||||||
|
Lets the frontend show a true total even when the page size caps the
|
||||||
|
number of rows actually returned (e.g. GET /tests's limit<=200).
|
||||||
|
"""
|
||||||
|
query = _build_test_query(
|
||||||
|
db,
|
||||||
|
state=state,
|
||||||
|
technique_id=technique_id,
|
||||||
|
technique_search=technique_search,
|
||||||
|
platform=platform,
|
||||||
|
created_by=created_by,
|
||||||
|
pending_validation_side=pending_validation_side,
|
||||||
|
reviewer_id=reviewer_id,
|
||||||
|
not_in_any_campaign=not_in_any_campaign,
|
||||||
|
attack_success=attack_success,
|
||||||
|
detection_result=detection_result,
|
||||||
|
validated_from=validated_from,
|
||||||
|
validated_to=validated_to,
|
||||||
|
)
|
||||||
|
return query.count()
|
||||||
|
|
||||||
|
|
||||||
# Define function create_test
|
# Define function create_test
|
||||||
def create_test(
|
def create_test(
|
||||||
# Entry: db
|
# Entry: db
|
||||||
@@ -142,6 +265,8 @@ def create_test(
|
|||||||
# Raise EntityNotFoundError
|
# Raise EntityNotFoundError
|
||||||
raise EntityNotFoundError("Technique", str(technique_id))
|
raise EntityNotFoundError("Technique", str(technique_id))
|
||||||
|
|
||||||
|
data_classification = fields.pop("data_classification", None) or determine_initial_classification(technique)
|
||||||
|
|
||||||
# Assign test = Test(
|
# Assign test = Test(
|
||||||
test = Test(
|
test = Test(
|
||||||
# Keyword argument: technique_id
|
# Keyword argument: technique_id
|
||||||
@@ -151,6 +276,7 @@ def create_test(
|
|||||||
# Keyword argument: state
|
# Keyword argument: state
|
||||||
state=TestState.draft,
|
state=TestState.draft,
|
||||||
created_at=datetime.utcnow(), # explicit — DB column has no server default
|
created_at=datetime.utcnow(), # explicit — DB column has no server default
|
||||||
|
data_classification=data_classification,
|
||||||
**fields,
|
**fields,
|
||||||
)
|
)
|
||||||
# Stage new record(s) for database insertion
|
# Stage new record(s) for database insertion
|
||||||
@@ -178,6 +304,7 @@ def create_test_from_template(
|
|||||||
platform_override: str | None = None,
|
platform_override: str | None = None,
|
||||||
procedure_text_override: str | None = None,
|
procedure_text_override: str | None = None,
|
||||||
tool_used_override: str | None = None,
|
tool_used_override: str | None = None,
|
||||||
|
detect_procedure_override: str | None = None,
|
||||||
) -> Test:
|
) -> Test:
|
||||||
"""Instantiate a Test from a TestTemplate.
|
"""Instantiate a Test from a TestTemplate.
|
||||||
|
|
||||||
@@ -238,12 +365,15 @@ def create_test_from_template(
|
|||||||
platform=platform_override if platform_override is not None else template.platform,
|
platform=platform_override if platform_override is not None else template.platform,
|
||||||
procedure_text=procedure_text_override if procedure_text_override is not None else template.attack_procedure,
|
procedure_text=procedure_text_override if procedure_text_override is not None else template.attack_procedure,
|
||||||
tool_used=tool_used_override if tool_used_override is not None else template.tool_suggested,
|
tool_used=tool_used_override if tool_used_override is not None else template.tool_suggested,
|
||||||
|
detect_procedure=detect_procedure_override if detect_procedure_override is not None else template.expected_detection,
|
||||||
remediation_steps=template.suggested_remediation,
|
remediation_steps=template.suggested_remediation,
|
||||||
# Keyword argument: created_by
|
# Keyword argument: created_by
|
||||||
created_by=creator_id,
|
created_by=creator_id,
|
||||||
|
source_template_id=template.id,
|
||||||
# Keyword argument: state
|
# Keyword argument: state
|
||||||
state=TestState.draft,
|
state=TestState.draft,
|
||||||
created_at=datetime.utcnow(), # explicit — DB column has no server default
|
created_at=datetime.utcnow(), # explicit — DB column has no server default
|
||||||
|
data_classification=determine_initial_classification(technique),
|
||||||
)
|
)
|
||||||
# Stage new record(s) for database insertion
|
# Stage new record(s) for database insertion
|
||||||
db.add(test)
|
db.add(test)
|
||||||
@@ -269,7 +399,12 @@ def get_test_detail(db: Session, test_id: uuid.UUID) -> Test:
|
|||||||
# Assign test = (
|
# Assign test = (
|
||||||
test = (
|
test = (
|
||||||
db.query(Test)
|
db.query(Test)
|
||||||
.options(joinedload(Test.evidences), joinedload(Test.technique))
|
.options(
|
||||||
|
joinedload(Test.evidences), joinedload(Test.technique),
|
||||||
|
joinedload(Test.round_history),
|
||||||
|
joinedload(Test.red_tech_assigned_user), joinedload(Test.blue_tech_assigned_user),
|
||||||
|
joinedload(Test.red_reviewer), joinedload(Test.blue_reviewer),
|
||||||
|
)
|
||||||
.filter(Test.id == test_id)
|
.filter(Test.id == test_id)
|
||||||
# Chain .first() call
|
# Chain .first() call
|
||||||
.first()
|
.first()
|
||||||
@@ -303,6 +438,28 @@ def get_test_or_raise(db: Session, test_id: uuid.UUID) -> Test:
|
|||||||
return test
|
return test
|
||||||
|
|
||||||
|
|
||||||
|
def delete_test(db: Session, test_id: uuid.UUID) -> None:
|
||||||
|
"""Delete a standalone, not-yet-started test from the queue.
|
||||||
|
|
||||||
|
Only a test still in ``draft`` (execution hasn't started) and not
|
||||||
|
linked to any campaign may be deleted this way — a test already being
|
||||||
|
worked on, or one that's part of a campaign's plan, must be handled
|
||||||
|
through the normal workflow/campaign-modification paths instead.
|
||||||
|
Raises EntityNotFoundError, BusinessRuleViolation. Does not commit;
|
||||||
|
caller commits.
|
||||||
|
"""
|
||||||
|
test = get_test_or_raise(db, test_id)
|
||||||
|
|
||||||
|
if test.state != TestState.draft:
|
||||||
|
raise BusinessRuleViolation("Only tests that haven't started yet can be deleted")
|
||||||
|
|
||||||
|
in_campaign = db.query(CampaignTest).filter(CampaignTest.test_id == test.id).first()
|
||||||
|
if in_campaign:
|
||||||
|
raise BusinessRuleViolation("Cannot delete a test that belongs to a campaign")
|
||||||
|
|
||||||
|
db.delete(test)
|
||||||
|
|
||||||
|
|
||||||
# Define function get_test_with_technique
|
# Define function get_test_with_technique
|
||||||
def get_test_with_technique(db: Session, test_id: uuid.UUID) -> Test:
|
def get_test_with_technique(db: Session, test_id: uuid.UUID) -> Test:
|
||||||
"""Fetch a test with technique joined. Raises EntityNotFoundError if not found.
|
"""Fetch a test with technique joined. Raises EntityNotFoundError if not found.
|
||||||
|
|||||||
@@ -12,15 +12,53 @@ from sqlalchemy import func, or_
|
|||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
# Import EntityNotFoundError from app.domain.errors
|
# Import EntityNotFoundError from app.domain.errors
|
||||||
from app.domain.errors import EntityNotFoundError
|
from app.domain.errors import BusinessRuleViolation, EntityNotFoundError
|
||||||
|
|
||||||
# Import TestTemplate from app.models.test_template
|
# Import TestTemplate from app.models.test_template
|
||||||
from app.models.test_template import TestTemplate
|
from app.models.test_template import TestTemplate
|
||||||
|
from app.models.technique import Technique
|
||||||
|
from app.models.test import Test
|
||||||
|
|
||||||
# Import escape_like from app.utils
|
# Import escape_like from app.utils
|
||||||
from app.utils import escape_like
|
from app.utils import escape_like
|
||||||
|
|
||||||
|
|
||||||
|
def _build_template_query(
|
||||||
|
db: Session,
|
||||||
|
*,
|
||||||
|
source: str | None = None,
|
||||||
|
platform: str | None = None,
|
||||||
|
severity: str | None = None,
|
||||||
|
mitre_technique_id: str | None = None,
|
||||||
|
search: str | None = None,
|
||||||
|
is_active: bool | None = None,
|
||||||
|
):
|
||||||
|
"""Build the filtered TestTemplate query shared by list_templates() and
|
||||||
|
count_templates() — a single source of truth so a paginated page of
|
||||||
|
results and the total count it's paginated against can never drift
|
||||||
|
apart (same pattern used for tests in test_crud_service.py)."""
|
||||||
|
query = db.query(TestTemplate)
|
||||||
|
if is_active is not None:
|
||||||
|
query = query.filter(TestTemplate.is_active == is_active)
|
||||||
|
if source:
|
||||||
|
query = query.filter(TestTemplate.source == source)
|
||||||
|
if platform:
|
||||||
|
query = query.filter(TestTemplate.platform.ilike(f"%{escape_like(platform)}%"))
|
||||||
|
if severity:
|
||||||
|
query = query.filter(TestTemplate.severity == severity)
|
||||||
|
if mitre_technique_id:
|
||||||
|
query = query.filter(TestTemplate.mitre_technique_id == mitre_technique_id)
|
||||||
|
if search:
|
||||||
|
pattern = f"%{escape_like(search)}%"
|
||||||
|
query = query.filter(
|
||||||
|
or_(
|
||||||
|
TestTemplate.name.ilike(pattern),
|
||||||
|
TestTemplate.description.ilike(pattern),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return query
|
||||||
|
|
||||||
|
|
||||||
# Define function list_templates
|
# Define function list_templates
|
||||||
def list_templates(
|
def list_templates(
|
||||||
# Entry: db
|
# Entry: db
|
||||||
@@ -44,57 +82,56 @@ def list_templates(
|
|||||||
limit: int = 50,
|
limit: int = 50,
|
||||||
) -> list:
|
) -> list:
|
||||||
"""Return paginated, filterable list of test templates."""
|
"""Return paginated, filterable list of test templates."""
|
||||||
# Assign query = db.query(TestTemplate)
|
query = _build_template_query(
|
||||||
query = db.query(TestTemplate)
|
db, source=source, platform=platform, severity=severity,
|
||||||
# Check: is_active is not None
|
mitre_technique_id=mitre_technique_id, search=search, is_active=is_active,
|
||||||
if is_active is not None:
|
|
||||||
# Assign query = query.filter(TestTemplate.is_active == is_active)
|
|
||||||
query = query.filter(TestTemplate.is_active == is_active)
|
|
||||||
|
|
||||||
# Check: source
|
|
||||||
if source:
|
|
||||||
# Assign query = query.filter(TestTemplate.source == source)
|
|
||||||
query = query.filter(TestTemplate.source == source)
|
|
||||||
# Check: platform
|
|
||||||
if platform:
|
|
||||||
# Assign query = query.filter(TestTemplate.platform.ilike(f"%{escape_like(platform)}...
|
|
||||||
query = query.filter(TestTemplate.platform.ilike(f"%{escape_like(platform)}%"))
|
|
||||||
# Check: severity
|
|
||||||
if severity:
|
|
||||||
# Assign query = query.filter(TestTemplate.severity == severity)
|
|
||||||
query = query.filter(TestTemplate.severity == severity)
|
|
||||||
# Check: mitre_technique_id
|
|
||||||
if mitre_technique_id:
|
|
||||||
# Assign query = query.filter(TestTemplate.mitre_technique_id == mitre_technique_id)
|
|
||||||
query = query.filter(TestTemplate.mitre_technique_id == mitre_technique_id)
|
|
||||||
# Check: search
|
|
||||||
if search:
|
|
||||||
# Assign pattern = f"%{escape_like(search)}%"
|
|
||||||
pattern = f"%{escape_like(search)}%"
|
|
||||||
# Assign query = query.filter(
|
|
||||||
query = query.filter(
|
|
||||||
or_(
|
|
||||||
TestTemplate.name.ilike(pattern),
|
|
||||||
TestTemplate.description.ilike(pattern),
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
|
|
||||||
# Assign templates = (
|
|
||||||
templates = (
|
templates = (
|
||||||
query
|
query
|
||||||
# Chain .order_by() call
|
|
||||||
.order_by(TestTemplate.mitre_technique_id, TestTemplate.name)
|
.order_by(TestTemplate.mitre_technique_id, TestTemplate.name)
|
||||||
# Chain .offset() call
|
|
||||||
.offset(offset)
|
.offset(offset)
|
||||||
# Chain .limit() call
|
|
||||||
.limit(limit)
|
.limit(limit)
|
||||||
# Chain .all() call
|
|
||||||
.all()
|
.all()
|
||||||
)
|
)
|
||||||
# Return templates
|
|
||||||
|
# Attach existing_test_count per template — lets the catalog warn before
|
||||||
|
# creating a likely-duplicate test for a technique that already has one.
|
||||||
|
if templates:
|
||||||
|
mitre_ids = {t.mitre_technique_id for t in templates}
|
||||||
|
counts = dict(
|
||||||
|
db.query(Technique.mitre_id, func.count(Test.id))
|
||||||
|
.join(Test, Test.technique_id == Technique.id)
|
||||||
|
.filter(Technique.mitre_id.in_(mitre_ids))
|
||||||
|
.group_by(Technique.mitre_id)
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
for t in templates:
|
||||||
|
t.existing_test_count = counts.get(t.mitre_technique_id, 0)
|
||||||
|
|
||||||
return templates
|
return templates
|
||||||
|
|
||||||
|
|
||||||
|
def count_templates(
|
||||||
|
db: Session,
|
||||||
|
*,
|
||||||
|
source: str | None = None,
|
||||||
|
platform: str | None = None,
|
||||||
|
severity: str | None = None,
|
||||||
|
mitre_technique_id: str | None = None,
|
||||||
|
search: str | None = None,
|
||||||
|
is_active: bool | None = None,
|
||||||
|
) -> int:
|
||||||
|
"""Return the total count of templates matching the same filters as
|
||||||
|
list_templates() — lets the catalog UI show a true page count even
|
||||||
|
though the list endpoint caps how many rows it returns per request."""
|
||||||
|
query = _build_template_query(
|
||||||
|
db, source=source, platform=platform, severity=severity,
|
||||||
|
mitre_technique_id=mitre_technique_id, search=search, is_active=is_active,
|
||||||
|
)
|
||||||
|
return query.count()
|
||||||
|
|
||||||
|
|
||||||
# Define function get_template_stats
|
# Define function get_template_stats
|
||||||
def get_template_stats(db: Session) -> dict:
|
def get_template_stats(db: Session) -> dict:
|
||||||
"""Return catalog statistics: totals by source, platform, active/inactive."""
|
"""Return catalog statistics: totals by source, platform, active/inactive."""
|
||||||
@@ -198,9 +235,22 @@ def get_template_or_raise(db: Session, template_id: uuid.UUID) -> TestTemplate:
|
|||||||
return template
|
return template
|
||||||
|
|
||||||
|
|
||||||
|
def validate_mitre_technique_id(db: Session, mitre_technique_id: str) -> None:
|
||||||
|
"""Raise BusinessRuleViolation unless *mitre_technique_id* matches a real,
|
||||||
|
already-synced MITRE ATT&CK technique — free text like "made up" or a
|
||||||
|
typo'd ID would otherwise silently create an orphaned template that can
|
||||||
|
never be found via technique lookups or coverage reporting."""
|
||||||
|
exists = db.query(Technique).filter(Technique.mitre_id == mitre_technique_id).first()
|
||||||
|
if not exists:
|
||||||
|
raise BusinessRuleViolation(
|
||||||
|
f"'{mitre_technique_id}' is not a known MITRE ATT&CK technique ID"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
# Define function create_template
|
# Define function create_template
|
||||||
def create_template(db: Session, **fields: object) -> TestTemplate:
|
def create_template(db: Session, **fields: object) -> TestTemplate:
|
||||||
"""Create a test template from keyword args (e.g. payload.model_dump()). Does NOT commit."""
|
"""Create a test template from keyword args (e.g. payload.model_dump()). Does NOT commit."""
|
||||||
|
validate_mitre_technique_id(db, fields["mitre_technique_id"])
|
||||||
# Assign template = TestTemplate(**fields)
|
# Assign template = TestTemplate(**fields)
|
||||||
template = TestTemplate(**fields)
|
template = TestTemplate(**fields)
|
||||||
# Stage new record(s) for database insertion
|
# Stage new record(s) for database insertion
|
||||||
@@ -212,6 +262,8 @@ def create_template(db: Session, **fields: object) -> TestTemplate:
|
|||||||
# Define function update_template
|
# Define function update_template
|
||||||
def update_template(db: Session, template_id: uuid.UUID, **fields: object) -> TestTemplate:
|
def update_template(db: Session, template_id: uuid.UUID, **fields: object) -> TestTemplate:
|
||||||
"""Update an existing template. Raises EntityNotFoundError if not found. Does NOT commit."""
|
"""Update an existing template. Raises EntityNotFoundError if not found. Does NOT commit."""
|
||||||
|
if fields.get("mitre_technique_id"):
|
||||||
|
validate_mitre_technique_id(db, fields["mitre_technique_id"])
|
||||||
# Assign template = get_template_or_raise(db, template_id)
|
# Assign template = get_template_or_raise(db, template_id)
|
||||||
template = get_template_or_raise(db, template_id)
|
template = get_template_or_raise(db, template_id)
|
||||||
# Iterate over fields.items()
|
# Iterate over fields.items()
|
||||||
|
|||||||
@@ -26,14 +26,21 @@ from sqlalchemy.orm import Session
|
|||||||
|
|
||||||
# Import settings from app.config
|
# Import settings from app.config
|
||||||
from app.config import settings
|
from app.config import settings
|
||||||
from app.domain.exceptions import InvalidOperationError
|
from app.domain.exceptions import BusinessRuleViolation, InvalidOperationError
|
||||||
from app.domain.test_entity import TestEntity
|
from app.domain.test_entity import TestEntity
|
||||||
|
from app.models.campaign import Campaign, CampaignTest
|
||||||
from app.models.enums import TestState, TeamSide
|
from app.models.enums import TestState, TeamSide
|
||||||
from app.models.evidence import Evidence
|
from app.models.evidence import Evidence
|
||||||
from app.models.test import Test
|
from app.models.test import Test
|
||||||
|
from app.models.test_round_history import TestRoundHistory
|
||||||
from app.models.user import User
|
from app.models.user import User
|
||||||
from app.services.audit_service import log_action
|
from app.services.audit_service import log_action
|
||||||
from app.services.notification_service import notify_test_state_change, create_notification
|
from app.services.notification_service import (
|
||||||
|
notify_test_state_change,
|
||||||
|
create_notification,
|
||||||
|
notify_role_with_email,
|
||||||
|
notify_roles_by_email,
|
||||||
|
)
|
||||||
|
|
||||||
# Assign logger = logging.getLogger(__name__)
|
# Assign logger = logging.getLogger(__name__)
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -170,8 +177,26 @@ def transition_state(
|
|||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _get_campaign_start_date(db: Session, test_id) -> datetime | None:
|
||||||
|
"""Return the scheduled start_date of the campaign *test_id* belongs to, if any."""
|
||||||
|
campaign = (
|
||||||
|
db.query(Campaign)
|
||||||
|
.join(CampaignTest, CampaignTest.campaign_id == Campaign.id)
|
||||||
|
.filter(CampaignTest.test_id == test_id)
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
return campaign.start_date if campaign else None
|
||||||
|
|
||||||
|
|
||||||
def start_execution(db: Session, test: Test, user: User) -> Test:
|
def start_execution(db: Session, test: Test, user: User) -> Test:
|
||||||
"""Move from ``draft`` → ``red_executing``."""
|
"""Move from ``draft`` → ``red_executing``."""
|
||||||
|
campaign_start_date = _get_campaign_start_date(db, test.id)
|
||||||
|
if campaign_start_date and campaign_start_date > datetime.utcnow():
|
||||||
|
raise BusinessRuleViolation(
|
||||||
|
"This test's campaign is scheduled to start on "
|
||||||
|
f"{campaign_start_date.date().isoformat()} — it cannot be started before then."
|
||||||
|
)
|
||||||
|
|
||||||
entity = TestEntity.from_orm(test)
|
entity = TestEntity.from_orm(test)
|
||||||
# Call entity.start_execution()
|
# Call entity.start_execution()
|
||||||
entity.start_execution()
|
entity.start_execution()
|
||||||
@@ -263,7 +288,7 @@ def submit_red_evidence(db: Session, test: Test, user: User) -> Test:
|
|||||||
|
|
||||||
# Assign test = transition_state(
|
# Assign test = transition_state(
|
||||||
test = transition_state(
|
test = transition_state(
|
||||||
db, test, TestState.blue_evaluating, user,
|
db, test, TestState.red_review, user,
|
||||||
# Keyword argument: action_name
|
# Keyword argument: action_name
|
||||||
action_name="submit_red_evidence",
|
action_name="submit_red_evidence",
|
||||||
)
|
)
|
||||||
@@ -287,10 +312,64 @@ def submit_red_evidence(db: Session, test: Test, user: User) -> Test:
|
|||||||
description=f"Red Team execution: {test.name}",
|
description=f"Red Team execution: {test.name}",
|
||||||
)
|
)
|
||||||
|
|
||||||
# Start Blue Team timer
|
reviewer = select_reviewer(
|
||||||
test.blue_started_at = now
|
db, role="red_lead",
|
||||||
# Assign test.blue_paused_seconds = 0
|
exclude_user_id=user.id if user.role == "red_lead" else None,
|
||||||
test.blue_paused_seconds = 0
|
)
|
||||||
|
test.red_reviewer_assignee = reviewer.id
|
||||||
|
db.flush()
|
||||||
|
|
||||||
|
try:
|
||||||
|
create_notification(
|
||||||
|
db, user_id=reviewer.id, type="review_assigned",
|
||||||
|
title="Test awaiting your review",
|
||||||
|
message=f'Test "{test.name}" is waiting for your red-team review.',
|
||||||
|
entity_type="test", entity_id=test.id,
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("Notification failed for test %s: %s", test.id, e, exc_info=True)
|
||||||
|
|
||||||
|
try:
|
||||||
|
from app.services.jira_service import push_test_event
|
||||||
|
push_test_event(db, test, user, "red_review", assignee=reviewer)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("Jira push failed for test %s: %s", test.id, e, exc_info=True)
|
||||||
|
|
||||||
|
try:
|
||||||
|
from app.services.jira_service import push_rt_submitted
|
||||||
|
push_rt_submitted(db, test)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("Jira RT end date push failed for test %s: %s", test.id, e, exc_info=True)
|
||||||
|
|
||||||
|
try:
|
||||||
|
from app.services.procedure_suggestion_service import create_suggestion_from_submission
|
||||||
|
create_suggestion_from_submission(db, test, "red")
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("Procedure suggestion extraction failed for test %s: %s", test.id, e, exc_info=True)
|
||||||
|
|
||||||
|
return test
|
||||||
|
|
||||||
|
|
||||||
|
def approve_red_review(db: Session, test: Test, user: User, notes: str | None = None) -> Test:
|
||||||
|
"""Red Lead approves the operator's work — moves red_review to blue_evaluating."""
|
||||||
|
entity = TestEntity.from_orm(test)
|
||||||
|
entity.approve_red_review()
|
||||||
|
entity.apply_to(test)
|
||||||
|
test.red_review_by = user.id
|
||||||
|
test.red_review_at = datetime.utcnow()
|
||||||
|
test.red_review_notes = notes
|
||||||
|
db.flush()
|
||||||
|
|
||||||
|
log_action(
|
||||||
|
db, user_id=user.id, action="approve_red_review",
|
||||||
|
entity_type="test", entity_id=test.id,
|
||||||
|
details={"notes": notes, "test_name": test.name},
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
notify_test_state_change(db, test, "blue_evaluating")
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("Notification failed for test %s: %s", test.id, e, exc_info=True)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
from app.services.jira_service import push_test_event
|
from app.services.jira_service import push_test_event
|
||||||
@@ -301,6 +380,78 @@ def submit_red_evidence(db: Session, test: Test, user: User) -> Test:
|
|||||||
return test
|
return test
|
||||||
|
|
||||||
|
|
||||||
|
def reopen_red_review(db: Session, test: Test, user: User, notes: str) -> Test:
|
||||||
|
"""Red Lead sends the operator's work back for rework — moves red_review to red_executing."""
|
||||||
|
if not notes or not notes.strip():
|
||||||
|
raise InvalidOperationError("A comment is required when reopening a test for rework")
|
||||||
|
|
||||||
|
# Archive this round's data before resetting the live fields — reopening
|
||||||
|
# is for a fresh attempt, not for erasing what Blue Team already saw
|
||||||
|
# about the first one.
|
||||||
|
archived_round = TestRoundHistory(
|
||||||
|
test_id=test.id, team="red", round_number=test.red_round_number or 1,
|
||||||
|
started_at=test.red_started_at, ended_at=datetime.utcnow(),
|
||||||
|
paused_seconds=test.red_paused_seconds or 0,
|
||||||
|
procedure_text=test.procedure_text, tool_used=test.tool_used,
|
||||||
|
attack_success=test.attack_success, red_summary=test.red_summary,
|
||||||
|
execution_start_time=test.execution_start_time, execution_end_time=test.execution_end_time,
|
||||||
|
review_notes=notes.strip(), reviewed_by=user.id,
|
||||||
|
)
|
||||||
|
db.add(archived_round)
|
||||||
|
|
||||||
|
entity = TestEntity.from_orm(test)
|
||||||
|
entity.reopen_red_review()
|
||||||
|
entity.apply_to(test)
|
||||||
|
test.red_review_by = user.id
|
||||||
|
test.red_review_at = datetime.utcnow()
|
||||||
|
test.red_review_notes = notes.strip()
|
||||||
|
test.red_round_number = (test.red_round_number or 1) + 1
|
||||||
|
# Only the per-round verdict/timing fields get a blank slate — the
|
||||||
|
# operator must explicitly answer "was THIS attempt successful" and
|
||||||
|
# "when did THIS attempt run" rather than silently inheriting round 1's
|
||||||
|
# answer. The old values aren't lost: they're in round_history and
|
||||||
|
# shown on the archived Phase Timeline card. Free-text fields
|
||||||
|
# (procedure, tool, summary) are NOT cleared — the operator edits them
|
||||||
|
# in place for the new round instead of retyping from scratch.
|
||||||
|
test.attack_success = None
|
||||||
|
test.execution_start_time = None
|
||||||
|
test.execution_end_time = None
|
||||||
|
db.flush()
|
||||||
|
|
||||||
|
log_action(
|
||||||
|
db, user_id=user.id, action="reopen_red_review",
|
||||||
|
entity_type="test", entity_id=test.id,
|
||||||
|
details={"notes": notes, "test_name": test.name, "round_number": archived_round.round_number},
|
||||||
|
)
|
||||||
|
|
||||||
|
if test.red_tech_assignee:
|
||||||
|
try:
|
||||||
|
create_notification(
|
||||||
|
db, user_id=test.red_tech_assignee, type="test_reopened",
|
||||||
|
title="Test sent back for rework",
|
||||||
|
message=f'Test "{test.name}" was sent back by your Red Lead: {notes.strip()[:200]}',
|
||||||
|
entity_type="test", entity_id=test.id,
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("Notification failed for test %s: %s", test.id, e, exc_info=True)
|
||||||
|
|
||||||
|
try:
|
||||||
|
from app.services.jira_service import push_test_event, push_round_archived
|
||||||
|
original_operator = (
|
||||||
|
db.query(User).filter(User.id == test.red_tech_assignee).first()
|
||||||
|
if test.red_tech_assignee else None
|
||||||
|
)
|
||||||
|
push_round_archived(db, test, user, round_data=archived_round)
|
||||||
|
push_test_event(
|
||||||
|
db, test, user, "red_executing",
|
||||||
|
extra={"notes": notes.strip()}, assignee=original_operator,
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("Jira push failed for test %s: %s", test.id, e, exc_info=True)
|
||||||
|
|
||||||
|
return test
|
||||||
|
|
||||||
|
|
||||||
def start_blue_work(db: Session, test: Test, user: User) -> Test:
|
def start_blue_work(db: Session, test: Test, user: User) -> Test:
|
||||||
"""Mark that a blue tech has picked up this test to start evaluating.
|
"""Mark that a blue tech has picked up this test to start evaluating.
|
||||||
|
|
||||||
@@ -331,6 +482,18 @@ def start_blue_work(db: Session, test: Test, user: User) -> Test:
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning("Notification failed for test %s: %s", test.id, e, exc_info=True)
|
logger.warning("Notification failed for test %s: %s", test.id, e, exc_info=True)
|
||||||
|
|
||||||
|
try:
|
||||||
|
from app.services.jira_service import push_bt_started
|
||||||
|
push_bt_started(db, test)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("Jira BT start date push failed for test %s: %s", test.id, e, exc_info=True)
|
||||||
|
|
||||||
|
try:
|
||||||
|
from app.services.jira_service import push_bt_work_started
|
||||||
|
push_bt_work_started(db, test, user)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("Jira push failed for test %s: %s", test.id, e, exc_info=True)
|
||||||
|
|
||||||
return test
|
return test
|
||||||
|
|
||||||
|
|
||||||
@@ -368,7 +531,7 @@ def submit_blue_evidence(db: Session, test: Test, user: User) -> Test:
|
|||||||
|
|
||||||
# Assign test = transition_state(
|
# Assign test = transition_state(
|
||||||
test = transition_state(
|
test = transition_state(
|
||||||
db, test, TestState.in_review, user,
|
db, test, TestState.blue_review, user,
|
||||||
# Keyword argument: action_name
|
# Keyword argument: action_name
|
||||||
action_name="submit_blue_evidence",
|
action_name="submit_blue_evidence",
|
||||||
)
|
)
|
||||||
@@ -392,6 +555,162 @@ def submit_blue_evidence(db: Session, test: Test, user: User) -> Test:
|
|||||||
description=f"Blue Team evaluation: {test.name}",
|
description=f"Blue Team evaluation: {test.name}",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
reviewer = select_reviewer(
|
||||||
|
db, role="blue_lead",
|
||||||
|
exclude_user_id=user.id if user.role == "blue_lead" else None,
|
||||||
|
)
|
||||||
|
test.blue_reviewer_assignee = reviewer.id
|
||||||
|
db.flush()
|
||||||
|
|
||||||
|
try:
|
||||||
|
create_notification(
|
||||||
|
db, user_id=reviewer.id, type="review_assigned",
|
||||||
|
title="Test awaiting your review",
|
||||||
|
message=f'Test "{test.name}" is waiting for your blue-team review.',
|
||||||
|
entity_type="test", entity_id=test.id,
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("Notification failed for test %s: %s", test.id, e, exc_info=True)
|
||||||
|
|
||||||
|
try:
|
||||||
|
from app.services.jira_service import push_test_event
|
||||||
|
push_test_event(db, test, user, "blue_review", assignee=reviewer)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("Jira push failed for test %s: %s", test.id, e, exc_info=True)
|
||||||
|
|
||||||
|
try:
|
||||||
|
from app.services.jira_service import push_bt_submitted
|
||||||
|
push_bt_submitted(db, test)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("Jira BT end date push failed for test %s: %s", test.id, e, exc_info=True)
|
||||||
|
|
||||||
|
try:
|
||||||
|
from app.services.procedure_suggestion_service import create_suggestion_from_submission
|
||||||
|
create_suggestion_from_submission(db, test, "blue")
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("Procedure suggestion extraction failed for test %s: %s", test.id, e, exc_info=True)
|
||||||
|
|
||||||
|
return test
|
||||||
|
|
||||||
|
|
||||||
|
def approve_blue_review(db: Session, test: Test, user: User, notes: str | None = None) -> Test:
|
||||||
|
"""Blue Lead approves the operator's work — moves blue_review to in_review."""
|
||||||
|
entity = TestEntity.from_orm(test)
|
||||||
|
entity.approve_blue_review()
|
||||||
|
entity.apply_to(test)
|
||||||
|
test.blue_review_by = user.id
|
||||||
|
test.blue_review_at = datetime.utcnow()
|
||||||
|
test.blue_review_notes = notes
|
||||||
|
db.flush()
|
||||||
|
|
||||||
|
log_action(
|
||||||
|
db, user_id=user.id, action="approve_blue_review",
|
||||||
|
entity_type="test", entity_id=test.id,
|
||||||
|
details={"notes": notes, "test_name": test.name},
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
notify_test_state_change(db, test, "in_review")
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("Notification failed for test %s: %s", test.id, e, exc_info=True)
|
||||||
|
|
||||||
|
try:
|
||||||
|
from app.services.jira_service import push_test_event
|
||||||
|
push_test_event(db, test, user, "in_review")
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("Jira push failed for test %s: %s", test.id, e, exc_info=True)
|
||||||
|
|
||||||
|
return test
|
||||||
|
|
||||||
|
|
||||||
|
def reopen_blue_review(db: Session, test: Test, user: User, notes: str) -> Test:
|
||||||
|
"""Blue Lead sends the operator's work back for rework — moves blue_review to blue_evaluating."""
|
||||||
|
if not notes or not notes.strip():
|
||||||
|
raise InvalidOperationError("A comment is required when reopening a test for rework")
|
||||||
|
|
||||||
|
# Archive this round's data before resetting the live fields — same
|
||||||
|
# reasoning as the red side: don't lose what was already evaluated.
|
||||||
|
archived_round = TestRoundHistory(
|
||||||
|
test_id=test.id, team="blue", round_number=test.blue_round_number or 1,
|
||||||
|
started_at=test.blue_work_started_at, ended_at=datetime.utcnow(),
|
||||||
|
paused_seconds=test.blue_paused_seconds or 0,
|
||||||
|
detection_result=test.detection_result, containment_result=test.containment_result,
|
||||||
|
detection_time=test.detection_time, containment_time=test.containment_time,
|
||||||
|
blue_summary=test.blue_summary, detect_procedure=test.detect_procedure,
|
||||||
|
review_notes=notes.strip(), reviewed_by=user.id,
|
||||||
|
)
|
||||||
|
db.add(archived_round)
|
||||||
|
|
||||||
|
entity = TestEntity.from_orm(test)
|
||||||
|
entity.reopen_blue_review()
|
||||||
|
entity.apply_to(test)
|
||||||
|
test.blue_work_started_at = None # split responsibility: entity doesn't own this field
|
||||||
|
test.blue_review_by = user.id
|
||||||
|
test.blue_review_at = datetime.utcnow()
|
||||||
|
test.blue_review_notes = notes.strip()
|
||||||
|
test.blue_round_number = (test.blue_round_number or 1) + 1
|
||||||
|
# Same split as the red side: verdict/timing fields get a blank slate
|
||||||
|
# for this round (old values live on in round_history + the archived
|
||||||
|
# Phase Timeline card), but blue_summary is free text the operator
|
||||||
|
# edits in place rather than retyping.
|
||||||
|
test.detection_result = None
|
||||||
|
test.containment_result = None
|
||||||
|
test.detection_time = None
|
||||||
|
test.containment_time = None
|
||||||
|
db.flush()
|
||||||
|
|
||||||
|
log_action(
|
||||||
|
db, user_id=user.id, action="reopen_blue_review",
|
||||||
|
entity_type="test", entity_id=test.id,
|
||||||
|
details={"notes": notes, "test_name": test.name, "round_number": archived_round.round_number},
|
||||||
|
)
|
||||||
|
|
||||||
|
if test.blue_tech_assignee:
|
||||||
|
try:
|
||||||
|
create_notification(
|
||||||
|
db, user_id=test.blue_tech_assignee, type="test_reopened",
|
||||||
|
title="Test sent back for rework",
|
||||||
|
message=f'Test "{test.name}" was sent back by your Blue Lead: {notes.strip()[:200]}',
|
||||||
|
entity_type="test", entity_id=test.id,
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("Notification failed for test %s: %s", test.id, e, exc_info=True)
|
||||||
|
|
||||||
|
try:
|
||||||
|
from app.services.jira_service import push_test_event, push_round_archived
|
||||||
|
push_round_archived(db, test, user, round_data=archived_round)
|
||||||
|
push_test_event(db, test, user, "blue_evaluating", extra={"notes": notes.strip()})
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("Jira push failed for test %s: %s", test.id, e, exc_info=True)
|
||||||
|
|
||||||
|
return test
|
||||||
|
|
||||||
|
|
||||||
|
def flag_blue_review_gap(db: Session, test: Test, user: User, system_gaps: str, notes: str | None = None) -> Test:
|
||||||
|
"""Blue Lead flags a capability gap — proceeds to in_review anyway (a retry can't fix a missing tool)."""
|
||||||
|
if not system_gaps or not system_gaps.strip():
|
||||||
|
raise InvalidOperationError("system_gaps description is required when flagging a capability gap")
|
||||||
|
|
||||||
|
entity = TestEntity.from_orm(test)
|
||||||
|
entity.flag_blue_review_gap()
|
||||||
|
entity.apply_to(test)
|
||||||
|
test.blue_review_by = user.id
|
||||||
|
test.blue_review_at = datetime.utcnow()
|
||||||
|
test.blue_review_notes = notes
|
||||||
|
test.system_gaps = system_gaps.strip()
|
||||||
|
db.flush()
|
||||||
|
|
||||||
|
log_action(
|
||||||
|
db, user_id=user.id, action="flag_blue_review_gap",
|
||||||
|
entity_type="test", entity_id=test.id,
|
||||||
|
details={"system_gaps": system_gaps, "notes": notes, "test_name": test.name},
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
notify_test_state_change(db, test, "in_review")
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("Notification failed for test %s: %s", test.id, e, exc_info=True)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
from app.services.jira_service import push_test_event
|
from app.services.jira_service import push_test_event
|
||||||
push_test_event(db, test, user, "in_review")
|
push_test_event(db, test, user, "in_review")
|
||||||
@@ -443,6 +762,13 @@ def pause_timer(db: Session, test: Test, user: User) -> Test:
|
|||||||
# Keyword argument: details
|
# Keyword argument: details
|
||||||
details={"state": test.state.value},
|
details={"state": test.state.value},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
from app.services.jira_service import push_pause_event
|
||||||
|
push_pause_event(db, test, user, resuming=False)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("Jira pause push failed for test %s: %s", test.id, e, exc_info=True)
|
||||||
|
|
||||||
# Return test
|
# Return test
|
||||||
return test
|
return test
|
||||||
|
|
||||||
@@ -498,10 +824,63 @@ def resume_timer(db: Session, test: Test, user: User) -> Test:
|
|||||||
# Keyword argument: details
|
# Keyword argument: details
|
||||||
details={"paused_seconds": paused_seconds, "state": test.state.value},
|
details={"paused_seconds": paused_seconds, "state": test.state.value},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
from app.services.jira_service import push_pause_event
|
||||||
|
push_pause_event(db, test, user, resuming=True)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("Jira resume push failed for test %s: %s", test.id, e, exc_info=True)
|
||||||
|
|
||||||
# Return test
|
# Return test
|
||||||
return test
|
return test
|
||||||
|
|
||||||
|
|
||||||
|
def select_reviewer(
|
||||||
|
db: Session,
|
||||||
|
*,
|
||||||
|
role: str,
|
||||||
|
exclude_user_id: uuid.UUID | None = None,
|
||||||
|
) -> User:
|
||||||
|
"""Pick the least-loaded active user with *role* to review a test.
|
||||||
|
|
||||||
|
Load is measured as the count of tests currently sitting in the
|
||||||
|
matching review state (``red_review`` for role ``red_lead``,
|
||||||
|
``blue_review`` for role ``blue_lead``) with that user set as the
|
||||||
|
reviewer. Ties broken by username for determinism.
|
||||||
|
|
||||||
|
Raises BusinessRuleViolation if no eligible reviewer exists (e.g. the
|
||||||
|
only lead is the person who executed the test, or there are no leads
|
||||||
|
with this role at all).
|
||||||
|
"""
|
||||||
|
review_state = TestState.red_review if role == "red_lead" else TestState.blue_review
|
||||||
|
reviewer_field = Test.red_reviewer_assignee if role == "red_lead" else Test.blue_reviewer_assignee
|
||||||
|
|
||||||
|
candidates_query = db.query(User).filter(User.role == role, User.is_active == True) # noqa: E712
|
||||||
|
if exclude_user_id is not None:
|
||||||
|
candidates_query = candidates_query.filter(User.id != exclude_user_id)
|
||||||
|
candidates = candidates_query.order_by(User.username).all()
|
||||||
|
|
||||||
|
if not candidates:
|
||||||
|
raise BusinessRuleViolation(
|
||||||
|
f"No available {role} to review this test (cannot self-review, "
|
||||||
|
f"and no other {role} is active)"
|
||||||
|
)
|
||||||
|
|
||||||
|
best_user = None
|
||||||
|
best_count = None
|
||||||
|
for candidate in candidates:
|
||||||
|
count = (
|
||||||
|
db.query(Test)
|
||||||
|
.filter(Test.state == review_state, reviewer_field == candidate.id)
|
||||||
|
.count()
|
||||||
|
)
|
||||||
|
if best_count is None or count < best_count:
|
||||||
|
best_user = candidate
|
||||||
|
best_count = count
|
||||||
|
|
||||||
|
return best_user
|
||||||
|
|
||||||
|
|
||||||
# Define function _create_phase_worklog
|
# Define function _create_phase_worklog
|
||||||
def _create_phase_worklog(
|
def _create_phase_worklog(
|
||||||
# Entry: db
|
# Entry: db
|
||||||
@@ -670,6 +1049,13 @@ def validate_as_red_lead(
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
notify_roles_by_email(
|
||||||
|
db, roles=["red_lead", "blue_lead"],
|
||||||
|
preference_key="email_on_all_team_validations",
|
||||||
|
subject=f"Red Lead Vote: {test.name}",
|
||||||
|
message=f'{user.full_name or user.username} cast a "{validation_status}" vote as Red Lead on test "{test.name}".',
|
||||||
|
exclude_user_id=user.id,
|
||||||
|
)
|
||||||
_dispatch_dual_validation_effects(db, test, entity, actor=user)
|
_dispatch_dual_validation_effects(db, test, entity, actor=user)
|
||||||
return test
|
return test
|
||||||
|
|
||||||
@@ -735,6 +1121,13 @@ def validate_as_blue_lead(
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
notify_roles_by_email(
|
||||||
|
db, roles=["red_lead", "blue_lead"],
|
||||||
|
preference_key="email_on_all_team_validations",
|
||||||
|
subject=f"Blue Lead Vote: {test.name}",
|
||||||
|
message=f'{user.full_name or user.username} cast a "{validation_status}" vote as Blue Lead on test "{test.name}".',
|
||||||
|
exclude_user_id=user.id,
|
||||||
|
)
|
||||||
_dispatch_dual_validation_effects(db, test, entity, actor=user)
|
_dispatch_dual_validation_effects(db, test, entity, actor=user)
|
||||||
return test
|
return test
|
||||||
|
|
||||||
@@ -769,9 +1162,14 @@ def check_dual_validation(db: Session, test: Test) -> Test:
|
|||||||
|
|
||||||
# Define function _dispatch_dual_validation_effects
|
# Define function _dispatch_dual_validation_effects
|
||||||
def _dispatch_dual_validation_effects(
|
def _dispatch_dual_validation_effects(
|
||||||
db: Session, test: Test, entity: TestEntity, actor: User | None = None
|
db: Session, test: Test, entity: TestEntity, actor: User | None = None,
|
||||||
|
extra: dict | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Dispatch side effects (notifications, cache, Jira) based on domain events."""
|
"""Dispatch side effects (notifications, cache, Jira) based on domain events.
|
||||||
|
|
||||||
|
``extra`` is forwarded onto the Jira comment (e.g. a manager's decision
|
||||||
|
notes) — see ``push_test_event``'s ``extra`` kwarg.
|
||||||
|
"""
|
||||||
for event in entity.events:
|
for event in entity.events:
|
||||||
# Check: event.name == "dual_validation_approved"
|
# Check: event.name == "dual_validation_approved"
|
||||||
if event.name == "dual_validation_approved":
|
if event.name == "dual_validation_approved":
|
||||||
@@ -800,7 +1198,7 @@ def _dispatch_dual_validation_effects(
|
|||||||
if actor:
|
if actor:
|
||||||
try:
|
try:
|
||||||
from app.services.jira_service import push_test_event
|
from app.services.jira_service import push_test_event
|
||||||
push_test_event(db, test, actor, "validated")
|
push_test_event(db, test, actor, "validated", extra=extra)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning("Jira push failed for test %s: %s", test.id, e, exc_info=True)
|
logger.warning("Jira push failed for test %s: %s", test.id, e, exc_info=True)
|
||||||
|
|
||||||
@@ -820,13 +1218,35 @@ def _dispatch_dual_validation_effects(
|
|||||||
if actor:
|
if actor:
|
||||||
try:
|
try:
|
||||||
from app.services.jira_service import push_test_event
|
from app.services.jira_service import push_test_event
|
||||||
push_test_event(db, test, actor, "rejected")
|
push_test_event(db, test, actor, "rejected", extra=extra)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning("Jira push failed for test %s: %s", test.id, e, exc_info=True)
|
logger.warning("Jira push failed for test %s: %s", test.id, e, exc_info=True)
|
||||||
|
|
||||||
elif event.name == "dual_validation_disputed":
|
elif event.name == "dual_validation_disputed":
|
||||||
|
if actor:
|
||||||
|
try:
|
||||||
|
from app.services.jira_service import push_test_event
|
||||||
|
push_test_event(db, test, actor, "disputed")
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("Jira push failed for test %s: %s", test.id, e, exc_info=True)
|
||||||
# Notify the lead who APPROVED asking them to review the rejection
|
# Notify the lead who APPROVED asking them to review the rejection
|
||||||
_notify_validation_conflict(db, test, actor)
|
_notify_validation_conflict(db, test, actor)
|
||||||
|
# Notify managers too, in case the dispute stalls and needs escalation
|
||||||
|
try:
|
||||||
|
notify_role_with_email(
|
||||||
|
db,
|
||||||
|
role="manager",
|
||||||
|
type="validation_disputed",
|
||||||
|
title="Validation dispute needs oversight",
|
||||||
|
message=(
|
||||||
|
f'Test "{test.name}" has a validation dispute — one lead approved, '
|
||||||
|
f'the other rejected. Escalate if it does not resolve.'
|
||||||
|
),
|
||||||
|
entity_type="test",
|
||||||
|
entity_id=test.id,
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("Manager dispute notification failed for test %s: %s", test.id, e, exc_info=True)
|
||||||
|
|
||||||
|
|
||||||
def _notify_validation_conflict(db: Session, test: Test, actor: User | None) -> None:
|
def _notify_validation_conflict(db: Session, test: Test, actor: User | None) -> None:
|
||||||
@@ -869,7 +1289,7 @@ def _notify_validation_conflict(db: Session, test: Test, actor: User | None) ->
|
|||||||
f"or contact {rejector_role} to resolve the disagreement."
|
f"or contact {rejector_role} to resolve the disagreement."
|
||||||
),
|
),
|
||||||
entity_type="test",
|
entity_type="test",
|
||||||
entity_id=str(test.id),
|
entity_id=test.id,
|
||||||
)
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(
|
logger.warning(
|
||||||
@@ -878,6 +1298,114 @@ def _notify_validation_conflict(db: Session, test: Test, actor: User | None) ->
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_dispute(db: Session, test: Test, user: User, target_team: str, notes: str | None = None) -> Test:
|
||||||
|
"""Resolve a disputed test by flipping the approving vote to reject.
|
||||||
|
|
||||||
|
Called by the lead who originally approved, now agreeing with the other
|
||||||
|
lead's rejection. Unlike a plain rejection, the flipping lead identifies
|
||||||
|
WHICH team's work needs redoing — the test routes straight back to that
|
||||||
|
team's active queue (red_executing or blue_evaluating) instead of the
|
||||||
|
generic 'rejected' state that would force a full draft restart for both
|
||||||
|
teams.
|
||||||
|
"""
|
||||||
|
if target_team not in ("red", "blue"):
|
||||||
|
raise InvalidOperationError("target_team must be 'red' or 'blue'")
|
||||||
|
|
||||||
|
is_red_approver = user.role == "red_lead" and test.red_validation_status == "approved"
|
||||||
|
is_blue_approver = user.role == "blue_lead" and test.blue_validation_status == "approved"
|
||||||
|
if not (is_red_approver or is_blue_approver):
|
||||||
|
raise InvalidOperationError(
|
||||||
|
"Only the lead who approved can flip their vote to resolve this dispute"
|
||||||
|
)
|
||||||
|
|
||||||
|
entity = TestEntity.from_orm(test)
|
||||||
|
entity.resolve_dispute_reject(target_team)
|
||||||
|
entity.apply_to(test)
|
||||||
|
|
||||||
|
if target_team == "blue":
|
||||||
|
test.blue_work_started_at = None # split responsibility: entity doesn't own this field
|
||||||
|
|
||||||
|
db.flush()
|
||||||
|
|
||||||
|
new_state = "red_executing" if target_team == "red" else "blue_evaluating"
|
||||||
|
|
||||||
|
log_action(
|
||||||
|
db, user_id=user.id, action="resolve_dispute",
|
||||||
|
entity_type="test", entity_id=test.id,
|
||||||
|
details={"target_team": target_team, "notes": notes, "test_name": test.name},
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
notify_test_state_change(db, test, new_state)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("Notification failed for test %s: %s", test.id, e, exc_info=True)
|
||||||
|
|
||||||
|
operator_id = test.red_tech_assignee if target_team == "red" else test.blue_tech_assignee
|
||||||
|
if operator_id and notes:
|
||||||
|
try:
|
||||||
|
create_notification(
|
||||||
|
db, user_id=operator_id, type="test_reopened",
|
||||||
|
title="Test sent back for rework (validation dispute)",
|
||||||
|
message=f'Test "{test.name}" needs rework: {notes[:200]}',
|
||||||
|
entity_type="test", entity_id=test.id,
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("Notification failed for test %s: %s", test.id, e, exc_info=True)
|
||||||
|
|
||||||
|
try:
|
||||||
|
from app.services.jira_service import push_test_event
|
||||||
|
push_test_event(db, test, user, new_state)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("Jira push failed for test %s: %s", test.id, e, exc_info=True)
|
||||||
|
|
||||||
|
return test
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_dispute_by_manager(db: Session, test: Test, user: User, outcome: str, notes: str | None = None) -> Test:
|
||||||
|
"""A manager makes the final call on a disputed test — validated or rejected.
|
||||||
|
|
||||||
|
Unlike ``resolve_dispute`` (only the approving lead flipping their own
|
||||||
|
vote), this is a manager override that ends the standoff directly,
|
||||||
|
without either lead having to concede. Reuses the same dual-validation
|
||||||
|
event dispatch as a normal in_review resolution so Jira/notifications
|
||||||
|
behave identically to an ordinary validated/rejected outcome.
|
||||||
|
"""
|
||||||
|
if test.state != TestState.disputed:
|
||||||
|
raise InvalidOperationError("Test is not in disputed state")
|
||||||
|
|
||||||
|
entity = TestEntity.from_orm(test)
|
||||||
|
entity.resolve_dispute_by_manager(outcome)
|
||||||
|
entity.apply_to(test)
|
||||||
|
db.flush()
|
||||||
|
|
||||||
|
log_action(
|
||||||
|
db, user_id=user.id, action="manager_resolve_dispute",
|
||||||
|
entity_type="test", entity_id=test.id,
|
||||||
|
details={"outcome": outcome, "notes": notes, "test_name": test.name},
|
||||||
|
)
|
||||||
|
|
||||||
|
jira_extra = {"Manager Decision": f"{user.username}" + (f" — {notes}" if notes else "")}
|
||||||
|
_dispatch_dual_validation_effects(db, test, entity, actor=user, extra=jira_extra)
|
||||||
|
|
||||||
|
# Let both leads know the manager made the final call, not just whichever
|
||||||
|
# side "won" — the losing side needs to know just as much.
|
||||||
|
for lead_id in filter(None, {test.red_validated_by, test.blue_validated_by}):
|
||||||
|
try:
|
||||||
|
create_notification(
|
||||||
|
db, user_id=lead_id, type="manager_dispute_resolution",
|
||||||
|
title=f"Manager resolved disputed test as {outcome}",
|
||||||
|
message=(
|
||||||
|
f'{user.username} resolved the dispute on "{test.name}" as {outcome}.'
|
||||||
|
+ (f" Notes: {notes}" if notes else "")
|
||||||
|
),
|
||||||
|
entity_type="test", entity_id=test.id,
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("Notification failed for test %s: %s", test.id, e, exc_info=True)
|
||||||
|
|
||||||
|
return test
|
||||||
|
|
||||||
|
|
||||||
# Define function handle_remediation_completed
|
# Define function handle_remediation_completed
|
||||||
def handle_remediation_completed(db: Session, test: Test, user: User) -> Test | None:
|
def handle_remediation_completed(db: Session, test: Test, user: User) -> Test | None:
|
||||||
"""Create a re-test when remediation is completed.
|
"""Create a re-test when remediation is completed.
|
||||||
|
|||||||
@@ -10,6 +10,9 @@ from __future__ import annotations
|
|||||||
# Import uuid
|
# Import uuid
|
||||||
import uuid
|
import uuid
|
||||||
|
|
||||||
|
# Import secrets
|
||||||
|
import secrets
|
||||||
|
|
||||||
# Import Session from sqlalchemy.orm
|
# Import Session from sqlalchemy.orm
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
@@ -26,8 +29,8 @@ from app.domain.errors import (
|
|||||||
# Import User from app.models.user
|
# Import User from app.models.user
|
||||||
from app.models.user import User
|
from app.models.user import User
|
||||||
|
|
||||||
# Assign VALID_ROLES = {"admin", "red_tech", "blue_tech", "red_lead", "blue_lead", "viewer"}
|
# Assign VALID_ROLES = {"admin", "red_tech", "blue_tech", "red_lead", "blue_lead", "manager", "viewer"}
|
||||||
VALID_ROLES = {"admin", "red_tech", "blue_tech", "red_lead", "blue_lead", "viewer"}
|
VALID_ROLES = {"admin", "red_tech", "blue_tech", "red_lead", "blue_lead", "manager", "viewer"}
|
||||||
|
|
||||||
|
|
||||||
# Define function list_users
|
# Define function list_users
|
||||||
@@ -42,27 +45,28 @@ def create_user(
|
|||||||
# Entry: db
|
# Entry: db
|
||||||
db: Session,
|
db: Session,
|
||||||
*,
|
*,
|
||||||
# Entry: username
|
|
||||||
username: str,
|
|
||||||
# Entry: email
|
# Entry: email
|
||||||
email: str | None,
|
email: str,
|
||||||
# Entry: password
|
# Entry: password
|
||||||
password: str,
|
password: str,
|
||||||
# Entry: role
|
# Entry: role
|
||||||
role: str,
|
role: str,
|
||||||
) -> User:
|
) -> User:
|
||||||
"""Create a new user.
|
"""Create a new user. Email is the unique login identifier.
|
||||||
|
|
||||||
Raises DuplicateEntityError if username already exists.
|
``username`` is derived from ``email`` — it's kept internally (JWT,
|
||||||
|
audit logs) but always mirrors email, never a separate value.
|
||||||
|
|
||||||
|
Raises DuplicateEntityError if a user with this email already exists.
|
||||||
Raises BusinessRuleViolation if role is invalid.
|
Raises BusinessRuleViolation if role is invalid.
|
||||||
Does not commit; the router handles that.
|
Does not commit; the router handles that.
|
||||||
"""
|
"""
|
||||||
# Assign existing = db.query(User).filter(User.username == username).first()
|
# Assign existing = db.query(User).filter(User.email == email).first()
|
||||||
existing = db.query(User).filter(User.username == username).first()
|
existing = db.query(User).filter(User.email == email).first()
|
||||||
# Check: existing
|
# Check: existing
|
||||||
if existing:
|
if existing:
|
||||||
# Raise DuplicateEntityError
|
# Raise DuplicateEntityError
|
||||||
raise DuplicateEntityError("User", "username", username)
|
raise DuplicateEntityError("User", "email", email)
|
||||||
|
|
||||||
# Check: role not in VALID_ROLES
|
# Check: role not in VALID_ROLES
|
||||||
if role not in VALID_ROLES:
|
if role not in VALID_ROLES:
|
||||||
@@ -74,7 +78,7 @@ def create_user(
|
|||||||
# Assign user = User(
|
# Assign user = User(
|
||||||
user = User(
|
user = User(
|
||||||
# Keyword argument: username
|
# Keyword argument: username
|
||||||
username=username,
|
username=email,
|
||||||
# Keyword argument: email
|
# Keyword argument: email
|
||||||
email=email,
|
email=email,
|
||||||
# Keyword argument: hashed_password
|
# Keyword argument: hashed_password
|
||||||
@@ -88,6 +92,40 @@ def create_user(
|
|||||||
return user
|
return user
|
||||||
|
|
||||||
|
|
||||||
|
def create_user_without_password(db: Session, *, full_name: str, email: str, role: str) -> User:
|
||||||
|
"""Create a new user without a password — they set their own via an
|
||||||
|
admin-triggered, emailed one-time link (see password_setup_service).
|
||||||
|
|
||||||
|
``username`` is derived from ``email`` since it's still needed
|
||||||
|
internally as the login identifier, but it's never surfaced in the UI.
|
||||||
|
|
||||||
|
Raises DuplicateEntityError if a user with this email/username already
|
||||||
|
exists. Raises BusinessRuleViolation if role is invalid. Does not
|
||||||
|
commit; the router handles that.
|
||||||
|
"""
|
||||||
|
existing = db.query(User).filter(User.email == email).first()
|
||||||
|
if existing:
|
||||||
|
raise DuplicateEntityError("User", "email", email)
|
||||||
|
|
||||||
|
if role not in VALID_ROLES:
|
||||||
|
raise BusinessRuleViolation(
|
||||||
|
f"Invalid role '{role}'. Must be one of: {', '.join(sorted(VALID_ROLES))}"
|
||||||
|
)
|
||||||
|
|
||||||
|
user = User(
|
||||||
|
username=email,
|
||||||
|
email=email,
|
||||||
|
full_name=full_name,
|
||||||
|
# Unusable random password — the user can only ever get in via the
|
||||||
|
# emailed set-password link, never by guessing/being told a value.
|
||||||
|
hashed_password=hash_password(secrets.token_urlsafe(32)),
|
||||||
|
role=role,
|
||||||
|
must_change_password=True,
|
||||||
|
)
|
||||||
|
db.add(user)
|
||||||
|
return user
|
||||||
|
|
||||||
|
|
||||||
# Define function get_user_or_raise
|
# Define function get_user_or_raise
|
||||||
def get_user_or_raise(db: Session, user_id: uuid.UUID) -> User:
|
def get_user_or_raise(db: Session, user_id: uuid.UUID) -> User:
|
||||||
"""Return a user by ID or raise EntityNotFoundError."""
|
"""Return a user by ID or raise EntityNotFoundError."""
|
||||||
@@ -122,11 +160,26 @@ def update_user(db: Session, user_id: uuid.UUID, **fields: object) -> User:
|
|||||||
f"Invalid role '{update_data['role']}'. Must be one of: {', '.join(sorted(VALID_ROLES))}"
|
f"Invalid role '{update_data['role']}'. Must be one of: {', '.join(sorted(VALID_ROLES))}"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if update_data.get("extra_roles") is not None:
|
||||||
|
invalid = [r for r in update_data["extra_roles"] if r not in VALID_ROLES]
|
||||||
|
if invalid:
|
||||||
|
raise BusinessRuleViolation(
|
||||||
|
f"Invalid role(s) {', '.join(invalid)}. Must be one of: {', '.join(sorted(VALID_ROLES))}"
|
||||||
|
)
|
||||||
|
|
||||||
# Check: "password" in update_data
|
# Check: "password" in update_data
|
||||||
if "password" in update_data:
|
if "password" in update_data:
|
||||||
# Assign update_data["hashed_password"] = hash_password(str(update_data.pop("password")))
|
# Assign update_data["hashed_password"] = hash_password(str(update_data.pop("password")))
|
||||||
update_data["hashed_password"] = hash_password(str(update_data.pop("password")))
|
update_data["hashed_password"] = hash_password(str(update_data.pop("password")))
|
||||||
|
|
||||||
|
# Email is the unique login identifier — enforce uniqueness on change,
|
||||||
|
# and keep username (the internal id) mirrored to it.
|
||||||
|
if update_data.get("email") is not None and update_data["email"] != user.email:
|
||||||
|
existing = db.query(User).filter(User.email == update_data["email"], User.id != user_id).first()
|
||||||
|
if existing:
|
||||||
|
raise DuplicateEntityError("User", "email", update_data["email"])
|
||||||
|
update_data["username"] = update_data["email"]
|
||||||
|
|
||||||
# Iterate over update_data.items()
|
# Iterate over update_data.items()
|
||||||
for field, value in update_data.items():
|
for field, value in update_data.items():
|
||||||
# Call setattr()
|
# Call setattr()
|
||||||
@@ -134,3 +187,92 @@ def update_user(db: Session, user_id: uuid.UUID, **fields: object) -> User:
|
|||||||
|
|
||||||
# Return user
|
# Return user
|
||||||
return user
|
return user
|
||||||
|
|
||||||
|
|
||||||
|
def delete_user(db: Session, user_id: uuid.UUID, actor_id: uuid.UUID) -> None:
|
||||||
|
"""Permanently delete a user — only if they have no activity footprint.
|
||||||
|
|
||||||
|
Raises EntityNotFoundError if the user doesn't exist. Raises
|
||||||
|
BusinessRuleViolation if the caller is trying to delete themselves, or
|
||||||
|
if the user has any historical footprint (tests, evidence, worklogs,
|
||||||
|
audit entries, Jira links, procedure/template suggestions, reviewed
|
||||||
|
imports) — deleting those users would either violate FK constraints or
|
||||||
|
silently erase audit trail we're required to keep; deactivate them
|
||||||
|
instead. Notifications and pending password-setup tokens are personal-
|
||||||
|
only records and are cleaned up as part of the deletion.
|
||||||
|
|
||||||
|
Does not commit; caller commits.
|
||||||
|
"""
|
||||||
|
user = get_user_or_raise(db, user_id)
|
||||||
|
|
||||||
|
if user_id == actor_id:
|
||||||
|
raise BusinessRuleViolation("You cannot delete your own account.")
|
||||||
|
|
||||||
|
from app.models.audit import AuditLog
|
||||||
|
from app.models.evaluation_import import EvaluationImport
|
||||||
|
from app.models.evidence import Evidence
|
||||||
|
from app.models.jira_link import JiraLink
|
||||||
|
from app.models.notification import Notification
|
||||||
|
from app.models.password_setup_token import PasswordSetupToken
|
||||||
|
from app.models.procedure_suggestion import ProcedureSuggestion
|
||||||
|
from app.models.template_suggestion import TemplateSuggestion
|
||||||
|
from app.models.test import Test
|
||||||
|
from app.models.test_round_history import TestRoundHistory
|
||||||
|
from app.models.worklog import Worklog
|
||||||
|
|
||||||
|
has_footprint = any(
|
||||||
|
db.query(model).filter(condition).first() is not None
|
||||||
|
for model, condition in [
|
||||||
|
(
|
||||||
|
Test,
|
||||||
|
(Test.created_by == user_id)
|
||||||
|
| (Test.red_validated_by == user_id)
|
||||||
|
| (Test.blue_validated_by == user_id)
|
||||||
|
| (Test.remediation_assignee == user_id)
|
||||||
|
| (Test.red_tech_assignee == user_id)
|
||||||
|
| (Test.blue_tech_assignee == user_id)
|
||||||
|
| (Test.red_reviewer_assignee == user_id)
|
||||||
|
| (Test.blue_reviewer_assignee == user_id),
|
||||||
|
),
|
||||||
|
(Evidence, Evidence.uploaded_by == user_id),
|
||||||
|
(AuditLog, AuditLog.user_id == user_id),
|
||||||
|
(JiraLink, JiraLink.created_by == user_id),
|
||||||
|
(Worklog, Worklog.user_id == user_id),
|
||||||
|
(ProcedureSuggestion, (ProcedureSuggestion.submitted_by == user_id) | (ProcedureSuggestion.reviewed_by == user_id)),
|
||||||
|
(TemplateSuggestion, (TemplateSuggestion.submitted_by == user_id) | (TemplateSuggestion.reviewed_by == user_id)),
|
||||||
|
(TestRoundHistory, TestRoundHistory.reviewed_by == user_id),
|
||||||
|
(EvaluationImport, EvaluationImport.imported_by == user_id),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
if has_footprint:
|
||||||
|
raise BusinessRuleViolation(
|
||||||
|
"This user has activity history (tests, evidence, worklogs, audit "
|
||||||
|
"entries, or similar) and can't be permanently deleted — "
|
||||||
|
"deactivate them instead to keep the audit trail intact."
|
||||||
|
)
|
||||||
|
|
||||||
|
db.query(Notification).filter(Notification.user_id == user_id).delete()
|
||||||
|
db.query(PasswordSetupToken).filter(PasswordSetupToken.user_id == user_id).delete()
|
||||||
|
db.delete(user)
|
||||||
|
|
||||||
|
|
||||||
|
def switch_active_role(db: Session, user: User, new_role: str) -> User:
|
||||||
|
"""Swap *user*'s currently-active role with one from their extra_roles.
|
||||||
|
|
||||||
|
Roles never mix — the user acts under exactly one role at a time, this
|
||||||
|
just changes which one. Raises BusinessRuleViolation if new_role isn't
|
||||||
|
one this user has been granted. Does not commit; caller commits.
|
||||||
|
"""
|
||||||
|
available = {user.role, *(user.extra_roles or [])}
|
||||||
|
if new_role not in available:
|
||||||
|
raise BusinessRuleViolation(f"Role '{new_role}' is not available to this user")
|
||||||
|
|
||||||
|
if new_role == user.role:
|
||||||
|
return user
|
||||||
|
|
||||||
|
old_role = user.role
|
||||||
|
remaining = [r for r in (user.extra_roles or []) if r != new_role]
|
||||||
|
remaining.append(old_role)
|
||||||
|
user.role = new_role
|
||||||
|
user.extra_roles = remaining
|
||||||
|
return user
|
||||||
|
|||||||
@@ -0,0 +1,193 @@
|
|||||||
|
"""Generic outbound-email sender — POSTs to an admin-configured webhook.
|
||||||
|
|
||||||
|
Replaces the old SMTP-based email system. Every notification email in the
|
||||||
|
platform (password setup/reset, test validated, campaign completed, new
|
||||||
|
MITRE techniques synced, etc.) goes through ``send_webhook_email`` here,
|
||||||
|
which POSTs a ``{to, subject, body}`` JSON payload to a single configured
|
||||||
|
webhook URL — intended for a Power Automate flow that actually delivers
|
||||||
|
the email (the ``body`` is full HTML; the flow's "send email" step must
|
||||||
|
have its "Is HTML" option enabled). An optional API key, if configured,
|
||||||
|
is sent as an ``x-api-key`` header.
|
||||||
|
|
||||||
|
SMTP (``app.services.email_service``) is intentionally left in place but
|
||||||
|
unused and unreachable from the UI — see that module's docstring.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import base64
|
||||||
|
import html as html_lib
|
||||||
|
import logging
|
||||||
|
import re
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import requests
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
_WEBHOOK_URL_CONFIG_KEY = "email_webhook.url"
|
||||||
|
_WEBHOOK_API_KEY_CONFIG_KEY = "email_webhook.api_key"
|
||||||
|
|
||||||
|
_LOGO_PATH = Path(__file__).resolve().parent.parent / "assets" / "email_logo.png"
|
||||||
|
_URL_RE = re.compile(r"^https?://\S+$")
|
||||||
|
|
||||||
|
_logo_b64_cache: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def _get_logo_base64() -> str:
|
||||||
|
"""Read + cache the inline email logo as a base64 string (empty if missing)."""
|
||||||
|
global _logo_b64_cache
|
||||||
|
if _logo_b64_cache is None:
|
||||||
|
try:
|
||||||
|
_logo_b64_cache = base64.b64encode(_LOGO_PATH.read_bytes()).decode("ascii")
|
||||||
|
except OSError:
|
||||||
|
logger.warning("Email logo asset missing at %s", _LOGO_PATH)
|
||||||
|
_logo_b64_cache = ""
|
||||||
|
return _logo_b64_cache
|
||||||
|
|
||||||
|
|
||||||
|
def _message_to_html(message: str) -> str:
|
||||||
|
"""Turn a plain-text message (paragraphs separated by blank lines) into
|
||||||
|
escaped HTML, rendering any lone-URL paragraph as a call-to-action button.
|
||||||
|
"""
|
||||||
|
parts: list[str] = []
|
||||||
|
for para in message.strip().split("\n\n"):
|
||||||
|
para = para.strip()
|
||||||
|
if not para:
|
||||||
|
continue
|
||||||
|
if _URL_RE.match(para):
|
||||||
|
url = html_lib.escape(para, quote=True)
|
||||||
|
parts.append(
|
||||||
|
f'<p style="margin:0 0 20px;">'
|
||||||
|
f'<a href="{url}" style="display:inline-block;background-color:#0891b2;'
|
||||||
|
f'color:#ffffff;text-decoration:none;padding:12px 24px;border-radius:6px;'
|
||||||
|
f'font-weight:600;font-size:14px;">Open Link →</a>'
|
||||||
|
f'</p>'
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
escaped = html_lib.escape(para).replace("\n", "<br>")
|
||||||
|
parts.append(f'<p style="margin:0 0 16px;">{escaped}</p>')
|
||||||
|
return "".join(parts)
|
||||||
|
|
||||||
|
|
||||||
|
def build_email_body(full_name: str | None, message: str) -> str:
|
||||||
|
"""Wrap *message* in Aegis's branded HTML email template (inline logo)."""
|
||||||
|
greeting = html_lib.escape(full_name) if full_name else "there"
|
||||||
|
message_html = _message_to_html(message)
|
||||||
|
logo_b64 = _get_logo_base64()
|
||||||
|
logo_tag = (
|
||||||
|
f'<img src="data:image/png;base64,{logo_b64}" width="56" height="57" alt="Aegis" '
|
||||||
|
f'style="display:block;margin:0 auto 10px;border:0;" />'
|
||||||
|
if logo_b64
|
||||||
|
else ""
|
||||||
|
)
|
||||||
|
return f"""<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<body style="margin:0;padding:0;background-color:#0b0f19;">
|
||||||
|
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="background-color:#0b0f19;padding:32px 0;">
|
||||||
|
<tr>
|
||||||
|
<td align="center">
|
||||||
|
<table role="presentation" width="560" cellpadding="0" cellspacing="0" style="max-width:560px;width:100%;background-color:#111827;border-radius:12px;overflow:hidden;border:1px solid #1f2937;">
|
||||||
|
<tr>
|
||||||
|
<td style="background-color:#0e7490;background-image:linear-gradient(135deg,#0e7490,#0891b2);padding:28px 32px;text-align:center;">
|
||||||
|
{logo_tag}
|
||||||
|
<div style="color:#ffffff;font-size:20px;font-weight:600;letter-spacing:0.5px;">AEGIS SECURITY PLATFORM</div>
|
||||||
|
<div style="color:#cffafe;font-size:11px;letter-spacing:2px;text-transform:uppercase;margin-top:4px;">Purple Team Engineering</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td style="padding:32px;">
|
||||||
|
<p style="color:#e5e7eb;font-size:16px;margin:0 0 16px;">Hi {greeting},</p>
|
||||||
|
<div style="color:#d1d5db;font-size:14px;line-height:1.6;">{message_html}</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td style="padding:20px 32px;background-color:#0b0f19;border-top:1px solid #1f2937;">
|
||||||
|
<p style="color:#67e8f9;font-size:12px;margin:0 0 8px;font-style:italic;">Assume breach. Validate controls. Improve continuously.</p>
|
||||||
|
<p style="color:#4b5563;font-size:11px;margin:0;">Owned and operated by Enterprise Corp. This is an automated notification — please do not reply.</p>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</body>
|
||||||
|
</html>"""
|
||||||
|
|
||||||
|
|
||||||
|
def _read_system_config(db: Session, key: str) -> str | None:
|
||||||
|
from app.models.system_config import SystemConfig # avoid circular at import time
|
||||||
|
|
||||||
|
row = db.query(SystemConfig).filter(SystemConfig.key == key).first()
|
||||||
|
return row.value if row else None
|
||||||
|
|
||||||
|
|
||||||
|
def _write_system_config(db: Session, key: str, value: str) -> None:
|
||||||
|
from app.models.system_config import SystemConfig
|
||||||
|
|
||||||
|
row = db.query(SystemConfig).filter(SystemConfig.key == key).first()
|
||||||
|
if row:
|
||||||
|
row.value = value
|
||||||
|
else:
|
||||||
|
db.add(SystemConfig(key=key, value=value))
|
||||||
|
|
||||||
|
|
||||||
|
def get_webhook_url(db: Session) -> str | None:
|
||||||
|
"""Return the configured Power-Automate-style webhook URL, or None."""
|
||||||
|
return _read_system_config(db, _WEBHOOK_URL_CONFIG_KEY) or None
|
||||||
|
|
||||||
|
|
||||||
|
def set_webhook_url(db: Session, url: str) -> None:
|
||||||
|
"""Persist the webhook URL. Does not commit; caller commits."""
|
||||||
|
_write_system_config(db, _WEBHOOK_URL_CONFIG_KEY, url)
|
||||||
|
|
||||||
|
|
||||||
|
def get_webhook_api_key(db: Session) -> str | None:
|
||||||
|
"""Return the configured webhook API key, or None."""
|
||||||
|
return _read_system_config(db, _WEBHOOK_API_KEY_CONFIG_KEY) or None
|
||||||
|
|
||||||
|
|
||||||
|
def set_webhook_api_key(db: Session, api_key: str) -> None:
|
||||||
|
"""Persist the webhook API key. Does not commit; caller commits."""
|
||||||
|
_write_system_config(db, _WEBHOOK_API_KEY_CONFIG_KEY, api_key)
|
||||||
|
|
||||||
|
|
||||||
|
def send_webhook_email(
|
||||||
|
db: Session, *, to: str | None, subject: str, message: str, full_name: str | None = None,
|
||||||
|
) -> bool:
|
||||||
|
"""POST a notification email to the configured webhook.
|
||||||
|
|
||||||
|
Best-effort: returns False (and logs a warning) if no webhook is
|
||||||
|
configured, *to* is empty, the request fails, or the webhook responds
|
||||||
|
with a non-2xx status — never raises, since a webhook outage must not
|
||||||
|
break whatever triggered the notification.
|
||||||
|
"""
|
||||||
|
if not to:
|
||||||
|
return False
|
||||||
|
|
||||||
|
webhook_url = get_webhook_url(db)
|
||||||
|
if not webhook_url:
|
||||||
|
logger.warning("Email webhook is not configured — dropping email to %s", to)
|
||||||
|
return False
|
||||||
|
|
||||||
|
api_key = get_webhook_api_key(db)
|
||||||
|
headers = {"x-api-key": api_key} if api_key else {}
|
||||||
|
|
||||||
|
try:
|
||||||
|
response = requests.post(
|
||||||
|
webhook_url,
|
||||||
|
json={"to": to, "subject": subject, "body": build_email_body(full_name, message)},
|
||||||
|
headers=headers,
|
||||||
|
timeout=10,
|
||||||
|
)
|
||||||
|
if not response.ok:
|
||||||
|
logger.warning(
|
||||||
|
"Email webhook rejected the request for %s: HTTP %d — %s",
|
||||||
|
to, response.status_code, response.text[:500],
|
||||||
|
)
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
except requests.RequestException:
|
||||||
|
logger.warning("Email webhook call failed for %s", to, exc_info=True)
|
||||||
|
return False
|
||||||
@@ -184,6 +184,19 @@ def _send_webhook(db, wh: WebhookConfig, event_type: str, payload: dict) -> None
|
|||||||
"Webhook '%s' (%s) failed for event=%s: %s (failure_count=%d)",
|
"Webhook '%s' (%s) failed for event=%s: %s (failure_count=%d)",
|
||||||
wh.name, wh.url, event_type, exc, wh.failure_count,
|
wh.name, wh.url, event_type, exc, wh.failure_count,
|
||||||
)
|
)
|
||||||
|
if wh.failure_count == 3:
|
||||||
|
# Email once on the 3rd consecutive failure — signals "this looks
|
||||||
|
# broken", without alerting on every transient single failure.
|
||||||
|
from app.services.notification_service import notify_roles_by_email
|
||||||
|
notify_roles_by_email(
|
||||||
|
db, roles=["admin"],
|
||||||
|
preference_key="email_on_webhook_failures",
|
||||||
|
subject=f"Webhook Delivery Failing: {wh.name}",
|
||||||
|
message=(
|
||||||
|
f'The webhook "{wh.name}" ({wh.url}) has failed {wh.failure_count} times '
|
||||||
|
f'in a row for event "{event_type}". Last error: {exc}'
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ pillow==12.2.0
|
|||||||
psycopg2-binary==2.9.12
|
psycopg2-binary==2.9.12
|
||||||
pycparser==3.0
|
pycparser==3.0
|
||||||
pydantic==2.13.4
|
pydantic==2.13.4
|
||||||
pydantic-settings==2.14.1
|
pydantic-settings==2.14.2
|
||||||
pydantic_core==2.46.4
|
pydantic_core==2.46.4
|
||||||
pydyf==0.12.1
|
pydyf==0.12.1
|
||||||
PyJWT==2.13.0
|
PyJWT==2.13.0
|
||||||
@@ -62,7 +62,7 @@ slowapi==0.1.9
|
|||||||
sortedcontainers==2.4.0
|
sortedcontainers==2.4.0
|
||||||
soupsieve==2.8.4
|
soupsieve==2.8.4
|
||||||
SQLAlchemy==2.0.50
|
SQLAlchemy==2.0.50
|
||||||
starlette==1.3.0
|
starlette==1.3.1
|
||||||
taxii2-client==2.3.0
|
taxii2-client==2.3.0
|
||||||
tempo-api-python-client==0.12.0
|
tempo-api-python-client==0.12.0
|
||||||
tinycss2==1.5.1
|
tinycss2==1.5.1
|
||||||
|
|||||||
@@ -15,7 +15,8 @@ pyyaml>=6.0.3
|
|||||||
toml>=0.10.2
|
toml>=0.10.2
|
||||||
taxii2-client>=2.3.0
|
taxii2-client>=2.3.0
|
||||||
python-multipart>=0.0.32
|
python-multipart>=0.0.32
|
||||||
pydantic-settings>=2.14.0
|
starlette>=1.3.1
|
||||||
|
pydantic-settings>=2.14.2
|
||||||
slowapi>=0.1.9
|
slowapi>=0.1.9
|
||||||
defusedxml>=0.7.1
|
defusedxml>=0.7.1
|
||||||
redis>=8.0.0
|
redis>=8.0.0
|
||||||
|
|||||||
@@ -217,12 +217,29 @@ def blue_lead_user(db):
|
|||||||
return user
|
return user
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="function")
|
||||||
|
def manager_user(db):
|
||||||
|
"""Create a manager user for testing."""
|
||||||
|
user = User(
|
||||||
|
username="manager",
|
||||||
|
email="manager@test.com",
|
||||||
|
hashed_password=hash_password("manager123"),
|
||||||
|
role="manager",
|
||||||
|
is_active=True,
|
||||||
|
must_change_password=False,
|
||||||
|
)
|
||||||
|
db.add(user)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(user)
|
||||||
|
return user
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(scope="function")
|
@pytest.fixture(scope="function")
|
||||||
def admin_token(client, admin_user):
|
def admin_token(client, admin_user):
|
||||||
"""Get an auth token for the admin user."""
|
"""Get an auth token for the admin user."""
|
||||||
response = client.post(
|
response = client.post(
|
||||||
"/api/v1/auth/login",
|
"/api/v1/auth/login",
|
||||||
data={"username": "admin", "password": "admin123"},
|
data={"username": "admin@test.com", "password": "admin123"},
|
||||||
)
|
)
|
||||||
return response.json()["access_token"]
|
return response.json()["access_token"]
|
||||||
|
|
||||||
@@ -232,7 +249,7 @@ def red_tech_token(client, red_tech_user):
|
|||||||
"""Get an auth token for the red_tech user."""
|
"""Get an auth token for the red_tech user."""
|
||||||
response = client.post(
|
response = client.post(
|
||||||
"/api/v1/auth/login",
|
"/api/v1/auth/login",
|
||||||
data={"username": "redtech", "password": "redtech123"},
|
data={"username": "redtech@test.com", "password": "redtech123"},
|
||||||
)
|
)
|
||||||
return response.json()["access_token"]
|
return response.json()["access_token"]
|
||||||
|
|
||||||
@@ -254,7 +271,7 @@ def blue_tech_token(client, blue_tech_user):
|
|||||||
"""Get an auth token for the blue_tech user."""
|
"""Get an auth token for the blue_tech user."""
|
||||||
response = client.post(
|
response = client.post(
|
||||||
"/api/v1/auth/login",
|
"/api/v1/auth/login",
|
||||||
data={"username": "bluetech", "password": "bluetech123"},
|
data={"username": "bluetech@test.com", "password": "bluetech123"},
|
||||||
)
|
)
|
||||||
return response.json()["access_token"]
|
return response.json()["access_token"]
|
||||||
|
|
||||||
@@ -270,7 +287,7 @@ def red_lead_token(client, red_lead_user):
|
|||||||
"""Get an auth token for the red_lead user."""
|
"""Get an auth token for the red_lead user."""
|
||||||
response = client.post(
|
response = client.post(
|
||||||
"/api/v1/auth/login",
|
"/api/v1/auth/login",
|
||||||
data={"username": "redlead", "password": "redlead123"},
|
data={"username": "redlead@test.com", "password": "redlead123"},
|
||||||
)
|
)
|
||||||
return response.json()["access_token"]
|
return response.json()["access_token"]
|
||||||
|
|
||||||
@@ -286,7 +303,7 @@ def blue_lead_token(client, blue_lead_user):
|
|||||||
"""Get an auth token for the blue_lead user."""
|
"""Get an auth token for the blue_lead user."""
|
||||||
response = client.post(
|
response = client.post(
|
||||||
"/api/v1/auth/login",
|
"/api/v1/auth/login",
|
||||||
data={"username": "bluelead", "password": "bluelead123"},
|
data={"username": "bluelead@test.com", "password": "bluelead123"},
|
||||||
)
|
)
|
||||||
return response.json()["access_token"]
|
return response.json()["access_token"]
|
||||||
|
|
||||||
@@ -295,3 +312,41 @@ def blue_lead_token(client, blue_lead_user):
|
|||||||
def blue_lead_headers(blue_lead_token):
|
def blue_lead_headers(blue_lead_token):
|
||||||
"""Return authorization headers for blue_lead user."""
|
"""Return authorization headers for blue_lead user."""
|
||||||
return {"Authorization": f"Bearer {blue_lead_token}"}
|
return {"Authorization": f"Bearer {blue_lead_token}"}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="function")
|
||||||
|
def manager_token(client, manager_user):
|
||||||
|
"""Get an auth token for the manager user."""
|
||||||
|
response = client.post(
|
||||||
|
"/api/v1/auth/login",
|
||||||
|
data={"username": "manager@test.com", "password": "manager123"},
|
||||||
|
)
|
||||||
|
return response.json()["access_token"]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="function")
|
||||||
|
def manager_headers(manager_token):
|
||||||
|
"""Return authorization headers for manager user."""
|
||||||
|
return {"Authorization": f"Bearer {manager_token}"}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="function")
|
||||||
|
def api(client):
|
||||||
|
"""Issue an authenticated request while avoiding stale-cookie role bleed.
|
||||||
|
|
||||||
|
``client``'s cookie jar persists across requests within a test, and
|
||||||
|
``get_current_user`` prefers the ``aegis_token`` cookie over the
|
||||||
|
``Authorization`` header. A test that uses more than one role's
|
||||||
|
``*_headers`` fixture (e.g. submit as red_lead, then approve as
|
||||||
|
manager) would otherwise have its *first* request silently
|
||||||
|
authenticate as whichever role's fixture happens to log in last,
|
||||||
|
since pytest resolves all fixtures before the test body runs. Use
|
||||||
|
this instead of ``client.post``/``client.get`` directly whenever a
|
||||||
|
test mixes more than one role.
|
||||||
|
|
||||||
|
Usage: ``api("post", url, headers, json=payload)``.
|
||||||
|
"""
|
||||||
|
def _request(method: str, url: str, headers: dict, **kwargs):
|
||||||
|
client.cookies.clear()
|
||||||
|
return getattr(client, method)(url, headers=headers, **kwargs)
|
||||||
|
return _request
|
||||||
|
|||||||
@@ -0,0 +1,198 @@
|
|||||||
|
"""Tests for the admin configuration export/import bundle (Block 4).
|
||||||
|
|
||||||
|
The feature was already implemented (GET/POST /admin/export-config,
|
||||||
|
/admin/import-config) but had zero test coverage for an admin-sensitive
|
||||||
|
data-migration endpoint. Covers: admin-only gating, secret redaction,
|
||||||
|
custom-template-only scoping, and import idempotency/user-provisioning
|
||||||
|
safety (forced password reset, no secret restoration from redacted values).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from app.models.scoring_config import ScoringConfig
|
||||||
|
from app.models.sso_config import SsoConfig
|
||||||
|
from app.models.system_config import SystemConfig
|
||||||
|
from app.models.test_template import TestTemplate
|
||||||
|
from app.models.user import User
|
||||||
|
from app.models.webhook_config import WebhookConfig
|
||||||
|
|
||||||
|
|
||||||
|
def test_export_config_requires_admin(client, red_tech_headers):
|
||||||
|
resp = client.get("/api/v1/admin/export-config", headers=red_tech_headers)
|
||||||
|
assert resp.status_code == 403
|
||||||
|
|
||||||
|
|
||||||
|
def test_import_config_requires_admin(client, red_tech_headers):
|
||||||
|
resp = client.post("/api/v1/admin/import-config", json={}, headers=red_tech_headers)
|
||||||
|
assert resp.status_code == 403
|
||||||
|
|
||||||
|
|
||||||
|
def test_export_config_returns_expected_sections(client, auth_headers):
|
||||||
|
resp = client.get("/api/v1/admin/export-config", headers=auth_headers)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
body = resp.json()
|
||||||
|
for key in ("_meta", "system_configs", "webhooks", "sso", "scoring", "custom_templates", "users"):
|
||||||
|
assert key in body
|
||||||
|
assert resp.headers["content-disposition"].startswith("attachment;")
|
||||||
|
|
||||||
|
|
||||||
|
def test_export_config_redacts_smtp_password(client, db, auth_headers):
|
||||||
|
db.add(SystemConfig(key="smtp.password", value="hunter2"))
|
||||||
|
db.add(SystemConfig(key="smtp.host", value="smtp.example.com"))
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
body = client.get("/api/v1/admin/export-config", headers=auth_headers).json()
|
||||||
|
values = {r["key"]: r["value"] for r in body["system_configs"]}
|
||||||
|
|
||||||
|
assert values["smtp.password"] == "[REDACTED]"
|
||||||
|
assert values["smtp.host"] == "smtp.example.com"
|
||||||
|
|
||||||
|
|
||||||
|
def test_export_config_redacts_live_jira_and_tempo_admin_tokens(client, db, auth_headers):
|
||||||
|
"""Regression test: the redaction key list previously named
|
||||||
|
jira.api_token/jira.password/tempo.api_token, which don't match the
|
||||||
|
actual keys routers/system.py writes (jira.admin_api_token,
|
||||||
|
tempo.admin_token) — so the live admin Jira/Tempo credentials were
|
||||||
|
exported in plaintext instead of being redacted."""
|
||||||
|
db.add(SystemConfig(key="jira.admin_api_token", value="ATATT-real-jira-token"))
|
||||||
|
db.add(SystemConfig(key="tempo.admin_token", value="real-tempo-token"))
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
body = client.get("/api/v1/admin/export-config", headers=auth_headers).json()
|
||||||
|
values = {r["key"]: r["value"] for r in body["system_configs"]}
|
||||||
|
|
||||||
|
assert values["jira.admin_api_token"] == "[REDACTED]"
|
||||||
|
assert values["tempo.admin_token"] == "[REDACTED]"
|
||||||
|
|
||||||
|
|
||||||
|
def test_export_config_never_exports_sso_private_key(client, db, auth_headers):
|
||||||
|
db.add(SsoConfig(is_enabled=True, provider_name="Okta", sp_private_key="super-secret-key"))
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
body = client.get("/api/v1/admin/export-config", headers=auth_headers).json()
|
||||||
|
|
||||||
|
assert body["sso"]["sp_private_key"] == "[REDACTED]"
|
||||||
|
|
||||||
|
|
||||||
|
def test_export_config_only_includes_custom_templates(client, db, auth_headers):
|
||||||
|
db.add(TestTemplate(mitre_technique_id="T1059", name="Atomic one", source="atomic_red_team"))
|
||||||
|
db.add(TestTemplate(mitre_technique_id="T1059", name="My custom one", source="custom"))
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
body = client.get("/api/v1/admin/export-config", headers=auth_headers).json()
|
||||||
|
names = {t["name"] for t in body["custom_templates"]}
|
||||||
|
|
||||||
|
assert "My custom one" in names
|
||||||
|
assert "Atomic one" not in names
|
||||||
|
|
||||||
|
|
||||||
|
def test_export_config_users_have_no_secrets(client, db, auth_headers, red_tech_user):
|
||||||
|
body = client.get("/api/v1/admin/export-config", headers=auth_headers).json()
|
||||||
|
exported = next(u for u in body["users"] if u["username"] == red_tech_user.username)
|
||||||
|
|
||||||
|
assert "hashed_password" not in exported
|
||||||
|
assert "jira_api_token" not in exported
|
||||||
|
assert exported["must_change_password"] is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_import_config_rejects_invalid_json(client, auth_headers):
|
||||||
|
resp = client.post(
|
||||||
|
"/api/v1/admin/import-config",
|
||||||
|
data=b"not json",
|
||||||
|
headers={**auth_headers, "Content-Type": "application/json"},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 400
|
||||||
|
|
||||||
|
|
||||||
|
def test_import_config_creates_new_user_with_forced_reset(client, db, auth_headers):
|
||||||
|
resp = client.post(
|
||||||
|
"/api/v1/admin/import-config",
|
||||||
|
json={"users": [{"username": "imported_user", "email": "imported_user@test.com", "role": "red_tech", "is_active": True}]},
|
||||||
|
headers=auth_headers,
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert resp.json()["summary"]["users_created"] == 1
|
||||||
|
|
||||||
|
user = db.query(User).filter(User.email == "imported_user@test.com").first()
|
||||||
|
assert user is not None
|
||||||
|
assert user.must_change_password is True
|
||||||
|
assert user.role == "red_tech"
|
||||||
|
|
||||||
|
|
||||||
|
def test_import_config_skips_user_with_no_email(client, db, auth_headers):
|
||||||
|
resp = client.post(
|
||||||
|
"/api/v1/admin/import-config",
|
||||||
|
json={"users": [{"username": "no_email_user", "role": "red_tech", "is_active": True}]},
|
||||||
|
headers=auth_headers,
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert resp.json()["summary"]["users_skipped_no_email"] == 1
|
||||||
|
assert db.query(User).filter(User.username == "no_email_user").first() is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_import_config_updates_existing_user_role_only(client, db, auth_headers, red_tech_user):
|
||||||
|
original_hash = red_tech_user.hashed_password
|
||||||
|
|
||||||
|
resp = client.post(
|
||||||
|
"/api/v1/admin/import-config",
|
||||||
|
json={"users": [{"username": red_tech_user.username, "email": red_tech_user.email, "role": "red_lead", "is_active": True}]},
|
||||||
|
headers=auth_headers,
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert resp.json()["summary"]["users_updated"] == 1
|
||||||
|
|
||||||
|
db.refresh(red_tech_user)
|
||||||
|
assert red_tech_user.role == "red_lead"
|
||||||
|
assert red_tech_user.hashed_password == original_hash
|
||||||
|
|
||||||
|
|
||||||
|
def test_import_config_skips_redacted_system_config_value(client, db, auth_headers):
|
||||||
|
db.add(SystemConfig(key="smtp.password", value="original-real-value"))
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
client.post(
|
||||||
|
"/api/v1/admin/import-config",
|
||||||
|
json={"system_configs": [{"key": "smtp.password", "value": "[REDACTED]"}]},
|
||||||
|
headers=auth_headers,
|
||||||
|
)
|
||||||
|
|
||||||
|
row = db.query(SystemConfig).filter(SystemConfig.key == "smtp.password").first()
|
||||||
|
assert row.value == "original-real-value"
|
||||||
|
|
||||||
|
|
||||||
|
def test_import_config_never_restores_sso_private_key(client, db, auth_headers):
|
||||||
|
resp = client.post(
|
||||||
|
"/api/v1/admin/import-config",
|
||||||
|
json={"sso": {"is_enabled": True, "provider_name": "Okta", "sp_private_key": "[REDACTED]"}},
|
||||||
|
headers=auth_headers,
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
|
||||||
|
row = db.query(SsoConfig).first()
|
||||||
|
assert row is not None
|
||||||
|
assert row.sp_private_key is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_import_config_upserts_scoring_weights(client, db, auth_headers):
|
||||||
|
db.add(ScoringConfig(weight_tests=40.0, weight_detection_rules=25.0, weight_d3fend=15.0, weight_recency=10.0, weight_severity=10.0))
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
client.post(
|
||||||
|
"/api/v1/admin/import-config",
|
||||||
|
json={"scoring": {"weight_tests": 55.0, "weight_detection_rules": 20.0, "weight_d3fend": 10.0, "weight_recency": 10.0, "weight_severity": 5.0}},
|
||||||
|
headers=auth_headers,
|
||||||
|
)
|
||||||
|
|
||||||
|
row = db.query(ScoringConfig).first()
|
||||||
|
assert row.weight_tests == 55.0
|
||||||
|
|
||||||
|
|
||||||
|
def test_import_config_round_trip_is_idempotent(client, db, auth_headers):
|
||||||
|
db.add(WebhookConfig(name="alert-hook", url="https://example.com/hook", events=["test_validated"], is_active=True))
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
exported = client.get("/api/v1/admin/export-config", headers=auth_headers).json()
|
||||||
|
|
||||||
|
resp = client.post("/api/v1/admin/import-config", json=exported, headers=auth_headers)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
|
||||||
|
hooks = db.query(WebhookConfig).filter(WebhookConfig.name == "alert-hook").all()
|
||||||
|
assert len(hooks) == 1
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
"""Admin must be locked out of every Test-workflow *action* endpoint.
|
||||||
|
|
||||||
|
Admin administers the site; leads/managers/operators run the test workflow.
|
||||||
|
The permission dependency runs before any DB lookup, so a random UUID is
|
||||||
|
enough to prove the 403 — the request never reaches the handler body.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
DUMMY_ID = str(uuid.uuid4())
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"method,path,payload",
|
||||||
|
[
|
||||||
|
("post", f"/api/v1/tests/{DUMMY_ID}/review-red", {"decision": "approve"}),
|
||||||
|
("post", f"/api/v1/tests/{DUMMY_ID}/review-blue", {"decision": "approve"}),
|
||||||
|
("post", f"/api/v1/tests/{DUMMY_ID}/validate-red", {"red_validation_status": "approved"}),
|
||||||
|
("post", f"/api/v1/tests/{DUMMY_ID}/validate-blue", {"blue_validation_status": "approved"}),
|
||||||
|
("post", f"/api/v1/tests/{DUMMY_ID}/resolve-dispute", {"target_team": "red"}),
|
||||||
|
("post", f"/api/v1/tests/{DUMMY_ID}/request-discussion", None),
|
||||||
|
("post", f"/api/v1/tests/{DUMMY_ID}/reopen", None),
|
||||||
|
("post", f"/api/v1/tests/{DUMMY_ID}/hold", {"reason": "x"}),
|
||||||
|
("post", f"/api/v1/tests/{DUMMY_ID}/resume", None),
|
||||||
|
("post", f"/api/v1/tests/{DUMMY_ID}/assign", {"red_tech_assignee": DUMMY_ID}),
|
||||||
|
("patch", f"/api/v1/tests/{DUMMY_ID}/classification", {"data_classification": "pii"}),
|
||||||
|
("post", f"/api/v1/tests/{DUMMY_ID}/start-execution", None),
|
||||||
|
("post", f"/api/v1/tests/{DUMMY_ID}/submit-red", None),
|
||||||
|
("post", f"/api/v1/tests/{DUMMY_ID}/start-blue-work", None),
|
||||||
|
("post", f"/api/v1/tests/{DUMMY_ID}/submit-blue", None),
|
||||||
|
("post", f"/api/v1/tests/{DUMMY_ID}/pause-timer", None),
|
||||||
|
("post", f"/api/v1/tests/{DUMMY_ID}/resume-timer", None),
|
||||||
|
("patch", f"/api/v1/tests/{DUMMY_ID}/red", {"procedure_text": "x"}),
|
||||||
|
("patch", f"/api/v1/tests/{DUMMY_ID}/blue", {"detection_result": "detected"}),
|
||||||
|
("patch", f"/api/v1/tests/{DUMMY_ID}", {"name": "x"}),
|
||||||
|
("patch", f"/api/v1/tests/{DUMMY_ID}/remediation", {"remediation_status": "completed"}),
|
||||||
|
("post", "/api/v1/tests", {"technique_id": DUMMY_ID, "name": "x"}),
|
||||||
|
("post", "/api/v1/tests/from-template", {"template_id": DUMMY_ID, "technique_id": DUMMY_ID}),
|
||||||
|
("post", "/api/v1/tests/import-rt", {"name": "x", "techniques": []}),
|
||||||
|
("post", "/api/v1/campaigns", {"name": "x"}),
|
||||||
|
("post", f"/api/v1/campaigns/from-threat-actor/{DUMMY_ID}", None),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_admin_forbidden(client, auth_headers, method, path, payload):
|
||||||
|
resp = getattr(client, method)(path, json=payload, headers=auth_headers)
|
||||||
|
assert resp.status_code == 403, f"{method.upper()} {path} -> {resp.status_code}: {resp.text}"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"method,path,payload",
|
||||||
|
[
|
||||||
|
("post", "/api/v1/test-templates", {"name": "x", "mitre_technique_id": "T1059"}),
|
||||||
|
("patch", f"/api/v1/test-templates/{DUMMY_ID}", {"name": "x"}),
|
||||||
|
("patch", f"/api/v1/test-templates/{DUMMY_ID}/toggle-active", None),
|
||||||
|
("delete", f"/api/v1/test-templates/{DUMMY_ID}", None),
|
||||||
|
("patch", "/api/v1/test-templates/bulk-activate", {"template_ids": [DUMMY_ID], "is_active": True}),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_admin_forbidden_templates(client, auth_headers, method, path, payload):
|
||||||
|
kwargs = {"headers": auth_headers}
|
||||||
|
if payload is not None:
|
||||||
|
kwargs["json"] = payload
|
||||||
|
resp = getattr(client, method)(path, **kwargs)
|
||||||
|
assert resp.status_code == 403, f"{method.upper()} {path} -> {resp.status_code}: {resp.text}"
|
||||||
@@ -0,0 +1,224 @@
|
|||||||
|
"""Tests for POST /tests/{id}/assign — lead/manager manual operator assignment."""
|
||||||
|
|
||||||
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
|
from app.models.test import Test
|
||||||
|
from app.models.technique import Technique
|
||||||
|
|
||||||
|
|
||||||
|
def _seed_technique(db, tactic="execution") -> Technique:
|
||||||
|
technique = Technique(
|
||||||
|
mitre_id="T9999",
|
||||||
|
name="Test Technique",
|
||||||
|
tactic=tactic,
|
||||||
|
platforms=["linux"],
|
||||||
|
)
|
||||||
|
db.add(technique)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(technique)
|
||||||
|
return technique
|
||||||
|
|
||||||
|
|
||||||
|
def _seed_test(db, technique, created_by) -> Test:
|
||||||
|
test = Test(technique_id=technique.id, name="Assignable test", created_by=created_by)
|
||||||
|
db.add(test)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(test)
|
||||||
|
return test
|
||||||
|
|
||||||
|
|
||||||
|
def test_red_lead_can_assign_red_tech(client, db, red_lead_headers, red_lead_user, red_tech_user):
|
||||||
|
technique = _seed_technique(db)
|
||||||
|
test = _seed_test(db, technique, red_lead_user.id)
|
||||||
|
|
||||||
|
resp = client.post(
|
||||||
|
f"/api/v1/tests/{test.id}/assign",
|
||||||
|
json={"red_tech_assignee": str(red_tech_user.id)},
|
||||||
|
headers=red_lead_headers,
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200, resp.text
|
||||||
|
assert resp.json()["red_tech_assignee"] == str(red_tech_user.id)
|
||||||
|
|
||||||
|
db.refresh(test)
|
||||||
|
assert test.red_tech_assignee == red_tech_user.id
|
||||||
|
|
||||||
|
|
||||||
|
def test_assign_rejects_wrong_role_for_side(client, db, red_lead_headers, red_lead_user, blue_tech_user):
|
||||||
|
technique = _seed_technique(db)
|
||||||
|
test = _seed_test(db, technique, red_lead_user.id)
|
||||||
|
|
||||||
|
resp = client.post(
|
||||||
|
f"/api/v1/tests/{test.id}/assign",
|
||||||
|
json={"red_tech_assignee": str(blue_tech_user.id)},
|
||||||
|
headers=red_lead_headers,
|
||||||
|
)
|
||||||
|
assert resp.status_code == 400
|
||||||
|
|
||||||
|
|
||||||
|
def test_admin_cannot_be_assigned_as_operator(client, db, red_lead_headers, red_lead_user, admin_user):
|
||||||
|
"""Admin administers the site and can't be assigned as an operator either."""
|
||||||
|
technique = _seed_technique(db)
|
||||||
|
test = _seed_test(db, technique, red_lead_user.id)
|
||||||
|
|
||||||
|
resp = client.post(
|
||||||
|
f"/api/v1/tests/{test.id}/assign",
|
||||||
|
json={"red_tech_assignee": str(admin_user.id)},
|
||||||
|
headers=red_lead_headers,
|
||||||
|
)
|
||||||
|
assert resp.status_code == 400
|
||||||
|
|
||||||
|
|
||||||
|
def test_red_tech_cannot_assign(client, db, red_tech_headers, red_tech_user):
|
||||||
|
technique = _seed_technique(db)
|
||||||
|
test = _seed_test(db, technique, red_tech_user.id)
|
||||||
|
|
||||||
|
resp = client.post(
|
||||||
|
f"/api/v1/tests/{test.id}/assign",
|
||||||
|
json={"red_tech_assignee": str(red_tech_user.id)},
|
||||||
|
headers=red_tech_headers,
|
||||||
|
)
|
||||||
|
assert resp.status_code == 403
|
||||||
|
|
||||||
|
|
||||||
|
def test_admin_cannot_assign(client, db, auth_headers, admin_user, red_tech_user):
|
||||||
|
"""Admin administers the site — coordinating operators is a lead/manager call."""
|
||||||
|
technique = _seed_technique(db)
|
||||||
|
test = _seed_test(db, technique, admin_user.id)
|
||||||
|
|
||||||
|
resp = client.post(
|
||||||
|
f"/api/v1/tests/{test.id}/assign",
|
||||||
|
json={"red_tech_assignee": str(red_tech_user.id)},
|
||||||
|
headers=auth_headers,
|
||||||
|
)
|
||||||
|
assert resp.status_code == 403
|
||||||
|
|
||||||
|
|
||||||
|
def test_manager_can_assign(client, db, manager_headers, manager_user, red_tech_user):
|
||||||
|
technique = _seed_technique(db)
|
||||||
|
test = _seed_test(db, technique, manager_user.id)
|
||||||
|
|
||||||
|
resp = client.post(
|
||||||
|
f"/api/v1/tests/{test.id}/assign",
|
||||||
|
json={"red_tech_assignee": str(red_tech_user.id)},
|
||||||
|
headers=manager_headers,
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200, resp.text
|
||||||
|
assert resp.json()["red_tech_assignee"] == str(red_tech_user.id)
|
||||||
|
|
||||||
|
|
||||||
|
def test_clearing_assignee_does_not_touch_jira(client, db, red_lead_headers, red_lead_user, red_tech_user):
|
||||||
|
"""Sending null explicitly clears the assignee without a Jira sync call."""
|
||||||
|
technique = _seed_technique(db)
|
||||||
|
test = _seed_test(db, technique, red_lead_user.id)
|
||||||
|
test.red_tech_assignee = red_tech_user.id
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
with patch("app.services.jira_service.push_assignee_update") as mock_push:
|
||||||
|
resp = client.post(
|
||||||
|
f"/api/v1/tests/{test.id}/assign",
|
||||||
|
json={"red_tech_assignee": None},
|
||||||
|
headers=red_lead_headers,
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert resp.json()["red_tech_assignee"] is None
|
||||||
|
mock_push.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
def test_assigning_operator_syncs_to_jira(client, db, red_lead_headers, red_lead_user, red_tech_user):
|
||||||
|
technique = _seed_technique(db)
|
||||||
|
test = _seed_test(db, technique, red_lead_user.id)
|
||||||
|
|
||||||
|
# Capture IDs *during* the call, while the endpoint's request-scoped
|
||||||
|
# session is still open — reading them afterward hits a
|
||||||
|
# DetachedInstanceError once that session closes.
|
||||||
|
captured = {}
|
||||||
|
|
||||||
|
def _capture(_db, test_arg, assignee_arg):
|
||||||
|
captured["test_id"] = test_arg.id
|
||||||
|
captured["assignee_id"] = assignee_arg.id
|
||||||
|
|
||||||
|
with patch("app.services.jira_service.push_assignee_update", side_effect=_capture) as mock_push:
|
||||||
|
resp = client.post(
|
||||||
|
f"/api/v1/tests/{test.id}/assign",
|
||||||
|
json={"red_tech_assignee": str(red_tech_user.id)},
|
||||||
|
headers=red_lead_headers,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert resp.status_code == 200
|
||||||
|
mock_push.assert_called_once()
|
||||||
|
assert captured["test_id"] == test.id
|
||||||
|
assert captured["assignee_id"] == red_tech_user.id
|
||||||
|
|
||||||
|
|
||||||
|
def _make_second_red_lead(db):
|
||||||
|
from app.auth import hash_password
|
||||||
|
from app.models.user import User
|
||||||
|
u = User(
|
||||||
|
username="redlead2", email="redlead2@test.com",
|
||||||
|
hashed_password=hash_password("x"), role="red_lead", is_active=True,
|
||||||
|
must_change_password=False,
|
||||||
|
)
|
||||||
|
db.add(u)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(u)
|
||||||
|
return u
|
||||||
|
|
||||||
|
|
||||||
|
def test_lead_can_reassign_red_reviewer_to_peer(client, db, red_lead_headers, red_lead_user):
|
||||||
|
"""A lead can hand a review off to a peer lead — e.g. if the currently
|
||||||
|
assigned reviewer can't get to it."""
|
||||||
|
technique = _seed_technique(db)
|
||||||
|
test = _seed_test(db, technique, red_lead_user.id)
|
||||||
|
peer = _make_second_red_lead(db)
|
||||||
|
test.red_reviewer_assignee = red_lead_user.id
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
resp = client.post(
|
||||||
|
f"/api/v1/tests/{test.id}/assign",
|
||||||
|
json={"red_reviewer_assignee": str(peer.id)},
|
||||||
|
headers=red_lead_headers,
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200, resp.text
|
||||||
|
assert resp.json()["red_reviewer_assignee"] == str(peer.id)
|
||||||
|
|
||||||
|
db.refresh(test)
|
||||||
|
assert test.red_reviewer_assignee == peer.id
|
||||||
|
|
||||||
|
|
||||||
|
def test_reviewer_reassign_rejects_non_lead(client, db, red_lead_headers, red_lead_user, red_tech_user):
|
||||||
|
"""Only a red_lead can be the red reviewer — a red_tech is not eligible."""
|
||||||
|
technique = _seed_technique(db)
|
||||||
|
test = _seed_test(db, technique, red_lead_user.id)
|
||||||
|
|
||||||
|
resp = client.post(
|
||||||
|
f"/api/v1/tests/{test.id}/assign",
|
||||||
|
json={"red_reviewer_assignee": str(red_tech_user.id)},
|
||||||
|
headers=red_lead_headers,
|
||||||
|
)
|
||||||
|
assert resp.status_code == 400
|
||||||
|
|
||||||
|
|
||||||
|
def test_reviewer_reassign_rejects_wrong_side(client, db, red_lead_headers, red_lead_user, blue_lead_user):
|
||||||
|
"""A blue_lead can't be set as the red reviewer."""
|
||||||
|
technique = _seed_technique(db)
|
||||||
|
test = _seed_test(db, technique, red_lead_user.id)
|
||||||
|
|
||||||
|
resp = client.post(
|
||||||
|
f"/api/v1/tests/{test.id}/assign",
|
||||||
|
json={"red_reviewer_assignee": str(blue_lead_user.id)},
|
||||||
|
headers=red_lead_headers,
|
||||||
|
)
|
||||||
|
assert resp.status_code == 400
|
||||||
|
|
||||||
|
|
||||||
|
def test_manager_can_reassign_blue_reviewer(client, db, manager_headers, manager_user, blue_lead_user):
|
||||||
|
technique = _seed_technique(db)
|
||||||
|
test = _seed_test(db, technique, manager_user.id)
|
||||||
|
|
||||||
|
resp = client.post(
|
||||||
|
f"/api/v1/tests/{test.id}/assign",
|
||||||
|
json={"blue_reviewer_assignee": str(blue_lead_user.id)},
|
||||||
|
headers=manager_headers,
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200, resp.text
|
||||||
|
assert resp.json()["blue_reviewer_assignee"] == str(blue_lead_user.id)
|
||||||
@@ -1,13 +1,29 @@
|
|||||||
"""Tests for authentication endpoints."""
|
"""Tests for authentication endpoints."""
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
|
||||||
|
import jwt
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
|
from app.config import settings
|
||||||
|
|
||||||
|
|
||||||
|
def _make_token(username: str, *, expired_seconds_ago: int | None = None) -> str:
|
||||||
|
"""Build a raw JWT mirroring ``create_access_token`` with a controllable exp."""
|
||||||
|
if expired_seconds_ago is None:
|
||||||
|
expire = datetime.now(timezone.utc) + timedelta(minutes=30)
|
||||||
|
else:
|
||||||
|
expire = datetime.now(timezone.utc) - timedelta(seconds=expired_seconds_ago)
|
||||||
|
payload = {"sub": username, "exp": expire, "jti": str(uuid.uuid4())}
|
||||||
|
return jwt.encode(payload, settings.SECRET_KEY, algorithm=settings.ALGORITHM)
|
||||||
|
|
||||||
|
|
||||||
def test_login_success(client, admin_user):
|
def test_login_success(client, admin_user):
|
||||||
"""Test successful login returns a token."""
|
"""Test successful login returns a token."""
|
||||||
response = client.post(
|
response = client.post(
|
||||||
"/api/v1/auth/login",
|
"/api/v1/auth/login",
|
||||||
data={"username": "admin", "password": "admin123"},
|
data={"username": "admin@test.com", "password": "admin123"},
|
||||||
)
|
)
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
data = response.json()
|
data = response.json()
|
||||||
@@ -19,7 +35,7 @@ def test_login_wrong_password(client, admin_user):
|
|||||||
"""Test login with wrong password returns 400."""
|
"""Test login with wrong password returns 400."""
|
||||||
response = client.post(
|
response = client.post(
|
||||||
"/api/v1/auth/login",
|
"/api/v1/auth/login",
|
||||||
data={"username": "admin", "password": "wrongpassword"},
|
data={"username": "admin@test.com", "password": "wrongpassword"},
|
||||||
)
|
)
|
||||||
assert response.status_code == 400
|
assert response.status_code == 400
|
||||||
|
|
||||||
@@ -40,6 +56,7 @@ def test_login_inactive_user(client, db):
|
|||||||
|
|
||||||
user = User(
|
user = User(
|
||||||
username="inactive",
|
username="inactive",
|
||||||
|
email="inactive@test.com",
|
||||||
hashed_password=hash_password("password"),
|
hashed_password=hash_password("password"),
|
||||||
role="viewer",
|
role="viewer",
|
||||||
is_active=False,
|
is_active=False,
|
||||||
@@ -49,7 +66,7 @@ def test_login_inactive_user(client, db):
|
|||||||
|
|
||||||
response = client.post(
|
response = client.post(
|
||||||
"/api/v1/auth/login",
|
"/api/v1/auth/login",
|
||||||
data={"username": "inactive", "password": "password"},
|
data={"username": "inactive@test.com", "password": "password"},
|
||||||
)
|
)
|
||||||
assert response.status_code == 403
|
assert response.status_code == 403
|
||||||
|
|
||||||
@@ -90,7 +107,7 @@ def test_logout_revokes_token(client, admin_user):
|
|||||||
"""
|
"""
|
||||||
login = client.post(
|
login = client.post(
|
||||||
"/api/v1/auth/login",
|
"/api/v1/auth/login",
|
||||||
data={"username": "admin", "password": "admin123"},
|
data={"username": "admin@test.com", "password": "admin123"},
|
||||||
)
|
)
|
||||||
assert login.status_code == 200
|
assert login.status_code == 200
|
||||||
token = login.json()["access_token"]
|
token = login.json()["access_token"]
|
||||||
@@ -122,3 +139,49 @@ def test_logout_revokes_token(client, admin_user):
|
|||||||
)
|
)
|
||||||
assert me.status_code == 401
|
assert me.status_code == 401
|
||||||
assert me.json()["detail"] == "Token has been revoked"
|
assert me.json()["detail"] == "Token has been revoked"
|
||||||
|
|
||||||
|
|
||||||
|
def test_refresh_without_cookie_fails(client):
|
||||||
|
response = client.post("/api/v1/auth/refresh")
|
||||||
|
assert response.status_code == 403
|
||||||
|
|
||||||
|
|
||||||
|
def test_refresh_valid_token_succeeds(client, admin_user):
|
||||||
|
token = _make_token("admin")
|
||||||
|
client.cookies.set("aegis_token", token)
|
||||||
|
response = client.post("/api/v1/auth/refresh")
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert "access_token" in response.json()
|
||||||
|
|
||||||
|
|
||||||
|
def test_refresh_recently_expired_token_succeeds_within_grace(client, admin_user):
|
||||||
|
"""A token that expired moments ago must still be refreshable.
|
||||||
|
|
||||||
|
This is the core of the refresh-token bug fix: without a grace window,
|
||||||
|
`/auth/refresh` decodes with the same strict expiry check as every
|
||||||
|
other endpoint, so a 401-triggered refresh attempt always also fails.
|
||||||
|
"""
|
||||||
|
token = _make_token("admin", expired_seconds_ago=60)
|
||||||
|
client.cookies.set("aegis_token", token)
|
||||||
|
response = client.post("/api/v1/auth/refresh")
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert "access_token" in response.json()
|
||||||
|
|
||||||
|
|
||||||
|
def test_refresh_long_expired_token_fails(client, admin_user):
|
||||||
|
token = _make_token("admin", expired_seconds_ago=60 * 60)
|
||||||
|
client.cookies.set("aegis_token", token)
|
||||||
|
response = client.post("/api/v1/auth/refresh")
|
||||||
|
assert response.status_code == 403
|
||||||
|
|
||||||
|
|
||||||
|
def test_refresh_blacklisted_token_fails_even_if_not_expired(client, admin_user):
|
||||||
|
from app.auth import blacklist_token
|
||||||
|
|
||||||
|
token = _make_token("admin")
|
||||||
|
payload = jwt.decode(token, settings.SECRET_KEY, algorithms=[settings.ALGORITHM])
|
||||||
|
blacklist_token(payload["jti"], float(payload["exp"]))
|
||||||
|
|
||||||
|
client.cookies.set("aegis_token", token)
|
||||||
|
response = client.post("/api/v1/auth/refresh")
|
||||||
|
assert response.status_code == 403
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ from app.models.audit import AuditLog
|
|||||||
def test_login_failed_creates_audit_entry(client, admin_user, db):
|
def test_login_failed_creates_audit_entry(client, admin_user, db):
|
||||||
response = client.post(
|
response = client.post(
|
||||||
"/api/v1/auth/login",
|
"/api/v1/auth/login",
|
||||||
data={"username": "admin", "password": "wrong"},
|
data={"username": "admin@test.com", "password": "wrong"},
|
||||||
headers={"X-Forwarded-For": "198.51.100.10", "User-Agent": "LoginAuditTest/1.0"},
|
headers={"X-Forwarded-For": "198.51.100.10", "User-Agent": "LoginAuditTest/1.0"},
|
||||||
)
|
)
|
||||||
assert response.status_code == 400
|
assert response.status_code == 400
|
||||||
@@ -19,7 +19,7 @@ def test_login_failed_creates_audit_entry(client, admin_user, db):
|
|||||||
)
|
)
|
||||||
assert log is not None
|
assert log is not None
|
||||||
assert log.entity_type == "auth"
|
assert log.entity_type == "auth"
|
||||||
assert log.details["username"] == "admin"
|
assert log.details["email"] == "admin@test.com"
|
||||||
assert log.details["reason"] == "invalid_credentials"
|
assert log.details["reason"] == "invalid_credentials"
|
||||||
assert log.ip_address == "198.51.100.10"
|
assert log.ip_address == "198.51.100.10"
|
||||||
assert log.user_agent == "LoginAuditTest/1.0"
|
assert log.user_agent == "LoginAuditTest/1.0"
|
||||||
@@ -30,7 +30,7 @@ def test_login_success_creates_audit_entry(client, admin_user, db):
|
|||||||
client.cookies.clear()
|
client.cookies.clear()
|
||||||
response = client.post(
|
response = client.post(
|
||||||
"/api/v1/auth/login",
|
"/api/v1/auth/login",
|
||||||
data={"username": "admin", "password": "admin123"},
|
data={"username": "admin@test.com", "password": "admin123"},
|
||||||
headers={"X-Forwarded-For": "198.51.100.20"},
|
headers={"X-Forwarded-For": "198.51.100.20"},
|
||||||
)
|
)
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
|
|||||||
@@ -0,0 +1,119 @@
|
|||||||
|
"""Regression: Red and Blue must see each other's fields at every stage.
|
||||||
|
|
||||||
|
Blind visibility (hiding the other team's fields until both reviews pass)
|
||||||
|
was a deliberate design in an earlier phase, but the org decided both teams
|
||||||
|
should see each other's work throughout — detection testing isn't meant to
|
||||||
|
be blind here. This confirms neither the frontend nor backend hides
|
||||||
|
anything based on role/state anymore.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from app.models.evidence import Evidence
|
||||||
|
from app.models.enums import TeamSide
|
||||||
|
|
||||||
|
_RED_FIELDS = [
|
||||||
|
"procedure_text", "tool_used", "attack_success",
|
||||||
|
"execution_start_time", "execution_end_time", "red_summary",
|
||||||
|
]
|
||||||
|
_BLUE_FIELDS = [
|
||||||
|
"detection_result", "containment_result", "detection_time",
|
||||||
|
"containment_time", "blue_summary", "system_gaps",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _add_evidence(db, test_id, team: TeamSide):
|
||||||
|
ev = Evidence(
|
||||||
|
test_id=uuid.UUID(test_id),
|
||||||
|
file_name="proof.txt",
|
||||||
|
file_path="s3://bucket/proof.txt",
|
||||||
|
sha256_hash="a" * 64,
|
||||||
|
team=team,
|
||||||
|
)
|
||||||
|
db.add(ev)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def technique(api, auth_headers):
|
||||||
|
resp = api(
|
||||||
|
"post", "/api/v1/techniques", auth_headers,
|
||||||
|
json={"mitre_id": "T1059.100", "name": "Command Line"},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 201, resp.text
|
||||||
|
return resp.json()["id"]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def in_progress_test(client, db, api, red_lead_headers, red_lead_user, red_tech_headers, technique):
|
||||||
|
"""A test in red_executing with red-side content already filled in."""
|
||||||
|
resp = api(
|
||||||
|
"post", "/api/v1/tests", red_lead_headers,
|
||||||
|
json={"technique_id": technique, "name": "Visibility test"},
|
||||||
|
)
|
||||||
|
test_id = resp.json()["id"]
|
||||||
|
|
||||||
|
api("post", f"/api/v1/tests/{test_id}/start-execution", red_tech_headers)
|
||||||
|
api(
|
||||||
|
"patch", f"/api/v1/tests/{test_id}/red", red_tech_headers,
|
||||||
|
json={"procedure_text": "run mimikatz", "tool_used": "mimikatz", "red_summary": "dumped creds"},
|
||||||
|
)
|
||||||
|
return test_id
|
||||||
|
|
||||||
|
|
||||||
|
def test_blue_viewer_sees_red_fields_during_red_executing(
|
||||||
|
client, db, api, in_progress_test, blue_tech_headers
|
||||||
|
):
|
||||||
|
resp = api("get", f"/api/v1/tests/{in_progress_test}", blue_tech_headers)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
body = resp.json()
|
||||||
|
assert body["procedure_text"] == "run mimikatz"
|
||||||
|
assert body["tool_used"] == "mimikatz"
|
||||||
|
assert body["red_summary"] == "dumped creds"
|
||||||
|
|
||||||
|
|
||||||
|
def test_red_viewer_sees_own_fields_during_red_executing(
|
||||||
|
client, db, api, in_progress_test, red_tech_headers
|
||||||
|
):
|
||||||
|
resp = api("get", f"/api/v1/tests/{in_progress_test}", red_tech_headers)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
body = resp.json()
|
||||||
|
assert body["procedure_text"] == "run mimikatz"
|
||||||
|
|
||||||
|
|
||||||
|
def test_both_sides_see_everything_once_in_review(
|
||||||
|
client, db, api, auth_headers, red_tech_headers, red_lead_headers,
|
||||||
|
blue_tech_headers, blue_lead_headers, red_lead_user, blue_lead_user, in_progress_test,
|
||||||
|
):
|
||||||
|
test_id = in_progress_test
|
||||||
|
_add_evidence(db, test_id, TeamSide.red)
|
||||||
|
submit_red = api("post", f"/api/v1/tests/{test_id}/submit-red", red_tech_headers)
|
||||||
|
assert submit_red.status_code == 200, submit_red.text
|
||||||
|
|
||||||
|
approve_red = api("post", f"/api/v1/tests/{test_id}/review-red", red_lead_headers, json={"decision": "approve"})
|
||||||
|
assert approve_red.status_code == 200, approve_red.text
|
||||||
|
|
||||||
|
api("post", f"/api/v1/tests/{test_id}/start-blue-work", blue_tech_headers)
|
||||||
|
api(
|
||||||
|
"patch", f"/api/v1/tests/{test_id}/blue", blue_tech_headers,
|
||||||
|
json={"detection_result": "detected", "blue_summary": "caught it"},
|
||||||
|
)
|
||||||
|
_add_evidence(db, test_id, TeamSide.blue)
|
||||||
|
submit_blue = api("post", f"/api/v1/tests/{test_id}/submit-blue", blue_tech_headers)
|
||||||
|
assert submit_blue.status_code == 200, submit_blue.text
|
||||||
|
|
||||||
|
approve_blue = api("post", f"/api/v1/tests/{test_id}/review-blue", blue_lead_headers, json={"decision": "approve"})
|
||||||
|
assert approve_blue.status_code == 200, approve_blue.text
|
||||||
|
assert approve_blue.json()["state"] == "in_review"
|
||||||
|
|
||||||
|
resp_as_red = api("get", f"/api/v1/tests/{test_id}", red_tech_headers)
|
||||||
|
body_red = resp_as_red.json()
|
||||||
|
assert body_red["detection_result"] == "detected"
|
||||||
|
assert body_red["blue_summary"] == "caught it"
|
||||||
|
|
||||||
|
resp_as_blue = api("get", f"/api/v1/tests/{test_id}", blue_tech_headers)
|
||||||
|
body_blue = resp_as_blue.json()
|
||||||
|
assert body_blue["procedure_text"] == "run mimikatz"
|
||||||
|
assert body_blue["red_summary"] == "dumped creds"
|
||||||
@@ -0,0 +1,406 @@
|
|||||||
|
"""Tests for the campaign manager-approval workflow."""
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from app.models.campaign import Campaign, CampaignModificationRequest, CampaignTest
|
||||||
|
from app.models.technique import Technique
|
||||||
|
from app.models.test import Test
|
||||||
|
from app.models.enums import TestState
|
||||||
|
from app.domain.errors import BusinessRuleViolation, EntityNotFoundError, PermissionViolation
|
||||||
|
from app.services.campaign_crud_service import (
|
||||||
|
create_campaign,
|
||||||
|
submit_campaign_for_approval,
|
||||||
|
approve_campaign,
|
||||||
|
reject_campaign,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def draft_campaign_with_test(db, admin_user):
|
||||||
|
"""A draft campaign with exactly one test, created by admin_user."""
|
||||||
|
tech = Technique(mitre_id="T1059", name="Command Line", tactic="execution", platforms=["windows"])
|
||||||
|
db.add(tech)
|
||||||
|
db.flush()
|
||||||
|
|
||||||
|
campaign = Campaign(name="Approval Test Campaign", type="custom", status="draft", created_by=admin_user.id)
|
||||||
|
db.add(campaign)
|
||||||
|
db.flush()
|
||||||
|
|
||||||
|
test = Test(technique_id=tech.id, name="T1059 test", state=TestState.draft, created_by=admin_user.id)
|
||||||
|
db.add(test)
|
||||||
|
db.flush()
|
||||||
|
|
||||||
|
ct = CampaignTest(campaign_id=campaign.id, test_id=test.id, order_index=0)
|
||||||
|
db.add(ct)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(campaign)
|
||||||
|
return campaign
|
||||||
|
|
||||||
|
|
||||||
|
class TestSubmitCampaignForApproval:
|
||||||
|
def test_submit_moves_draft_to_pending_approval(self, db, draft_campaign_with_test, admin_user):
|
||||||
|
campaign = submit_campaign_for_approval(
|
||||||
|
db, str(draft_campaign_with_test.id),
|
||||||
|
submitter_id=admin_user.id, submitter_role="admin",
|
||||||
|
)
|
||||||
|
assert campaign.status == "pending_approval"
|
||||||
|
|
||||||
|
def test_submit_without_tests_raises(self, db, admin_user):
|
||||||
|
campaign = Campaign(name="Empty", type="custom", status="draft", created_by=admin_user.id)
|
||||||
|
db.add(campaign)
|
||||||
|
db.commit()
|
||||||
|
with pytest.raises(BusinessRuleViolation, match="at least one test"):
|
||||||
|
submit_campaign_for_approval(
|
||||||
|
db, str(campaign.id), submitter_id=admin_user.id, submitter_role="admin",
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_submit_by_non_creator_non_admin_raises(self, db, draft_campaign_with_test, red_lead_user):
|
||||||
|
with pytest.raises(PermissionViolation):
|
||||||
|
submit_campaign_for_approval(
|
||||||
|
db, str(draft_campaign_with_test.id),
|
||||||
|
submitter_id=red_lead_user.id, submitter_role="red_lead",
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_submit_non_draft_raises(self, db, draft_campaign_with_test, admin_user):
|
||||||
|
draft_campaign_with_test.status = "active"
|
||||||
|
db.commit()
|
||||||
|
with pytest.raises(BusinessRuleViolation, match="Only draft campaigns"):
|
||||||
|
submit_campaign_for_approval(
|
||||||
|
db, str(draft_campaign_with_test.id),
|
||||||
|
submitter_id=admin_user.id, submitter_role="admin",
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_submit_nonexistent_campaign_raises(self, db, admin_user):
|
||||||
|
with pytest.raises(EntityNotFoundError):
|
||||||
|
submit_campaign_for_approval(
|
||||||
|
db, str(uuid.uuid4()), submitter_id=admin_user.id, submitter_role="admin",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestApproveCampaign:
|
||||||
|
def test_approve_sets_start_date_and_activates(self, db, draft_campaign_with_test, admin_user):
|
||||||
|
submit_campaign_for_approval(
|
||||||
|
db, str(draft_campaign_with_test.id), submitter_id=admin_user.id, submitter_role="admin",
|
||||||
|
)
|
||||||
|
db.commit()
|
||||||
|
campaign = approve_campaign(
|
||||||
|
db, str(draft_campaign_with_test.id),
|
||||||
|
approver_id=admin_user.id, start_date="2026-08-01T00:00:00",
|
||||||
|
)
|
||||||
|
assert campaign.status == "active"
|
||||||
|
assert campaign.start_date is not None
|
||||||
|
assert campaign.approved_by == admin_user.id
|
||||||
|
assert campaign.approved_at is not None
|
||||||
|
|
||||||
|
def test_approve_without_start_date_raises(self, db, draft_campaign_with_test, admin_user):
|
||||||
|
submit_campaign_for_approval(
|
||||||
|
db, str(draft_campaign_with_test.id), submitter_id=admin_user.id, submitter_role="admin",
|
||||||
|
)
|
||||||
|
db.commit()
|
||||||
|
with pytest.raises(BusinessRuleViolation, match="start_date is required"):
|
||||||
|
approve_campaign(db, str(draft_campaign_with_test.id), approver_id=admin_user.id, start_date="")
|
||||||
|
|
||||||
|
def test_approve_non_pending_raises(self, db, draft_campaign_with_test, admin_user):
|
||||||
|
with pytest.raises(BusinessRuleViolation, match="pending approval"):
|
||||||
|
approve_campaign(
|
||||||
|
db, str(draft_campaign_with_test.id),
|
||||||
|
approver_id=admin_user.id, start_date="2026-08-01T00:00:00",
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_approve_nonexistent_campaign_raises(self, db, admin_user):
|
||||||
|
with pytest.raises(EntityNotFoundError):
|
||||||
|
approve_campaign(
|
||||||
|
db, str(uuid.uuid4()), approver_id=admin_user.id, start_date="2026-08-01T00:00:00",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestRejectCampaign:
|
||||||
|
def test_reject_returns_to_draft_with_reason(self, db, draft_campaign_with_test, admin_user):
|
||||||
|
submit_campaign_for_approval(
|
||||||
|
db, str(draft_campaign_with_test.id), submitter_id=admin_user.id, submitter_role="admin",
|
||||||
|
)
|
||||||
|
db.commit()
|
||||||
|
campaign = reject_campaign(
|
||||||
|
db, str(draft_campaign_with_test.id),
|
||||||
|
rejecter_id=admin_user.id, reason="Missing scope details",
|
||||||
|
)
|
||||||
|
assert campaign.status == "draft"
|
||||||
|
assert campaign.rejection_reason == "Missing scope details"
|
||||||
|
|
||||||
|
def test_reject_without_reason_raises(self, db, draft_campaign_with_test, admin_user):
|
||||||
|
submit_campaign_for_approval(
|
||||||
|
db, str(draft_campaign_with_test.id), submitter_id=admin_user.id, submitter_role="admin",
|
||||||
|
)
|
||||||
|
db.commit()
|
||||||
|
with pytest.raises(BusinessRuleViolation, match="reason is required"):
|
||||||
|
reject_campaign(db, str(draft_campaign_with_test.id), rejecter_id=admin_user.id, reason="")
|
||||||
|
|
||||||
|
def test_reject_non_pending_raises(self, db, draft_campaign_with_test, admin_user):
|
||||||
|
with pytest.raises(BusinessRuleViolation, match="pending approval"):
|
||||||
|
reject_campaign(db, str(draft_campaign_with_test.id), rejecter_id=admin_user.id, reason="No")
|
||||||
|
|
||||||
|
def test_reject_clears_prior_approval_fields(self, db, draft_campaign_with_test, admin_user):
|
||||||
|
submit_campaign_for_approval(
|
||||||
|
db, str(draft_campaign_with_test.id), submitter_id=admin_user.id, submitter_role="admin",
|
||||||
|
)
|
||||||
|
db.commit()
|
||||||
|
approve_campaign(
|
||||||
|
db, str(draft_campaign_with_test.id),
|
||||||
|
approver_id=admin_user.id, start_date="2026-08-01T00:00:00",
|
||||||
|
)
|
||||||
|
db.commit()
|
||||||
|
# Simulate the campaign being sent back into the approval queue
|
||||||
|
# (e.g. after a modification request) so reject_campaign has
|
||||||
|
# approved_by/approved_at to clear.
|
||||||
|
draft_campaign_with_test.status = "pending_approval"
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
campaign = reject_campaign(
|
||||||
|
db, str(draft_campaign_with_test.id),
|
||||||
|
rejecter_id=admin_user.id, reason="Needs rework",
|
||||||
|
)
|
||||||
|
assert campaign.status == "draft"
|
||||||
|
assert campaign.approved_by is None
|
||||||
|
assert campaign.approved_at is None
|
||||||
|
|
||||||
|
def test_reject_nonexistent_campaign_raises(self, db, admin_user):
|
||||||
|
with pytest.raises(EntityNotFoundError):
|
||||||
|
reject_campaign(db, str(uuid.uuid4()), rejecter_id=admin_user.id, reason="No")
|
||||||
|
|
||||||
|
|
||||||
|
from app.services.campaign_crud_service import (
|
||||||
|
add_test_to_campaign,
|
||||||
|
remove_test_from_campaign,
|
||||||
|
create_modification_request,
|
||||||
|
approve_modification_request,
|
||||||
|
reject_modification_request,
|
||||||
|
list_modification_requests,
|
||||||
|
serialize_modification_request,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def active_campaign_with_test(db, draft_campaign_with_test, admin_user):
|
||||||
|
"""An active campaign (bypasses the approval flow directly for test setup)."""
|
||||||
|
draft_campaign_with_test.status = "active"
|
||||||
|
db.commit()
|
||||||
|
db.refresh(draft_campaign_with_test)
|
||||||
|
return draft_campaign_with_test
|
||||||
|
|
||||||
|
|
||||||
|
class TestDirectTestEditingGate:
|
||||||
|
def test_add_test_blocked_on_active_campaign(self, db, active_campaign_with_test, admin_user):
|
||||||
|
tech = Technique(mitre_id="T1078", name="Valid Accounts", tactic="persistence", platforms=["windows"])
|
||||||
|
db.add(tech)
|
||||||
|
db.flush()
|
||||||
|
new_test = Test(technique_id=tech.id, name="New test", state=TestState.draft, created_by=admin_user.id)
|
||||||
|
db.add(new_test)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
with pytest.raises(BusinessRuleViolation, match="draft"):
|
||||||
|
add_test_to_campaign(db, str(active_campaign_with_test.id), test_id=str(new_test.id))
|
||||||
|
|
||||||
|
def test_remove_test_blocked_on_active_campaign(self, db, active_campaign_with_test):
|
||||||
|
ct = db.query(CampaignTest).filter(CampaignTest.campaign_id == active_campaign_with_test.id).first()
|
||||||
|
with pytest.raises(BusinessRuleViolation, match="draft"):
|
||||||
|
remove_test_from_campaign(db, str(active_campaign_with_test.id), str(ct.id))
|
||||||
|
|
||||||
|
|
||||||
|
class TestModificationRequests:
|
||||||
|
def test_create_add_test_request(self, db, active_campaign_with_test, admin_user):
|
||||||
|
tech = Technique(mitre_id="T1547", name="Boot Autostart", tactic="persistence", platforms=["windows"])
|
||||||
|
db.add(tech)
|
||||||
|
db.flush()
|
||||||
|
new_test = Test(technique_id=tech.id, name="Autostart test", state=TestState.draft, created_by=admin_user.id)
|
||||||
|
db.add(new_test)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
request = create_modification_request(
|
||||||
|
db, str(active_campaign_with_test.id),
|
||||||
|
requester_id=admin_user.id, action="add_test",
|
||||||
|
test_id=str(new_test.id), justification="Coverage gap found in review",
|
||||||
|
)
|
||||||
|
assert request.status == "pending"
|
||||||
|
|
||||||
|
# Underlying campaign is untouched until approval
|
||||||
|
cts = db.query(CampaignTest).filter(CampaignTest.campaign_id == active_campaign_with_test.id).all()
|
||||||
|
assert len(cts) == 1
|
||||||
|
|
||||||
|
def test_create_request_without_justification_raises(self, db, active_campaign_with_test, admin_user):
|
||||||
|
ct = db.query(CampaignTest).filter(CampaignTest.campaign_id == active_campaign_with_test.id).first()
|
||||||
|
with pytest.raises(BusinessRuleViolation, match="justification is required"):
|
||||||
|
create_modification_request(
|
||||||
|
db, str(active_campaign_with_test.id),
|
||||||
|
requester_id=admin_user.id, action="remove_test",
|
||||||
|
test_id=str(ct.test_id), justification="",
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_create_request_on_non_active_campaign_raises(self, db, draft_campaign_with_test, admin_user):
|
||||||
|
ct = db.query(CampaignTest).filter(CampaignTest.campaign_id == draft_campaign_with_test.id).first()
|
||||||
|
with pytest.raises(BusinessRuleViolation, match="active campaigns"):
|
||||||
|
create_modification_request(
|
||||||
|
db, str(draft_campaign_with_test.id),
|
||||||
|
requester_id=admin_user.id, action="remove_test",
|
||||||
|
test_id=str(ct.test_id), justification="Not needed",
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_approve_add_test_request_applies_change(self, db, active_campaign_with_test, admin_user):
|
||||||
|
tech = Technique(mitre_id="T1547", name="Boot Autostart", tactic="persistence", platforms=["windows"])
|
||||||
|
db.add(tech)
|
||||||
|
db.flush()
|
||||||
|
new_test = Test(technique_id=tech.id, name="Autostart test", state=TestState.draft, created_by=admin_user.id)
|
||||||
|
db.add(new_test)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
request = create_modification_request(
|
||||||
|
db, str(active_campaign_with_test.id),
|
||||||
|
requester_id=admin_user.id, action="add_test",
|
||||||
|
test_id=str(new_test.id), justification="Coverage gap found in review",
|
||||||
|
)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
approved = approve_modification_request(db, str(request.id), reviewer_id=admin_user.id)
|
||||||
|
assert approved.status == "approved"
|
||||||
|
|
||||||
|
cts = db.query(CampaignTest).filter(CampaignTest.campaign_id == active_campaign_with_test.id).all()
|
||||||
|
assert len(cts) == 2
|
||||||
|
assert any(ct.test_id == new_test.id for ct in cts)
|
||||||
|
|
||||||
|
def test_approve_remove_test_request_applies_change(self, db, active_campaign_with_test, admin_user):
|
||||||
|
ct = db.query(CampaignTest).filter(CampaignTest.campaign_id == active_campaign_with_test.id).first()
|
||||||
|
request = create_modification_request(
|
||||||
|
db, str(active_campaign_with_test.id),
|
||||||
|
requester_id=admin_user.id, action="remove_test",
|
||||||
|
test_id=str(ct.test_id), justification="Test superseded",
|
||||||
|
)
|
||||||
|
db.commit()
|
||||||
|
request_id = request.id
|
||||||
|
|
||||||
|
approve_modification_request(db, str(request_id), reviewer_id=admin_user.id)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
cts = db.query(CampaignTest).filter(CampaignTest.campaign_id == active_campaign_with_test.id).all()
|
||||||
|
assert len(cts) == 0
|
||||||
|
|
||||||
|
# The request row must survive deletion of the underlying Test — this
|
||||||
|
# locks in test_id's ondelete="SET NULL" (not CASCADE), which is what
|
||||||
|
# keeps this audit record intact after the test it referenced is gone.
|
||||||
|
# (Commit above first: ON DELETE SET NULL is applied DB-side by the
|
||||||
|
# cascade, and the ORM only picks that up on already-loaded objects
|
||||||
|
# after expire_on_commit forces a fresh read — exactly what happens
|
||||||
|
# for real in the router, which commits before serializing.)
|
||||||
|
reloaded = (
|
||||||
|
db.query(CampaignModificationRequest)
|
||||||
|
.filter(CampaignModificationRequest.id == request_id)
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
assert reloaded is not None
|
||||||
|
assert reloaded.status == "approved"
|
||||||
|
assert reloaded.reviewed_by == admin_user.id
|
||||||
|
assert reloaded.reviewed_at is not None
|
||||||
|
|
||||||
|
# Serialization must not crash and must emit a real null, not the
|
||||||
|
# string "None", for a request whose test_id has been nulled out.
|
||||||
|
assert reloaded.test_id is None
|
||||||
|
serialized = serialize_modification_request(db, reloaded)
|
||||||
|
assert serialized["test_id"] is None
|
||||||
|
assert serialized["test_name"] is None
|
||||||
|
|
||||||
|
def test_approve_already_decided_request_raises(self, db, active_campaign_with_test, admin_user):
|
||||||
|
ct = db.query(CampaignTest).filter(CampaignTest.campaign_id == active_campaign_with_test.id).first()
|
||||||
|
request = create_modification_request(
|
||||||
|
db, str(active_campaign_with_test.id),
|
||||||
|
requester_id=admin_user.id, action="remove_test",
|
||||||
|
test_id=str(ct.test_id), justification="Test superseded",
|
||||||
|
)
|
||||||
|
db.commit()
|
||||||
|
approve_modification_request(db, str(request.id), reviewer_id=admin_user.id)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
with pytest.raises(BusinessRuleViolation, match="Only pending"):
|
||||||
|
approve_modification_request(db, str(request.id), reviewer_id=admin_user.id)
|
||||||
|
|
||||||
|
def test_reject_request_leaves_campaign_untouched(self, db, active_campaign_with_test, admin_user):
|
||||||
|
ct = db.query(CampaignTest).filter(CampaignTest.campaign_id == active_campaign_with_test.id).first()
|
||||||
|
request = create_modification_request(
|
||||||
|
db, str(active_campaign_with_test.id),
|
||||||
|
requester_id=admin_user.id, action="remove_test",
|
||||||
|
test_id=str(ct.test_id), justification="Test superseded",
|
||||||
|
)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
rejected = reject_modification_request(
|
||||||
|
db, str(request.id), reviewer_id=admin_user.id, review_notes="Still needed for coverage",
|
||||||
|
)
|
||||||
|
assert rejected.status == "rejected"
|
||||||
|
assert rejected.review_notes == "Still needed for coverage"
|
||||||
|
|
||||||
|
cts = db.query(CampaignTest).filter(CampaignTest.campaign_id == active_campaign_with_test.id).all()
|
||||||
|
assert len(cts) == 1
|
||||||
|
|
||||||
|
def test_reject_without_notes_raises(self, db, active_campaign_with_test, admin_user):
|
||||||
|
ct = db.query(CampaignTest).filter(CampaignTest.campaign_id == active_campaign_with_test.id).first()
|
||||||
|
request = create_modification_request(
|
||||||
|
db, str(active_campaign_with_test.id),
|
||||||
|
requester_id=admin_user.id, action="remove_test",
|
||||||
|
test_id=str(ct.test_id), justification="Test superseded",
|
||||||
|
)
|
||||||
|
db.commit()
|
||||||
|
with pytest.raises(BusinessRuleViolation, match="Review notes are required"):
|
||||||
|
reject_modification_request(db, str(request.id), reviewer_id=admin_user.id, review_notes="")
|
||||||
|
|
||||||
|
def test_reject_already_decided_request_raises(self, db, active_campaign_with_test, admin_user):
|
||||||
|
ct = db.query(CampaignTest).filter(CampaignTest.campaign_id == active_campaign_with_test.id).first()
|
||||||
|
request = create_modification_request(
|
||||||
|
db, str(active_campaign_with_test.id),
|
||||||
|
requester_id=admin_user.id, action="remove_test",
|
||||||
|
test_id=str(ct.test_id), justification="Test superseded",
|
||||||
|
)
|
||||||
|
db.commit()
|
||||||
|
reject_modification_request(db, str(request.id), reviewer_id=admin_user.id, review_notes="Not needed")
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
with pytest.raises(BusinessRuleViolation, match="Only pending"):
|
||||||
|
reject_modification_request(db, str(request.id), reviewer_id=admin_user.id, review_notes="Again")
|
||||||
|
|
||||||
|
def test_list_modification_requests_filters_by_status(self, db, active_campaign_with_test, admin_user):
|
||||||
|
ct = db.query(CampaignTest).filter(CampaignTest.campaign_id == active_campaign_with_test.id).first()
|
||||||
|
create_modification_request(
|
||||||
|
db, str(active_campaign_with_test.id),
|
||||||
|
requester_id=admin_user.id, action="remove_test",
|
||||||
|
test_id=str(ct.test_id), justification="Test superseded",
|
||||||
|
)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
pending = list_modification_requests(db, status="pending")
|
||||||
|
assert len(pending) == 1
|
||||||
|
assert pending[0]["action"] == "remove_test"
|
||||||
|
|
||||||
|
|
||||||
|
from app.services.audit_service import log_action
|
||||||
|
from app.services.campaign_crud_service import get_campaign_timeline
|
||||||
|
|
||||||
|
|
||||||
|
class TestCampaignTimeline:
|
||||||
|
def test_timeline_returns_entries_in_chronological_order(self, db, draft_campaign_with_test, admin_user):
|
||||||
|
log_action(
|
||||||
|
db, user_id=admin_user.id, action="create_campaign",
|
||||||
|
entity_type="campaign", entity_id=str(draft_campaign_with_test.id), details={},
|
||||||
|
)
|
||||||
|
log_action(
|
||||||
|
db, user_id=admin_user.id, action="submit_campaign_for_approval",
|
||||||
|
entity_type="campaign", entity_id=str(draft_campaign_with_test.id), details={},
|
||||||
|
)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
timeline = get_campaign_timeline(db, str(draft_campaign_with_test.id))
|
||||||
|
assert len(timeline) == 2
|
||||||
|
assert timeline[0]["action"] == "create_campaign"
|
||||||
|
assert timeline[1]["action"] == "submit_campaign_for_approval"
|
||||||
|
|
||||||
|
def test_timeline_missing_campaign_raises(self, db):
|
||||||
|
import uuid as uuid_mod
|
||||||
|
with pytest.raises(EntityNotFoundError):
|
||||||
|
get_campaign_timeline(db, str(uuid_mod.uuid4()))
|
||||||
@@ -0,0 +1,472 @@
|
|||||||
|
"""Router-level tests for the campaign manager-approval workflow."""
|
||||||
|
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
from app.models.campaign import Campaign, CampaignTest
|
||||||
|
from app.models.technique import Technique
|
||||||
|
from app.models.test import Test
|
||||||
|
from app.models.enums import TestState
|
||||||
|
|
||||||
|
|
||||||
|
def _make_draft_campaign(db, owner_id):
|
||||||
|
tech = Technique(mitre_id="T1059", name="Command Line", tactic="execution", platforms=["windows"])
|
||||||
|
db.add(tech)
|
||||||
|
db.flush()
|
||||||
|
campaign = Campaign(name="Router Test Campaign", type="custom", status="draft", created_by=owner_id)
|
||||||
|
db.add(campaign)
|
||||||
|
db.flush()
|
||||||
|
test = Test(technique_id=tech.id, name="T1059 test", state=TestState.draft, created_by=owner_id)
|
||||||
|
db.add(test)
|
||||||
|
db.flush()
|
||||||
|
db.add(CampaignTest(campaign_id=campaign.id, test_id=test.id, order_index=0))
|
||||||
|
db.commit()
|
||||||
|
db.refresh(campaign)
|
||||||
|
return campaign
|
||||||
|
|
||||||
|
|
||||||
|
def test_lead_can_submit_own_campaign(api, db, red_lead_user, red_lead_headers):
|
||||||
|
campaign = _make_draft_campaign(db, red_lead_user.id)
|
||||||
|
resp = api("post", f"/api/v1/campaigns/{campaign.id}/submit", red_lead_headers)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert resp.json()["status"] == "pending_approval"
|
||||||
|
|
||||||
|
|
||||||
|
def test_red_tech_cannot_submit(api, db, red_lead_user, red_tech_headers):
|
||||||
|
campaign = _make_draft_campaign(db, red_lead_user.id)
|
||||||
|
resp = api("post", f"/api/v1/campaigns/{campaign.id}/submit", red_tech_headers)
|
||||||
|
assert resp.status_code == 403
|
||||||
|
|
||||||
|
|
||||||
|
def test_manager_can_approve_and_sets_start_date(api, db, red_lead_user, red_lead_headers, manager_headers):
|
||||||
|
campaign = _make_draft_campaign(db, red_lead_user.id)
|
||||||
|
api("post", f"/api/v1/campaigns/{campaign.id}/submit", red_lead_headers)
|
||||||
|
|
||||||
|
resp = api(
|
||||||
|
"post",
|
||||||
|
f"/api/v1/campaigns/{campaign.id}/approve",
|
||||||
|
manager_headers,
|
||||||
|
json={"start_date": "2020-01-01T00:00:00"},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
body = resp.json()
|
||||||
|
assert body["status"] == "active"
|
||||||
|
assert body["start_date"] is not None
|
||||||
|
|
||||||
|
|
||||||
|
def test_manager_approval_creates_jira_tickets_when_start_date_is_due(
|
||||||
|
api, db, red_lead_user, red_lead_headers, manager_headers
|
||||||
|
):
|
||||||
|
"""The normal manager-approval path must create Jira tickets too, not just
|
||||||
|
the admin-only emergency /activate override — this was the Block 1 gap:
|
||||||
|
campaigns approved through the standard flow never got a Jira ticket.
|
||||||
|
Uses a past start_date so ticket creation is due immediately."""
|
||||||
|
campaign = _make_draft_campaign(db, red_lead_user.id)
|
||||||
|
api("post", f"/api/v1/campaigns/{campaign.id}/submit", red_lead_headers)
|
||||||
|
|
||||||
|
with patch("app.services.jira_service.get_campaign_jira_key", return_value=None) as mock_get_key, \
|
||||||
|
patch("app.services.jira_service.auto_create_campaign_issue", return_value="PT-100") as mock_create_campaign, \
|
||||||
|
patch("app.services.jira_service.get_test_jira_key", return_value=None), \
|
||||||
|
patch("app.services.jira_service.auto_create_test_issue") as mock_create_test:
|
||||||
|
resp = api(
|
||||||
|
"post",
|
||||||
|
f"/api/v1/campaigns/{campaign.id}/approve",
|
||||||
|
manager_headers,
|
||||||
|
json={"start_date": "2020-01-01T00:00:00"},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert resp.status_code == 200
|
||||||
|
mock_get_key.assert_called_once()
|
||||||
|
mock_create_campaign.assert_called_once()
|
||||||
|
mock_create_test.assert_called_once()
|
||||||
|
|
||||||
|
|
||||||
|
def test_manager_approval_skips_jira_tickets_when_start_date_is_future(
|
||||||
|
api, db, red_lead_user, red_lead_headers, manager_headers
|
||||||
|
):
|
||||||
|
"""A campaign scheduled to start in the future must not get its Jira
|
||||||
|
tickets created at approval time — that must wait for the periodic
|
||||||
|
due-campaign sync job, once the scheduled start_date actually arrives."""
|
||||||
|
campaign = _make_draft_campaign(db, red_lead_user.id)
|
||||||
|
api("post", f"/api/v1/campaigns/{campaign.id}/submit", red_lead_headers)
|
||||||
|
|
||||||
|
with patch("app.services.jira_service.get_campaign_jira_key", return_value=None) as mock_get_key, \
|
||||||
|
patch("app.services.jira_service.auto_create_campaign_issue", return_value="PT-100") as mock_create_campaign:
|
||||||
|
resp = api(
|
||||||
|
"post",
|
||||||
|
f"/api/v1/campaigns/{campaign.id}/approve",
|
||||||
|
manager_headers,
|
||||||
|
json={"start_date": "2099-01-01T00:00:00"},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert resp.status_code == 200
|
||||||
|
mock_get_key.assert_not_called()
|
||||||
|
mock_create_campaign.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
def test_lead_cannot_approve(api, db, red_lead_user, red_lead_headers):
|
||||||
|
campaign = _make_draft_campaign(db, red_lead_user.id)
|
||||||
|
api("post", f"/api/v1/campaigns/{campaign.id}/submit", red_lead_headers)
|
||||||
|
|
||||||
|
resp = api(
|
||||||
|
"post",
|
||||||
|
f"/api/v1/campaigns/{campaign.id}/approve",
|
||||||
|
red_lead_headers,
|
||||||
|
json={"start_date": "2026-09-01T00:00:00"},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 403
|
||||||
|
|
||||||
|
|
||||||
|
def test_admin_can_approve_as_manager_backup(api, db, red_lead_user, red_lead_headers, auth_headers):
|
||||||
|
campaign = _make_draft_campaign(db, red_lead_user.id)
|
||||||
|
api("post", f"/api/v1/campaigns/{campaign.id}/submit", red_lead_headers)
|
||||||
|
|
||||||
|
resp = api(
|
||||||
|
"post",
|
||||||
|
f"/api/v1/campaigns/{campaign.id}/approve",
|
||||||
|
auth_headers,
|
||||||
|
json={"start_date": "2026-09-01T00:00:00"},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
|
||||||
|
|
||||||
|
def test_manager_can_reject_with_reason(api, db, red_lead_user, red_lead_headers, manager_headers):
|
||||||
|
campaign = _make_draft_campaign(db, red_lead_user.id)
|
||||||
|
api("post", f"/api/v1/campaigns/{campaign.id}/submit", red_lead_headers)
|
||||||
|
|
||||||
|
resp = api(
|
||||||
|
"post",
|
||||||
|
f"/api/v1/campaigns/{campaign.id}/reject",
|
||||||
|
manager_headers,
|
||||||
|
json={"reason": "Needs more detail"},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert resp.json()["status"] == "draft"
|
||||||
|
|
||||||
|
|
||||||
|
def test_manager_reject_without_reason_rejected_by_validation(api, db, red_lead_user, red_lead_headers, manager_headers):
|
||||||
|
campaign = _make_draft_campaign(db, red_lead_user.id)
|
||||||
|
api("post", f"/api/v1/campaigns/{campaign.id}/submit", red_lead_headers)
|
||||||
|
|
||||||
|
resp = api(
|
||||||
|
"post",
|
||||||
|
f"/api/v1/campaigns/{campaign.id}/reject",
|
||||||
|
manager_headers,
|
||||||
|
json={"reason": ""},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 400
|
||||||
|
|
||||||
|
|
||||||
|
def _activate_campaign_directly(db, campaign):
|
||||||
|
"""Test helper — bypass the approval flow to get straight to 'active' for setup."""
|
||||||
|
campaign.status = "active"
|
||||||
|
db.commit()
|
||||||
|
db.refresh(campaign)
|
||||||
|
return campaign
|
||||||
|
|
||||||
|
|
||||||
|
def test_lead_can_create_modification_request(api, db, red_lead_user, red_lead_headers):
|
||||||
|
campaign = _make_draft_campaign(db, red_lead_user.id)
|
||||||
|
_activate_campaign_directly(db, campaign)
|
||||||
|
|
||||||
|
tech = Technique(mitre_id="T1547", name="Boot Autostart", tactic="persistence", platforms=["windows"])
|
||||||
|
db.add(tech)
|
||||||
|
db.flush()
|
||||||
|
new_test = Test(technique_id=tech.id, name="Autostart test", state=TestState.draft, created_by=red_lead_user.id)
|
||||||
|
db.add(new_test)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
resp = api(
|
||||||
|
"post",
|
||||||
|
f"/api/v1/campaigns/{campaign.id}/modification-requests",
|
||||||
|
red_lead_headers,
|
||||||
|
json={"action": "add_test", "test_id": str(new_test.id), "justification": "Coverage gap"},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 201
|
||||||
|
assert resp.json()["status"] == "pending"
|
||||||
|
|
||||||
|
|
||||||
|
def test_modification_request_without_justification_rejected(api, db, red_lead_user, red_lead_headers):
|
||||||
|
campaign = _make_draft_campaign(db, red_lead_user.id)
|
||||||
|
_activate_campaign_directly(db, campaign)
|
||||||
|
ct = db.query(CampaignTest).filter(CampaignTest.campaign_id == campaign.id).first()
|
||||||
|
|
||||||
|
resp = api(
|
||||||
|
"post",
|
||||||
|
f"/api/v1/campaigns/{campaign.id}/modification-requests",
|
||||||
|
red_lead_headers,
|
||||||
|
json={"action": "remove_test", "test_id": str(ct.test_id), "justification": ""},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 400
|
||||||
|
|
||||||
|
|
||||||
|
def test_manager_can_list_pending_modification_requests(api, db, red_lead_user, red_lead_headers, manager_headers):
|
||||||
|
campaign = _make_draft_campaign(db, red_lead_user.id)
|
||||||
|
_activate_campaign_directly(db, campaign)
|
||||||
|
ct = db.query(CampaignTest).filter(CampaignTest.campaign_id == campaign.id).first()
|
||||||
|
|
||||||
|
api(
|
||||||
|
"post",
|
||||||
|
f"/api/v1/campaigns/{campaign.id}/modification-requests",
|
||||||
|
red_lead_headers,
|
||||||
|
json={"action": "remove_test", "test_id": str(ct.test_id), "justification": "Superseded"},
|
||||||
|
)
|
||||||
|
|
||||||
|
resp = api("get", "/api/v1/campaigns/modification-requests/pending", manager_headers)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert len(resp.json()) == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_manager_can_approve_modification_request(api, db, red_lead_user, red_lead_headers, manager_headers):
|
||||||
|
campaign = _make_draft_campaign(db, red_lead_user.id)
|
||||||
|
_activate_campaign_directly(db, campaign)
|
||||||
|
ct = db.query(CampaignTest).filter(CampaignTest.campaign_id == campaign.id).first()
|
||||||
|
|
||||||
|
create_resp = api(
|
||||||
|
"post",
|
||||||
|
f"/api/v1/campaigns/{campaign.id}/modification-requests",
|
||||||
|
red_lead_headers,
|
||||||
|
json={"action": "remove_test", "test_id": str(ct.test_id), "justification": "Superseded"},
|
||||||
|
)
|
||||||
|
request_id = create_resp.json()["id"]
|
||||||
|
|
||||||
|
resp = api(
|
||||||
|
"post",
|
||||||
|
f"/api/v1/campaigns/modification-requests/{request_id}/approve",
|
||||||
|
manager_headers,
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert resp.json()["status"] == "approved"
|
||||||
|
|
||||||
|
remaining = db.query(CampaignTest).filter(CampaignTest.campaign_id == campaign.id).all()
|
||||||
|
assert len(remaining) == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_manager_can_reject_modification_request(api, db, red_lead_user, red_lead_headers, manager_headers):
|
||||||
|
campaign = _make_draft_campaign(db, red_lead_user.id)
|
||||||
|
_activate_campaign_directly(db, campaign)
|
||||||
|
ct = db.query(CampaignTest).filter(CampaignTest.campaign_id == campaign.id).first()
|
||||||
|
|
||||||
|
create_resp = api(
|
||||||
|
"post",
|
||||||
|
f"/api/v1/campaigns/{campaign.id}/modification-requests",
|
||||||
|
red_lead_headers,
|
||||||
|
json={"action": "remove_test", "test_id": str(ct.test_id), "justification": "Superseded"},
|
||||||
|
)
|
||||||
|
request_id = create_resp.json()["id"]
|
||||||
|
|
||||||
|
resp = api(
|
||||||
|
"post",
|
||||||
|
f"/api/v1/campaigns/modification-requests/{request_id}/reject",
|
||||||
|
manager_headers,
|
||||||
|
json={"review_notes": "Still needed"},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert resp.json()["status"] == "rejected"
|
||||||
|
|
||||||
|
remaining = db.query(CampaignTest).filter(CampaignTest.campaign_id == campaign.id).all()
|
||||||
|
assert len(remaining) == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_lead_cannot_approve_modification_request(api, db, red_lead_user, red_lead_headers):
|
||||||
|
campaign = _make_draft_campaign(db, red_lead_user.id)
|
||||||
|
_activate_campaign_directly(db, campaign)
|
||||||
|
ct = db.query(CampaignTest).filter(CampaignTest.campaign_id == campaign.id).first()
|
||||||
|
|
||||||
|
create_resp = api(
|
||||||
|
"post",
|
||||||
|
f"/api/v1/campaigns/{campaign.id}/modification-requests",
|
||||||
|
red_lead_headers,
|
||||||
|
json={"action": "remove_test", "test_id": str(ct.test_id), "justification": "Superseded"},
|
||||||
|
)
|
||||||
|
request_id = create_resp.json()["id"]
|
||||||
|
|
||||||
|
resp = api(
|
||||||
|
"post",
|
||||||
|
f"/api/v1/campaigns/modification-requests/{request_id}/approve",
|
||||||
|
red_lead_headers,
|
||||||
|
)
|
||||||
|
assert resp.status_code == 403
|
||||||
|
|
||||||
|
|
||||||
|
def test_direct_add_test_blocked_on_active_campaign_via_router(api, db, red_lead_user, red_lead_headers):
|
||||||
|
campaign = _make_draft_campaign(db, red_lead_user.id)
|
||||||
|
_activate_campaign_directly(db, campaign)
|
||||||
|
|
||||||
|
tech = Technique(mitre_id="T1547", name="Boot Autostart", tactic="persistence", platforms=["windows"])
|
||||||
|
db.add(tech)
|
||||||
|
db.flush()
|
||||||
|
new_test = Test(technique_id=tech.id, name="Autostart test", state=TestState.draft, created_by=red_lead_user.id)
|
||||||
|
db.add(new_test)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
resp = api(
|
||||||
|
"post",
|
||||||
|
f"/api/v1/campaigns/{campaign.id}/tests",
|
||||||
|
red_lead_headers,
|
||||||
|
json={"test_id": str(new_test.id)},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 400
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_campaign_timeline(api, db, red_lead_user, red_lead_headers):
|
||||||
|
campaign = _make_draft_campaign(db, red_lead_user.id)
|
||||||
|
api("post", f"/api/v1/campaigns/{campaign.id}/submit", red_lead_headers)
|
||||||
|
|
||||||
|
resp = api("get", f"/api/v1/campaigns/{campaign.id}/timeline", red_lead_headers)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
actions = [e["action"] for e in resp.json()]
|
||||||
|
assert "submit_campaign_for_approval" in actions
|
||||||
|
|
||||||
|
|
||||||
|
def test_lead_cannot_directly_activate_draft_campaign(api, db, red_lead_user, red_lead_headers):
|
||||||
|
campaign = _make_draft_campaign(db, red_lead_user.id)
|
||||||
|
resp = api("post", f"/api/v1/campaigns/{campaign.id}/activate", red_lead_headers)
|
||||||
|
assert resp.status_code == 403
|
||||||
|
|
||||||
|
|
||||||
|
def test_admin_can_still_directly_activate_as_override(api, db, red_lead_user, red_lead_headers, auth_headers):
|
||||||
|
campaign = _make_draft_campaign(db, red_lead_user.id)
|
||||||
|
resp = api("post", f"/api/v1/campaigns/{campaign.id}/activate", auth_headers)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert resp.json()["status"] == "active"
|
||||||
|
|
||||||
|
|
||||||
|
def test_manager_cannot_directly_activate_draft_campaign(api, db, red_lead_user, manager_headers):
|
||||||
|
"""A manager approves via /approve — the direct /activate bypass is admin-only."""
|
||||||
|
campaign = _make_draft_campaign(db, red_lead_user.id)
|
||||||
|
resp = api("post", f"/api/v1/campaigns/{campaign.id}/activate", manager_headers)
|
||||||
|
assert resp.status_code == 403
|
||||||
|
|
||||||
|
|
||||||
|
def test_create_campaign_payload_has_no_start_date_field(api, db, red_lead_headers):
|
||||||
|
resp = api(
|
||||||
|
"post",
|
||||||
|
"/api/v1/campaigns",
|
||||||
|
red_lead_headers,
|
||||||
|
json={"name": "No date campaign", "start_date": "2026-01-01"},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 201
|
||||||
|
# start_date silently ignored for a lead — only a manager's own
|
||||||
|
# creation (or a later /approve) can ever set it.
|
||||||
|
assert resp.json()["start_date"] is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_manager_can_create_campaign_auto_approved(api, db, manager_headers):
|
||||||
|
"""A manager is the same role that would otherwise approve a campaign,
|
||||||
|
so their own campaign skips the draft -> submit -> pending_approval
|
||||||
|
queue and goes straight to active with the start_date they provide."""
|
||||||
|
resp = api(
|
||||||
|
"post",
|
||||||
|
"/api/v1/campaigns",
|
||||||
|
manager_headers,
|
||||||
|
json={"name": "Manager-created campaign", "start_date": "2020-01-01T00:00:00"},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 201, resp.text
|
||||||
|
body = resp.json()
|
||||||
|
assert body["status"] == "active"
|
||||||
|
assert body["start_date"] is not None
|
||||||
|
|
||||||
|
|
||||||
|
def test_manager_campaign_creation_requires_start_date(api, db, manager_headers):
|
||||||
|
resp = api(
|
||||||
|
"post",
|
||||||
|
"/api/v1/campaigns",
|
||||||
|
manager_headers,
|
||||||
|
json={"name": "Manager-created campaign, no date"},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 400
|
||||||
|
|
||||||
|
|
||||||
|
def test_manager_creation_creates_jira_tickets_when_start_date_is_due(api, db, manager_headers):
|
||||||
|
with patch(
|
||||||
|
"app.services.jira_service.get_campaign_jira_key", return_value=None
|
||||||
|
) as mock_get_key, patch(
|
||||||
|
"app.services.jira_service.auto_create_campaign_issue", return_value="PT-300"
|
||||||
|
) as mock_create_campaign:
|
||||||
|
resp = api(
|
||||||
|
"post",
|
||||||
|
"/api/v1/campaigns",
|
||||||
|
manager_headers,
|
||||||
|
json={"name": "Manager-created, due now", "start_date": "2020-01-01T00:00:00"},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 201, resp.text
|
||||||
|
mock_get_key.assert_called_once()
|
||||||
|
mock_create_campaign.assert_called_once()
|
||||||
|
|
||||||
|
|
||||||
|
def test_manager_creation_skips_jira_tickets_when_start_date_is_future(api, db, manager_headers):
|
||||||
|
with patch(
|
||||||
|
"app.services.jira_service.get_campaign_jira_key", return_value=None
|
||||||
|
) as mock_get_key:
|
||||||
|
resp = api(
|
||||||
|
"post",
|
||||||
|
"/api/v1/campaigns",
|
||||||
|
manager_headers,
|
||||||
|
json={"name": "Manager-created, future", "start_date": "2099-01-01T00:00:00"},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 201, resp.text
|
||||||
|
mock_get_key.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
def test_lead_created_campaign_is_still_draft_not_auto_approved(api, db, red_lead_headers):
|
||||||
|
resp = api(
|
||||||
|
"post",
|
||||||
|
"/api/v1/campaigns",
|
||||||
|
red_lead_headers,
|
||||||
|
json={"name": "Lead-created campaign"},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 201, resp.text
|
||||||
|
assert resp.json()["status"] == "draft"
|
||||||
|
|
||||||
|
|
||||||
|
def test_manager_can_edit_a_campaign_they_rejected(api, db, red_lead_user, red_lead_headers, manager_headers):
|
||||||
|
campaign = _make_draft_campaign(db, red_lead_user.id)
|
||||||
|
api("post", f"/api/v1/campaigns/{campaign.id}/submit", red_lead_headers)
|
||||||
|
reject = api(
|
||||||
|
"post", f"/api/v1/campaigns/{campaign.id}/reject", manager_headers,
|
||||||
|
json={"reason": "Needs a clearer scope"},
|
||||||
|
)
|
||||||
|
assert reject.status_code == 200, reject.text
|
||||||
|
assert reject.json()["status"] == "draft"
|
||||||
|
|
||||||
|
resp = api(
|
||||||
|
"patch", f"/api/v1/campaigns/{campaign.id}", manager_headers,
|
||||||
|
json={"name": "Edited by manager after rejection"},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200, resp.text
|
||||||
|
assert resp.json()["name"] == "Edited by manager after rejection"
|
||||||
|
|
||||||
|
|
||||||
|
def test_manager_cannot_edit_a_draft_campaign_that_was_never_rejected(
|
||||||
|
api, db, red_lead_user, red_lead_headers, manager_headers,
|
||||||
|
):
|
||||||
|
campaign = _make_draft_campaign(db, red_lead_user.id)
|
||||||
|
resp = api(
|
||||||
|
"patch", f"/api/v1/campaigns/{campaign.id}", manager_headers,
|
||||||
|
json={"name": "Manager should not be able to do this"},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 403
|
||||||
|
|
||||||
|
|
||||||
|
def test_manager_can_approve_a_campaign_they_previously_rejected(
|
||||||
|
api, db, red_lead_user, red_lead_headers, manager_headers,
|
||||||
|
):
|
||||||
|
campaign = _make_draft_campaign(db, red_lead_user.id)
|
||||||
|
api("post", f"/api/v1/campaigns/{campaign.id}/submit", red_lead_headers)
|
||||||
|
api(
|
||||||
|
"post", f"/api/v1/campaigns/{campaign.id}/reject", manager_headers,
|
||||||
|
json={"reason": "Needs a clearer scope"},
|
||||||
|
)
|
||||||
|
api(
|
||||||
|
"patch", f"/api/v1/campaigns/{campaign.id}", manager_headers,
|
||||||
|
json={"description": "Scope clarified after rejection"},
|
||||||
|
)
|
||||||
|
|
||||||
|
resp = api(
|
||||||
|
"post", f"/api/v1/campaigns/{campaign.id}/approve", manager_headers,
|
||||||
|
json={"start_date": "2020-01-01T00:00:00"},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200, resp.text
|
||||||
|
body = resp.json()
|
||||||
|
assert body["status"] == "active"
|
||||||
|
assert body["rejection_reason"] is None
|
||||||
@@ -173,3 +173,61 @@ def test_from_orm_handles_none_tags():
|
|||||||
orm.tags = None
|
orm.tags = None
|
||||||
e = CampaignEntity.from_orm(orm)
|
e = CampaignEntity.from_orm(orm)
|
||||||
assert e.tags == []
|
assert e.tags == []
|
||||||
|
|
||||||
|
|
||||||
|
# ── 9. Submit for approval ────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def test_submit_for_approval_from_draft_with_tests_success():
|
||||||
|
e = _entity("draft", test_count=1)
|
||||||
|
e.submit_for_approval()
|
||||||
|
assert e.status == CampaignStatus.pending_approval
|
||||||
|
|
||||||
|
|
||||||
|
def test_submit_for_approval_from_draft_with_zero_tests_raises():
|
||||||
|
e = _entity("draft", test_count=0)
|
||||||
|
with pytest.raises(BusinessRuleViolation, match="at least one test"):
|
||||||
|
e.submit_for_approval()
|
||||||
|
assert e.status == CampaignStatus.draft
|
||||||
|
|
||||||
|
|
||||||
|
def test_submit_for_approval_from_active_raises():
|
||||||
|
e = _entity("active", test_count=1)
|
||||||
|
with pytest.raises(InvalidStateTransition):
|
||||||
|
e.submit_for_approval()
|
||||||
|
|
||||||
|
|
||||||
|
# ── 10. Approve ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def test_approve_from_pending_approval_success():
|
||||||
|
e = _entity("pending_approval", test_count=1)
|
||||||
|
e.approve()
|
||||||
|
assert e.status == CampaignStatus.active
|
||||||
|
|
||||||
|
|
||||||
|
def test_approve_from_draft_raises():
|
||||||
|
e = _entity("draft", test_count=1)
|
||||||
|
with pytest.raises(InvalidStateTransition):
|
||||||
|
e.approve()
|
||||||
|
|
||||||
|
|
||||||
|
def test_approve_from_completed_raises():
|
||||||
|
e = _entity("completed", test_count=1)
|
||||||
|
with pytest.raises(InvalidStateTransition):
|
||||||
|
e.approve()
|
||||||
|
|
||||||
|
|
||||||
|
# ── 11. Reject ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def test_reject_from_pending_approval_returns_to_draft():
|
||||||
|
e = _entity("pending_approval", test_count=1)
|
||||||
|
e.reject()
|
||||||
|
assert e.status == CampaignStatus.draft
|
||||||
|
|
||||||
|
|
||||||
|
def test_reject_from_draft_raises():
|
||||||
|
e = _entity("draft", test_count=1)
|
||||||
|
with pytest.raises(InvalidStateTransition):
|
||||||
|
e.reject()
|
||||||
|
|||||||
@@ -0,0 +1,72 @@
|
|||||||
|
"""Tests for generating a campaign from a threat actor's uncovered techniques.
|
||||||
|
|
||||||
|
Covers the Block 1 fix: the frontend sends a `start_date` when generating a
|
||||||
|
campaign from a Threat Actor, but `GenerateFromActorPayload` used to be an
|
||||||
|
empty schema, so the date was silently discarded and the campaign was
|
||||||
|
created with `start_date = NULL`.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from app.models.enums import TechniqueStatus
|
||||||
|
from app.models.technique import Technique
|
||||||
|
from app.models.test_template import TestTemplate
|
||||||
|
from app.models.threat_actor import ThreatActor, ThreatActorTechnique
|
||||||
|
from app.services.campaign_service import generate_campaign_from_threat_actor
|
||||||
|
|
||||||
|
|
||||||
|
def _seed_actor_with_gap_technique(db):
|
||||||
|
tech = Technique(
|
||||||
|
mitre_id="T1059.001",
|
||||||
|
name="PowerShell",
|
||||||
|
tactic="execution",
|
||||||
|
platforms=["windows"],
|
||||||
|
status_global=TechniqueStatus.not_evaluated,
|
||||||
|
)
|
||||||
|
db.add(tech)
|
||||||
|
db.flush()
|
||||||
|
|
||||||
|
template = TestTemplate(
|
||||||
|
mitre_technique_id=tech.mitre_id,
|
||||||
|
name="PowerShell template",
|
||||||
|
source="custom",
|
||||||
|
severity="high",
|
||||||
|
is_active=True,
|
||||||
|
)
|
||||||
|
db.add(template)
|
||||||
|
|
||||||
|
actor = ThreatActor(name="APT-Test", mitre_id="G9999")
|
||||||
|
db.add(actor)
|
||||||
|
db.flush()
|
||||||
|
|
||||||
|
db.add(ThreatActorTechnique(threat_actor_id=actor.id, technique_id=tech.id))
|
||||||
|
db.commit()
|
||||||
|
db.refresh(actor)
|
||||||
|
return actor
|
||||||
|
|
||||||
|
|
||||||
|
def test_generate_from_actor_without_start_date_leaves_it_null(db, red_lead_user):
|
||||||
|
actor = _seed_actor_with_gap_technique(db)
|
||||||
|
campaign = generate_campaign_from_threat_actor(db, actor.id, red_lead_user)
|
||||||
|
assert campaign.start_date is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_generate_from_actor_persists_start_date(db, red_lead_user):
|
||||||
|
actor = _seed_actor_with_gap_technique(db)
|
||||||
|
start_date = datetime(2026, 9, 1)
|
||||||
|
campaign = generate_campaign_from_threat_actor(
|
||||||
|
db, actor.id, red_lead_user, start_date=start_date,
|
||||||
|
)
|
||||||
|
assert campaign.start_date == start_date
|
||||||
|
|
||||||
|
|
||||||
|
def test_generate_from_actor_router_forwards_start_date(api, db, red_lead_user, red_lead_headers):
|
||||||
|
actor = _seed_actor_with_gap_technique(db)
|
||||||
|
resp = api(
|
||||||
|
"post",
|
||||||
|
f"/api/v1/campaigns/from-threat-actor/{actor.id}",
|
||||||
|
red_lead_headers,
|
||||||
|
json={"start_date": "2026-09-01T00:00:00"},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 201, resp.text
|
||||||
|
assert resp.json()["start_date"] is not None
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
"""Tests for the campaign start_date scheduling gate on test execution.
|
||||||
|
|
||||||
|
Block 3 fix: `Campaign.start_date` was persisted and displayed but never
|
||||||
|
actually enforced — a test belonging to a campaign scheduled for the future
|
||||||
|
could still be started immediately. `start_execution` now blocks this.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from app.domain.errors import BusinessRuleViolation
|
||||||
|
from app.models.campaign import Campaign, CampaignTest
|
||||||
|
from app.models.enums import TestState
|
||||||
|
from app.models.technique import Technique
|
||||||
|
from app.models.test import Test
|
||||||
|
from app.services.test_workflow_service import start_execution
|
||||||
|
|
||||||
|
|
||||||
|
def _seed_test_in_campaign(db, owner_id, start_date):
|
||||||
|
tech = Technique(mitre_id="T1059.003", name="Windows Command Shell", tactic="execution", platforms=["windows"])
|
||||||
|
db.add(tech)
|
||||||
|
db.flush()
|
||||||
|
|
||||||
|
campaign = Campaign(name="Scheduled Campaign", type="custom", status="active", created_by=owner_id, start_date=start_date)
|
||||||
|
db.add(campaign)
|
||||||
|
db.flush()
|
||||||
|
|
||||||
|
test = Test(technique_id=tech.id, name="Scheduled test", state=TestState.draft, created_by=owner_id)
|
||||||
|
db.add(test)
|
||||||
|
db.flush()
|
||||||
|
|
||||||
|
db.add(CampaignTest(campaign_id=campaign.id, test_id=test.id, order_index=0))
|
||||||
|
db.commit()
|
||||||
|
db.refresh(test)
|
||||||
|
return test
|
||||||
|
|
||||||
|
|
||||||
|
def test_start_execution_blocked_before_campaign_start_date(db, red_tech_user):
|
||||||
|
future = datetime.utcnow() + timedelta(days=7)
|
||||||
|
test = _seed_test_in_campaign(db, red_tech_user.id, future)
|
||||||
|
|
||||||
|
with pytest.raises(BusinessRuleViolation):
|
||||||
|
start_execution(db, test, red_tech_user)
|
||||||
|
|
||||||
|
|
||||||
|
def test_start_execution_allowed_after_campaign_start_date(db, red_tech_user):
|
||||||
|
past = datetime.utcnow() - timedelta(days=1)
|
||||||
|
test = _seed_test_in_campaign(db, red_tech_user.id, past)
|
||||||
|
|
||||||
|
result = start_execution(db, test, red_tech_user)
|
||||||
|
|
||||||
|
assert result.state == TestState.red_executing
|
||||||
|
|
||||||
|
|
||||||
|
def test_start_execution_allowed_for_standalone_test(db, red_tech_user):
|
||||||
|
"""A test with no campaign at all is never gated by this check."""
|
||||||
|
tech = Technique(mitre_id="T1059.004", name="Unix Shell", tactic="execution", platforms=["linux"])
|
||||||
|
db.add(tech)
|
||||||
|
db.flush()
|
||||||
|
test = Test(technique_id=tech.id, name="Standalone test", state=TestState.draft, created_by=red_tech_user.id)
|
||||||
|
db.add(test)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(test)
|
||||||
|
|
||||||
|
result = start_execution(db, test, red_tech_user)
|
||||||
|
|
||||||
|
assert result.state == TestState.red_executing
|
||||||
|
|
||||||
|
|
||||||
|
def test_start_execution_allowed_when_campaign_has_no_start_date(db, red_tech_user):
|
||||||
|
test = _seed_test_in_campaign(db, red_tech_user.id, None)
|
||||||
|
|
||||||
|
result = start_execution(db, test, red_tech_user)
|
||||||
|
|
||||||
|
assert result.state == TestState.red_executing
|
||||||
@@ -21,6 +21,7 @@ from app.services.campaign_service import (
|
|||||||
from app.services.campaign_scheduler_service import (
|
from app.services.campaign_scheduler_service import (
|
||||||
calculate_next_run,
|
calculate_next_run,
|
||||||
check_and_run_recurring_campaigns,
|
check_and_run_recurring_campaigns,
|
||||||
|
sync_due_campaign_jira_tickets,
|
||||||
)
|
)
|
||||||
from app.services.snapshot_service import (
|
from app.services.snapshot_service import (
|
||||||
create_snapshot,
|
create_snapshot,
|
||||||
@@ -199,7 +200,10 @@ class TestCampaigns:
|
|||||||
)
|
)
|
||||||
assert child is not None
|
assert child is not None
|
||||||
assert "Run" in child.name
|
assert "Run" in child.name
|
||||||
assert child.status == "active"
|
# Recurrence only automates spawning the run — it still needs a
|
||||||
|
# manager's approval like any other campaign, same as the manual
|
||||||
|
# draft -> submit -> approve path.
|
||||||
|
assert child.status == "pending_approval"
|
||||||
|
|
||||||
# Check child tests are fresh copies (new IDs, draft state)
|
# Check child tests are fresh copies (new IDs, draft state)
|
||||||
child_cts = (
|
child_cts = (
|
||||||
@@ -217,12 +221,92 @@ class TestCampaigns:
|
|||||||
test = db.query(Test).filter(Test.id == ct.test_id).first()
|
test = db.query(Test).filter(Test.id == ct.test_id).first()
|
||||||
assert test.state == TestState.draft
|
assert test.state == TestState.draft
|
||||||
|
|
||||||
|
def test_campaign_cloning_notifies_managers(self, db, campaign_with_tests, manager_user):
|
||||||
|
"""Managers must hear about a spawned recurring run — it's sitting in
|
||||||
|
their approval queue just like a manually-submitted campaign."""
|
||||||
|
from app.models.notification import Notification
|
||||||
|
|
||||||
|
campaign = campaign_with_tests["campaign"]
|
||||||
|
campaign.is_recurring = True
|
||||||
|
campaign.recurrence_pattern = "monthly"
|
||||||
|
campaign.next_run_at = datetime.utcnow() - timedelta(hours=1)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
check_and_run_recurring_campaigns(db)
|
||||||
|
|
||||||
|
notif = (
|
||||||
|
db.query(Notification)
|
||||||
|
.filter(Notification.user_id == manager_user.id, Notification.type == "campaign_pending_approval")
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
assert notif is not None
|
||||||
|
|
||||||
# Check parent was updated
|
# Check parent was updated
|
||||||
db.refresh(campaign)
|
db.refresh(campaign)
|
||||||
assert campaign.last_run_at is not None
|
assert campaign.last_run_at is not None
|
||||||
assert campaign.next_run_at > datetime.utcnow()
|
assert campaign.next_run_at > datetime.utcnow()
|
||||||
|
|
||||||
|
|
||||||
|
class TestDueCampaignJiraSync:
|
||||||
|
"""sync_due_campaign_jira_tickets — the periodic catch-up job for
|
||||||
|
campaigns approved with a future start_date whose Jira tickets were
|
||||||
|
deliberately skipped at approval time."""
|
||||||
|
|
||||||
|
def test_creates_tickets_for_due_campaign_without_jira_link(self, db, campaign_with_tests, admin_user):
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
campaign = campaign_with_tests["campaign"]
|
||||||
|
campaign.status = "active"
|
||||||
|
campaign.approved_by = admin_user.id
|
||||||
|
campaign.start_date = datetime.utcnow() - timedelta(hours=1) # now due
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
"app.services.jira_service.get_campaign_jira_key", return_value=None
|
||||||
|
) as mock_get_key, patch(
|
||||||
|
"app.services.jira_service.auto_create_campaign_issue", return_value="PT-200"
|
||||||
|
) as mock_create_campaign, patch(
|
||||||
|
"app.services.jira_service.get_test_jira_key", return_value=None
|
||||||
|
), patch(
|
||||||
|
"app.services.jira_service.auto_create_test_issue"
|
||||||
|
) as mock_create_test:
|
||||||
|
processed = sync_due_campaign_jira_tickets(db)
|
||||||
|
|
||||||
|
assert processed == 1
|
||||||
|
mock_get_key.assert_called_once()
|
||||||
|
mock_create_campaign.assert_called_once()
|
||||||
|
assert mock_create_test.call_count == len(campaign_with_tests["tests"])
|
||||||
|
|
||||||
|
def test_skips_campaign_with_future_start_date(self, db, campaign_with_tests, admin_user):
|
||||||
|
campaign = campaign_with_tests["campaign"]
|
||||||
|
campaign.status = "active"
|
||||||
|
campaign.approved_by = admin_user.id
|
||||||
|
campaign.start_date = datetime.utcnow() + timedelta(days=30)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
processed = sync_due_campaign_jira_tickets(db)
|
||||||
|
assert processed == 0
|
||||||
|
|
||||||
|
def test_skips_campaign_already_linked_to_jira(self, db, campaign_with_tests, admin_user):
|
||||||
|
from app.models.jira_link import JiraLink, JiraLinkEntityType
|
||||||
|
|
||||||
|
campaign = campaign_with_tests["campaign"]
|
||||||
|
campaign.status = "active"
|
||||||
|
campaign.approved_by = admin_user.id
|
||||||
|
campaign.start_date = datetime.utcnow() - timedelta(hours=1)
|
||||||
|
db.add(JiraLink(
|
||||||
|
entity_type=JiraLinkEntityType.campaign,
|
||||||
|
entity_id=campaign.id,
|
||||||
|
jira_issue_key="PT-1",
|
||||||
|
jira_project_key="PT",
|
||||||
|
created_by=admin_user.id,
|
||||||
|
))
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
processed = sync_due_campaign_jira_tickets(db)
|
||||||
|
assert processed == 0
|
||||||
|
|
||||||
|
|
||||||
# ═══════════════════════════════════════════════════════════════════════
|
# ═══════════════════════════════════════════════════════════════════════
|
||||||
# Snapshot Tests
|
# Snapshot Tests
|
||||||
# ═══════════════════════════════════════════════════════════════════════
|
# ═══════════════════════════════════════════════════════════════════════
|
||||||
|
|||||||
@@ -0,0 +1,48 @@
|
|||||||
|
"""Tests for canonical MITRE kill-chain ordering in coverage-by-tactic.
|
||||||
|
|
||||||
|
Block 3 fix: TacticCoverageChart.tsx (dashboard) rendered whatever order
|
||||||
|
the backend happened to return (alphabetical, and only tactics with
|
||||||
|
seeded techniques), while ExecutiveDashboardPage.tsx re-sorted client-side
|
||||||
|
into the correct kill-chain order. get_coverage_by_tactic now returns the
|
||||||
|
canonical order — and all 14 tactics, zero-filled — so both consumers get
|
||||||
|
consistent results without needing their own reordering logic.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from app.models.technique import Technique
|
||||||
|
from app.services.metrics_query_service import MITRE_TACTIC_ORDER, get_coverage_by_tactic
|
||||||
|
|
||||||
|
|
||||||
|
def test_coverage_by_tactic_returns_canonical_kill_chain_order(db):
|
||||||
|
# Seed a few tactics out of order to prove the response isn't alphabetical.
|
||||||
|
db.add(Technique(mitre_id="T3001", name="A", tactic="impact"))
|
||||||
|
db.add(Technique(mitre_id="T3002", name="B", tactic="execution"))
|
||||||
|
db.add(Technique(mitre_id="T3003", name="C", tactic="reconnaissance"))
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
rows = get_coverage_by_tactic(db)
|
||||||
|
tactics = [r.tactic for r in rows]
|
||||||
|
|
||||||
|
canonical_present = [t for t in tactics if t in MITRE_TACTIC_ORDER]
|
||||||
|
assert canonical_present == MITRE_TACTIC_ORDER
|
||||||
|
|
||||||
|
|
||||||
|
def test_coverage_by_tactic_zero_fills_tactics_with_no_techniques(db):
|
||||||
|
db.add(Technique(mitre_id="T3004", name="D", tactic="execution"))
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
rows = get_coverage_by_tactic(db)
|
||||||
|
by_tactic = {r.tactic: r for r in rows}
|
||||||
|
|
||||||
|
assert "persistence" in by_tactic
|
||||||
|
assert by_tactic["persistence"].total == 0
|
||||||
|
assert by_tactic["execution"].total == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_coverage_by_tactic_unknown_tactic_sorts_last(db):
|
||||||
|
db.add(Technique(mitre_id="T3005", name="E", tactic=None))
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
rows = get_coverage_by_tactic(db)
|
||||||
|
tactics = [r.tactic for r in rows]
|
||||||
|
|
||||||
|
assert tactics[-1] == "unknown"
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
"""Tests for D3FEND mapping import — review_required trigger parity.
|
||||||
|
|
||||||
|
Every other import source (Atomic Red Team, Caldera, Elastic, Sigma, LOLBAS)
|
||||||
|
flags a technique with review_required=True when it gains new content, so a
|
||||||
|
lead knows to look at it. D3FEND mapping import was missing this — this test
|
||||||
|
locks in the fix.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from app.models.defensive_technique import DefensiveTechnique
|
||||||
|
from app.models.technique import Technique
|
||||||
|
from app.services.d3fend_import_service import import_d3fend_mappings
|
||||||
|
|
||||||
|
|
||||||
|
def test_import_d3fend_mappings_sets_review_required(db):
|
||||||
|
technique = Technique(mitre_id="T1590", name="Gather Victim Network Information", review_required=False)
|
||||||
|
db.add(technique)
|
||||||
|
db.add(DefensiveTechnique(d3fend_id="D3-NTA", name="Network Traffic Analysis"))
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
result = import_d3fend_mappings(db)
|
||||||
|
|
||||||
|
assert result["created"] >= 1
|
||||||
|
db.refresh(technique)
|
||||||
|
assert technique.review_required is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_import_d3fend_mappings_skips_existing_without_reflagging(db):
|
||||||
|
technique = Technique(mitre_id="T1590", name="Gather Victim Network Information", review_required=False)
|
||||||
|
db.add(technique)
|
||||||
|
db.add(DefensiveTechnique(d3fend_id="D3-NTA", name="Network Traffic Analysis"))
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
import_d3fend_mappings(db)
|
||||||
|
db.refresh(technique)
|
||||||
|
technique.review_required = False
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
result = import_d3fend_mappings(db)
|
||||||
|
|
||||||
|
assert result["created"] == 0
|
||||||
|
db.refresh(technique)
|
||||||
|
assert technique.review_required is False
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user