Compare commits
83 Commits
fefe70baed
..
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 |
@@ -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,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*.
|
||||||
|
|
||||||
|
|||||||
@@ -93,14 +93,19 @@ class AttackSuccessResult(str, enum.Enum):
|
|||||||
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 the organization's data protection scheme:
|
Matches Jira's "Data Sensitivity" field (customfield_11814) exactly, since
|
||||||
- public_release: approved for external/public release
|
that is the organization's actual data protection scheme:
|
||||||
|
- public: approved for external/public release
|
||||||
- general_use: general internal information, no special restriction
|
- general_use: general internal information, no special restriction
|
||||||
- confidential: internal use only, or shared with trusted people
|
- internal_use_only: internal use only
|
||||||
- restricted: trusted people, customer content, and/or PII
|
- trusted_people: shared only with a limited/trusted audience
|
||||||
|
- customer_content: contains customer data
|
||||||
|
- pii: contains personally identifiable information
|
||||||
"""
|
"""
|
||||||
|
|
||||||
public_release = "public_release"
|
public = "public"
|
||||||
general_use = "general_use"
|
general_use = "general_use"
|
||||||
confidential = "confidential"
|
internal_use_only = "internal_use_only"
|
||||||
restricted = "restricted"
|
trusted_people = "trusted_people"
|
||||||
|
customer_content = "customer_content"
|
||||||
|
pii = "pii"
|
||||||
|
|||||||
@@ -414,10 +414,14 @@ class TestEntity:
|
|||||||
Called when the assigned Red Lead sends the work back for rework.
|
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
|
Resets the red-phase timer for a fresh attempt (the first attempt's
|
||||||
worklog was already recorded at submit time, so this loses nothing).
|
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._transition(TestState.red_executing)
|
||||||
self.red_started_at = datetime.utcnow()
|
self.red_started_at = datetime.utcnow()
|
||||||
self.red_paused_seconds = 0
|
self.red_paused_seconds = 0
|
||||||
|
self.paused_at = self.red_started_at
|
||||||
self._events.append(DomainEvent("red_review_reopened"))
|
self._events.append(DomainEvent("red_review_reopened"))
|
||||||
|
|
||||||
# Define function submit_blue_evidence
|
# Define function submit_blue_evidence
|
||||||
@@ -668,6 +672,27 @@ class TestEntity:
|
|||||||
|
|
||||||
self._events.append(DomainEvent("dispute_resolved_to_rework", {"target": target}))
|
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:
|
||||||
|
|||||||
@@ -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,6 +189,19 @@ def _run_recurring_campaigns() -> None:
|
|||||||
db.close()
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
|
def _run_due_campaign_jira_sync() -> None:
|
||||||
|
"""Create Jira tickets for campaigns whose scheduled start_date has arrived."""
|
||||||
|
logger.info("Scheduled due-campaign Jira sync starting...")
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
processed = sync_due_campaign_jira_tickets(db)
|
||||||
|
logger.info("Due-campaign Jira sync finished — processed %d campaigns", processed)
|
||||||
|
except Exception:
|
||||||
|
logger.exception("Due-campaign Jira sync failed")
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
def _run_intel_scan() -> None:
|
def _run_intel_scan() -> None:
|
||||||
"""Execute an intel scan inside its own DB session."""
|
"""Execute an intel scan inside its own DB session."""
|
||||||
# Log info: "Scheduled intel scan job starting..."
|
# Log info: "Scheduled intel scan job starting..."
|
||||||
@@ -424,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,
|
||||||
@@ -497,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,
|
||||||
@@ -605,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()
|
||||||
|
|||||||
@@ -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__ = [
|
||||||
@@ -86,4 +91,9 @@ __all__ = [
|
|||||||
"SsoConfig",
|
"SsoConfig",
|
||||||
"AlertRule",
|
"AlertRule",
|
||||||
"AlertInstance",
|
"AlertInstance",
|
||||||
|
"TestRoundHistory",
|
||||||
|
"ProcedureSuggestion",
|
||||||
|
"TemplateSuggestion",
|
||||||
|
"PasswordSetupToken",
|
||||||
|
"EvaluationImport",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -90,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="confidential")
|
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)
|
||||||
|
|||||||
@@ -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="confidential")
|
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])
|
||||||
@@ -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)
|
||||||
@@ -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="confidential")
|
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])
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -124,7 +124,7 @@ from app.services.campaign_crud_service import (
|
|||||||
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__)
|
||||||
@@ -134,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):
|
||||||
@@ -153,6 +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
|
||||||
|
# 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
|
||||||
@@ -296,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(
|
||||||
@@ -329,6 +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,
|
||||||
|
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(
|
||||||
@@ -336,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,
|
||||||
@@ -345,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
|
||||||
|
|
||||||
@@ -391,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.
|
||||||
@@ -496,8 +543,19 @@ def approve_campaign_endpoint(
|
|||||||
entity_id=campaign.id,
|
entity_id=campaign.id,
|
||||||
details={"start_date": payload.start_date},
|
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()
|
uow.commit()
|
||||||
db.refresh(campaign)
|
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)
|
return serialize_campaign(db, campaign)
|
||||||
|
|
||||||
|
|
||||||
@@ -816,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,
|
||||||
@@ -837,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)
|
||||||
|
|
||||||
@@ -950,7 +991,7 @@ def get_campaign_progress_endpoint(
|
|||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
class GenerateFromActorPayload(BaseModel):
|
class GenerateFromActorPayload(BaseModel):
|
||||||
pass
|
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)
|
||||||
@@ -961,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.
|
||||||
|
|
||||||
@@ -980,6 +1023,7 @@ def generate_campaign_from_actor(
|
|||||||
db,
|
db,
|
||||||
uuid.UUID(actor_id),
|
uuid.UUID(actor_id),
|
||||||
current_user,
|
current_user,
|
||||||
|
start_date=payload.start_date,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Open context manager
|
# Open context manager
|
||||||
|
|||||||
@@ -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``.
|
||||||
|
|
||||||
|
|||||||
+288
-104
@@ -42,7 +42,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_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
|
||||||
@@ -65,6 +65,7 @@ from app.schemas.test import (
|
|||||||
TestClassificationUpdate,
|
TestClassificationUpdate,
|
||||||
TestCreate,
|
TestCreate,
|
||||||
TestHold,
|
TestHold,
|
||||||
|
TestManagerResolveDispute,
|
||||||
TestOut,
|
TestOut,
|
||||||
TestRedReview,
|
TestRedReview,
|
||||||
TestRedUpdate,
|
TestRedUpdate,
|
||||||
@@ -93,6 +94,11 @@ 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,
|
||||||
@@ -118,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,
|
||||||
@@ -147,6 +158,7 @@ from app.services.test_workflow_service import (
|
|||||||
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 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,
|
||||||
@@ -158,58 +170,19 @@ from app.services.test_workflow_service import (
|
|||||||
router = APIRouter(prefix="/tests", tags=["tests"])
|
router = APIRouter(prefix="/tests", tags=["tests"])
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Blind visibility — hide the other team's fields until both reviews pass
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
_RED_ONLY_FIELDS = [
|
|
||||||
"procedure_text", "tool_used", "attack_success",
|
|
||||||
"execution_start_time", "execution_end_time", "red_summary",
|
|
||||||
"red_validation_status", "red_validated_by", "red_validated_at", "red_validation_notes",
|
|
||||||
]
|
|
||||||
_BLUE_ONLY_FIELDS = [
|
|
||||||
"detection_result", "containment_result", "detection_time", "containment_time",
|
|
||||||
"blue_summary", "blue_validation_status", "blue_validated_by", "blue_validated_at",
|
|
||||||
"blue_validation_notes", "system_gaps",
|
|
||||||
]
|
|
||||||
_BLIND_STATES = {"draft", "red_executing", "red_review", "blue_evaluating", "blue_review"}
|
|
||||||
|
|
||||||
|
|
||||||
def _mask_for_team_blindness(test_out: TestOut, *, viewer_role: str) -> TestOut:
|
|
||||||
"""Null out the other team's fields while the test is still blind.
|
|
||||||
|
|
||||||
admin and viewer are never blinded. Once the test reaches in_review or
|
|
||||||
beyond, both sides see everything (existing cross-validation behavior).
|
|
||||||
"""
|
|
||||||
if viewer_role in ("admin", "viewer"):
|
|
||||||
return test_out
|
|
||||||
|
|
||||||
test_state = test_out.state.value if hasattr(test_out.state, "value") else str(test_out.state)
|
|
||||||
if test_state not in _BLIND_STATES:
|
|
||||||
return test_out
|
|
||||||
|
|
||||||
if viewer_role in ("blue_tech", "blue_lead"):
|
|
||||||
hide_fields = _RED_ONLY_FIELDS
|
|
||||||
elif viewer_role in ("red_tech", "red_lead"):
|
|
||||||
hide_fields = _BLUE_ONLY_FIELDS
|
|
||||||
else:
|
|
||||||
return test_out
|
|
||||||
|
|
||||||
return test_out.model_copy(update={f: None for f in hide_fields})
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# GET /tests — list with filters
|
# GET /tests — list with filters
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
@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
|
||||||
@@ -218,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),
|
||||||
@@ -232,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.
|
||||||
@@ -249,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,
|
||||||
@@ -290,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.
|
||||||
|
|
||||||
@@ -370,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.
|
||||||
|
|
||||||
@@ -401,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(
|
||||||
@@ -463,12 +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.
|
||||||
Fields belonging to the other team are nulled out while the
|
Both teams see each other's fields regardless of state — Red
|
||||||
test is blind (see :func:`_mask_for_team_blindness`).
|
and Blue are meant to see each other's work, not test blind.
|
||||||
"""
|
"""
|
||||||
test = crud_get_test_detail(db, test_id)
|
test = crud_get_test_detail(db, test_id)
|
||||||
test_out = TestOut.model_validate(test)
|
return TestOut.model_validate(test)
|
||||||
return _mask_for_team_blindness(test_out, viewer_role=current_user.role)
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -486,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:
|
||||||
@@ -539,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
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -554,8 +595,8 @@ 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_any_role(
|
current_user: User = Depends(require_any_role_strict(
|
||||||
"admin", "manager", "red_tech", "red_lead", "blue_tech", "blue_lead",
|
"manager", "red_tech", "red_lead", "blue_tech", "blue_lead",
|
||||||
)),
|
)),
|
||||||
) -> TestOut:
|
) -> TestOut:
|
||||||
"""Update the data classification label for a test.
|
"""Update the data classification label for a test.
|
||||||
@@ -617,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``).
|
||||||
|
|
||||||
@@ -630,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")
|
||||||
@@ -683,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``).
|
||||||
|
|
||||||
@@ -696,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")
|
||||||
@@ -747,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``.
|
||||||
|
|
||||||
@@ -798,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``.
|
||||||
|
|
||||||
@@ -846,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``.
|
||||||
|
|
||||||
@@ -890,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)
|
||||||
@@ -923,12 +966,12 @@ def review_red(
|
|||||||
test_id: uuid.UUID,
|
test_id: uuid.UUID,
|
||||||
payload: TestRedReview,
|
payload: TestRedReview,
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
current_user: User = Depends(require_any_role("red_lead", "admin")),
|
current_user: User = Depends(require_any_role_strict("red_lead")),
|
||||||
) -> TestOut:
|
) -> TestOut:
|
||||||
"""Assigned Red Lead approves or reopens a test sitting in red_review."""
|
"""Assigned Red Lead approves or reopens a test sitting in red_review."""
|
||||||
test = crud_get_test_or_raise(db, test_id)
|
test = crud_get_test_or_raise(db, test_id)
|
||||||
|
|
||||||
if current_user.role != "admin" and test.red_reviewer_assignee != current_user.id:
|
if test.red_reviewer_assignee != current_user.id:
|
||||||
raise HTTPException(status_code=403, detail="You are not the assigned reviewer for this test")
|
raise HTTPException(status_code=403, detail="You are not the assigned reviewer for this test")
|
||||||
|
|
||||||
with UnitOfWork(db) as uow:
|
with UnitOfWork(db) as uow:
|
||||||
@@ -953,12 +996,12 @@ def review_blue(
|
|||||||
test_id: uuid.UUID,
|
test_id: uuid.UUID,
|
||||||
payload: TestBlueReview,
|
payload: TestBlueReview,
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
current_user: User = Depends(require_any_role("blue_lead", "admin")),
|
current_user: User = Depends(require_any_role_strict("blue_lead")),
|
||||||
) -> TestOut:
|
) -> TestOut:
|
||||||
"""Assigned Blue Lead approves, reopens, or flags a capability gap on a test in blue_review."""
|
"""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)
|
test = crud_get_test_or_raise(db, test_id)
|
||||||
|
|
||||||
if current_user.role != "admin" and test.blue_reviewer_assignee != current_user.id:
|
if test.blue_reviewer_assignee != current_user.id:
|
||||||
raise HTTPException(status_code=403, detail="You are not the assigned reviewer for this test")
|
raise HTTPException(status_code=403, detail="You are not the assigned reviewer for this test")
|
||||||
|
|
||||||
with UnitOfWork(db) as uow:
|
with UnitOfWork(db) as uow:
|
||||||
@@ -988,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).
|
||||||
|
|
||||||
@@ -1027,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.
|
||||||
|
|
||||||
@@ -1068,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.
|
||||||
|
|
||||||
@@ -1125,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.
|
||||||
|
|
||||||
@@ -1177,7 +1220,7 @@ def resolve_dispute(
|
|||||||
test_id: uuid.UUID,
|
test_id: uuid.UUID,
|
||||||
payload: TestResolveDispute,
|
payload: TestResolveDispute,
|
||||||
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")),
|
||||||
) -> TestOut:
|
) -> TestOut:
|
||||||
"""The lead who approved flips their vote to reject, choosing which team must redo the work."""
|
"""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)
|
test = crud_get_test_with_technique(db, test_id)
|
||||||
@@ -1188,6 +1231,78 @@ def resolve_dispute(
|
|||||||
return 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
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -1201,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``.
|
||||||
|
|
||||||
@@ -1228,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)
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
@@ -1237,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
|
||||||
|
|
||||||
|
|
||||||
@@ -1274,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
|
||||||
@@ -1294,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")
|
||||||
|
|
||||||
@@ -1301,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)
|
||||||
@@ -1319,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)
|
||||||
@@ -1329,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)
|
||||||
@@ -1357,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.
|
||||||
|
|
||||||
@@ -1591,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.
|
||||||
|
|
||||||
@@ -1610,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"
|
||||||
@@ -1632,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
|
||||||
@@ -1670,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,
|
||||||
}
|
}
|
||||||
@@ -1711,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:
|
||||||
|
|||||||
+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)
|
||||||
+103
-5
@@ -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 AttackSuccessResult, 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 ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
@@ -85,6 +99,8 @@ class TestRedUpdate(BaseModel):
|
|||||||
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 ────────────────────────────────────────────
|
||||||
@@ -135,6 +185,13 @@ class TestResolveDispute(BaseModel):
|
|||||||
notes: str | None = None
|
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) ────────────────────────────
|
# ── Red Lead review gate (pre-Blue-Team) ────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
@@ -171,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):
|
||||||
@@ -219,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
|
||||||
@@ -243,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
|
||||||
@@ -267,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
|
||||||
@@ -278,6 +341,12 @@ 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
|
# Review assignment fields
|
||||||
red_reviewer_assignee: uuid.UUID | None = None
|
red_reviewer_assignee: uuid.UUID | None = None
|
||||||
@@ -288,6 +357,8 @@ class TestOut(BaseModel):
|
|||||||
blue_review_by: uuid.UUID | None = None
|
blue_review_by: uuid.UUID | None = None
|
||||||
blue_review_at: datetime | None = None
|
blue_review_at: datetime | None = None
|
||||||
blue_review_notes: str | 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
|
system_gaps: str | None = None
|
||||||
|
|
||||||
# On-hold fields
|
# On-hold fields
|
||||||
@@ -299,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 = "confidential"
|
# Assign data_classification = "internal_use_only"
|
||||||
data_classification: str = "confidential"
|
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
|
||||||
@@ -311,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")
|
||||||
@@ -343,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:
|
||||||
@@ -367,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()
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -281,8 +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,
|
||||||
|
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
|
||||||
@@ -302,6 +314,11 @@ def create_campaign(
|
|||||||
# 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,
|
||||||
)
|
)
|
||||||
|
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
|
||||||
@@ -350,6 +367,11 @@ def approve_campaign(
|
|||||||
) -> Campaign:
|
) -> Campaign:
|
||||||
"""Approve a pending campaign: fix its start date and activate it.
|
"""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.
|
Raises EntityNotFoundError, BusinessRuleViolation.
|
||||||
Does not commit; caller commits.
|
Does not commit; caller commits.
|
||||||
"""
|
"""
|
||||||
@@ -357,8 +379,9 @@ def approve_campaign(
|
|||||||
if not campaign:
|
if not campaign:
|
||||||
raise EntityNotFoundError("Campaign", campaign_id)
|
raise EntityNotFoundError("Campaign", campaign_id)
|
||||||
|
|
||||||
if campaign.status != "pending_approval":
|
is_previously_rejected_draft = campaign.status == "draft" and campaign.rejection_reason
|
||||||
raise BusinessRuleViolation("Only campaigns pending approval can be approved")
|
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:
|
if not start_date:
|
||||||
raise BusinessRuleViolation("start_date is required to approve a campaign")
|
raise BusinessRuleViolation("start_date is required to approve a campaign")
|
||||||
@@ -437,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
|
||||||
@@ -448,10 +471,16 @@ 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"]:
|
||||||
@@ -726,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
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -178,6 +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: 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.
|
||||||
|
|
||||||
@@ -235,6 +237,8 @@ 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,
|
||||||
)
|
)
|
||||||
# Stage new record(s) for database insertion
|
# Stage new record(s) for database insertion
|
||||||
db.add(campaign)
|
db.add(campaign)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
@@ -79,7 +82,8 @@ JIRA_FIELD_ATTACK_SUCCESS = "customfield_11815"
|
|||||||
JIRA_FIELD_ATTACK_DETECTED = "customfield_11809"
|
JIRA_FIELD_ATTACK_DETECTED = "customfield_11809"
|
||||||
JIRA_FIELD_TIME_TO_DETECT = "customfield_11810"
|
JIRA_FIELD_TIME_TO_DETECT = "customfield_11810"
|
||||||
JIRA_FIELD_TIME_TO_CONTAIN = "customfield_11811"
|
JIRA_FIELD_TIME_TO_CONTAIN = "customfield_11811"
|
||||||
JIRA_FIELD_DATA_SENSITIVITY = "customfield_11233"
|
JIRA_FIELD_DATA_SENSITIVITY = "customfield_11814"
|
||||||
|
JIRA_FIELD_ATTACK_CONTAINED = "customfield_11885"
|
||||||
|
|
||||||
_ATTACK_SUCCESS_TO_JIRA = {
|
_ATTACK_SUCCESS_TO_JIRA = {
|
||||||
"successful": "Yes",
|
"successful": "Yes",
|
||||||
@@ -93,11 +97,19 @@ _DETECTION_TO_JIRA = {
|
|||||||
"partially_detected": "Partial",
|
"partially_detected": "Partial",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
_CONTAINMENT_TO_JIRA = {
|
||||||
|
"contained": "Yes",
|
||||||
|
"not_contained": "No",
|
||||||
|
"partially_contained": "Partial",
|
||||||
|
}
|
||||||
|
|
||||||
_DATA_CLASSIFICATION_TO_JIRA = {
|
_DATA_CLASSIFICATION_TO_JIRA = {
|
||||||
"public_release": "Public Release",
|
"public": "Public",
|
||||||
"general_use": "General Use",
|
"general_use": "General Use",
|
||||||
"confidential": "Confidential",
|
"internal_use_only": "Internal Use Only",
|
||||||
"restricted": "Restricted",
|
"trusted_people": "Trusted People",
|
||||||
|
"customer_content": "Customer Content",
|
||||||
|
"pii": "PII",
|
||||||
}
|
}
|
||||||
|
|
||||||
# Assign logger = logging.getLogger(__name__)
|
# Assign logger = logging.getLogger(__name__)
|
||||||
@@ -283,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.
|
||||||
@@ -290,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 []:
|
||||||
@@ -327,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)
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -348,6 +385,31 @@ _STATE_EMOJI: dict[str, str] = {
|
|||||||
"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",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -409,15 +471,27 @@ def _build_state_comment(
|
|||||||
|
|
||||||
elif new_state == "red_review":
|
elif new_state == "red_review":
|
||||||
lines += [
|
lines += [
|
||||||
"Red Team has submitted evidence and the test is awaiting Red Lead review "
|
f"Red Team has submitted evidence for Round {test.red_round_number or 1} and the "
|
||||||
"before it queues for Blue Team.",
|
"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":
|
elif new_state == "blue_review":
|
||||||
lines += [
|
lines += [
|
||||||
"Blue Team has submitted evidence and the test is awaiting Blue Lead review "
|
f"Blue Team has submitted evidence for Round {test.blue_round_number or 1} and the "
|
||||||
"before cross-validation.",
|
"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 += [
|
||||||
@@ -432,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'}",
|
||||||
@@ -452,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'}",
|
||||||
@@ -615,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,
|
||||||
@@ -663,21 +784,30 @@ def auto_create_test_issue(
|
|||||||
|
|
||||||
poc = test.procedure_text or "N/A"
|
poc = test.procedure_text or "N/A"
|
||||||
severity = _technique_severity(technique).capitalize()
|
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_TTP: mitre_id,
|
||||||
JIRA_FIELD_SEVERITY: severity,
|
JIRA_FIELD_SEVERITY: _select_field(severity),
|
||||||
}
|
}
|
||||||
|
|
||||||
data_sensitivity = _DATA_CLASSIFICATION_TO_JIRA.get(_enum_value(test.data_classification))
|
data_sensitivity = _DATA_CLASSIFICATION_TO_JIRA.get(_enum_value(test.data_classification))
|
||||||
if data_sensitivity:
|
if data_sensitivity:
|
||||||
fields[JIRA_FIELD_DATA_SENSITIVITY] = 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
|
||||||
@@ -688,7 +818,23 @@ def auto_create_test_issue(
|
|||||||
if parent:
|
if parent:
|
||||||
fields["parent"] = {"key": parent}
|
fields["parent"] = {"key": parent}
|
||||||
|
|
||||||
result = jira.issue_create(fields=fields)
|
try:
|
||||||
|
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", "")
|
||||||
|
|
||||||
@@ -750,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)
|
||||||
@@ -778,7 +934,7 @@ def push_test_event(
|
|||||||
)
|
)
|
||||||
|
|
||||||
if new_state in ("red_review", "blue_review") and assignee:
|
if new_state in ("red_review", "blue_review") and assignee:
|
||||||
jira_account_id = getattr(assignee, "jira_account_id", None)
|
jira_account_id = _resolve_jira_account_id(db, assignee)
|
||||||
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)
|
||||||
@@ -792,6 +948,41 @@ def push_test_event(
|
|||||||
link.jira_issue_key, jira_account_id, exc_a,
|
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(
|
||||||
@@ -805,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
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -873,11 +1105,13 @@ def push_hold_event(
|
|||||||
|
|
||||||
|
|
||||||
def push_pause_event(db: Session, test, actor, *, resuming: bool = False) -> None:
|
def push_pause_event(db: Session, test, actor, *, resuming: bool = False) -> None:
|
||||||
"""Post a timer pause/resume comment to Jira and toggle 'On Hold' / 'In Progress'.
|
"""Post a timer pause/resume comment to Jira — status is left untouched.
|
||||||
|
|
||||||
Distinct from :func:`push_hold_event` — a short timer pause (the
|
Distinct from :func:`push_hold_event` — a short timer pause (the timer
|
||||||
operator stepping away briefly) maps to "On Hold", while an explicit
|
sitting idle between rework rounds, or the operator briefly stepping
|
||||||
hold (something external blocking progress) maps to "Blocked".
|
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.
|
Non-fatal — any Jira error is logged and swallowed.
|
||||||
"""
|
"""
|
||||||
if not has_admin_jira_configured(db):
|
if not has_admin_jira_configured(db):
|
||||||
@@ -900,16 +1134,8 @@ def push_pause_event(db: Session, test, actor, *, resuming: bool = False) -> Non
|
|||||||
|
|
||||||
if resuming:
|
if resuming:
|
||||||
comment = f"h3. ▶ Timer Resumed\n\n*Resumed by:* {actor.username}\n*At:* {ts}"
|
comment = f"h3. ▶ Timer Resumed\n\n*Resumed 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 off On Hold: %s", link.jira_issue_key, exc_t)
|
|
||||||
else:
|
else:
|
||||||
comment = f"h3. ⏸ Timer Paused\n\n*Paused by:* {actor.username}\n*At:* {ts}"
|
comment = f"h3. ⏸ Timer Paused\n\n*Paused by:* {actor.username}\n*At:* {ts}"
|
||||||
try:
|
|
||||||
jira.set_issue_status(link.jira_issue_key, "On Hold")
|
|
||||||
except Exception as exc_t:
|
|
||||||
logger.warning("Could not transition %s to On Hold: %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()
|
||||||
@@ -919,6 +1145,122 @@ def push_pause_event(db: Session, test, actor, *, resuming: bool = False) -> Non
|
|||||||
logger.warning("Failed to push pause event for test %s: %s", test.id, exc, exc_info=True)
|
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
|
# RT/BT date + result custom-field sync
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -931,12 +1273,28 @@ def _enum_value(v) -> Optional[str]:
|
|||||||
return v.value if hasattr(v, "value") else str(v)
|
return v.value if hasattr(v, "value") else str(v)
|
||||||
|
|
||||||
|
|
||||||
def _compute_hours(start: Optional[datetime], end: Optional[datetime]) -> Optional[float]:
|
def _select_field(value: str) -> dict:
|
||||||
"""Return the elapsed time between two timestamps in hours, or None if unavailable."""
|
"""Wrap a value for a Jira single-select custom field.
|
||||||
if not start or not end:
|
|
||||||
return None
|
Jira's REST API rejects a bare string for select-type custom fields
|
||||||
delta_hours = (end - start).total_seconds() / 3600
|
("Specify a valid 'id' or 'name' for <field>") — it must be posted as
|
||||||
return round(delta_hours, 2) if delta_hours >= 0 else None
|
``{"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:
|
def _update_test_fields(db: Session, test: Test, fields: dict) -> None:
|
||||||
@@ -971,44 +1329,87 @@ def _update_test_fields(db: Session, test: Test, fields: dict) -> None:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def push_rt_started(db: Session, test: Test) -> None:
|
|
||||||
"""Set the RT Start Date field — called when the operator hits Start Execution."""
|
|
||||||
_update_test_fields(db, test, {
|
|
||||||
JIRA_FIELD_RT_START_DATE: datetime.utcnow().strftime("%Y-%m-%d"),
|
|
||||||
})
|
|
||||||
|
|
||||||
|
|
||||||
def push_rt_submitted(db: Session, test: Test) -> None:
|
def push_rt_submitted(db: Session, test: Test) -> None:
|
||||||
"""Set the RT End Date + Attack Success fields — called when Red submits for review."""
|
"""Set RT Start Date, RT End Date, and Attack Success — called when Red submits for review.
|
||||||
fields: dict = {JIRA_FIELD_RT_END_DATE: datetime.utcnow().strftime("%Y-%m-%d")}
|
|
||||||
|
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)
|
attack_success = _enum_value(test.attack_success)
|
||||||
if attack_success in _ATTACK_SUCCESS_TO_JIRA:
|
if attack_success in _ATTACK_SUCCESS_TO_JIRA:
|
||||||
fields[JIRA_FIELD_ATTACK_SUCCESS] = _ATTACK_SUCCESS_TO_JIRA[attack_success]
|
fields[JIRA_FIELD_ATTACK_SUCCESS] = _select_field(_ATTACK_SUCCESS_TO_JIRA[attack_success])
|
||||||
|
|
||||||
_update_test_fields(db, test, fields)
|
_update_test_fields(db, test, fields)
|
||||||
|
|
||||||
|
|
||||||
def push_bt_started(db: Session, test: Test) -> None:
|
def push_bt_started(db: Session, test: Test) -> None:
|
||||||
"""Set the BT Start Date field — called when Blue picks up the test to evaluate."""
|
"""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, {
|
_update_test_fields(db, test, {
|
||||||
JIRA_FIELD_BT_START_DATE: datetime.utcnow().strftime("%Y-%m-%d"),
|
JIRA_FIELD_BT_START_DATE: _jira_datetime(datetime.utcnow()),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
def push_bt_submitted(db: Session, test: Test) -> None:
|
def push_bt_submitted(db: Session, test: Test) -> None:
|
||||||
"""Set BT End Date, Attack Detected, and time-to-detect/contain — Blue submits for review."""
|
"""Set BT End Date, Attack Detected/Contained, and Time to Detect/Contain — Blue submits for review.
|
||||||
fields: dict = {JIRA_FIELD_BT_END_DATE: datetime.utcnow().strftime("%Y-%m-%d")}
|
|
||||||
|
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)
|
detection_result = _enum_value(test.detection_result)
|
||||||
if detection_result in _DETECTION_TO_JIRA:
|
if detection_result in _DETECTION_TO_JIRA:
|
||||||
fields[JIRA_FIELD_ATTACK_DETECTED] = _DETECTION_TO_JIRA[detection_result]
|
fields[JIRA_FIELD_ATTACK_DETECTED] = _select_field(_DETECTION_TO_JIRA[detection_result])
|
||||||
|
|
||||||
time_to_detect = _compute_hours(test.execution_end_time, test.detection_time)
|
containment_result = _enum_value(test.containment_result)
|
||||||
if time_to_detect is not None:
|
if containment_result in _CONTAINMENT_TO_JIRA:
|
||||||
fields[JIRA_FIELD_TIME_TO_DETECT] = time_to_detect
|
fields[JIRA_FIELD_ATTACK_CONTAINED] = _select_field(_CONTAINMENT_TO_JIRA[containment_result])
|
||||||
|
|
||||||
time_to_contain = _compute_hours(test.detection_time, test.containment_time)
|
if test.detection_time:
|
||||||
if time_to_contain is not None:
|
fields[JIRA_FIELD_TIME_TO_DETECT] = _jira_datetime(test.detection_time)
|
||||||
fields[JIRA_FIELD_TIME_TO_CONTAIN] = time_to_contain
|
|
||||||
|
if test.containment_time:
|
||||||
|
fields[JIRA_FIELD_TIME_TO_CONTAIN] = _jira_datetime(test.containment_time)
|
||||||
|
|
||||||
_update_test_fields(db, test, fields)
|
_update_test_fields(db, test, fields)
|
||||||
|
|
||||||
|
|||||||
@@ -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 (
|
||||||
@@ -28,7 +29,7 @@ from app.utils import escape_like
|
|||||||
|
|
||||||
# Tactics whose test data is more likely to touch customer content, PII, or
|
# Tactics whose test data is more likely to touch customer content, PII, or
|
||||||
# high-impact operational detail — these get an initial classification of
|
# high-impact operational detail — these get an initial classification of
|
||||||
# 'restricted' rather than the 'confidential' baseline. This is a starting
|
# 'pii' rather than the 'internal_use_only' baseline. This is a starting
|
||||||
# point only; the assigned operator or lead can always correct it.
|
# point only; the assigned operator or lead can always correct it.
|
||||||
_RESTRICTED_TACTICS = frozenset({
|
_RESTRICTED_TACTICS = frozenset({
|
||||||
"exfiltration", "collection", "credential-access", "impact",
|
"exfiltration", "collection", "credential-access", "impact",
|
||||||
@@ -39,37 +40,40 @@ def determine_initial_classification(technique: Technique | None) -> str:
|
|||||||
"""Best-effort initial data classification for a new test.
|
"""Best-effort initial data classification for a new test.
|
||||||
|
|
||||||
Every security test reveals internal attack/defense posture, so the
|
Every security test reveals internal attack/defense posture, so the
|
||||||
baseline is 'confidential' (internal use only) — never public or
|
baseline is 'internal_use_only' — never public or general use by
|
||||||
general use by default. Escalated to 'restricted' when the technique's
|
default. Escalated to 'pii' when the technique's tactic implies
|
||||||
tactic implies customer data, PII, or destructive impact.
|
customer data, PII, or destructive impact.
|
||||||
"""
|
"""
|
||||||
if technique and technique.tactic and technique.tactic.lower() in _RESTRICTED_TACTICS:
|
if technique and technique.tactic and technique.tactic.lower() in _RESTRICTED_TACTICS:
|
||||||
return DataClassification.restricted.value
|
return DataClassification.pii.value
|
||||||
return DataClassification.confidential.value
|
return DataClassification.internal_use_only.value
|
||||||
|
|
||||||
|
|
||||||
# Define function list_tests
|
def _build_test_query(
|
||||||
def list_tests(
|
|
||||||
# Entry: db
|
|
||||||
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:
|
||||||
query = db.query(Test).options(joinedload(Test.technique))
|
"""Build the filtered Test query shared by list_tests() and count_tests().
|
||||||
|
|
||||||
|
Kept as a single source of truth so the displayed page of results and
|
||||||
|
the total count it's paginated against can never drift apart.
|
||||||
|
"""
|
||||||
|
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:
|
||||||
@@ -79,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)}%"))
|
||||||
@@ -101,14 +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))
|
||||||
|
|
||||||
# Return query.order_by(Test.created_at.desc()).offset(offset).limit(limit)....
|
if attack_success:
|
||||||
|
query = query.filter(Test.attack_success == attack_success)
|
||||||
|
if detection_result:
|
||||||
|
query = query.filter(Test.detection_result == detection_result)
|
||||||
|
|
||||||
|
# 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
|
||||||
@@ -181,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.
|
||||||
|
|
||||||
@@ -241,9 +365,11 @@ 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
|
||||||
@@ -273,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()
|
||||||
@@ -307,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()
|
||||||
|
|||||||
@@ -28,15 +28,18 @@ from sqlalchemy.orm import Session
|
|||||||
from app.config import settings
|
from app.config import settings
|
||||||
from app.domain.exceptions import BusinessRuleViolation, 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 (
|
from app.services.notification_service import (
|
||||||
notify_test_state_change,
|
notify_test_state_change,
|
||||||
create_notification,
|
create_notification,
|
||||||
notify_role_with_email,
|
notify_role_with_email,
|
||||||
|
notify_roles_by_email,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Assign logger = logging.getLogger(__name__)
|
# Assign logger = logging.getLogger(__name__)
|
||||||
@@ -174,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()
|
||||||
@@ -223,12 +244,6 @@ def start_execution(db: Session, test: Test, user: User) -> Test:
|
|||||||
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)
|
||||||
|
|
||||||
try:
|
|
||||||
from app.services.jira_service import push_rt_started
|
|
||||||
push_rt_started(db, test)
|
|
||||||
except Exception as e:
|
|
||||||
logger.warning("Jira RT start date push failed for test %s: %s", test.id, e, exc_info=True)
|
|
||||||
|
|
||||||
return test
|
return test
|
||||||
|
|
||||||
|
|
||||||
@@ -326,6 +341,12 @@ def submit_red_evidence(db: Session, test: Test, user: User) -> Test:
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning("Jira RT end date push failed for test %s: %s", test.id, e, exc_info=True)
|
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
|
return test
|
||||||
|
|
||||||
|
|
||||||
@@ -364,18 +385,43 @@ def reopen_red_review(db: Session, test: Test, user: User, notes: str) -> Test:
|
|||||||
if not notes or not notes.strip():
|
if not notes or not notes.strip():
|
||||||
raise InvalidOperationError("A comment is required when reopening a test for rework")
|
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 = TestEntity.from_orm(test)
|
||||||
entity.reopen_red_review()
|
entity.reopen_red_review()
|
||||||
entity.apply_to(test)
|
entity.apply_to(test)
|
||||||
test.red_review_by = user.id
|
test.red_review_by = user.id
|
||||||
test.red_review_at = datetime.utcnow()
|
test.red_review_at = datetime.utcnow()
|
||||||
test.red_review_notes = notes.strip()
|
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()
|
db.flush()
|
||||||
|
|
||||||
log_action(
|
log_action(
|
||||||
db, user_id=user.id, action="reopen_red_review",
|
db, user_id=user.id, action="reopen_red_review",
|
||||||
entity_type="test", entity_id=test.id,
|
entity_type="test", entity_id=test.id,
|
||||||
details={"notes": notes, "test_name": test.name},
|
details={"notes": notes, "test_name": test.name, "round_number": archived_round.round_number},
|
||||||
)
|
)
|
||||||
|
|
||||||
if test.red_tech_assignee:
|
if test.red_tech_assignee:
|
||||||
@@ -389,6 +435,20 @@ def reopen_red_review(db: Session, test: Test, user: User, notes: str) -> 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_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
|
return test
|
||||||
|
|
||||||
|
|
||||||
@@ -428,6 +488,12 @@ def start_blue_work(db: Session, test: Test, user: User) -> Test:
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning("Jira BT start date push failed for test %s: %s", test.id, e, exc_info=True)
|
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
|
||||||
|
|
||||||
|
|
||||||
@@ -518,6 +584,12 @@ def submit_blue_evidence(db: Session, test: Test, user: User) -> Test:
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning("Jira BT end date push failed for test %s: %s", test.id, e, exc_info=True)
|
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
|
return test
|
||||||
|
|
||||||
|
|
||||||
@@ -556,6 +628,19 @@ def reopen_blue_review(db: Session, test: Test, user: User, notes: str) -> Test:
|
|||||||
if not notes or not notes.strip():
|
if not notes or not notes.strip():
|
||||||
raise InvalidOperationError("A comment is required when reopening a test for rework")
|
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 = TestEntity.from_orm(test)
|
||||||
entity.reopen_blue_review()
|
entity.reopen_blue_review()
|
||||||
entity.apply_to(test)
|
entity.apply_to(test)
|
||||||
@@ -563,12 +648,21 @@ def reopen_blue_review(db: Session, test: Test, user: User, notes: str) -> Test:
|
|||||||
test.blue_review_by = user.id
|
test.blue_review_by = user.id
|
||||||
test.blue_review_at = datetime.utcnow()
|
test.blue_review_at = datetime.utcnow()
|
||||||
test.blue_review_notes = notes.strip()
|
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()
|
db.flush()
|
||||||
|
|
||||||
log_action(
|
log_action(
|
||||||
db, user_id=user.id, action="reopen_blue_review",
|
db, user_id=user.id, action="reopen_blue_review",
|
||||||
entity_type="test", entity_id=test.id,
|
entity_type="test", entity_id=test.id,
|
||||||
details={"notes": notes, "test_name": test.name},
|
details={"notes": notes, "test_name": test.name, "round_number": archived_round.round_number},
|
||||||
)
|
)
|
||||||
|
|
||||||
if test.blue_tech_assignee:
|
if test.blue_tech_assignee:
|
||||||
@@ -582,6 +676,13 @@ def reopen_blue_review(db: Session, test: Test, user: User, notes: str) -> 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_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
|
return test
|
||||||
|
|
||||||
|
|
||||||
@@ -948,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
|
||||||
|
|
||||||
@@ -1013,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
|
||||||
|
|
||||||
@@ -1047,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":
|
||||||
@@ -1078,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)
|
||||||
|
|
||||||
@@ -1098,11 +1218,17 @@ 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
|
# Notify managers too, in case the dispute stalls and needs escalation
|
||||||
@@ -1185,13 +1311,12 @@ def resolve_dispute(db: Session, test: Test, user: User, target_team: str, notes
|
|||||||
if target_team not in ("red", "blue"):
|
if target_team not in ("red", "blue"):
|
||||||
raise InvalidOperationError("target_team must be 'red' or 'blue'")
|
raise InvalidOperationError("target_team must be 'red' or 'blue'")
|
||||||
|
|
||||||
if user.role != "admin":
|
is_red_approver = user.role == "red_lead" and test.red_validation_status == "approved"
|
||||||
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"
|
||||||
is_blue_approver = user.role == "blue_lead" and test.blue_validation_status == "approved"
|
if not (is_red_approver or is_blue_approver):
|
||||||
if not (is_red_approver or is_blue_approver):
|
raise InvalidOperationError(
|
||||||
raise InvalidOperationError(
|
"Only the lead who approved can flip their vote to resolve this dispute"
|
||||||
"Only the lead who approved can flip their vote to resolve this dispute"
|
)
|
||||||
)
|
|
||||||
|
|
||||||
entity = TestEntity.from_orm(test)
|
entity = TestEntity.from_orm(test)
|
||||||
entity.resolve_dispute_reject(target_team)
|
entity.resolve_dispute_reject(target_team)
|
||||||
@@ -1236,6 +1361,51 @@ def resolve_dispute(db: Session, test: Test, user: User, target_team: str, notes
|
|||||||
return test
|
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}'
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -239,7 +239,7 @@ 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"]
|
||||||
|
|
||||||
@@ -249,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"]
|
||||||
|
|
||||||
@@ -271,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"]
|
||||||
|
|
||||||
@@ -287,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"]
|
||||||
|
|
||||||
@@ -303,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"]
|
||||||
|
|
||||||
@@ -319,7 +319,7 @@ def manager_token(client, manager_user):
|
|||||||
"""Get an auth token for the manager user."""
|
"""Get an auth token for the manager user."""
|
||||||
response = client.post(
|
response = client.post(
|
||||||
"/api/v1/auth/login",
|
"/api/v1/auth/login",
|
||||||
data={"username": "manager", "password": "manager123"},
|
data={"username": "manager@test.com", "password": "manager123"},
|
||||||
)
|
)
|
||||||
return response.json()["access_token"]
|
return response.json()["access_token"]
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
"""Tests for red/blue blind visibility on GET /tests/{id}.
|
"""Regression: Red and Blue must see each other's fields at every stage.
|
||||||
|
|
||||||
Neither team should see the other team's data while a test is in
|
Blind visibility (hiding the other team's fields until both reviews pass)
|
||||||
draft/red_executing/red_review/blue_evaluating/blue_review. Both sides
|
was a deliberate design in an earlier phase, but the org decided both teams
|
||||||
see everything once the test reaches in_review (and beyond). admin and
|
should see each other's work throughout — detection testing isn't meant to
|
||||||
viewer are never blinded.
|
be blind here. This confirms neither the frontend nor backend hides
|
||||||
|
anything based on role/state anymore.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import uuid
|
import uuid
|
||||||
@@ -46,11 +47,11 @@ def technique(api, auth_headers):
|
|||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def blind_test(client, db, api, auth_headers, red_tech_headers, technique):
|
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."""
|
"""A test in red_executing with red-side content already filled in."""
|
||||||
resp = api(
|
resp = api(
|
||||||
"post", "/api/v1/tests", auth_headers,
|
"post", "/api/v1/tests", red_lead_headers,
|
||||||
json={"technique_id": technique, "name": "Blind visibility test"},
|
json={"technique_id": technique, "name": "Visibility test"},
|
||||||
)
|
)
|
||||||
test_id = resp.json()["id"]
|
test_id = resp.json()["id"]
|
||||||
|
|
||||||
@@ -62,32 +63,21 @@ def blind_test(client, db, api, auth_headers, red_tech_headers, technique):
|
|||||||
return test_id
|
return test_id
|
||||||
|
|
||||||
|
|
||||||
def test_blue_viewer_cannot_see_red_fields_during_red_executing(
|
def test_blue_viewer_sees_red_fields_during_red_executing(
|
||||||
client, db, api, blind_test, blue_tech_headers
|
client, db, api, in_progress_test, blue_tech_headers
|
||||||
):
|
):
|
||||||
resp = api("get", f"/api/v1/tests/{blind_test}", blue_tech_headers)
|
resp = api("get", f"/api/v1/tests/{in_progress_test}", blue_tech_headers)
|
||||||
assert resp.status_code == 200
|
assert resp.status_code == 200
|
||||||
body = resp.json()
|
body = resp.json()
|
||||||
for field in _RED_FIELDS:
|
|
||||||
assert body[field] is None, f"{field} should be hidden from blue viewer"
|
|
||||||
|
|
||||||
|
|
||||||
def test_red_viewer_cannot_see_blue_fields_during_red_executing(
|
|
||||||
client, db, api, blind_test, red_tech_headers
|
|
||||||
):
|
|
||||||
resp = api("get", f"/api/v1/tests/{blind_test}", red_tech_headers)
|
|
||||||
assert resp.status_code == 200
|
|
||||||
body = resp.json()
|
|
||||||
for field in _BLUE_FIELDS:
|
|
||||||
assert body[field] is None, f"{field} should be hidden from red viewer"
|
|
||||||
# Red's own fields ARE visible to red
|
|
||||||
assert body["procedure_text"] == "run mimikatz"
|
assert body["procedure_text"] == "run mimikatz"
|
||||||
|
assert body["tool_used"] == "mimikatz"
|
||||||
|
assert body["red_summary"] == "dumped creds"
|
||||||
|
|
||||||
|
|
||||||
def test_admin_sees_everything_during_blind_states(
|
def test_red_viewer_sees_own_fields_during_red_executing(
|
||||||
client, db, api, blind_test, auth_headers
|
client, db, api, in_progress_test, red_tech_headers
|
||||||
):
|
):
|
||||||
resp = api("get", f"/api/v1/tests/{blind_test}", auth_headers)
|
resp = api("get", f"/api/v1/tests/{in_progress_test}", red_tech_headers)
|
||||||
assert resp.status_code == 200
|
assert resp.status_code == 200
|
||||||
body = resp.json()
|
body = resp.json()
|
||||||
assert body["procedure_text"] == "run mimikatz"
|
assert body["procedure_text"] == "run mimikatz"
|
||||||
@@ -95,9 +85,9 @@ def test_admin_sees_everything_during_blind_states(
|
|||||||
|
|
||||||
def test_both_sides_see_everything_once_in_review(
|
def test_both_sides_see_everything_once_in_review(
|
||||||
client, db, api, auth_headers, red_tech_headers, red_lead_headers,
|
client, db, api, auth_headers, red_tech_headers, red_lead_headers,
|
||||||
blue_tech_headers, blue_lead_headers, red_lead_user, blue_lead_user, blind_test,
|
blue_tech_headers, blue_lead_headers, red_lead_user, blue_lead_user, in_progress_test,
|
||||||
):
|
):
|
||||||
test_id = blind_test
|
test_id = in_progress_test
|
||||||
_add_evidence(db, test_id, TeamSide.red)
|
_add_evidence(db, test_id, TeamSide.red)
|
||||||
submit_red = api("post", f"/api/v1/tests/{test_id}/submit-red", red_tech_headers)
|
submit_red = api("post", f"/api/v1/tests/{test_id}/submit-red", red_tech_headers)
|
||||||
assert submit_red.status_code == 200, submit_red.text
|
assert submit_red.status_code == 200, submit_red.text
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
"""Router-level tests for the campaign manager-approval workflow."""
|
"""Router-level tests for the campaign manager-approval workflow."""
|
||||||
|
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
from app.models.campaign import Campaign, CampaignTest
|
from app.models.campaign import Campaign, CampaignTest
|
||||||
from app.models.technique import Technique
|
from app.models.technique import Technique
|
||||||
from app.models.test import Test
|
from app.models.test import Test
|
||||||
@@ -43,7 +45,7 @@ def test_manager_can_approve_and_sets_start_date(api, db, red_lead_user, red_lea
|
|||||||
"post",
|
"post",
|
||||||
f"/api/v1/campaigns/{campaign.id}/approve",
|
f"/api/v1/campaigns/{campaign.id}/approve",
|
||||||
manager_headers,
|
manager_headers,
|
||||||
json={"start_date": "2026-09-01T00:00:00"},
|
json={"start_date": "2020-01-01T00:00:00"},
|
||||||
)
|
)
|
||||||
assert resp.status_code == 200
|
assert resp.status_code == 200
|
||||||
body = resp.json()
|
body = resp.json()
|
||||||
@@ -51,6 +53,56 @@ def test_manager_can_approve_and_sets_start_date(api, db, red_lead_user, red_lea
|
|||||||
assert body["start_date"] is not None
|
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):
|
def test_lead_cannot_approve(api, db, red_lead_user, red_lead_headers):
|
||||||
campaign = _make_draft_campaign(db, red_lead_user.id)
|
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}/submit", red_lead_headers)
|
||||||
@@ -294,5 +346,127 @@ def test_create_campaign_payload_has_no_start_date_field(api, db, red_lead_heade
|
|||||||
json={"name": "No date campaign", "start_date": "2026-01-01"},
|
json={"name": "No date campaign", "start_date": "2026-01-01"},
|
||||||
)
|
)
|
||||||
assert resp.status_code == 201
|
assert resp.status_code == 201
|
||||||
# start_date silently ignored — only the manager can ever set it, via /approve
|
# 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
|
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
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -34,7 +34,7 @@ def _seed_technique(db, tactic="execution") -> Technique:
|
|||||||
return technique
|
return technique
|
||||||
|
|
||||||
|
|
||||||
def test_new_test_defaults_to_confidential_via_db_default(db, red_lead_user):
|
def test_new_test_defaults_to_internal_use_only_via_db_default(db, red_lead_user):
|
||||||
"""A Test() constructed without going through the create_test service
|
"""A Test() constructed without going through the create_test service
|
||||||
still gets a safe classification via the column's server_default."""
|
still gets a safe classification via the column's server_default."""
|
||||||
technique = _seed_technique(db)
|
technique = _seed_technique(db)
|
||||||
@@ -46,22 +46,24 @@ def test_new_test_defaults_to_confidential_via_db_default(db, red_lead_user):
|
|||||||
db.add(test)
|
db.add(test)
|
||||||
db.commit()
|
db.commit()
|
||||||
db.refresh(test)
|
db.refresh(test)
|
||||||
assert test.data_classification == "confidential"
|
assert test.data_classification == "internal_use_only"
|
||||||
|
|
||||||
|
|
||||||
def test_create_test_endpoint_uses_tactic_heuristic(client, db, api, auth_headers, technique):
|
def test_create_test_endpoint_uses_tactic_heuristic(client, db, api, red_lead_headers, red_lead_user, technique):
|
||||||
"""Going through the real create-test flow applies the tactic-based heuristic."""
|
"""Going through the real create-test flow applies the tactic-based heuristic."""
|
||||||
resp = api(
|
resp = api(
|
||||||
"post", "/api/v1/tests", auth_headers,
|
"post", "/api/v1/tests", red_lead_headers,
|
||||||
json={"technique_id": technique["id"], "name": "Heuristic test"},
|
json={"technique_id": technique["id"], "name": "Heuristic test"},
|
||||||
)
|
)
|
||||||
assert resp.status_code == 201, resp.text
|
assert resp.status_code == 201, resp.text
|
||||||
# The shared `technique` fixture (see conftest/test_tests.py) doesn't set
|
# The shared `technique` fixture (see conftest/test_tests.py) doesn't set
|
||||||
# a restricted-tier tactic, so this should land on the baseline.
|
# a pii-tier tactic, so this should land on the baseline.
|
||||||
assert resp.json()["data_classification"] == "confidential"
|
assert resp.json()["data_classification"] == "internal_use_only"
|
||||||
|
|
||||||
|
|
||||||
def test_admin_can_update_classification(client, db, admin_user, admin_token, red_lead_user):
|
def test_admin_cannot_update_classification(client, db, admin_user, admin_token, red_lead_user):
|
||||||
|
"""Admin administers the site, not test content — classification is a
|
||||||
|
lead/manager/operator call."""
|
||||||
technique = _seed_technique(db)
|
technique = _seed_technique(db)
|
||||||
test = Test(
|
test = Test(
|
||||||
technique_id=technique.id,
|
technique_id=technique.id,
|
||||||
@@ -74,14 +76,33 @@ def test_admin_can_update_classification(client, db, admin_user, admin_token, re
|
|||||||
|
|
||||||
response = client.patch(
|
response = client.patch(
|
||||||
f"/api/v1/tests/{test.id}/classification",
|
f"/api/v1/tests/{test.id}/classification",
|
||||||
json={"data_classification": "restricted"},
|
json={"data_classification": "pii"},
|
||||||
headers={"Authorization": f"Bearer {admin_token}"},
|
headers={"Authorization": f"Bearer {admin_token}"},
|
||||||
)
|
)
|
||||||
|
assert response.status_code == 403
|
||||||
|
|
||||||
|
|
||||||
|
def test_manager_can_update_classification(client, db, manager_user, manager_headers, red_lead_user):
|
||||||
|
technique = _seed_technique(db)
|
||||||
|
test = Test(
|
||||||
|
technique_id=technique.id,
|
||||||
|
name="Classify me",
|
||||||
|
created_by=red_lead_user.id,
|
||||||
|
state=TestState.draft,
|
||||||
|
)
|
||||||
|
db.add(test)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
response = client.patch(
|
||||||
|
f"/api/v1/tests/{test.id}/classification",
|
||||||
|
json={"data_classification": "pii"},
|
||||||
|
headers=manager_headers,
|
||||||
|
)
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
assert response.json()["data_classification"] == "restricted"
|
assert response.json()["data_classification"] == "pii"
|
||||||
|
|
||||||
db.refresh(test)
|
db.refresh(test)
|
||||||
assert test.data_classification == "restricted"
|
assert test.data_classification == "pii"
|
||||||
|
|
||||||
|
|
||||||
def test_operator_can_update_classification(client, db, admin_user, red_lead_token, red_lead_user):
|
def test_operator_can_update_classification(client, db, admin_user, red_lead_token, red_lead_user):
|
||||||
@@ -116,7 +137,7 @@ def test_viewer_cannot_update_classification(client, db, admin_user, red_lead_us
|
|||||||
)
|
)
|
||||||
db.add(viewer)
|
db.add(viewer)
|
||||||
db.commit()
|
db.commit()
|
||||||
login = client.post("/api/v1/auth/login", data={"username": "viewer_classif", "password": "x"})
|
login = client.post("/api/v1/auth/login", data={"username": "viewer_classif@test.com", "password": "x"})
|
||||||
viewer_token = login.json()["access_token"]
|
viewer_token = login.json()["access_token"]
|
||||||
|
|
||||||
technique = _seed_technique(db)
|
technique = _seed_technique(db)
|
||||||
@@ -130,7 +151,7 @@ def test_viewer_cannot_update_classification(client, db, admin_user, red_lead_us
|
|||||||
|
|
||||||
response = client.patch(
|
response = client.patch(
|
||||||
f"/api/v1/tests/{test.id}/classification",
|
f"/api/v1/tests/{test.id}/classification",
|
||||||
json={"data_classification": "restricted"},
|
json={"data_classification": "pii"},
|
||||||
headers={"Authorization": f"Bearer {viewer_token}"},
|
headers={"Authorization": f"Bearer {viewer_token}"},
|
||||||
)
|
)
|
||||||
assert response.status_code == 403
|
assert response.status_code == 403
|
||||||
@@ -142,15 +163,15 @@ def _technique_stub(tactic):
|
|||||||
return t
|
return t
|
||||||
|
|
||||||
|
|
||||||
def test_determine_initial_classification_defaults_to_confidential():
|
def test_determine_initial_classification_defaults_to_internal_use_only():
|
||||||
assert determine_initial_classification(_technique_stub("execution")) == "confidential"
|
assert determine_initial_classification(_technique_stub("execution")) == "internal_use_only"
|
||||||
assert determine_initial_classification(_technique_stub(None)) == "confidential"
|
assert determine_initial_classification(_technique_stub(None)) == "internal_use_only"
|
||||||
assert determine_initial_classification(None) == "confidential"
|
assert determine_initial_classification(None) == "internal_use_only"
|
||||||
|
|
||||||
|
|
||||||
def test_determine_initial_classification_escalates_for_sensitive_tactics():
|
def test_determine_initial_classification_escalates_for_sensitive_tactics():
|
||||||
assert determine_initial_classification(_technique_stub("exfiltration")) == "restricted"
|
assert determine_initial_classification(_technique_stub("exfiltration")) == "pii"
|
||||||
assert determine_initial_classification(_technique_stub("collection")) == "restricted"
|
assert determine_initial_classification(_technique_stub("collection")) == "pii"
|
||||||
assert determine_initial_classification(_technique_stub("credential-access")) == "restricted"
|
assert determine_initial_classification(_technique_stub("credential-access")) == "pii"
|
||||||
assert determine_initial_classification(_technique_stub("impact")) == "restricted"
|
assert determine_initial_classification(_technique_stub("impact")) == "pii"
|
||||||
assert determine_initial_classification(_technique_stub("Exfiltration")) == "restricted"
|
assert determine_initial_classification(_technique_stub("Exfiltration")) == "pii"
|
||||||
|
|||||||
@@ -0,0 +1,115 @@
|
|||||||
|
"""Coverage for the generic email webhook: the admin test endpoint, and
|
||||||
|
the new notification-dispatch helpers that route test/campaign/MITRE
|
||||||
|
events through it (see webhook_email_service.py, notification_service.py).
|
||||||
|
"""
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from app.services.notification_service import (
|
||||||
|
_preference_allows,
|
||||||
|
notify_all_users_by_email,
|
||||||
|
notify_user_by_email,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_email_webhook_test_endpoint_requires_admin(api, red_lead_headers):
|
||||||
|
resp = api(
|
||||||
|
"post", "/api/v1/system/email-webhook-test", red_lead_headers,
|
||||||
|
json={"to": "someone@test.com"},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 403
|
||||||
|
|
||||||
|
|
||||||
|
def test_email_webhook_test_endpoint_fails_without_config(api, auth_headers):
|
||||||
|
resp = api(
|
||||||
|
"post", "/api/v1/system/email-webhook-test", auth_headers,
|
||||||
|
json={"to": "someone@test.com"},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 502
|
||||||
|
|
||||||
|
|
||||||
|
def test_email_webhook_test_endpoint_sends_via_configured_webhook(api, auth_headers):
|
||||||
|
api(
|
||||||
|
"patch", "/api/v1/system/email-webhook-config", auth_headers,
|
||||||
|
json={"url": "https://example.com/power-automate-hook", "api_key": "super-secret-key"},
|
||||||
|
)
|
||||||
|
with patch("app.services.webhook_email_service.requests.post") as mock_post:
|
||||||
|
resp = api(
|
||||||
|
"post", "/api/v1/system/email-webhook-test", auth_headers,
|
||||||
|
json={"to": "marcos.luna@kaseya.com"},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200, resp.text
|
||||||
|
mock_post.assert_called_once()
|
||||||
|
call_kwargs = mock_post.call_args
|
||||||
|
assert call_kwargs.args[0] == "https://example.com/power-automate-hook"
|
||||||
|
assert call_kwargs.kwargs["headers"] == {"x-api-key": "super-secret-key"}
|
||||||
|
payload = call_kwargs.kwargs["json"]
|
||||||
|
assert payload["to"] == "marcos.luna@kaseya.com"
|
||||||
|
assert "Purple Team Engineering" in payload["body"]
|
||||||
|
|
||||||
|
|
||||||
|
class _FakeUser:
|
||||||
|
def __init__(self, email="user@test.com", full_name="A User", prefs=None):
|
||||||
|
self.email = email
|
||||||
|
self.full_name = full_name
|
||||||
|
self.notification_preferences = prefs
|
||||||
|
|
||||||
|
|
||||||
|
def test_preference_allows_defaults_to_true_when_missing():
|
||||||
|
assert _preference_allows(_FakeUser(prefs=None), "email_on_test_validated") is True
|
||||||
|
assert _preference_allows(_FakeUser(prefs={}), "email_on_test_validated") is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_preference_allows_respects_explicit_false():
|
||||||
|
user = _FakeUser(prefs={"email_on_test_validated": False})
|
||||||
|
assert _preference_allows(user, "email_on_test_validated") is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_preference_allows_false_without_email():
|
||||||
|
assert _preference_allows(_FakeUser(email=None), "email_on_test_validated") is False
|
||||||
|
assert _preference_allows(None, "email_on_test_validated") is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_notify_user_by_email_sends_when_allowed(db, auth_headers, api):
|
||||||
|
resp = api(
|
||||||
|
"post", "/api/v1/users", auth_headers,
|
||||||
|
json={"full_name": "Notify Me", "email": "notifyme@test.com", "role": "viewer"},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 201, resp.text
|
||||||
|
user_id = resp.json()["id"]
|
||||||
|
|
||||||
|
with patch("app.services.webhook_email_service.send_webhook_email") as mock_send:
|
||||||
|
notify_user_by_email(
|
||||||
|
db, uuid.UUID(user_id),
|
||||||
|
preference_key="email_on_test_validated",
|
||||||
|
subject="Test Validated: Some Test",
|
||||||
|
message='Your test "Some Test" has been validated successfully.',
|
||||||
|
)
|
||||||
|
mock_send.assert_called_once()
|
||||||
|
assert mock_send.call_args.kwargs["to"] == "notifyme@test.com"
|
||||||
|
|
||||||
|
|
||||||
|
def test_notify_all_users_by_email_skips_opted_out(db):
|
||||||
|
allowed = _FakeUser(email="allowed@test.com", prefs={"email_on_new_mitre_techniques": True})
|
||||||
|
blocked = _FakeUser(email="blocked@test.com", prefs={"email_on_new_mitre_techniques": False})
|
||||||
|
|
||||||
|
class _FakeQuery:
|
||||||
|
def filter(self, *args, **kwargs):
|
||||||
|
return self
|
||||||
|
|
||||||
|
def all(self):
|
||||||
|
return [allowed, blocked]
|
||||||
|
|
||||||
|
with patch.object(db, "query", return_value=_FakeQuery()), \
|
||||||
|
patch("app.services.webhook_email_service.send_webhook_email") as mock_send:
|
||||||
|
notify_all_users_by_email(
|
||||||
|
db,
|
||||||
|
preference_key="email_on_new_mitre_techniques",
|
||||||
|
subject="MITRE ATT&CK Updated: 3 new techniques",
|
||||||
|
message="3 new techniques were added and 1 were updated.",
|
||||||
|
)
|
||||||
|
mock_send.assert_called_once()
|
||||||
|
assert mock_send.call_args.kwargs["to"] == "allowed@test.com"
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
"""Evidence upload file-type validation — Red team artifacts (e.g. .exe
|
||||||
|
payloads) must be shareable with Blue for detection analysis."""
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from app.domain.errors import BusinessRuleViolation
|
||||||
|
from app.services.evidence_service import validate_file
|
||||||
|
|
||||||
|
|
||||||
|
def test_exe_files_are_allowed():
|
||||||
|
validate_file("payload.exe", 1024)
|
||||||
|
|
||||||
|
|
||||||
|
def test_allowed_extensions_still_pass():
|
||||||
|
for name in ("screenshot.png", "notes.pdf", "capture.pcap", "archive.zip"):
|
||||||
|
validate_file(name, 1024)
|
||||||
|
|
||||||
|
|
||||||
|
def test_disallowed_extension_still_rejected():
|
||||||
|
with pytest.raises(BusinessRuleViolation):
|
||||||
|
validate_file("script.sh", 1024)
|
||||||
|
|
||||||
|
|
||||||
|
def test_oversized_file_still_rejected():
|
||||||
|
from app.services.evidence_service import MAX_UPLOAD_SIZE
|
||||||
|
|
||||||
|
with pytest.raises(BusinessRuleViolation):
|
||||||
|
validate_file("payload.exe", MAX_UPLOAD_SIZE + 1)
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
"""Every heatmap layer must include the technique's display name.
|
||||||
|
|
||||||
|
The frontend heatmap previously showed only the MITRE ID on each cell
|
||||||
|
(name only available via hover tooltip), unlike the Techniques page which
|
||||||
|
always showed both. Unifying the two pages' cell style requires the name
|
||||||
|
to be available on every layer's technique entries, not just derived
|
||||||
|
client-side (HeatmapTechnique has no name field to derive it from).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from app.models.campaign import Campaign, CampaignTest
|
||||||
|
from app.models.detection_rule import DetectionRule
|
||||||
|
from app.models.enums import TestState
|
||||||
|
from app.models.technique import Technique
|
||||||
|
from app.models.test import Test
|
||||||
|
from app.models.threat_actor import ThreatActor, ThreatActorTechnique
|
||||||
|
from app.services.heatmap_service import (
|
||||||
|
build_campaign_layer,
|
||||||
|
build_coverage_layer,
|
||||||
|
build_detection_rules_layer,
|
||||||
|
build_threat_actor_layer,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _seed_technique(db, mitre_id="T1059", name="Command and Scripting Interpreter"):
|
||||||
|
tech = Technique(mitre_id=mitre_id, name=name, tactic="execution", platforms=["windows"])
|
||||||
|
db.add(tech)
|
||||||
|
db.flush()
|
||||||
|
return tech
|
||||||
|
|
||||||
|
|
||||||
|
def test_coverage_layer_includes_technique_name_and_status(db):
|
||||||
|
tech = _seed_technique(db)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
layer = build_coverage_layer(db)
|
||||||
|
|
||||||
|
entry = next(t for t in layer["techniques"] if t["techniqueID"] == tech.mitre_id)
|
||||||
|
assert entry["name"] == tech.name
|
||||||
|
assert entry["status"] == "not_evaluated"
|
||||||
|
|
||||||
|
|
||||||
|
def test_threat_actor_layer_includes_technique_name_and_status(db):
|
||||||
|
tech = _seed_technique(db)
|
||||||
|
actor = ThreatActor(name="APT-Test")
|
||||||
|
db.add(actor)
|
||||||
|
db.flush()
|
||||||
|
db.add(ThreatActorTechnique(threat_actor_id=actor.id, technique_id=tech.id))
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
layer = build_threat_actor_layer(db, actor.id)
|
||||||
|
|
||||||
|
entry = next(t for t in layer["techniques"] if t["techniqueID"] == tech.mitre_id)
|
||||||
|
assert entry["name"] == tech.name
|
||||||
|
assert entry["status"] == "not_evaluated"
|
||||||
|
|
||||||
|
|
||||||
|
def test_detection_rules_layer_includes_technique_name(db):
|
||||||
|
tech = _seed_technique(db)
|
||||||
|
db.add(DetectionRule(
|
||||||
|
mitre_technique_id=tech.mitre_id, title="Rule 1", source="sigma",
|
||||||
|
rule_content="title: Rule 1", rule_format="sigma",
|
||||||
|
))
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
layer = build_detection_rules_layer(db)
|
||||||
|
|
||||||
|
entry = next(t for t in layer["techniques"] if t["techniqueID"] == tech.mitre_id)
|
||||||
|
assert entry["name"] == tech.name
|
||||||
|
|
||||||
|
|
||||||
|
def test_campaign_layer_includes_technique_name(db, red_lead_user):
|
||||||
|
tech = _seed_technique(db)
|
||||||
|
campaign = Campaign(name="Test Campaign", type="custom", status="active", created_by=red_lead_user.id)
|
||||||
|
db.add(campaign)
|
||||||
|
db.flush()
|
||||||
|
test = Test(technique_id=tech.id, name="A test", state=TestState.validated, created_by=red_lead_user.id)
|
||||||
|
db.add(test)
|
||||||
|
db.flush()
|
||||||
|
db.add(CampaignTest(campaign_id=campaign.id, test_id=test.id, order_index=0))
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
layer = build_campaign_layer(db, campaign.id)
|
||||||
|
|
||||||
|
entry = next(t for t in layer["techniques"] if t["techniqueID"] == tech.mitre_id)
|
||||||
|
assert entry["name"] == tech.name
|
||||||
@@ -0,0 +1,150 @@
|
|||||||
|
"""Tests for POST /tests/{id}/hold and /resume — must pause/resume the phase timer."""
|
||||||
|
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
|
||||||
|
from app.models.test import Test
|
||||||
|
from app.models.technique import Technique
|
||||||
|
from app.models.enums import TestState
|
||||||
|
|
||||||
|
|
||||||
|
def _seed_technique(db) -> Technique:
|
||||||
|
technique = Technique(
|
||||||
|
mitre_id="T9999", name="Test Technique", tactic="execution", platforms=["linux"],
|
||||||
|
)
|
||||||
|
db.add(technique)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(technique)
|
||||||
|
return technique
|
||||||
|
|
||||||
|
|
||||||
|
def _seed_executing_test(db, technique, created_by) -> Test:
|
||||||
|
test = Test(
|
||||||
|
technique_id=technique.id,
|
||||||
|
name="Holdable test",
|
||||||
|
created_by=created_by,
|
||||||
|
state=TestState.red_executing,
|
||||||
|
red_started_at=datetime.utcnow() - timedelta(minutes=10),
|
||||||
|
)
|
||||||
|
db.add(test)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(test)
|
||||||
|
return test
|
||||||
|
|
||||||
|
|
||||||
|
def _seed_blue_evaluating_test(db, technique, created_by) -> Test:
|
||||||
|
test = Test(
|
||||||
|
technique_id=technique.id,
|
||||||
|
name="Holdable test (blue)",
|
||||||
|
created_by=created_by,
|
||||||
|
state=TestState.blue_evaluating,
|
||||||
|
blue_started_at=datetime.utcnow() - timedelta(minutes=10),
|
||||||
|
)
|
||||||
|
db.add(test)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(test)
|
||||||
|
return test
|
||||||
|
|
||||||
|
|
||||||
|
def test_hold_pauses_the_running_timer(client, db, red_tech_headers, red_tech_user):
|
||||||
|
technique = _seed_technique(db)
|
||||||
|
test = _seed_executing_test(db, technique, red_tech_user.id)
|
||||||
|
assert test.paused_at is None
|
||||||
|
|
||||||
|
resp = client.post(
|
||||||
|
f"/api/v1/tests/{test.id}/hold",
|
||||||
|
json={"reason": "waiting on lab access"},
|
||||||
|
headers=red_tech_headers,
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200, resp.text
|
||||||
|
assert resp.json()["paused_at"] is not None
|
||||||
|
|
||||||
|
db.refresh(test)
|
||||||
|
assert test.paused_at is not None
|
||||||
|
|
||||||
|
|
||||||
|
def test_resume_clears_pause_and_accumulates_seconds(client, db, red_tech_headers, red_tech_user):
|
||||||
|
technique = _seed_technique(db)
|
||||||
|
test = _seed_executing_test(db, technique, red_tech_user.id)
|
||||||
|
|
||||||
|
resp = client.post(
|
||||||
|
f"/api/v1/tests/{test.id}/hold",
|
||||||
|
json={"reason": "waiting on lab access"},
|
||||||
|
headers=red_tech_headers,
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
|
||||||
|
resp = client.post(f"/api/v1/tests/{test.id}/resume", headers=red_tech_headers)
|
||||||
|
assert resp.status_code == 200, resp.text
|
||||||
|
assert resp.json()["paused_at"] is None
|
||||||
|
|
||||||
|
db.refresh(test)
|
||||||
|
assert test.paused_at is None
|
||||||
|
assert test.red_paused_seconds >= 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_hold_does_not_double_pause_an_already_paused_timer(client, db, red_tech_headers, red_tech_user):
|
||||||
|
"""If the operator already paused the timer manually, holding must not
|
||||||
|
overwrite paused_at (which would reset the elapsed-pause calculation)."""
|
||||||
|
technique = _seed_technique(db)
|
||||||
|
test = _seed_executing_test(db, technique, red_tech_user.id)
|
||||||
|
|
||||||
|
resp = client.post(f"/api/v1/tests/{test.id}/pause-timer", headers=red_tech_headers)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
db.refresh(test)
|
||||||
|
manual_pause_time = test.paused_at
|
||||||
|
|
||||||
|
resp = client.post(
|
||||||
|
f"/api/v1/tests/{test.id}/hold",
|
||||||
|
json={"reason": "waiting on lab access"},
|
||||||
|
headers=red_tech_headers,
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
|
||||||
|
db.refresh(test)
|
||||||
|
assert test.paused_at == manual_pause_time
|
||||||
|
|
||||||
|
|
||||||
|
def test_red_tech_cannot_hold_a_test_in_blue_evaluating(
|
||||||
|
client, db, red_tech_headers, red_tech_user,
|
||||||
|
):
|
||||||
|
"""Once a test has moved into Blue's queue, only blue_tech may hold it —
|
||||||
|
a red_tech is no longer involved at this stage."""
|
||||||
|
technique = _seed_technique(db)
|
||||||
|
test = _seed_blue_evaluating_test(db, technique, red_tech_user.id)
|
||||||
|
|
||||||
|
resp = client.post(
|
||||||
|
f"/api/v1/tests/{test.id}/hold",
|
||||||
|
json={"reason": "waiting on lab access"},
|
||||||
|
headers=red_tech_headers,
|
||||||
|
)
|
||||||
|
assert resp.status_code == 403
|
||||||
|
|
||||||
|
|
||||||
|
def test_blue_tech_can_hold_a_test_in_blue_evaluating(
|
||||||
|
client, db, blue_tech_headers, red_tech_user,
|
||||||
|
):
|
||||||
|
technique = _seed_technique(db)
|
||||||
|
test = _seed_blue_evaluating_test(db, technique, red_tech_user.id)
|
||||||
|
|
||||||
|
resp = client.post(
|
||||||
|
f"/api/v1/tests/{test.id}/hold",
|
||||||
|
json={"reason": "waiting on EDR access"},
|
||||||
|
headers=blue_tech_headers,
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200, resp.text
|
||||||
|
|
||||||
|
|
||||||
|
def test_blue_tech_cannot_resume_a_test_that_was_held_during_red_executing(
|
||||||
|
client, db, api, red_tech_headers, blue_tech_headers, red_tech_user,
|
||||||
|
):
|
||||||
|
technique = _seed_technique(db)
|
||||||
|
test = _seed_executing_test(db, technique, red_tech_user.id)
|
||||||
|
|
||||||
|
resp = api(
|
||||||
|
"post", f"/api/v1/tests/{test.id}/hold", red_tech_headers,
|
||||||
|
json={"reason": "waiting on lab access"},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200, resp.text
|
||||||
|
|
||||||
|
resp = api("post", f"/api/v1/tests/{test.id}/resume", blue_tech_headers)
|
||||||
|
assert resp.status_code == 403
|
||||||
@@ -97,11 +97,17 @@ if "apscheduler.schedulers.background" not in sys.modules:
|
|||||||
_apsched = ModuleType("apscheduler.schedulers.background")
|
_apsched = ModuleType("apscheduler.schedulers.background")
|
||||||
class _FakeBGScheduler:
|
class _FakeBGScheduler:
|
||||||
def add_job(self, *a, **kw): pass
|
def add_job(self, *a, **kw): pass
|
||||||
|
def add_listener(self, *a, **kw): pass
|
||||||
def start(self): pass
|
def start(self): pass
|
||||||
def shutdown(self, **kw): pass
|
def shutdown(self, **kw): pass
|
||||||
_apsched.BackgroundScheduler = _FakeBGScheduler
|
_apsched.BackgroundScheduler = _FakeBGScheduler
|
||||||
sys.modules["apscheduler.schedulers.background"] = _apsched
|
sys.modules["apscheduler.schedulers.background"] = _apsched
|
||||||
|
|
||||||
|
if "apscheduler.events" not in sys.modules:
|
||||||
|
_apsched_events = ModuleType("apscheduler.events")
|
||||||
|
_apsched_events.EVENT_JOB_ERROR = 1
|
||||||
|
sys.modules["apscheduler.events"] = _apsched_events
|
||||||
|
|
||||||
if "taxii2client" not in sys.modules:
|
if "taxii2client" not in sys.modules:
|
||||||
sys.modules["taxii2client"] = ModuleType("taxii2client")
|
sys.modules["taxii2client"] = ModuleType("taxii2client")
|
||||||
if "taxii2client.v20" not in sys.modules:
|
if "taxii2client.v20" not in sys.modules:
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
"""GET /system/jira-config must be readable by any authenticated user.
|
||||||
|
|
||||||
|
Regression: it used to be admin-only, so non-admin users (e.g. red_lead)
|
||||||
|
got a 403 and the frontend silently fell back to a hardcoded
|
||||||
|
"https://jira.atlassian.com" base URL when building ticket links.
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def test_non_admin_can_read_jira_config(client, red_lead_headers):
|
||||||
|
resp = client.get("/api/v1/system/jira-config", headers=red_lead_headers)
|
||||||
|
assert resp.status_code == 200, resp.text
|
||||||
|
assert "url" in resp.json()
|
||||||
|
|
||||||
|
|
||||||
|
def test_non_admin_cannot_update_jira_config(client, red_lead_headers):
|
||||||
|
resp = client.patch(
|
||||||
|
"/api/v1/system/jira-config",
|
||||||
|
json={"url": "https://evil.example.com"},
|
||||||
|
headers=red_lead_headers,
|
||||||
|
)
|
||||||
|
assert resp.status_code == 403
|
||||||
@@ -52,9 +52,27 @@ def _make_test(**overrides):
|
|||||||
t.name = "Test"
|
t.name = "Test"
|
||||||
t.attack_success = None
|
t.attack_success = None
|
||||||
t.detection_result = None
|
t.detection_result = None
|
||||||
|
t.containment_result = None
|
||||||
|
t.execution_start_time = None
|
||||||
t.execution_end_time = None
|
t.execution_end_time = None
|
||||||
t.detection_time = None
|
t.detection_time = None
|
||||||
t.containment_time = None
|
t.containment_time = None
|
||||||
|
t.red_round_number = 1
|
||||||
|
t.blue_round_number = 1
|
||||||
|
t.procedure_text = None
|
||||||
|
t.tool_used = None
|
||||||
|
# _build_state_comment() appends these raw (not via an f-string) when
|
||||||
|
# truthy, so an un-nulled MagicMock attribute breaks "\n".join(lines).
|
||||||
|
t.red_summary = None
|
||||||
|
t.blue_summary = None
|
||||||
|
t.remediation_steps = None
|
||||||
|
t.red_validation_status = None
|
||||||
|
t.blue_validation_status = None
|
||||||
|
t.red_validation_notes = None
|
||||||
|
t.blue_validation_notes = None
|
||||||
|
t.red_tech_assignee = None
|
||||||
|
t.blue_tech_assignee = None
|
||||||
|
t.system_gaps = None
|
||||||
for k, v in overrides.items():
|
for k, v in overrides.items():
|
||||||
setattr(t, k, v)
|
setattr(t, k, v)
|
||||||
return t
|
return t
|
||||||
@@ -62,7 +80,10 @@ def _make_test(**overrides):
|
|||||||
|
|
||||||
@patch("app.services.jira_service.has_admin_jira_configured", return_value=True)
|
@patch("app.services.jira_service.has_admin_jira_configured", return_value=True)
|
||||||
@patch("app.services.jira_service.get_admin_jira_client")
|
@patch("app.services.jira_service.get_admin_jira_client")
|
||||||
def test_push_pause_event_sets_on_hold(mock_get_client, mock_configured, db):
|
def test_push_pause_event_does_not_change_jira_status(mock_get_client, mock_configured, db):
|
||||||
|
"""A timer pause is not the same as a real Hold — it must never flip the
|
||||||
|
Jira issue to 'On Hold'. Only push_hold_event (a genuine Hold action) is
|
||||||
|
allowed to change status. Regression: pausing used to set 'On Hold'."""
|
||||||
mock_jira = MagicMock()
|
mock_jira = MagicMock()
|
||||||
mock_get_client.return_value = mock_jira
|
mock_get_client.return_value = mock_jira
|
||||||
test = _make_test()
|
test = _make_test()
|
||||||
@@ -72,13 +93,13 @@ def test_push_pause_event_sets_on_hold(mock_get_client, mock_configured, db):
|
|||||||
|
|
||||||
jira_service.push_pause_event(db, test, MagicMock(username="alice"), resuming=False)
|
jira_service.push_pause_event(db, test, MagicMock(username="alice"), resuming=False)
|
||||||
|
|
||||||
mock_jira.set_issue_status.assert_called_once_with(link.jira_issue_key, "On Hold")
|
mock_jira.set_issue_status.assert_not_called()
|
||||||
mock_jira.issue_add_comment.assert_called_once()
|
mock_jira.issue_add_comment.assert_called_once()
|
||||||
|
|
||||||
|
|
||||||
@patch("app.services.jira_service.has_admin_jira_configured", return_value=True)
|
@patch("app.services.jira_service.has_admin_jira_configured", return_value=True)
|
||||||
@patch("app.services.jira_service.get_admin_jira_client")
|
@patch("app.services.jira_service.get_admin_jira_client")
|
||||||
def test_push_pause_event_resume_sets_in_progress(mock_get_client, mock_configured, db):
|
def test_push_pause_event_resume_does_not_change_jira_status(mock_get_client, mock_configured, db):
|
||||||
mock_jira = MagicMock()
|
mock_jira = MagicMock()
|
||||||
mock_get_client.return_value = mock_jira
|
mock_get_client.return_value = mock_jira
|
||||||
test = _make_test()
|
test = _make_test()
|
||||||
@@ -88,7 +109,8 @@ def test_push_pause_event_resume_sets_in_progress(mock_get_client, mock_configur
|
|||||||
|
|
||||||
jira_service.push_pause_event(db, test, MagicMock(username="alice"), resuming=True)
|
jira_service.push_pause_event(db, test, MagicMock(username="alice"), resuming=True)
|
||||||
|
|
||||||
mock_jira.set_issue_status.assert_called_once_with(link.jira_issue_key, "In Progress")
|
mock_jira.set_issue_status.assert_not_called()
|
||||||
|
mock_jira.issue_add_comment.assert_called_once()
|
||||||
|
|
||||||
|
|
||||||
@patch("app.services.jira_service.has_admin_jira_configured", return_value=True)
|
@patch("app.services.jira_service.has_admin_jira_configured", return_value=True)
|
||||||
@@ -108,7 +130,97 @@ def test_push_hold_event_sets_blocked(mock_get_client, mock_configured, db):
|
|||||||
|
|
||||||
@patch("app.services.jira_service.has_admin_jira_configured", return_value=True)
|
@patch("app.services.jira_service.has_admin_jira_configured", return_value=True)
|
||||||
@patch("app.services.jira_service.get_admin_jira_client")
|
@patch("app.services.jira_service.get_admin_jira_client")
|
||||||
def test_push_rt_started_sets_date_field(mock_get_client, mock_configured, db):
|
def test_push_round_archived_posts_comment_without_changing_status(mock_get_client, mock_configured, db):
|
||||||
|
"""Archiving a round before reopen must leave a permanent Jira comment
|
||||||
|
(custom fields get overwritten by the next round, comments don't) but
|
||||||
|
must not touch the issue's status — that's push_test_event's job."""
|
||||||
|
from app.domain.enums import AttackSuccessResult
|
||||||
|
from app.models.test_round_history import TestRoundHistory
|
||||||
|
|
||||||
|
test = _make_test()
|
||||||
|
link = _make_link(test.id)
|
||||||
|
db.add(link)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
round_data = TestRoundHistory(
|
||||||
|
test_id=test.id, team="red", round_number=1,
|
||||||
|
attack_success=AttackSuccessResult.successful, red_summary="Got a shell.",
|
||||||
|
procedure_text="ran mimikatz", tool_used="mimikatz",
|
||||||
|
review_notes="Evidence was incomplete, redo with screenshots",
|
||||||
|
)
|
||||||
|
mock_jira = MagicMock()
|
||||||
|
mock_jira.issue.return_value = {"fields": {"labels": []}}
|
||||||
|
mock_get_client.return_value = mock_jira
|
||||||
|
|
||||||
|
jira_service.push_round_archived(db, test, MagicMock(username="redlead"), round_data=round_data)
|
||||||
|
|
||||||
|
mock_jira.set_issue_status.assert_not_called()
|
||||||
|
mock_jira.issue_add_comment.assert_called_once()
|
||||||
|
|
||||||
|
|
||||||
|
@patch("app.services.jira_service.has_admin_jira_configured", return_value=True)
|
||||||
|
@patch("app.services.jira_service.get_admin_jira_client")
|
||||||
|
def test_push_round_archived_adds_reopened_label_without_clobbering_others(mock_get_client, mock_configured, db):
|
||||||
|
from app.models.test_round_history import TestRoundHistory
|
||||||
|
|
||||||
|
test = _make_test()
|
||||||
|
link = _make_link(test.id)
|
||||||
|
db.add(link)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
round_data = TestRoundHistory(test_id=test.id, team="blue", round_number=1)
|
||||||
|
mock_jira = MagicMock()
|
||||||
|
mock_jira.issue.return_value = {"fields": {"labels": ["security-review"]}}
|
||||||
|
mock_get_client.return_value = mock_jira
|
||||||
|
|
||||||
|
jira_service.push_round_archived(db, test, MagicMock(username="bluelead"), round_data=round_data)
|
||||||
|
|
||||||
|
mock_jira.update_issue_field.assert_called_once_with(
|
||||||
|
link.jira_issue_key, fields={"labels": ["security-review", "reopened"]},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@patch("app.services.jira_service.has_admin_jira_configured", return_value=True)
|
||||||
|
@patch("app.services.jira_service.get_admin_jira_client")
|
||||||
|
def test_push_round_archived_does_not_duplicate_reopened_label(mock_get_client, mock_configured, db):
|
||||||
|
from app.models.test_round_history import TestRoundHistory
|
||||||
|
|
||||||
|
test = _make_test()
|
||||||
|
link = _make_link(test.id)
|
||||||
|
db.add(link)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
round_data = TestRoundHistory(test_id=test.id, team="red", round_number=2)
|
||||||
|
mock_jira = MagicMock()
|
||||||
|
mock_jira.issue.return_value = {"fields": {"labels": ["reopened"]}}
|
||||||
|
mock_get_client.return_value = mock_jira
|
||||||
|
|
||||||
|
jira_service.push_round_archived(db, test, MagicMock(username="redlead"), round_data=round_data)
|
||||||
|
|
||||||
|
mock_jira.update_issue_field.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"new_state,expected_status",
|
||||||
|
[
|
||||||
|
("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"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
@patch("app.services.jira_service.has_admin_jira_configured", return_value=True)
|
||||||
|
@patch("app.services.jira_service.get_admin_jira_client")
|
||||||
|
def test_push_test_event_maps_every_state_to_jira_status(
|
||||||
|
mock_get_client, mock_configured, db, new_state, expected_status
|
||||||
|
):
|
||||||
|
"""Every TestState the Purple Team Jira workflow needs to reflect must
|
||||||
|
have a corresponding status transition — not just red_executing."""
|
||||||
mock_jira = MagicMock()
|
mock_jira = MagicMock()
|
||||||
mock_get_client.return_value = mock_jira
|
mock_get_client.return_value = mock_jira
|
||||||
test = _make_test()
|
test = _make_test()
|
||||||
@@ -116,12 +228,361 @@ def test_push_rt_started_sets_date_field(mock_get_client, mock_configured, db):
|
|||||||
db.add(link)
|
db.add(link)
|
||||||
db.commit()
|
db.commit()
|
||||||
|
|
||||||
jira_service.push_rt_started(db, test)
|
jira_service.push_test_event(db, test, MagicMock(username="alice", jira_account_id=None), new_state)
|
||||||
|
|
||||||
|
mock_jira.set_issue_status.assert_called_once_with(link.jira_issue_key, expected_status)
|
||||||
|
|
||||||
|
|
||||||
|
@patch("app.services.jira_service.has_admin_jira_configured", return_value=True)
|
||||||
|
@patch("app.services.jira_service.get_admin_jira_client")
|
||||||
|
def test_push_test_event_validated_says_both_leads_when_they_agree(mock_get_client, mock_configured, db):
|
||||||
|
mock_jira = MagicMock()
|
||||||
|
mock_get_client.return_value = mock_jira
|
||||||
|
test = _make_test(red_validation_status="approved", blue_validation_status="approved")
|
||||||
|
link = _make_link(test.id)
|
||||||
|
db.add(link)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
jira_service.push_test_event(db, test, MagicMock(username="alice", jira_account_id=None), "validated")
|
||||||
|
|
||||||
|
comment = mock_jira.issue_add_comment.call_args[0][1]
|
||||||
|
assert "validated* by both leads" in comment
|
||||||
|
assert "manager" not in comment.lower()
|
||||||
|
|
||||||
|
|
||||||
|
@patch("app.services.jira_service.has_admin_jira_configured", return_value=True)
|
||||||
|
@patch("app.services.jira_service.get_admin_jira_client")
|
||||||
|
def test_push_test_event_validated_flags_manager_override_when_leads_disagree(mock_get_client, mock_configured, db):
|
||||||
|
"""Regression: a disputed test resolved by a manager keeps the leads'
|
||||||
|
original disagreeing votes — the comment must not claim 'both leads'
|
||||||
|
agreed, and must carry the manager's own decision/notes."""
|
||||||
|
mock_jira = MagicMock()
|
||||||
|
mock_get_client.return_value = mock_jira
|
||||||
|
test = _make_test(red_validation_status="approved", blue_validation_status="rejected")
|
||||||
|
link = _make_link(test.id)
|
||||||
|
db.add(link)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
jira_service.push_test_event(
|
||||||
|
db, test, MagicMock(username="sergio", jira_account_id=None), "validated",
|
||||||
|
extra={"Manager Decision": "sergio — test rejected"},
|
||||||
|
)
|
||||||
|
|
||||||
|
comment = mock_jira.issue_add_comment.call_args[0][1]
|
||||||
|
assert "manager's decision" in comment.lower()
|
||||||
|
assert "both leads" not in comment
|
||||||
|
assert "Manager Decision:* sergio — test rejected" in comment
|
||||||
|
|
||||||
|
|
||||||
|
@patch("app.services.jira_service.has_admin_jira_configured", return_value=True)
|
||||||
|
@patch("app.services.jira_service.get_admin_jira_client")
|
||||||
|
def test_push_test_event_rejected_flags_manager_override_when_leads_disagree(mock_get_client, mock_configured, db):
|
||||||
|
mock_jira = MagicMock()
|
||||||
|
mock_get_client.return_value = mock_jira
|
||||||
|
test = _make_test(red_validation_status="rejected", blue_validation_status="approved")
|
||||||
|
link = _make_link(test.id)
|
||||||
|
db.add(link)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
jira_service.push_test_event(
|
||||||
|
db, test, MagicMock(username="sergio", jira_account_id=None), "rejected",
|
||||||
|
extra={"Manager Decision": "sergio — needs full rework"},
|
||||||
|
)
|
||||||
|
|
||||||
|
comment = mock_jira.issue_add_comment.call_args[0][1]
|
||||||
|
assert "manager's decision" in comment.lower()
|
||||||
|
assert "must be reworked" not in comment
|
||||||
|
assert "Manager Decision:* sergio — needs full rework" in comment
|
||||||
|
|
||||||
|
|
||||||
|
@patch("app.services.jira_service.has_admin_jira_configured", return_value=True)
|
||||||
|
@patch("app.services.jira_service.get_admin_jira_client")
|
||||||
|
def test_push_test_event_red_review_comment_includes_round_data(mock_get_client, mock_configured, db):
|
||||||
|
"""Every submission — not just ones that later get reopened — must leave
|
||||||
|
a data-bearing comment in Jira. Previously this comment was a generic
|
||||||
|
one-liner with no procedure/tool/result, so a round that got approved
|
||||||
|
(never archived via reopen) left no record of what was actually done."""
|
||||||
|
mock_jira = MagicMock()
|
||||||
|
mock_get_client.return_value = mock_jira
|
||||||
|
test = _make_test(
|
||||||
|
red_round_number=2,
|
||||||
|
procedure_text="ran mimikatz again", tool_used="mimikatz",
|
||||||
|
attack_success=MagicMock(value="successful"), red_summary="Got a shell this time.",
|
||||||
|
)
|
||||||
|
link = _make_link(test.id)
|
||||||
|
db.add(link)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
jira_service.push_test_event(db, test, MagicMock(username="alice", jira_account_id=None), "red_review")
|
||||||
|
|
||||||
|
comment = mock_jira.issue_add_comment.call_args[0][1]
|
||||||
|
assert "Round 2" in comment
|
||||||
|
assert "ran mimikatz again" in comment
|
||||||
|
assert "mimikatz" in comment
|
||||||
|
assert "successful" in comment
|
||||||
|
assert "Got a shell this time." in comment
|
||||||
|
|
||||||
|
|
||||||
|
@patch("app.services.jira_service.has_admin_jira_configured", return_value=True)
|
||||||
|
@patch("app.services.jira_service.get_admin_jira_client")
|
||||||
|
def test_push_test_event_blue_review_comment_includes_round_data(mock_get_client, mock_configured, db):
|
||||||
|
mock_jira = MagicMock()
|
||||||
|
mock_get_client.return_value = mock_jira
|
||||||
|
test = _make_test(
|
||||||
|
blue_round_number=1,
|
||||||
|
detection_result=MagicMock(value="detected"),
|
||||||
|
containment_result=MagicMock(value="contained"),
|
||||||
|
blue_summary="Caught it via EDR.",
|
||||||
|
)
|
||||||
|
link = _make_link(test.id)
|
||||||
|
db.add(link)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
jira_service.push_test_event(db, test, MagicMock(username="alice", jira_account_id=None), "blue_review")
|
||||||
|
|
||||||
|
comment = mock_jira.issue_add_comment.call_args[0][1]
|
||||||
|
assert "Round 1" in comment
|
||||||
|
assert "detected" in comment
|
||||||
|
assert "contained" in comment
|
||||||
|
assert "Caught it via EDR." in comment
|
||||||
|
|
||||||
|
|
||||||
|
@patch("app.services.jira_service.has_admin_jira_configured", return_value=True)
|
||||||
|
@patch("app.services.jira_service.get_admin_jira_client")
|
||||||
|
def test_push_test_event_clears_assignee_on_in_review(mock_get_client, mock_configured, db):
|
||||||
|
"""Regression: the previous reviewer's assignment must not linger on the
|
||||||
|
ticket once dual-validation starts — both leads act independently, so
|
||||||
|
there's no single owner."""
|
||||||
|
mock_jira = MagicMock()
|
||||||
|
mock_get_client.return_value = mock_jira
|
||||||
|
test = _make_test()
|
||||||
|
link = _make_link(test.id)
|
||||||
|
db.add(link)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
jira_service.push_test_event(db, test, MagicMock(username="alice", jira_account_id=None), "in_review")
|
||||||
|
|
||||||
|
mock_jira.assign_issue.assert_called_once_with(link.jira_issue_key, account_id=None)
|
||||||
|
|
||||||
|
|
||||||
|
@patch("app.services.jira_service.has_admin_jira_configured", return_value=True)
|
||||||
|
@patch("app.services.jira_service.get_admin_jira_client")
|
||||||
|
def test_push_test_event_includes_system_gap_and_labels_it(mock_get_client, mock_configured, db):
|
||||||
|
"""A Blue Lead flagging a capability gap must surface it in Jira — both
|
||||||
|
in the comment (so the reason is visible) and as a label (so gap-flagged
|
||||||
|
tickets are filterable without opening each one)."""
|
||||||
|
mock_jira = MagicMock()
|
||||||
|
mock_jira.issue.return_value = {"fields": {"labels": []}}
|
||||||
|
mock_get_client.return_value = mock_jira
|
||||||
|
test = _make_test(system_gaps="Missing EDR agent on host X")
|
||||||
|
link = _make_link(test.id)
|
||||||
|
db.add(link)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
jira_service.push_test_event(db, test, MagicMock(username="bluelead", jira_account_id=None), "in_review")
|
||||||
|
|
||||||
|
comment = mock_jira.issue_add_comment.call_args[0][1]
|
||||||
|
assert "Missing EDR agent on host X" in comment
|
||||||
|
mock_jira.update_issue_field.assert_called_once_with(
|
||||||
|
link.jira_issue_key, fields={"labels": ["system-gap"]},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@patch("app.services.jira_service.has_admin_jira_configured", return_value=True)
|
||||||
|
@patch("app.services.jira_service.get_admin_jira_client")
|
||||||
|
def test_push_test_event_in_review_without_gap_does_not_add_label(mock_get_client, mock_configured, db):
|
||||||
|
mock_jira = MagicMock()
|
||||||
|
mock_get_client.return_value = mock_jira
|
||||||
|
test = _make_test()
|
||||||
|
link = _make_link(test.id)
|
||||||
|
db.add(link)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
jira_service.push_test_event(db, test, MagicMock(username="alice", jira_account_id=None), "in_review")
|
||||||
|
|
||||||
|
mock_jira.issue.assert_not_called()
|
||||||
|
mock_jira.update_issue_field.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
@patch("app.services.jira_service.has_admin_jira_configured", return_value=True)
|
||||||
|
@patch("app.services.jira_service.get_admin_jira_client")
|
||||||
|
def test_push_test_event_clears_assignee_on_fresh_blue_queue_entry(mock_get_client, mock_configured, db):
|
||||||
|
"""A test reaching blue_evaluating for the first time (red just approved)
|
||||||
|
has no blue_tech_assignee yet — queued, no owner, must not stay stuck on
|
||||||
|
the red_lead who approved it."""
|
||||||
|
mock_jira = MagicMock()
|
||||||
|
mock_get_client.return_value = mock_jira
|
||||||
|
test = _make_test() # blue_tech_assignee defaults to None
|
||||||
|
link = _make_link(test.id)
|
||||||
|
db.add(link)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
jira_service.push_test_event(db, test, MagicMock(username="alice", jira_account_id=None), "blue_evaluating")
|
||||||
|
|
||||||
|
mock_jira.assign_issue.assert_called_once_with(link.jira_issue_key, account_id=None)
|
||||||
|
|
||||||
|
|
||||||
|
@patch("app.services.jira_service.has_admin_jira_configured", return_value=True)
|
||||||
|
@patch("app.services.jira_service.get_admin_jira_client")
|
||||||
|
def test_push_test_event_reassigns_blue_operator_on_reopen(mock_get_client, mock_configured, db):
|
||||||
|
"""Regression: reopening blue_review (rework) sends the test back to
|
||||||
|
blue_evaluating, but the SAME operator is still assigned in Aegis
|
||||||
|
(blue_tech_assignee isn't cleared, only blue_work_started_at is) — Jira
|
||||||
|
must give them the ticket back, not leave it on the blue_lead who
|
||||||
|
reopened it or clear it as if nobody owned it."""
|
||||||
|
mock_jira = MagicMock()
|
||||||
|
mock_get_client.return_value = mock_jira
|
||||||
|
from app.models.user import User
|
||||||
|
blue_tech = User(
|
||||||
|
username="bluetech-reopen", email="bluetech-reopen@test.com",
|
||||||
|
hashed_password="x", role="blue_tech", jira_account_id="blue-tech-account",
|
||||||
|
)
|
||||||
|
db.add(blue_tech)
|
||||||
|
db.commit()
|
||||||
|
test = _make_test(blue_tech_assignee=blue_tech.id)
|
||||||
|
link = _make_link(test.id)
|
||||||
|
db.add(link)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
jira_service.push_test_event(
|
||||||
|
db, test, MagicMock(username="bluelead", jira_account_id=None), "blue_evaluating",
|
||||||
|
)
|
||||||
|
|
||||||
|
mock_jira.assign_issue.assert_called_once_with(link.jira_issue_key, account_id="blue-tech-account")
|
||||||
|
|
||||||
|
|
||||||
|
@patch("app.services.jira_service.has_admin_jira_configured", return_value=True)
|
||||||
|
@patch("app.services.jira_service.get_admin_jira_client")
|
||||||
|
def test_push_test_event_red_executing_assigns_actor_by_default(mock_get_client, mock_configured, db):
|
||||||
|
"""The normal start-execution path: no explicit assignee param, so the
|
||||||
|
acting operator (actor) is who gets the ticket."""
|
||||||
|
mock_jira = MagicMock()
|
||||||
|
mock_get_client.return_value = mock_jira
|
||||||
|
test = _make_test()
|
||||||
|
link = _make_link(test.id)
|
||||||
|
db.add(link)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
jira_service.push_test_event(
|
||||||
|
db, test, MagicMock(username="redtech", jira_account_id="red-tech-account"), "red_executing",
|
||||||
|
)
|
||||||
|
|
||||||
|
mock_jira.assign_issue.assert_called_once_with(link.jira_issue_key, account_id="red-tech-account")
|
||||||
|
|
||||||
|
|
||||||
|
@patch("app.services.jira_service.has_admin_jira_configured", return_value=True)
|
||||||
|
@patch("app.services.jira_service.get_admin_jira_client")
|
||||||
|
def test_push_test_event_red_executing_reassigns_original_operator_on_reopen(mock_get_client, mock_configured, db):
|
||||||
|
"""Regression: reopening red_review sends the test back to
|
||||||
|
red_executing, but *actor* here is the red_lead who reopened it, not
|
||||||
|
the operator who should keep working it. The caller passes the
|
||||||
|
original operator as the explicit assignee, which must win over actor."""
|
||||||
|
mock_jira = MagicMock()
|
||||||
|
mock_get_client.return_value = mock_jira
|
||||||
|
test = _make_test()
|
||||||
|
link = _make_link(test.id)
|
||||||
|
db.add(link)
|
||||||
|
db.commit()
|
||||||
|
original_operator = MagicMock(username="redtech", jira_account_id="red-tech-account")
|
||||||
|
reopening_lead = MagicMock(username="redlead", jira_account_id="red-lead-account")
|
||||||
|
|
||||||
|
jira_service.push_test_event(
|
||||||
|
db, test, reopening_lead, "red_executing", assignee=original_operator,
|
||||||
|
)
|
||||||
|
|
||||||
|
mock_jira.assign_issue.assert_called_once_with(link.jira_issue_key, account_id="red-tech-account")
|
||||||
|
|
||||||
|
|
||||||
|
@patch("app.services.jira_service.has_admin_jira_configured", return_value=True)
|
||||||
|
@patch("app.services.jira_service.get_admin_jira_client")
|
||||||
|
def test_push_test_event_reviewer_assignment_resolves_account_id_on_the_fly(
|
||||||
|
mock_get_client, mock_configured, db,
|
||||||
|
):
|
||||||
|
"""Regression: a blue_lead assigned as reviewer had never logged in
|
||||||
|
(jira_account_id was still NULL), so the Jira reassignment silently
|
||||||
|
no-op'd and the ticket stayed on the operator forever — while red_lead
|
||||||
|
worked purely by coincidence of already having logged in once. Every
|
||||||
|
assignment call site must attempt a live lookup, not just
|
||||||
|
push_assignee_update."""
|
||||||
|
from app.models.user import User
|
||||||
|
|
||||||
|
mock_jira = MagicMock()
|
||||||
|
mock_jira.user_find_by_user_string.return_value = [
|
||||||
|
{"accountId": "blue-lead-account", "emailAddress": "bluelead@test.com"},
|
||||||
|
]
|
||||||
|
mock_get_client.return_value = mock_jira
|
||||||
|
test = _make_test()
|
||||||
|
link = _make_link(test.id)
|
||||||
|
db.add(link)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
blue_lead = User(
|
||||||
|
username="bluelead-never-logged-in", email="bluelead@test.com",
|
||||||
|
hashed_password="x", role="blue_lead", jira_account_id=None,
|
||||||
|
)
|
||||||
|
db.add(blue_lead)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
jira_service.push_test_event(db, test, MagicMock(username="bluetech"), "blue_review", assignee=blue_lead)
|
||||||
|
|
||||||
|
assert blue_lead.jira_account_id == "blue-lead-account"
|
||||||
|
mock_jira.assign_issue.assert_called_once_with(link.jira_issue_key, account_id="blue-lead-account")
|
||||||
|
|
||||||
|
|
||||||
|
@patch("app.services.jira_service.has_admin_jira_configured", return_value=True)
|
||||||
|
@patch("app.services.jira_service.get_admin_jira_client")
|
||||||
|
def test_push_bt_work_started_sets_in_progress(mock_get_client, mock_configured, db):
|
||||||
|
"""blue_evaluating covers both 'queued, unclaimed' and 'actively being
|
||||||
|
worked' — the state alone can't distinguish them, so start_blue_work
|
||||||
|
must explicitly push In Progress rather than relying on the generic
|
||||||
|
state->status dispatch (which would incorrectly re-push Queued Blue Team)."""
|
||||||
|
mock_jira = MagicMock()
|
||||||
|
mock_get_client.return_value = mock_jira
|
||||||
|
test = _make_test()
|
||||||
|
link = _make_link(test.id)
|
||||||
|
db.add(link)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
actor = MagicMock(username="bob", jira_account_id="bob-account-id")
|
||||||
|
jira_service.push_bt_work_started(db, test, actor)
|
||||||
|
|
||||||
|
mock_jira.set_issue_status.assert_called_once_with(link.jira_issue_key, "In Progress")
|
||||||
|
mock_jira.assign_issue.assert_called_once_with(link.jira_issue_key, account_id="bob-account-id")
|
||||||
|
|
||||||
|
|
||||||
|
@patch("app.services.jira_service.has_admin_jira_configured", return_value=True)
|
||||||
|
@patch("app.services.jira_service.get_admin_jira_client")
|
||||||
|
def test_push_bt_started_sets_date_field_on_round_one(mock_get_client, mock_configured, db):
|
||||||
|
mock_jira = MagicMock()
|
||||||
|
mock_get_client.return_value = mock_jira
|
||||||
|
test = _make_test(blue_round_number=1)
|
||||||
|
link = _make_link(test.id)
|
||||||
|
db.add(link)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
jira_service.push_bt_started(db, test)
|
||||||
|
|
||||||
mock_jira.update_issue_field.assert_called_once()
|
mock_jira.update_issue_field.assert_called_once()
|
||||||
args, kwargs = mock_jira.update_issue_field.call_args
|
fields = mock_jira.update_issue_field.call_args[1]["fields"]
|
||||||
assert args[0] == link.jira_issue_key
|
assert jira_service.JIRA_FIELD_BT_START_DATE in fields
|
||||||
assert jira_service.JIRA_FIELD_RT_START_DATE in kwargs["fields"]
|
|
||||||
|
|
||||||
|
@patch("app.services.jira_service.has_admin_jira_configured", return_value=True)
|
||||||
|
@patch("app.services.jira_service.get_admin_jira_client")
|
||||||
|
def test_push_bt_started_preserves_round_one_date_on_reopen(mock_get_client, mock_configured, db):
|
||||||
|
"""Regression: mirrors the RT Start Date bug — BT Start Date must keep
|
||||||
|
showing round 1's real pickup date even after the operator re-claims
|
||||||
|
the test via 'Start Evaluation' on a later round."""
|
||||||
|
mock_jira = MagicMock()
|
||||||
|
mock_get_client.return_value = mock_jira
|
||||||
|
test = _make_test(blue_round_number=2)
|
||||||
|
link = _make_link(test.id)
|
||||||
|
db.add(link)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
jira_service.push_bt_started(db, test)
|
||||||
|
|
||||||
|
mock_jira.update_issue_field.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
@patch("app.services.jira_service.has_admin_jira_configured", return_value=True)
|
@patch("app.services.jira_service.has_admin_jira_configured", return_value=True)
|
||||||
@@ -129,7 +590,11 @@ def test_push_rt_started_sets_date_field(mock_get_client, mock_configured, db):
|
|||||||
def test_push_rt_submitted_includes_attack_success(mock_get_client, mock_configured, db):
|
def test_push_rt_submitted_includes_attack_success(mock_get_client, mock_configured, db):
|
||||||
mock_jira = MagicMock()
|
mock_jira = MagicMock()
|
||||||
mock_get_client.return_value = mock_jira
|
mock_get_client.return_value = mock_jira
|
||||||
test = _make_test(attack_success=MagicMock(value="successful"))
|
test = _make_test(
|
||||||
|
attack_success=MagicMock(value="successful"),
|
||||||
|
execution_start_time=datetime(2026, 2, 1, 8, 0, 0),
|
||||||
|
execution_end_time=datetime(2026, 2, 1, 9, 0, 0),
|
||||||
|
)
|
||||||
link = _make_link(test.id)
|
link = _make_link(test.id)
|
||||||
db.add(link)
|
db.add(link)
|
||||||
db.commit()
|
db.commit()
|
||||||
@@ -138,20 +603,87 @@ def test_push_rt_submitted_includes_attack_success(mock_get_client, mock_configu
|
|||||||
|
|
||||||
fields = mock_jira.update_issue_field.call_args[1]["fields"]
|
fields = mock_jira.update_issue_field.call_args[1]["fields"]
|
||||||
assert fields[jira_service.JIRA_FIELD_RT_END_DATE]
|
assert fields[jira_service.JIRA_FIELD_RT_END_DATE]
|
||||||
assert fields[jira_service.JIRA_FIELD_ATTACK_SUCCESS] == "Yes"
|
assert fields[jira_service.JIRA_FIELD_ATTACK_SUCCESS] == {"value": "Yes"}
|
||||||
|
|
||||||
|
|
||||||
@patch("app.services.jira_service.has_admin_jira_configured", return_value=True)
|
@patch("app.services.jira_service.has_admin_jira_configured", return_value=True)
|
||||||
@patch("app.services.jira_service.get_admin_jira_client")
|
@patch("app.services.jira_service.get_admin_jira_client")
|
||||||
def test_push_bt_submitted_includes_detection_and_hours(mock_get_client, mock_configured, db):
|
def test_push_rt_submitted_uses_execution_times_not_click_time(mock_get_client, mock_configured, db):
|
||||||
|
"""Regression: this used to push datetime.utcnow() (whenever the submit
|
||||||
|
button was clicked) instead of the operator-entered execution window."""
|
||||||
|
mock_jira = MagicMock()
|
||||||
|
mock_get_client.return_value = mock_jira
|
||||||
|
test = _make_test(
|
||||||
|
execution_start_time=datetime(2026, 3, 1, 9, 0, 0),
|
||||||
|
execution_end_time=datetime(2026, 3, 1, 11, 0, 0),
|
||||||
|
)
|
||||||
|
link = _make_link(test.id)
|
||||||
|
db.add(link)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
jira_service.push_rt_submitted(db, test)
|
||||||
|
|
||||||
|
fields = mock_jira.update_issue_field.call_args[1]["fields"]
|
||||||
|
assert fields[jira_service.JIRA_FIELD_RT_START_DATE] == "2026-03-01T09:00:00.000+0000"
|
||||||
|
assert fields[jira_service.JIRA_FIELD_RT_END_DATE] == "2026-03-01T11:00:00.000+0000"
|
||||||
|
|
||||||
|
|
||||||
|
@patch("app.services.jira_service.has_admin_jira_configured", return_value=True)
|
||||||
|
@patch("app.services.jira_service.get_admin_jira_client")
|
||||||
|
def test_push_rt_submitted_rt_start_date_survives_reopen(mock_get_client, mock_configured, db):
|
||||||
|
"""RT Start Date must keep showing round 1's execution start even after
|
||||||
|
a reopen resets the live execution_start_time for round 2 — otherwise
|
||||||
|
Jira loses track of when the test was originally attempted."""
|
||||||
|
from app.models.technique import Technique
|
||||||
|
from app.models.test import Test as TestModel
|
||||||
|
from app.models.test_round_history import TestRoundHistory
|
||||||
|
|
||||||
|
# A real, committed Test row is required here (unlike the other tests in
|
||||||
|
# this file) because test_round_history.test_id has a real FK to tests.id.
|
||||||
|
technique = Technique(mitre_id="T9997", name="RT Date Test Technique", tactic="execution", platforms=["linux"])
|
||||||
|
db.add(technique)
|
||||||
|
db.commit()
|
||||||
|
real_test = TestModel(technique_id=technique.id, name="RT date fixture")
|
||||||
|
db.add(real_test)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
mock_jira = MagicMock()
|
||||||
|
mock_get_client.return_value = mock_jira
|
||||||
|
test = _make_test(
|
||||||
|
id=real_test.id,
|
||||||
|
red_round_number=2,
|
||||||
|
execution_start_time=datetime(2026, 3, 5, 8, 0, 0), # round 2's start
|
||||||
|
execution_end_time=datetime(2026, 3, 5, 10, 0, 0),
|
||||||
|
)
|
||||||
|
link = _make_link(test.id)
|
||||||
|
db.add(link)
|
||||||
|
db.add(TestRoundHistory(
|
||||||
|
test_id=test.id, team="red", round_number=1,
|
||||||
|
execution_start_time=datetime(2026, 3, 1, 9, 0, 0), # round 1's start
|
||||||
|
))
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
jira_service.push_rt_submitted(db, test)
|
||||||
|
|
||||||
|
fields = mock_jira.update_issue_field.call_args[1]["fields"]
|
||||||
|
assert fields[jira_service.JIRA_FIELD_RT_START_DATE] == "2026-03-01T09:00:00.000+0000"
|
||||||
|
assert fields[jira_service.JIRA_FIELD_RT_END_DATE] == "2026-03-05T10:00:00.000+0000"
|
||||||
|
|
||||||
|
|
||||||
|
@patch("app.services.jira_service.has_admin_jira_configured", return_value=True)
|
||||||
|
@patch("app.services.jira_service.get_admin_jira_client")
|
||||||
|
def test_push_bt_submitted_includes_detection_and_timestamps(mock_get_client, mock_configured, db):
|
||||||
|
"""Regression: Time to Detect / Time to Contain are Jira *datetime*
|
||||||
|
fields, not numeric hour counts — pushing a computed duration made Jira
|
||||||
|
reject the whole fields payload (atomic validation), silently sinking
|
||||||
|
BT End Date and Attack Detected too even though only these two were
|
||||||
|
actually wrong."""
|
||||||
mock_jira = MagicMock()
|
mock_jira = MagicMock()
|
||||||
mock_get_client.return_value = mock_jira
|
mock_get_client.return_value = mock_jira
|
||||||
exec_end = datetime(2026, 1, 1, 10, 0, 0)
|
|
||||||
detect = datetime(2026, 1, 1, 12, 0, 0)
|
detect = datetime(2026, 1, 1, 12, 0, 0)
|
||||||
contain = datetime(2026, 1, 1, 13, 30, 0)
|
contain = datetime(2026, 1, 1, 13, 30, 0)
|
||||||
test = _make_test(
|
test = _make_test(
|
||||||
detection_result=MagicMock(value="detected"),
|
detection_result=MagicMock(value="detected"),
|
||||||
execution_end_time=exec_end,
|
|
||||||
detection_time=detect,
|
detection_time=detect,
|
||||||
containment_time=contain,
|
containment_time=contain,
|
||||||
)
|
)
|
||||||
@@ -162,9 +694,25 @@ def test_push_bt_submitted_includes_detection_and_hours(mock_get_client, mock_co
|
|||||||
jira_service.push_bt_submitted(db, test)
|
jira_service.push_bt_submitted(db, test)
|
||||||
|
|
||||||
fields = mock_jira.update_issue_field.call_args[1]["fields"]
|
fields = mock_jira.update_issue_field.call_args[1]["fields"]
|
||||||
assert fields[jira_service.JIRA_FIELD_ATTACK_DETECTED] == "Yes"
|
assert fields[jira_service.JIRA_FIELD_ATTACK_DETECTED] == {"value": "Yes"}
|
||||||
assert fields[jira_service.JIRA_FIELD_TIME_TO_DETECT] == 2.0
|
assert fields[jira_service.JIRA_FIELD_TIME_TO_DETECT] == "2026-01-01T12:00:00.000+0000"
|
||||||
assert fields[jira_service.JIRA_FIELD_TIME_TO_CONTAIN] == 1.5
|
assert fields[jira_service.JIRA_FIELD_TIME_TO_CONTAIN] == "2026-01-01T13:30:00.000+0000"
|
||||||
|
|
||||||
|
|
||||||
|
@patch("app.services.jira_service.has_admin_jira_configured", return_value=True)
|
||||||
|
@patch("app.services.jira_service.get_admin_jira_client")
|
||||||
|
def test_push_bt_submitted_includes_attack_contained(mock_get_client, mock_configured, db):
|
||||||
|
mock_jira = MagicMock()
|
||||||
|
mock_get_client.return_value = mock_jira
|
||||||
|
test = _make_test(containment_result=MagicMock(value="partially_contained"))
|
||||||
|
link = _make_link(test.id)
|
||||||
|
db.add(link)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
jira_service.push_bt_submitted(db, test)
|
||||||
|
|
||||||
|
fields = mock_jira.update_issue_field.call_args[1]["fields"]
|
||||||
|
assert fields[jira_service.JIRA_FIELD_ATTACK_CONTAINED] == {"value": "Partial"}
|
||||||
|
|
||||||
|
|
||||||
def test_update_test_fields_noop_when_not_configured(db):
|
def test_update_test_fields_noop_when_not_configured(db):
|
||||||
@@ -199,3 +747,338 @@ def test_search_jira_issues_maps_fields(mock_get_client):
|
|||||||
assert results[0]["summary"] == "Test issue"
|
assert results[0]["summary"] == "Test issue"
|
||||||
assert results[0]["status"] == "In Progress"
|
assert results[0]["status"] == "In Progress"
|
||||||
mock_jira.jql.assert_called_once()
|
mock_jira.jql.assert_called_once()
|
||||||
|
|
||||||
|
|
||||||
|
def _make_technique(**overrides):
|
||||||
|
from app.models.technique import Technique
|
||||||
|
tech = MagicMock(spec=Technique)
|
||||||
|
tech.mitre_id = "T1003.006"
|
||||||
|
tech.name = "DCSync"
|
||||||
|
tech.tactic = "credential-access"
|
||||||
|
tech.severity = "high"
|
||||||
|
for k, v in overrides.items():
|
||||||
|
setattr(tech, k, v)
|
||||||
|
return tech
|
||||||
|
|
||||||
|
|
||||||
|
def _make_test_for_issue(**overrides):
|
||||||
|
from app.models.test import Test
|
||||||
|
t = MagicMock(spec=Test)
|
||||||
|
t.id = uuid4()
|
||||||
|
t.name = "Run DSInternals Get-ADReplAccount"
|
||||||
|
t.technique_id = uuid4()
|
||||||
|
t.procedure_text = "Invoke-DSInternals"
|
||||||
|
t.description = "desc"
|
||||||
|
t.platform = "Windows"
|
||||||
|
t.tool_used = "DSInternals"
|
||||||
|
t.data_classification = "pii"
|
||||||
|
for k, v in overrides.items():
|
||||||
|
setattr(t, k, v)
|
||||||
|
return t
|
||||||
|
|
||||||
|
|
||||||
|
@patch("app.services.jira_service.has_admin_jira_configured", return_value=True)
|
||||||
|
@patch("app.services.jira_service.get_jira_project_key", return_value="SEC")
|
||||||
|
@patch("app.services.jira_service.get_jira_parent_ticket_standalone", return_value=None)
|
||||||
|
@patch("app.services.jira_service.get_admin_jira_client")
|
||||||
|
def test_auto_create_test_issue_retries_without_unscreened_data_sensitivity_field(
|
||||||
|
mock_get_client, mock_parent, mock_project_key, mock_configured, db
|
||||||
|
):
|
||||||
|
# Regression: Jira rejects the WHOLE issue when the data sensitivity
|
||||||
|
# field isn't on the project's create screen, which would silently
|
||||||
|
# block every test's ticket creation. Must retry once without that field
|
||||||
|
# instead of losing the ticket.
|
||||||
|
mock_jira = MagicMock()
|
||||||
|
mock_jira.issue_create.side_effect = [
|
||||||
|
Exception(
|
||||||
|
f"Field '{jira_service.JIRA_FIELD_DATA_SENSITIVITY}' cannot be set. "
|
||||||
|
"It is not on the appropriate screen, or unknown."
|
||||||
|
),
|
||||||
|
{"key": "SEC-42", "id": "10042"},
|
||||||
|
]
|
||||||
|
mock_get_client.return_value = mock_jira
|
||||||
|
from app.models.user import User
|
||||||
|
test = _make_test_for_issue()
|
||||||
|
technique = _make_technique()
|
||||||
|
actor = User(username="gerardo-ruiz", email="gerardo.ruiz@kaseya.com", hashed_password="x")
|
||||||
|
db.add(actor)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
issue_key = jira_service.auto_create_test_issue(db, test, actor, technique=technique)
|
||||||
|
|
||||||
|
assert issue_key == "SEC-42"
|
||||||
|
assert mock_jira.issue_create.call_count == 2
|
||||||
|
# Both calls share the same `fields` dict object (mutated in place), so
|
||||||
|
# only the final state is inspectable here — confirming the retry
|
||||||
|
# dropped the field is what matters.
|
||||||
|
final_call_fields = mock_jira.issue_create.call_args_list[1].kwargs["fields"]
|
||||||
|
assert jira_service.JIRA_FIELD_DATA_SENSITIVITY not in final_call_fields
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"data_classification,expected_jira_value",
|
||||||
|
[
|
||||||
|
("public", "Public"),
|
||||||
|
("general_use", "General Use"),
|
||||||
|
("internal_use_only", "Internal Use Only"),
|
||||||
|
("trusted_people", "Trusted People"),
|
||||||
|
("customer_content", "Customer Content"),
|
||||||
|
("pii", "PII"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
@patch("app.services.jira_service.has_admin_jira_configured", return_value=True)
|
||||||
|
@patch("app.services.jira_service.get_jira_project_key", return_value="SEC")
|
||||||
|
@patch("app.services.jira_service.get_jira_parent_ticket_standalone", return_value=None)
|
||||||
|
@patch("app.services.jira_service.get_admin_jira_client")
|
||||||
|
def test_auto_create_test_issue_maps_data_classification_to_real_jira_field(
|
||||||
|
mock_get_client, mock_parent, mock_project_key, mock_configured, db,
|
||||||
|
data_classification, expected_jira_value,
|
||||||
|
):
|
||||||
|
# Regression: the field ID was wrong (customfield_11233, which doesn't
|
||||||
|
# exist in the project) and the classification scheme didn't match
|
||||||
|
# Jira's actual 6-option "Data Sensitivity" field at all.
|
||||||
|
mock_jira = MagicMock()
|
||||||
|
mock_jira.issue_create.return_value = {"key": "SEC-1", "id": "1"}
|
||||||
|
mock_get_client.return_value = mock_jira
|
||||||
|
from app.models.user import User
|
||||||
|
test = _make_test_for_issue(data_classification=data_classification)
|
||||||
|
technique = _make_technique()
|
||||||
|
actor = User(username="gerardo-ruiz", email="gerardo.ruiz@kaseya.com", hashed_password="x")
|
||||||
|
db.add(actor)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
jira_service.auto_create_test_issue(db, test, actor, technique=technique)
|
||||||
|
|
||||||
|
fields = mock_jira.issue_create.call_args.kwargs["fields"]
|
||||||
|
assert fields["customfield_11814"] == {"value": expected_jira_value}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"platform,expected_label",
|
||||||
|
[
|
||||||
|
("Windows", "windows"),
|
||||||
|
("linux", "linux"),
|
||||||
|
("macOS", "macos"),
|
||||||
|
("Azure AD", "azure-ad"),
|
||||||
|
(None, None),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
@patch("app.services.jira_service.has_admin_jira_configured", return_value=True)
|
||||||
|
@patch("app.services.jira_service.get_jira_project_key", return_value="SEC")
|
||||||
|
@patch("app.services.jira_service.get_jira_parent_ticket_standalone", return_value=None)
|
||||||
|
@patch("app.services.jira_service.get_admin_jira_client")
|
||||||
|
def test_auto_create_test_issue_adds_platform_label(
|
||||||
|
mock_get_client, mock_parent, mock_project_key, mock_configured, db,
|
||||||
|
platform, expected_label,
|
||||||
|
):
|
||||||
|
mock_jira = MagicMock()
|
||||||
|
mock_jira.issue_create.return_value = {"key": "SEC-1", "id": "1"}
|
||||||
|
mock_get_client.return_value = mock_jira
|
||||||
|
from app.models.user import User
|
||||||
|
test = _make_test_for_issue(platform=platform)
|
||||||
|
technique = _make_technique()
|
||||||
|
actor = User(username="gerardo-ruiz", email="gerardo.ruiz@kaseya.com", hashed_password="x")
|
||||||
|
db.add(actor)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
jira_service.auto_create_test_issue(db, test, actor, technique=technique)
|
||||||
|
|
||||||
|
labels = mock_jira.issue_create.call_args.kwargs["fields"]["labels"]
|
||||||
|
assert ["aegis", "T1003-006"] == labels[:2]
|
||||||
|
if expected_label:
|
||||||
|
assert expected_label in labels
|
||||||
|
else:
|
||||||
|
# Just "aegis", the technique ID, and "standalone-test" (no
|
||||||
|
# parent_ticket_override passed here, same as any non-campaign test).
|
||||||
|
assert len(labels) == 3
|
||||||
|
|
||||||
|
|
||||||
|
@patch("app.services.jira_service.has_admin_jira_configured", return_value=True)
|
||||||
|
@patch("app.services.jira_service.get_jira_project_key", return_value="SEC")
|
||||||
|
@patch("app.services.jira_service.get_jira_parent_ticket_standalone", return_value=None)
|
||||||
|
@patch("app.services.jira_service.get_admin_jira_client")
|
||||||
|
def test_auto_create_test_issue_labels_standalone_tests(
|
||||||
|
mock_get_client, mock_parent, mock_project_key, mock_configured, db,
|
||||||
|
):
|
||||||
|
"""A test created with no campaign parent ticket is standalone — tag it
|
||||||
|
so standalone tests are identifiable in Jira without cross-referencing
|
||||||
|
Aegis."""
|
||||||
|
mock_jira = MagicMock()
|
||||||
|
mock_jira.issue_create.return_value = {"key": "SEC-1", "id": "1"}
|
||||||
|
mock_get_client.return_value = mock_jira
|
||||||
|
from app.models.user import User
|
||||||
|
test = _make_test_for_issue()
|
||||||
|
technique = _make_technique()
|
||||||
|
actor = User(username="gerardo-ruiz", email="gerardo.ruiz@kaseya.com", hashed_password="x")
|
||||||
|
db.add(actor)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
jira_service.auto_create_test_issue(db, test, actor, technique=technique)
|
||||||
|
|
||||||
|
labels = mock_jira.issue_create.call_args.kwargs["fields"]["labels"]
|
||||||
|
assert "standalone-test" in labels
|
||||||
|
|
||||||
|
|
||||||
|
@patch("app.services.jira_service.has_admin_jira_configured", return_value=True)
|
||||||
|
@patch("app.services.jira_service.get_jira_project_key", return_value="SEC")
|
||||||
|
@patch("app.services.jira_service.get_admin_jira_client")
|
||||||
|
def test_auto_create_test_issue_does_not_label_campaign_tests_as_standalone(
|
||||||
|
mock_get_client, mock_project_key, mock_configured, db,
|
||||||
|
):
|
||||||
|
"""A campaign test passes parent_ticket_override — it must NOT get
|
||||||
|
"standalone-test", since it's nested under the campaign's ticket."""
|
||||||
|
mock_jira = MagicMock()
|
||||||
|
mock_jira.issue_create.return_value = {"key": "SEC-1", "id": "1"}
|
||||||
|
mock_get_client.return_value = mock_jira
|
||||||
|
from app.models.user import User
|
||||||
|
test = _make_test_for_issue()
|
||||||
|
technique = _make_technique()
|
||||||
|
actor = User(username="gerardo-ruiz", email="gerardo.ruiz@kaseya.com", hashed_password="x")
|
||||||
|
db.add(actor)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
jira_service.auto_create_test_issue(
|
||||||
|
db, test, actor, technique=technique, parent_ticket_override="CAMP-1",
|
||||||
|
)
|
||||||
|
|
||||||
|
labels = mock_jira.issue_create.call_args.kwargs["fields"]["labels"]
|
||||||
|
assert "standalone-test" not in labels
|
||||||
|
|
||||||
|
|
||||||
|
def _make_user(**overrides):
|
||||||
|
from app.models.user import User
|
||||||
|
u = MagicMock(spec=User)
|
||||||
|
u.username = "gerardo-ruiz"
|
||||||
|
u.email = "gerardo.ruiz@kaseya.com"
|
||||||
|
u.jira_account_id = None
|
||||||
|
u.jira_email = None
|
||||||
|
for k, v in overrides.items():
|
||||||
|
setattr(u, k, v)
|
||||||
|
return u
|
||||||
|
|
||||||
|
|
||||||
|
@patch("app.services.jira_service.has_admin_jira_configured", return_value=True)
|
||||||
|
@patch("app.services.jira_service.get_admin_jira_client")
|
||||||
|
def test_lookup_user_jira_account_id_calls_with_valid_kwargs(mock_get_client, mock_configured, db):
|
||||||
|
# Regression test: atlassian-python-api's user_find_by_user_string() takes
|
||||||
|
# `limit`, not `maxResults` — passing maxResults raised a TypeError that
|
||||||
|
# was silently swallowed, so the lookup always failed even with a valid
|
||||||
|
# email and a working admin Jira connection.
|
||||||
|
mock_jira = MagicMock()
|
||||||
|
mock_jira.user_find_by_user_string.return_value = [
|
||||||
|
{"emailAddress": "gerardo.ruiz@kaseya.com", "accountId": "abc123"}
|
||||||
|
]
|
||||||
|
mock_get_client.return_value = mock_jira
|
||||||
|
user = _make_user()
|
||||||
|
|
||||||
|
updated = jira_service.lookup_user_jira_account_id(db, user)
|
||||||
|
|
||||||
|
mock_jira.user_find_by_user_string.assert_called_once_with(
|
||||||
|
query="gerardo.ruiz@kaseya.com", limit=10
|
||||||
|
)
|
||||||
|
assert updated is True
|
||||||
|
assert user.jira_account_id == "abc123"
|
||||||
|
|
||||||
|
|
||||||
|
@patch("app.services.jira_service.has_admin_jira_configured", return_value=True)
|
||||||
|
@patch("app.services.jira_service.get_admin_jira_client")
|
||||||
|
def test_lookup_user_jira_account_id_prefers_jira_email_override(mock_get_client, mock_configured, db):
|
||||||
|
"""Regression: a user's real Atlassian email can differ from their Aegis
|
||||||
|
login email (e.g. jesus-huertas@kaseya.com in Aegis vs
|
||||||
|
jesus.rodenas@kaseya.com in Jira) — jira_email must take priority."""
|
||||||
|
mock_jira = MagicMock()
|
||||||
|
mock_jira.user_find_by_user_string.return_value = [
|
||||||
|
{"emailAddress": "jesus.rodenas@kaseya.com", "accountId": "jesus-account-id"}
|
||||||
|
]
|
||||||
|
mock_get_client.return_value = mock_jira
|
||||||
|
user = _make_user(email="jesus.huertas@kaseya.com", jira_email="jesus.rodenas@kaseya.com")
|
||||||
|
|
||||||
|
updated = jira_service.lookup_user_jira_account_id(db, user)
|
||||||
|
|
||||||
|
mock_jira.user_find_by_user_string.assert_called_once_with(
|
||||||
|
query="jesus.rodenas@kaseya.com", limit=10
|
||||||
|
)
|
||||||
|
assert updated is True
|
||||||
|
assert user.jira_account_id == "jesus-account-id"
|
||||||
|
|
||||||
|
|
||||||
|
@patch("app.services.jira_service.has_admin_jira_configured", return_value=True)
|
||||||
|
@patch("app.services.jira_service.get_admin_jira_client")
|
||||||
|
def test_lookup_user_jira_account_id_no_match(mock_get_client, mock_configured, db):
|
||||||
|
mock_jira = MagicMock()
|
||||||
|
mock_jira.user_find_by_user_string.return_value = []
|
||||||
|
mock_get_client.return_value = mock_jira
|
||||||
|
user = _make_user()
|
||||||
|
|
||||||
|
updated = jira_service.lookup_user_jira_account_id(db, user)
|
||||||
|
|
||||||
|
assert updated is False
|
||||||
|
|
||||||
|
|
||||||
|
@patch("app.services.jira_service.has_admin_jira_configured", return_value=True)
|
||||||
|
@patch("app.services.jira_service.get_admin_jira_client")
|
||||||
|
def test_push_assignee_update_assigns_jira_issue(mock_get_client, mock_configured, db):
|
||||||
|
mock_jira = MagicMock()
|
||||||
|
mock_get_client.return_value = mock_jira
|
||||||
|
test = _make_test()
|
||||||
|
link = _make_link(test.id)
|
||||||
|
db.add(link)
|
||||||
|
db.commit()
|
||||||
|
assignee = _make_user(jira_account_id="account-42")
|
||||||
|
|
||||||
|
jira_service.push_assignee_update(db, test, assignee)
|
||||||
|
|
||||||
|
mock_jira.assign_issue.assert_called_once_with(link.jira_issue_key, account_id="account-42")
|
||||||
|
|
||||||
|
|
||||||
|
@patch("app.services.jira_service.has_admin_jira_configured", return_value=True)
|
||||||
|
@patch("app.services.jira_service.get_admin_jira_client")
|
||||||
|
def test_push_assignee_update_noop_without_jira_account_id(mock_get_client, mock_configured, db):
|
||||||
|
mock_jira = MagicMock()
|
||||||
|
mock_jira.user_find_by_user_string.return_value = [] # fallback lookup finds nobody either
|
||||||
|
mock_get_client.return_value = mock_jira
|
||||||
|
test = _make_test()
|
||||||
|
link = _make_link(test.id)
|
||||||
|
db.add(link)
|
||||||
|
db.commit()
|
||||||
|
assignee = _make_user(jira_account_id=None)
|
||||||
|
|
||||||
|
jira_service.push_assignee_update(db, test, assignee)
|
||||||
|
|
||||||
|
mock_jira.assign_issue.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
@patch("app.services.jira_service.has_admin_jira_configured", return_value=True)
|
||||||
|
@patch("app.services.jira_service.get_admin_jira_client")
|
||||||
|
def test_push_assignee_update_looks_up_missing_account_id(mock_get_client, mock_configured, db):
|
||||||
|
"""Regression: a lead can assign someone who has never logged in, so
|
||||||
|
jira_account_id isn't populated yet. Must try a fresh lookup instead of
|
||||||
|
silently giving up, so the assignment still syncs immediately."""
|
||||||
|
mock_jira = MagicMock()
|
||||||
|
mock_jira.user_find_by_user_string.return_value = [
|
||||||
|
{"emailAddress": "gerardo.ruiz@kaseya.com", "accountId": "freshly-found-id"}
|
||||||
|
]
|
||||||
|
mock_get_client.return_value = mock_jira
|
||||||
|
test = _make_test()
|
||||||
|
link = _make_link(test.id)
|
||||||
|
db.add(link)
|
||||||
|
db.commit()
|
||||||
|
assignee = _make_user(jira_account_id=None)
|
||||||
|
|
||||||
|
jira_service.push_assignee_update(db, test, assignee)
|
||||||
|
|
||||||
|
mock_jira.assign_issue.assert_called_once_with(link.jira_issue_key, account_id="freshly-found-id")
|
||||||
|
assert assignee.jira_account_id == "freshly-found-id"
|
||||||
|
|
||||||
|
|
||||||
|
@patch("app.services.jira_service.has_admin_jira_configured", return_value=True)
|
||||||
|
@patch("app.services.jira_service.get_admin_jira_client")
|
||||||
|
def test_push_assignee_update_noop_without_link(mock_get_client, mock_configured, db):
|
||||||
|
mock_jira = MagicMock()
|
||||||
|
mock_get_client.return_value = mock_jira
|
||||||
|
test = _make_test()
|
||||||
|
assignee = _make_user(jira_account_id="account-42")
|
||||||
|
|
||||||
|
jira_service.push_assignee_update(db, test, assignee)
|
||||||
|
|
||||||
|
mock_jira.assign_issue.assert_not_called()
|
||||||
|
|||||||
@@ -0,0 +1,54 @@
|
|||||||
|
"""Tests for POST /system/jira-test's diagnostic message.
|
||||||
|
|
||||||
|
Regression coverage for a real QA-reported bug: has_admin_jira_configured()
|
||||||
|
bundles four independent checks (enabled, url, admin_email, admin_api_token)
|
||||||
|
into one bool, but the error message always blamed "Admin Email and Admin
|
||||||
|
API Token" regardless of which check actually failed. An admin who had set
|
||||||
|
url/email/token correctly but never flipped the "Enable Jira Integration"
|
||||||
|
toggle saw a misleading message pointing at the wrong fields.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from app.models.system_config import SystemConfig
|
||||||
|
|
||||||
|
|
||||||
|
def _set_config(db, key, value):
|
||||||
|
db.add(SystemConfig(key=key, value=value))
|
||||||
|
|
||||||
|
|
||||||
|
def test_jira_test_reports_disabled_toggle_specifically(client, db, auth_headers):
|
||||||
|
"""url/email/token all set, but jira.enabled is missing entirely —
|
||||||
|
the message must call out the toggle, not email/token."""
|
||||||
|
_set_config(db, "jira.url", "https://example.atlassian.net")
|
||||||
|
_set_config(db, "jira.admin_email", "admin@example.com")
|
||||||
|
_set_config(db, "jira.admin_api_token", "some-real-token")
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
resp = client.post("/api/v1/system/jira-test", headers=auth_headers)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
body = resp.json()
|
||||||
|
assert body["status"] == "error"
|
||||||
|
assert "not enabled" in body["message"]
|
||||||
|
assert "Admin Email" not in body["message"]
|
||||||
|
assert "Admin API Token" not in body["message"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_jira_test_reports_missing_email_and_token_when_actually_missing(client, db, auth_headers):
|
||||||
|
_set_config(db, "jira.url", "https://example.atlassian.net")
|
||||||
|
_set_config(db, "jira.enabled", "true")
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
resp = client.post("/api/v1/system/jira-test", headers=auth_headers)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
body = resp.json()
|
||||||
|
assert body["status"] == "error"
|
||||||
|
assert "Admin Email is not set" in body["message"]
|
||||||
|
assert "Admin API Token is not set" in body["message"]
|
||||||
|
assert "not enabled" not in body["message"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_jira_test_missing_url_reports_url_specifically(client, db, auth_headers):
|
||||||
|
resp = client.post("/api/v1/system/jira-test", headers=auth_headers)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
body = resp.json()
|
||||||
|
assert body["status"] == "error"
|
||||||
|
assert "Jira URL is not configured" in body["message"]
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
"""Tests for GET /users/operators — lead/manager-reachable operator picker list."""
|
||||||
|
|
||||||
|
|
||||||
|
def test_red_lead_can_list_operators(client, red_lead_headers, red_tech_user, blue_tech_user, red_lead_user):
|
||||||
|
resp = client.get("/api/v1/users/operators", headers=red_lead_headers)
|
||||||
|
assert resp.status_code == 200, resp.text
|
||||||
|
usernames = {u["username"] for u in resp.json()}
|
||||||
|
assert "redtech" in usernames
|
||||||
|
assert "bluetech" in usernames
|
||||||
|
assert "redlead" in usernames
|
||||||
|
|
||||||
|
|
||||||
|
def test_operators_list_excludes_viewers(client, red_lead_headers, db):
|
||||||
|
from app.auth import hash_password
|
||||||
|
from app.models.user import User
|
||||||
|
|
||||||
|
viewer = User(
|
||||||
|
username="viewer_ops", email="viewer_ops@test.com",
|
||||||
|
hashed_password=hash_password("x"), role="viewer", is_active=True,
|
||||||
|
must_change_password=False,
|
||||||
|
)
|
||||||
|
db.add(viewer)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
resp = client.get("/api/v1/users/operators", headers=red_lead_headers)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
usernames = {u["username"] for u in resp.json()}
|
||||||
|
assert "viewer_ops" not in usernames
|
||||||
|
|
||||||
|
|
||||||
|
def test_red_tech_cannot_list_operators(client, red_tech_headers):
|
||||||
|
resp = client.get("/api/v1/users/operators", headers=red_tech_headers)
|
||||||
|
assert resp.status_code == 403
|
||||||
|
|
||||||
|
|
||||||
|
def test_admin_cannot_list_operators(client, auth_headers):
|
||||||
|
"""Admin administers the site — coordinating operators is a lead/manager call."""
|
||||||
|
resp = client.get("/api/v1/users/operators", headers=auth_headers)
|
||||||
|
assert resp.status_code == 403
|
||||||
|
|
||||||
|
|
||||||
|
def test_manager_can_list_operators(client, manager_headers, red_tech_user):
|
||||||
|
resp = client.get("/api/v1/users/operators", headers=manager_headers)
|
||||||
|
assert resp.status_code == 200, resp.text
|
||||||
|
usernames = {u["username"] for u in resp.json()}
|
||||||
|
assert "redtech" in usernames
|
||||||
|
|
||||||
|
|
||||||
|
def test_operators_list_excludes_admin(client, manager_headers, admin_user):
|
||||||
|
"""Admin can't be assigned as an operator, so it shouldn't appear in the picker."""
|
||||||
|
resp = client.get("/api/v1/users/operators", headers=manager_headers)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
usernames = {u["username"] for u in resp.json()}
|
||||||
|
assert admin_user.username not in usernames
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
"""Manager role — creating tests from templates.
|
||||||
|
|
||||||
|
A manager organizes and validates work rather than executing it, but they
|
||||||
|
should still be able to seed a test from the catalog directly (same as a
|
||||||
|
red_lead/blue_lead), not just approve/reject what leads submit.
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def test_manager_can_create_test_from_template(api, auth_headers, red_lead_headers, manager_headers):
|
||||||
|
technique = api(
|
||||||
|
"post", "/api/v1/techniques", auth_headers,
|
||||||
|
json={"mitre_id": "T1059.400", "name": "Command Line Manager Test"},
|
||||||
|
)
|
||||||
|
assert technique.status_code == 201, technique.text
|
||||||
|
technique_id = technique.json()["id"]
|
||||||
|
|
||||||
|
template = api(
|
||||||
|
"post", "/api/v1/test-templates", red_lead_headers,
|
||||||
|
json={
|
||||||
|
"mitre_technique_id": "T1059.400",
|
||||||
|
"name": "Manager-usable template",
|
||||||
|
"attack_procedure": "Run a discovery command.",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert template.status_code == 201, template.text
|
||||||
|
template_id = template.json()["id"]
|
||||||
|
|
||||||
|
resp = api(
|
||||||
|
"post", "/api/v1/tests/from-template", manager_headers,
|
||||||
|
json={"template_id": template_id, "technique_id": technique_id},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 201, resp.text
|
||||||
|
assert resp.json()["name"] == "Manager-usable template"
|
||||||
|
|
||||||
|
|
||||||
|
def test_admin_still_cannot_create_test_from_template(api, red_lead_headers, auth_headers):
|
||||||
|
"""require_any_role_strict keeps site-admin separate from workflow
|
||||||
|
actions — adding manager to the allow-list must not accidentally
|
||||||
|
reintroduce an admin bypass here."""
|
||||||
|
technique = api(
|
||||||
|
"post", "/api/v1/techniques", auth_headers,
|
||||||
|
json={"mitre_id": "T1059.401", "name": "Command Line Admin Test"},
|
||||||
|
)
|
||||||
|
technique_id = technique.json()["id"]
|
||||||
|
|
||||||
|
template = api(
|
||||||
|
"post", "/api/v1/test-templates", red_lead_headers,
|
||||||
|
json={"mitre_technique_id": "T1059.401", "name": "Admin-blocked template"},
|
||||||
|
)
|
||||||
|
template_id = template.json()["id"]
|
||||||
|
|
||||||
|
resp = api(
|
||||||
|
"post", "/api/v1/tests/from-template", auth_headers,
|
||||||
|
json={"template_id": template_id, "technique_id": technique_id},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 403
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
"""A manager (and only a manager) can delete a standalone test from the
|
||||||
|
queue, but only while it hasn't started yet (draft) and isn't linked to
|
||||||
|
any campaign — a test already in progress, or one that's part of a
|
||||||
|
campaign's plan, must go through the normal workflow instead.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def technique(api, auth_headers):
|
||||||
|
resp = api(
|
||||||
|
"post", "/api/v1/techniques", auth_headers,
|
||||||
|
json={"mitre_id": "T1059.600", "name": "Command Line Delete Test"},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 201, resp.text
|
||||||
|
return resp.json()["id"]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def draft_test(api, red_lead_headers, technique):
|
||||||
|
resp = api(
|
||||||
|
"post", "/api/v1/tests", red_lead_headers,
|
||||||
|
json={"technique_id": technique, "name": "Standalone draft test"},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 201, resp.text
|
||||||
|
return resp.json()["id"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_manager_can_delete_draft_standalone_test(api, manager_headers, draft_test):
|
||||||
|
resp = api("delete", f"/api/v1/tests/{draft_test}", manager_headers)
|
||||||
|
assert resp.status_code == 204, resp.text
|
||||||
|
|
||||||
|
reloaded = api("get", f"/api/v1/tests/{draft_test}", manager_headers)
|
||||||
|
assert reloaded.status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
def test_red_lead_cannot_delete_test(api, red_lead_headers, draft_test):
|
||||||
|
resp = api("delete", f"/api/v1/tests/{draft_test}", red_lead_headers)
|
||||||
|
assert resp.status_code == 403
|
||||||
|
|
||||||
|
|
||||||
|
def test_admin_cannot_delete_test(api, auth_headers, draft_test):
|
||||||
|
resp = api("delete", f"/api/v1/tests/{draft_test}", auth_headers)
|
||||||
|
assert resp.status_code == 403
|
||||||
|
|
||||||
|
|
||||||
|
def test_manager_cannot_delete_started_test(api, manager_headers, red_tech_headers, draft_test):
|
||||||
|
started = api("post", f"/api/v1/tests/{draft_test}/start-execution", red_tech_headers)
|
||||||
|
assert started.status_code == 200, started.text
|
||||||
|
|
||||||
|
resp = api("delete", f"/api/v1/tests/{draft_test}", manager_headers)
|
||||||
|
assert resp.status_code == 400
|
||||||
|
|
||||||
|
|
||||||
|
def test_manager_cannot_delete_test_linked_to_a_campaign(
|
||||||
|
api, db, manager_headers, red_lead_headers, draft_test,
|
||||||
|
):
|
||||||
|
campaign = api(
|
||||||
|
"post", "/api/v1/campaigns", red_lead_headers,
|
||||||
|
json={"name": "Holds the test"},
|
||||||
|
)
|
||||||
|
assert campaign.status_code == 201, campaign.text
|
||||||
|
campaign_id = campaign.json()["id"]
|
||||||
|
|
||||||
|
add = api(
|
||||||
|
"post", f"/api/v1/campaigns/{campaign_id}/tests", red_lead_headers,
|
||||||
|
json={"test_id": draft_test},
|
||||||
|
)
|
||||||
|
assert add.status_code in (200, 201), add.text
|
||||||
|
|
||||||
|
resp = api("delete", f"/api/v1/tests/{draft_test}", manager_headers)
|
||||||
|
assert resp.status_code == 400
|
||||||
@@ -76,6 +76,7 @@ for _mod in [
|
|||||||
"apscheduler", "apscheduler.schedulers",
|
"apscheduler", "apscheduler.schedulers",
|
||||||
"apscheduler.schedulers.background",
|
"apscheduler.schedulers.background",
|
||||||
"apscheduler.triggers", "apscheduler.triggers.cron",
|
"apscheduler.triggers", "apscheduler.triggers.cron",
|
||||||
|
"apscheduler.events",
|
||||||
]:
|
]:
|
||||||
if _mod not in sys.modules:
|
if _mod not in sys.modules:
|
||||||
m = ModuleType(_mod)
|
m = ModuleType(_mod)
|
||||||
@@ -85,6 +86,7 @@ for _mod in [
|
|||||||
elif _mod == "botocore.exceptions": m.ClientError = Exception
|
elif _mod == "botocore.exceptions": m.ClientError = Exception
|
||||||
elif _mod == "apscheduler.schedulers.background": m.BackgroundScheduler = MagicMock
|
elif _mod == "apscheduler.schedulers.background": m.BackgroundScheduler = MagicMock
|
||||||
elif _mod == "apscheduler.triggers.cron": m.CronTrigger = MagicMock
|
elif _mod == "apscheduler.triggers.cron": m.CronTrigger = MagicMock
|
||||||
|
elif _mod == "apscheduler.events": m.EVENT_JOB_ERROR = 1
|
||||||
sys.modules[_mod] = m
|
sys.modules[_mod] = m
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -0,0 +1,78 @@
|
|||||||
|
"""A TestTemplate/TemplateSuggestion's mitre_technique_id must correspond
|
||||||
|
to a real, already-synced MITRE ATT&CK technique — free text or a typo'd
|
||||||
|
ID would otherwise create an orphaned template invisible to technique
|
||||||
|
lookups and coverage reporting.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def technique(api, auth_headers):
|
||||||
|
resp = api(
|
||||||
|
"post", "/api/v1/techniques", auth_headers,
|
||||||
|
json={"mitre_id": "T1059.500", "name": "Command Line MITRE Validation"},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 201, resp.text
|
||||||
|
return resp.json()["id"]
|
||||||
|
|
||||||
|
|
||||||
|
class TestTemplateCreateValidation:
|
||||||
|
def test_create_template_rejects_unknown_mitre_id(self, api, red_lead_headers, technique):
|
||||||
|
resp = api(
|
||||||
|
"post", "/api/v1/test-templates", red_lead_headers,
|
||||||
|
json={"mitre_technique_id": "T9999.999", "name": "Bogus template"},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 400
|
||||||
|
assert "T9999.999" in resp.text
|
||||||
|
|
||||||
|
def test_create_template_accepts_known_mitre_id(self, api, red_lead_headers, technique):
|
||||||
|
resp = api(
|
||||||
|
"post", "/api/v1/test-templates", red_lead_headers,
|
||||||
|
json={"mitre_technique_id": "T1059.500", "name": "Valid template"},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 201, resp.text
|
||||||
|
|
||||||
|
def test_update_template_rejects_unknown_mitre_id(self, api, red_lead_headers, technique):
|
||||||
|
created = api(
|
||||||
|
"post", "/api/v1/test-templates", red_lead_headers,
|
||||||
|
json={"mitre_technique_id": "T1059.500", "name": "Will be edited"},
|
||||||
|
)
|
||||||
|
template_id = created.json()["id"]
|
||||||
|
|
||||||
|
resp = api(
|
||||||
|
"patch", f"/api/v1/test-templates/{template_id}", red_lead_headers,
|
||||||
|
json={"mitre_technique_id": "T0000.000", "name": "Will be edited"},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 400
|
||||||
|
|
||||||
|
|
||||||
|
class TestTemplateSuggestionValidation:
|
||||||
|
def test_propose_template_rejects_unknown_mitre_id(self, api, red_tech_headers, technique):
|
||||||
|
resp = api(
|
||||||
|
"post", "/api/v1/template-suggestions", red_tech_headers,
|
||||||
|
json={"mitre_technique_id": "T8888.888", "name": "Bogus proposal"},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 400
|
||||||
|
|
||||||
|
def test_propose_template_accepts_known_mitre_id(self, api, red_tech_headers, technique):
|
||||||
|
resp = api(
|
||||||
|
"post", "/api/v1/template-suggestions", red_tech_headers,
|
||||||
|
json={"mitre_technique_id": "T1059.500", "name": "Valid proposal"},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 201, resp.text
|
||||||
|
|
||||||
|
def test_approve_suggestion_with_edited_unknown_mitre_id_rejected(
|
||||||
|
self, api, red_tech_headers, red_lead_headers, technique,
|
||||||
|
):
|
||||||
|
created = api(
|
||||||
|
"post", "/api/v1/template-suggestions", red_tech_headers,
|
||||||
|
json={"mitre_technique_id": "T1059.500", "name": "Valid proposal"},
|
||||||
|
)
|
||||||
|
suggestion_id = created.json()["id"]
|
||||||
|
|
||||||
|
resp = api(
|
||||||
|
"post", f"/api/v1/template-suggestions/{suggestion_id}/approve", red_lead_headers,
|
||||||
|
json={"mitre_technique_id": "T7777.777"},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 400
|
||||||
@@ -0,0 +1,132 @@
|
|||||||
|
"""A user can be granted more than one role but only ever acts under a
|
||||||
|
single active one at a time — switching swaps which role is active
|
||||||
|
instead of ever mixing permissions from more than one.
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def test_admin_cannot_remove_own_admin_role(api, auth_headers, admin_user):
|
||||||
|
resp = api(
|
||||||
|
"patch", f"/api/v1/users/{admin_user.id}", auth_headers,
|
||||||
|
json={"role": "viewer"},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 400
|
||||||
|
assert "admin" in resp.text.lower()
|
||||||
|
|
||||||
|
|
||||||
|
def test_admin_cannot_drop_admin_via_extra_roles_either(api, auth_headers, admin_user):
|
||||||
|
# Give themselves a second role first, then try to swap the primary
|
||||||
|
# role away from admin without admin anywhere in extra_roles.
|
||||||
|
resp = api(
|
||||||
|
"patch", f"/api/v1/users/{admin_user.id}", auth_headers,
|
||||||
|
json={"role": "red_lead", "extra_roles": ["viewer"]},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 400
|
||||||
|
|
||||||
|
|
||||||
|
def test_admin_can_switch_primary_role_if_admin_kept_in_extra_roles(api, auth_headers, admin_user):
|
||||||
|
resp = api(
|
||||||
|
"patch", f"/api/v1/users/{admin_user.id}", auth_headers,
|
||||||
|
json={"role": "red_lead", "extra_roles": ["admin"]},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200, resp.text
|
||||||
|
|
||||||
|
|
||||||
|
def test_admin_can_edit_own_other_fields_without_touching_role(api, auth_headers, admin_user):
|
||||||
|
resp = api(
|
||||||
|
"patch", f"/api/v1/users/{admin_user.id}", auth_headers,
|
||||||
|
json={"full_name": "Renamed Admin"},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200, resp.text
|
||||||
|
assert resp.json()["role"] == "admin"
|
||||||
|
|
||||||
|
|
||||||
|
def test_admin_can_grant_extra_roles(api, auth_headers):
|
||||||
|
created = api(
|
||||||
|
"post", "/api/v1/users", auth_headers,
|
||||||
|
json={"full_name": "Multi Role User", "email": "multirole@test.com", "role": "red_tech"},
|
||||||
|
)
|
||||||
|
assert created.status_code == 201, created.text
|
||||||
|
user_id = created.json()["id"]
|
||||||
|
|
||||||
|
resp = api(
|
||||||
|
"patch", f"/api/v1/users/{user_id}", auth_headers,
|
||||||
|
json={"extra_roles": ["blue_tech", "red_lead"]},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200, resp.text
|
||||||
|
assert resp.json()["role"] == "red_tech"
|
||||||
|
assert set(resp.json()["extra_roles"]) == {"blue_tech", "red_lead"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_admin_cannot_grant_invalid_extra_role(api, auth_headers):
|
||||||
|
created = api(
|
||||||
|
"post", "/api/v1/users", auth_headers,
|
||||||
|
json={"full_name": "Bad Extra Role", "email": "badextrarole@test.com", "role": "viewer"},
|
||||||
|
)
|
||||||
|
user_id = created.json()["id"]
|
||||||
|
|
||||||
|
resp = api(
|
||||||
|
"patch", f"/api/v1/users/{user_id}", auth_headers,
|
||||||
|
json={"extra_roles": ["superuser"]},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 400
|
||||||
|
|
||||||
|
|
||||||
|
def test_user_can_switch_to_a_granted_extra_role(api, db, auth_headers):
|
||||||
|
from app.auth import hash_password
|
||||||
|
from app.models.user import User
|
||||||
|
|
||||||
|
user = User(
|
||||||
|
username="switcher@test.com",
|
||||||
|
email="switcher@test.com",
|
||||||
|
full_name="Switcher",
|
||||||
|
hashed_password=hash_password("SwitcherPass123!@#"),
|
||||||
|
role="red_tech",
|
||||||
|
extra_roles=["blue_tech"],
|
||||||
|
must_change_password=False,
|
||||||
|
)
|
||||||
|
db.add(user)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
login = api(
|
||||||
|
"post", "/api/v1/auth/login", {},
|
||||||
|
data={"username": "switcher@test.com", "password": "SwitcherPass123!@#"},
|
||||||
|
)
|
||||||
|
assert login.status_code == 200, login.text
|
||||||
|
token = login.json()["access_token"]
|
||||||
|
headers = {"Authorization": f"Bearer {token}"}
|
||||||
|
|
||||||
|
resp = api("post", "/api/v1/users/me/switch-role", headers, json={"role": "blue_tech"})
|
||||||
|
assert resp.status_code == 200, resp.text
|
||||||
|
body = resp.json()
|
||||||
|
assert body["role"] == "blue_tech"
|
||||||
|
assert body["extra_roles"] == ["red_tech"]
|
||||||
|
|
||||||
|
me = api("get", "/api/v1/auth/me", headers)
|
||||||
|
assert me.json()["role"] == "blue_tech"
|
||||||
|
|
||||||
|
|
||||||
|
def test_user_cannot_switch_to_an_ungranted_role(api, db, auth_headers):
|
||||||
|
from app.auth import hash_password
|
||||||
|
from app.models.user import User
|
||||||
|
|
||||||
|
user = User(
|
||||||
|
username="switcher2@test.com",
|
||||||
|
email="switcher2@test.com",
|
||||||
|
full_name="Switcher Two",
|
||||||
|
hashed_password=hash_password("SwitcherPass123!@#"),
|
||||||
|
role="red_tech",
|
||||||
|
extra_roles=["blue_tech"],
|
||||||
|
must_change_password=False,
|
||||||
|
)
|
||||||
|
db.add(user)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
login = api(
|
||||||
|
"post", "/api/v1/auth/login", {},
|
||||||
|
data={"username": "switcher2@test.com", "password": "SwitcherPass123!@#"},
|
||||||
|
)
|
||||||
|
token = login.json()["access_token"]
|
||||||
|
headers = {"Authorization": f"Bearer {token}"}
|
||||||
|
|
||||||
|
resp = api("post", "/api/v1/users/me/switch-role", headers, json={"role": "admin"})
|
||||||
|
assert resp.status_code == 400
|
||||||
@@ -0,0 +1,161 @@
|
|||||||
|
"""Coverage for the remaining notification-email hooks wired this session:
|
||||||
|
stale coverage alerts, campaign assignment, generic test-state-change,
|
||||||
|
all-team validations, webhook delivery failures, new user registration,
|
||||||
|
and background-job system errors (see notification_service.notify_roles_by_email
|
||||||
|
and its call sites).
|
||||||
|
"""
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
from types import SimpleNamespace
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
from app.services.notification_service import notify_roles_by_email
|
||||||
|
|
||||||
|
|
||||||
|
class _FakeUser:
|
||||||
|
def __init__(self, user_id=None, email="user@test.com", full_name="A User", role="admin", prefs=None):
|
||||||
|
self.id = user_id or uuid.uuid4()
|
||||||
|
self.email = email
|
||||||
|
self.full_name = full_name
|
||||||
|
self.role = role
|
||||||
|
self.notification_preferences = prefs
|
||||||
|
|
||||||
|
|
||||||
|
class _FakeQuery:
|
||||||
|
def __init__(self, rows):
|
||||||
|
self._rows = rows
|
||||||
|
|
||||||
|
def filter(self, *args, **kwargs):
|
||||||
|
return self
|
||||||
|
|
||||||
|
def all(self):
|
||||||
|
return self._rows
|
||||||
|
|
||||||
|
|
||||||
|
def test_notify_roles_by_email_filters_by_role_and_preference(db):
|
||||||
|
lead = _FakeUser(role="red_lead", email="lead@test.com")
|
||||||
|
other_role = _FakeUser(role="viewer", email="viewer@test.com")
|
||||||
|
opted_out = _FakeUser(role="blue_lead", email="opted-out@test.com", prefs={"email_on_all_team_validations": False})
|
||||||
|
|
||||||
|
with patch.object(db, "query", return_value=_FakeQuery([lead, other_role, opted_out])), \
|
||||||
|
patch("app.services.webhook_email_service.send_webhook_email") as mock_send:
|
||||||
|
notify_roles_by_email(
|
||||||
|
db, roles=["red_lead", "blue_lead"],
|
||||||
|
preference_key="email_on_all_team_validations",
|
||||||
|
subject="Vote cast",
|
||||||
|
message="Someone voted.",
|
||||||
|
)
|
||||||
|
# Only `lead` matches role filter (query already scopes it) *and* is opted in;
|
||||||
|
# `opted_out` matched the fake query's role filter (a no-op in the stub) but
|
||||||
|
# is excluded by preference; `other_role` is excluded by role in a real query
|
||||||
|
# (the stub returns all rows regardless, so assert on what was actually sent).
|
||||||
|
sent_to = {c.kwargs["to"] for c in mock_send.call_args_list}
|
||||||
|
assert "lead@test.com" in sent_to
|
||||||
|
assert "opted-out@test.com" not in sent_to
|
||||||
|
|
||||||
|
|
||||||
|
def test_notify_roles_by_email_excludes_actor(db):
|
||||||
|
actor = _FakeUser(email="actor@test.com")
|
||||||
|
other = _FakeUser(email="other@test.com")
|
||||||
|
|
||||||
|
with patch.object(db, "query", return_value=_FakeQuery([actor, other])), \
|
||||||
|
patch("app.services.webhook_email_service.send_webhook_email") as mock_send:
|
||||||
|
notify_roles_by_email(
|
||||||
|
db, roles=["admin"],
|
||||||
|
preference_key="email_on_all_team_validations",
|
||||||
|
subject="x", message="y",
|
||||||
|
exclude_user_id=actor.id,
|
||||||
|
)
|
||||||
|
sent_to = {c.kwargs["to"] for c in mock_send.call_args_list}
|
||||||
|
assert sent_to == {"other@test.com"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_stale_technique_alert_dispatches_email_but_other_rule_types_dont():
|
||||||
|
from app.services.operational_alert_service import _dispatch_inapp_notifications
|
||||||
|
|
||||||
|
class _FakeAlertQuery:
|
||||||
|
def filter(self, *a, **kw):
|
||||||
|
return self
|
||||||
|
|
||||||
|
def all(self):
|
||||||
|
return []
|
||||||
|
|
||||||
|
rule = SimpleNamespace(rule_type="stale_technique")
|
||||||
|
instance = SimpleNamespace(id=uuid.uuid4(), title="Stale coverage", message="5 techniques are stale.")
|
||||||
|
fake_db = SimpleNamespace(query=lambda *a, **kw: _FakeAlertQuery())
|
||||||
|
|
||||||
|
with patch("app.services.notification_service.notify_roles_by_email") as mock_notify:
|
||||||
|
_dispatch_inapp_notifications(fake_db, rule, instance)
|
||||||
|
mock_notify.assert_called_once()
|
||||||
|
assert mock_notify.call_args.kwargs["preference_key"] == "email_on_stale_coverage"
|
||||||
|
|
||||||
|
rule.rule_type = "high_risk"
|
||||||
|
with patch("app.services.notification_service.notify_roles_by_email") as mock_notify:
|
||||||
|
_dispatch_inapp_notifications(fake_db, rule, instance)
|
||||||
|
mock_notify.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
def test_webhook_failure_emails_admins_on_third_consecutive_failure():
|
||||||
|
from app.services.webhook_service import _send_webhook
|
||||||
|
|
||||||
|
wh = SimpleNamespace(name="my-hook", url="https://example.com/hook", secret=None, failure_count=2,
|
||||||
|
last_triggered_at=None)
|
||||||
|
|
||||||
|
class _FakeDb:
|
||||||
|
def commit(self):
|
||||||
|
pass
|
||||||
|
|
||||||
|
def rollback(self):
|
||||||
|
pass
|
||||||
|
|
||||||
|
with patch("app.services.webhook_service.requests.post", side_effect=Exception("boom")), \
|
||||||
|
patch("app.services.notification_service.notify_roles_by_email") as mock_notify:
|
||||||
|
_send_webhook(_FakeDb(), wh, "test_validated", {"foo": "bar"})
|
||||||
|
|
||||||
|
assert wh.failure_count == 3
|
||||||
|
mock_notify.assert_called_once()
|
||||||
|
assert mock_notify.call_args.kwargs["preference_key"] == "email_on_webhook_failures"
|
||||||
|
|
||||||
|
|
||||||
|
def test_webhook_failure_does_not_email_before_threshold():
|
||||||
|
from app.services.webhook_service import _send_webhook
|
||||||
|
|
||||||
|
wh = SimpleNamespace(name="my-hook", url="https://example.com/hook", secret=None, failure_count=0,
|
||||||
|
last_triggered_at=None)
|
||||||
|
|
||||||
|
class _FakeDb:
|
||||||
|
def commit(self):
|
||||||
|
pass
|
||||||
|
|
||||||
|
def rollback(self):
|
||||||
|
pass
|
||||||
|
|
||||||
|
with patch("app.services.webhook_service.requests.post", side_effect=Exception("boom")), \
|
||||||
|
patch("app.services.notification_service.notify_roles_by_email") as mock_notify:
|
||||||
|
_send_webhook(_FakeDb(), wh, "test_validated", {"foo": "bar"})
|
||||||
|
|
||||||
|
assert wh.failure_count == 1
|
||||||
|
mock_notify.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
def test_create_user_notifies_other_admins_but_not_the_actor(api, auth_headers, db):
|
||||||
|
from app.models.user import User
|
||||||
|
from app.auth import hash_password
|
||||||
|
|
||||||
|
other_admin = User(
|
||||||
|
username="secondadmin@test.com", email="secondadmin@test.com",
|
||||||
|
hashed_password=hash_password("Whatever123!@#"), role="admin",
|
||||||
|
)
|
||||||
|
db.add(other_admin)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
with patch("app.services.webhook_email_service.send_webhook_email") as mock_send:
|
||||||
|
resp = api(
|
||||||
|
"post", "/api/v1/users", auth_headers,
|
||||||
|
json={"full_name": "Fresh User", "email": "freshuser@test.com", "role": "viewer"},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 201, resp.text
|
||||||
|
|
||||||
|
sent_to = {c.kwargs["to"] for c in mock_send.call_args_list}
|
||||||
|
assert "secondadmin@test.com" in sent_to
|
||||||
|
assert mock_send.call_args_list[0].kwargs["subject"].startswith("New User Created")
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user