Compare commits
71 Commits
adae7e617e
...
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 |
@@ -23,7 +23,10 @@ TOKEN_EXPIRE_MINUTES=60
|
||||
# ── Initial Admin Account ────────────────────────────────────────────────────
|
||||
# If ADMIN_PASSWORD is empty, a random password is auto-generated and
|
||||
# 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_EMAIL=
|
||||
ADMIN_PASSWORD=
|
||||
|
||||
# ── MinIO Object Storage ─────────────────────────────────────────────────────
|
||||
@@ -39,6 +42,13 @@ CORS_ORIGINS=https://your-domain.com
|
||||
# ── Frontend ─────────────────────────────────────────────────────────────────
|
||||
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 ─────────────────────────────────────────────────────────
|
||||
# Set to "production" for production deployments (enforces SECRET_KEY, etc.)
|
||||
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
|
||||
|
||||
# ── 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"
|
||||
# Assign REPORT_OUTPUT_DIR = "/tmp/aegis_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"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
|
||||
|
||||
|
||||
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):
|
||||
"""Return a dependency that enforces the API key carries *scope*.
|
||||
|
||||
|
||||
@@ -93,14 +93,19 @@ class AttackSuccessResult(str, enum.Enum):
|
||||
class DataClassification(str, enum.Enum):
|
||||
"""Data sensitivity classification levels for compliance and retention policies.
|
||||
|
||||
Matches the organization's data protection scheme:
|
||||
- public_release: approved for external/public release
|
||||
Matches Jira's "Data Sensitivity" field (customfield_11814) exactly, since
|
||||
that is the organization's actual data protection scheme:
|
||||
- public: approved for external/public release
|
||||
- general_use: general internal information, no special restriction
|
||||
- confidential: internal use only, or shared with trusted people
|
||||
- restricted: trusted people, customer content, and/or PII
|
||||
- internal_use_only: internal use only
|
||||
- trusted_people: shared only with a limited/trusted audience
|
||||
- customer_content: contains customer data
|
||||
- pii: contains personally identifiable information
|
||||
"""
|
||||
|
||||
public_release = "public_release"
|
||||
public = "public"
|
||||
general_use = "general_use"
|
||||
confidential = "confidential"
|
||||
restricted = "restricted"
|
||||
internal_use_only = "internal_use_only"
|
||||
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.
|
||||
Resets the red-phase timer for a fresh attempt (the first attempt's
|
||||
worklog was already recorded at submit time, so this loses nothing).
|
||||
Starts the timer PAUSED — the operator hasn't resumed working yet,
|
||||
just because the lead reopened it. It only starts counting once they
|
||||
explicitly resume it, same as coming back from a hold.
|
||||
"""
|
||||
self._transition(TestState.red_executing)
|
||||
self.red_started_at = datetime.utcnow()
|
||||
self.red_paused_seconds = 0
|
||||
self.paused_at = self.red_started_at
|
||||
self._events.append(DomainEvent("red_review_reopened"))
|
||||
|
||||
# Define function submit_blue_evidence
|
||||
@@ -668,6 +672,27 @@ class TestEntity:
|
||||
|
||||
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 -------------------------------------------------------
|
||||
|
||||
def _auto_resume(self) -> int:
|
||||
|
||||
@@ -16,6 +16,7 @@ from datetime import datetime, timedelta, timezone
|
||||
|
||||
# Import BackgroundScheduler from apscheduler.schedulers.background
|
||||
from apscheduler.schedulers.background import BackgroundScheduler
|
||||
from apscheduler.events import EVENT_JOB_ERROR
|
||||
|
||||
# Import SessionLocal from app.database
|
||||
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
|
||||
|
||||
# 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
|
||||
from app.services.intel_service import scan_intel
|
||||
@@ -57,6 +61,27 @@ logger = logging.getLogger(__name__)
|
||||
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
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -164,6 +189,19 @@ def _run_recurring_campaigns() -> None:
|
||||
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:
|
||||
"""Execute an intel scan inside its own DB session."""
|
||||
# Log info: "Scheduled intel scan job starting..."
|
||||
@@ -424,6 +462,7 @@ def start_scheduler() -> None:
|
||||
|
||||
Neither job fires immediately on startup.
|
||||
"""
|
||||
scheduler.add_listener(_on_job_error, EVENT_JOB_ERROR)
|
||||
# Call scheduler.add_job()
|
||||
scheduler.add_job(
|
||||
_run_mitre_sync,
|
||||
@@ -497,6 +536,14 @@ def start_scheduler() -> None:
|
||||
# Keyword argument: replace_existing
|
||||
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()
|
||||
scheduler.add_job(
|
||||
sync_all_jira_links,
|
||||
@@ -605,7 +652,7 @@ def start_scheduler() -> None:
|
||||
# Literal argument value
|
||||
"notification_cleanup (24h), weekly_snapshot (Sundays 00:00), "
|
||||
# Literal argument value
|
||||
"recurring_campaigns (daily), jira_sync (1h), "
|
||||
"recurring_campaigns (daily), due_campaign_jira_sync (15m), jira_sync (1h), "
|
||||
# Literal argument value
|
||||
"osint_enrichment (weekly), stale_detection (daily), "
|
||||
"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 evidence as evidence_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 metrics as metrics_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")
|
||||
# Call app.include_router()
|
||||
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()
|
||||
app.include_router(system_router.router, prefix="/api/v1")
|
||||
# Call app.include_router()
|
||||
|
||||
@@ -43,8 +43,13 @@ from app.models.evidence import Evidence
|
||||
from app.models.intel import IntelItem
|
||||
from app.models.technique import Technique
|
||||
from app.models.test import Test
|
||||
from app.models.test_round_history import TestRoundHistory
|
||||
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.password_setup_token import PasswordSetupToken
|
||||
from app.models.evaluation_import import EvaluationImport
|
||||
|
||||
# Assign __all__ = [
|
||||
__all__ = [
|
||||
@@ -86,4 +91,9 @@ __all__ = [
|
||||
"SsoConfig",
|
||||
"AlertRule",
|
||||
"AlertInstance",
|
||||
"TestRoundHistory",
|
||||
"ProcedureSuggestion",
|
||||
"TemplateSuggestion",
|
||||
"PasswordSetupToken",
|
||||
"EvaluationImport",
|
||||
]
|
||||
|
||||
@@ -90,8 +90,8 @@ class Campaign(Base):
|
||||
tags = Column(JSONB, nullable=True, default=[])
|
||||
# Assign 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")
|
||||
data_classification = Column(String(20), nullable=False, server_default="confidential")
|
||||
# Assign data_classification = Column(String(20), nullable=False, server_default="internal_use_only")
|
||||
data_classification = Column(String(20), nullable=False, server_default="internal_use_only")
|
||||
|
||||
# Recurring scheduling fields
|
||||
is_recurring = Column(Boolean, default=False)
|
||||
|
||||
@@ -50,8 +50,8 @@ class Evidence(Base):
|
||||
team = Column(Enum(TeamSide, name="teamside"), nullable=False, default=TeamSide.red)
|
||||
# Assign notes = Column(Text, nullable=True)
|
||||
notes = Column(Text, nullable=True)
|
||||
# Assign data_classification = Column(String(20), nullable=False, server_default="internal")
|
||||
data_classification = Column(String(20), nullable=False, server_default="confidential")
|
||||
# Assign data_classification = Column(String(20), nullable=False, server_default="internal_use_only")
|
||||
data_classification = Column(String(20), nullable=False, server_default="internal_use_only")
|
||||
|
||||
# Relationships
|
||||
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)
|
||||
# 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)
|
||||
# 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)
|
||||
result = Column(Enum(TestResult, name="testresult"), nullable=True)
|
||||
# Assign state = Column(Enum(TestState, name="teststate"), default=TestState.draft)
|
||||
@@ -83,6 +89,9 @@ class Test(Base):
|
||||
|
||||
# ── Blue Team fields ────────────────────────────────────────────
|
||||
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)
|
||||
detection_result = Column(Enum(TestResult, name="testresult"), 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)
|
||||
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_steps = Column(Text, nullable=True)
|
||||
# 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)
|
||||
# Assign retest_count = Column(Integer, default=0)
|
||||
retest_count = Column(Integer, default=0)
|
||||
# Assign data_classification = Column(String(20), nullable=False, server_default="internal")
|
||||
data_classification = Column(String(20), nullable=False, server_default="confidential")
|
||||
# Assign data_classification = Column(String(20), nullable=False, server_default="internal_use_only")
|
||||
data_classification = Column(String(20), nullable=False, server_default="internal_use_only")
|
||||
|
||||
# ── Relationships ───────────────────────────────────────────────
|
||||
technique = relationship("Technique", back_populates="tests")
|
||||
# Assign 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])
|
||||
creator = relationship("User", foreign_keys=[created_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)
|
||||
# Assign 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
|
||||
expected_detection = Column(Text, nullable=True) # What blue team should detect
|
||||
# What blue team should detect — narrative guidance from imports/leads,
|
||||
# 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...
|
||||
platform = Column(String, nullable=True) # windows / linux / macos
|
||||
# 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)
|
||||
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)
|
||||
# Assign email = Column(String, nullable=True)
|
||||
email = Column(String, nullable=True)
|
||||
# The unique identifier a user logs in with. Every user must have one.
|
||||
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)
|
||||
hashed_password = Column(String, nullable=False)
|
||||
# Assign 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)
|
||||
is_active = Column(Boolean, default=True)
|
||||
# Assign must_change_password = Column(Boolean, default=True)
|
||||
|
||||
@@ -41,6 +41,7 @@ _REDACTED_KEYS = {
|
||||
"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",
|
||||
@@ -221,6 +222,7 @@ async def import_config(
|
||||
"custom_templates": 0,
|
||||
"users_created": 0,
|
||||
"users_updated": 0,
|
||||
"users_skipped_no_email": 0,
|
||||
}
|
||||
|
||||
# ── 1. system_configs ────────────────────────────────────────────
|
||||
@@ -308,12 +310,16 @@ async def import_config(
|
||||
summary["custom_templates"] += 1
|
||||
|
||||
# ── 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
|
||||
for item in bundle.get("users", []):
|
||||
username = item.get("username")
|
||||
if not username:
|
||||
email = item.get("email")
|
||||
if not email:
|
||||
summary["users_skipped_no_email"] += 1
|
||||
continue
|
||||
existing = db.query(User).filter(User.username == username).first()
|
||||
existing = db.query(User).filter(User.email == email).first()
|
||||
if existing:
|
||||
existing.role = item.get("role", existing.role)
|
||||
existing.is_active = item.get("is_active", existing.is_active)
|
||||
@@ -322,14 +328,13 @@ async def import_config(
|
||||
# Create with random temp password — user must reset on login
|
||||
temp_pw = _secrets.token_urlsafe(16) + "Aa1!"
|
||||
new_user = User(
|
||||
username=username,
|
||||
username=email,
|
||||
email=email,
|
||||
hashed_password=hash_password(temp_pw),
|
||||
role=item.get("role", "viewer"),
|
||||
is_active=item.get("is_active", True),
|
||||
must_change_password=True,
|
||||
)
|
||||
if item.get("email") and hasattr(User, "email"):
|
||||
new_user.email = item["email"]
|
||||
db.add(new_user)
|
||||
summary["users_created"] += 1
|
||||
|
||||
|
||||
@@ -57,7 +57,7 @@ from app.models.user import User
|
||||
from app.schemas.auth import TokenResponse, UserOut
|
||||
|
||||
# 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
|
||||
from app.services.audit_service import log_action
|
||||
@@ -72,6 +72,12 @@ from app.services.auth_service import (
|
||||
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"])
|
||||
router = APIRouter(prefix="/auth", tags=["auth"])
|
||||
|
||||
@@ -108,8 +114,10 @@ def login(
|
||||
Rate-limited to **5 attempts per minute per IP**. Failed and successful
|
||||
logins are recorded in the audit log (SEC-009).
|
||||
"""
|
||||
# Assign user = db.query(User).filter(User.username == form_data.username).first()
|
||||
user = db.query(User).filter(User.username == form_data.username).first()
|
||||
# OAuth2PasswordRequestForm's field is spec-named "username" but the
|
||||
# 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
|
||||
target_hash = user.hashed_password if user else _DUMMY_HASH
|
||||
# Assign password_valid = verify_password(form_data.password, target_hash)
|
||||
@@ -134,7 +142,7 @@ def login(
|
||||
# Keyword argument: details
|
||||
details={
|
||||
# Literal argument value
|
||||
"username": form_data.username,
|
||||
"email": form_data.username,
|
||||
# Literal argument value
|
||||
"ip": ip,
|
||||
# Literal argument value
|
||||
@@ -146,7 +154,7 @@ def login(
|
||||
# Call uow.commit()
|
||||
uow.commit()
|
||||
# Raise BusinessRuleViolation
|
||||
raise BusinessRuleViolation("Incorrect username or password")
|
||||
raise BusinessRuleViolation("Incorrect email or password")
|
||||
|
||||
# Check: not user.is_active
|
||||
if not user.is_active:
|
||||
@@ -168,7 +176,7 @@ def login(
|
||||
"auth",
|
||||
str(user.id),
|
||||
# Keyword argument: details
|
||||
details={"username": user.username, "ip": ip},
|
||||
details={"email": user.email, "ip": ip},
|
||||
# Keyword argument: ip_address
|
||||
ip_address=ip,
|
||||
)
|
||||
@@ -381,3 +389,37 @@ def change_password(
|
||||
|
||||
# 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
|
||||
|
||||
# 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
|
||||
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
|
||||
|
||||
# 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
|
||||
|
||||
# Assign logger = logging.getLogger(__name__)
|
||||
@@ -135,39 +135,21 @@ 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 a campaign and its already-linked tests, if missing.
|
||||
"""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.
|
||||
Tests may already be linked to the campaign from when it was still a
|
||||
draft, i.e. before any Jira ticket existed for the campaign — so they
|
||||
need their own tickets created here too. Best-effort: failures are
|
||||
logged, not raised, since a Jira outage must not block activation.
|
||||
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.
|
||||
"""
|
||||
try:
|
||||
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, 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 while activating campaign %s",
|
||||
campaign_id,
|
||||
)
|
||||
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 ─────────────────────────────────────────────────
|
||||
@@ -189,6 +171,11 @@ class CampaignCreate(BaseModel):
|
||||
tags: Optional[list[str]] = Field(default_factory=list)
|
||||
# Assign scheduled_at = 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
|
||||
@@ -332,18 +319,28 @@ def create_campaign(
|
||||
# Entry: db
|
||||
db: Session = Depends(get_db),
|
||||
# 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:
|
||||
"""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:
|
||||
payload (CampaignCreate): Fields for the new campaign (name, type, threat actor, etc.).
|
||||
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:
|
||||
dict: Serialised representation of the newly created campaign.
|
||||
"""
|
||||
is_manager_create = current_user.role == "manager"
|
||||
# Open context manager
|
||||
with UnitOfWork(db) as uow:
|
||||
# Assign result = crud_create(
|
||||
@@ -365,6 +362,9 @@ def create_campaign(
|
||||
tags=payload.tags,
|
||||
# Keyword argument: 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"]
|
||||
log_action(
|
||||
@@ -372,7 +372,7 @@ def create_campaign(
|
||||
# Keyword argument: user_id
|
||||
user_id=current_user.id,
|
||||
# Keyword argument: action
|
||||
action="create_campaign",
|
||||
action="create_campaign_auto_approved" if is_manager_create else "create_campaign",
|
||||
# Keyword argument: entity_type
|
||||
entity_type="campaign",
|
||||
entity_id=campaign_id,
|
||||
@@ -381,6 +381,13 @@ def create_campaign(
|
||||
# Call 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
|
||||
|
||||
@@ -427,15 +434,19 @@ def update_campaign(
|
||||
# Entry: db
|
||||
db: Session = Depends(get_db),
|
||||
# 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:
|
||||
"""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:
|
||||
campaign_id (str): UUID string of the campaign to update.
|
||||
payload (CampaignUpdate): Partial update payload; only set fields are applied.
|
||||
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:
|
||||
dict: Serialised representation of the updated campaign.
|
||||
@@ -532,6 +543,12 @@ def approve_campaign_endpoint(
|
||||
entity_id=campaign.id,
|
||||
details={"start_date": payload.start_date},
|
||||
)
|
||||
notify_roles_by_email(
|
||||
db, roles=["red_tech"],
|
||||
preference_key="email_on_assigned_to_campaign",
|
||||
subject=f"Campaign Activated: {campaign.name}",
|
||||
message=f'Campaign "{campaign.name}" has been approved and activated. You may have tests assigned.',
|
||||
)
|
||||
uow.commit()
|
||||
db.refresh(campaign)
|
||||
|
||||
@@ -857,6 +874,12 @@ def activate_campaign(
|
||||
# Keyword argument: entity_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()
|
||||
log_action(
|
||||
db,
|
||||
@@ -979,7 +1002,9 @@ def generate_campaign_from_actor(
|
||||
payload: GenerateFromActorPayload = GenerateFromActorPayload(),
|
||||
db: Session = Depends(get_db),
|
||||
# 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:
|
||||
"""Auto-generate a campaign from a threat actor's uncovered techniques.
|
||||
|
||||
|
||||
@@ -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")
|
||||
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"])
|
||||
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")),
|
||||
) -> FileResponse:
|
||||
"""Generate a Purple Team campaign assessment report."""
|
||||
_assert_reports_enabled()
|
||||
# Assign filepath = report_generation_service.generate_purple_campaign_report(
|
||||
filepath = report_generation_service.generate_purple_campaign_report(
|
||||
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")),
|
||||
) -> FileResponse:
|
||||
"""Generate an organization-wide MITRE ATT&CK coverage report."""
|
||||
_assert_reports_enabled()
|
||||
# Assign filepath = report_generation_service.generate_coverage_report(
|
||||
filepath = report_generation_service.generate_coverage_report(
|
||||
db, output_format=format,
|
||||
@@ -132,6 +146,7 @@ def generate_executive_report(
|
||||
user: User = Depends(require_any_role("red_lead", "blue_lead", "viewer")),
|
||||
) -> FileResponse:
|
||||
"""Generate an executive security summary report."""
|
||||
_assert_reports_enabled()
|
||||
# Assign filepath = report_generation_service.generate_executive_summary(
|
||||
filepath = report_generation_service.generate_executive_summary(
|
||||
db, output_format=format,
|
||||
@@ -162,6 +177,7 @@ def generate_quarterly_report(
|
||||
user: User = Depends(require_any_role("red_lead", "blue_lead", "viewer")),
|
||||
) -> FileResponse:
|
||||
"""Generate a quarterly security summary report."""
|
||||
_assert_reports_enabled()
|
||||
# Assign filepath = report_generation_service.generate_quarterly_summary(
|
||||
filepath = report_generation_service.generate_quarterly_summary(
|
||||
db, output_format=format,
|
||||
@@ -194,6 +210,7 @@ def generate_technique_report(
|
||||
user: User = Depends(get_current_user),
|
||||
) -> FileResponse:
|
||||
"""Generate a detailed report for one MITRE technique."""
|
||||
_assert_reports_enabled()
|
||||
# Assign filepath = report_generation_service.generate_technique_detail_report(
|
||||
filepath = report_generation_service.generate_technique_detail_report(
|
||||
db, str(technique_id), output_format=format,
|
||||
|
||||
@@ -165,7 +165,7 @@ def score_history(
|
||||
db: Session = Depends(get_db),
|
||||
# Entry: current_user
|
||||
current_user: User = Depends(get_current_user),
|
||||
) -> dict:
|
||||
) -> list:
|
||||
"""Get historical score data points (weekly).
|
||||
|
||||
Args:
|
||||
@@ -174,7 +174,7 @@ def score_history(
|
||||
current_user (User): Authenticated user making the request.
|
||||
|
||||
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)
|
||||
|
||||
@@ -17,7 +17,7 @@ from pydantic import BaseModel
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
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.services.mitre_sync_service import sync_mitre
|
||||
from app.services.intel_service import scan_intel
|
||||
@@ -285,11 +285,13 @@ _JIRA_KEYS = {
|
||||
@router.get("/jira-config", response_model=JiraConfigOut)
|
||||
def get_jira_config(
|
||||
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).
|
||||
|
||||
**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 (
|
||||
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")
|
||||
def test_jira_connection(
|
||||
db: Session = Depends(get_db),
|
||||
|
||||
@@ -254,7 +254,7 @@ def review_technique(
|
||||
# Entry: repo
|
||||
repo: SATechniqueRepository = Depends(get_technique_repository),
|
||||
# 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:
|
||||
"""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
|
||||
from app.database import get_db
|
||||
|
||||
# Import get_current_user, require_any_role from app.dependencies.auth
|
||||
from app.dependencies.auth import get_current_user, require_any_role
|
||||
# Import get_current_user, require_any_role_strict from app.dependencies.auth
|
||||
from app.dependencies.auth import get_current_user, require_any_role_strict
|
||||
|
||||
# Import UnitOfWork from app.domain.unit_of_work
|
||||
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
|
||||
from app.services.test_template_service import (
|
||||
bulk_activate,
|
||||
count_templates,
|
||||
get_template_or_raise,
|
||||
get_template_stats,
|
||||
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)
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -167,7 +197,7 @@ def template_stats(
|
||||
# Entry: db
|
||||
db: Session = Depends(get_db),
|
||||
# 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:
|
||||
"""Return catalog statistics: active, by_source, by_platform.
|
||||
|
||||
@@ -195,7 +225,7 @@ def bulk_activate_templates(
|
||||
# Entry: db
|
||||
db: Session = Depends(get_db),
|
||||
# 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:
|
||||
"""Set all templates to active or inactive.
|
||||
|
||||
@@ -317,7 +347,7 @@ def create_template(
|
||||
# Entry: db
|
||||
db: Session = Depends(get_db),
|
||||
# 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:
|
||||
"""Create a custom test template.
|
||||
|
||||
@@ -386,7 +416,7 @@ def update_template(
|
||||
# Entry: db
|
||||
db: Session = Depends(get_db),
|
||||
# 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:
|
||||
"""Update fields of an existing test template.
|
||||
|
||||
@@ -439,7 +469,7 @@ def toggle_template_active(
|
||||
# Entry: db
|
||||
db: Session = Depends(get_db),
|
||||
# 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:
|
||||
"""Toggle a template between active and inactive (is_active = not is_active).
|
||||
|
||||
@@ -491,7 +521,7 @@ def delete_template(
|
||||
# Entry: db
|
||||
db: Session = Depends(get_db),
|
||||
# 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:
|
||||
"""Soft-delete a test template by setting ``is_active=False``.
|
||||
|
||||
|
||||
+284
-105
@@ -42,7 +42,7 @@ from sqlalchemy.orm import Session
|
||||
from app.database import get_db
|
||||
|
||||
# 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
|
||||
from app.domain.unit_of_work import UnitOfWork
|
||||
@@ -65,6 +65,7 @@ from app.schemas.test import (
|
||||
TestClassificationUpdate,
|
||||
TestCreate,
|
||||
TestHold,
|
||||
TestManagerResolveDispute,
|
||||
TestOut,
|
||||
TestRedReview,
|
||||
TestRedUpdate,
|
||||
@@ -93,6 +94,11 @@ from app.services.test_crud_service import (
|
||||
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
|
||||
from app.services.test_crud_service import (
|
||||
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,
|
||||
)
|
||||
|
||||
# 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
|
||||
from app.services.test_crud_service import (
|
||||
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_blue_lead as wf_validate_blue,
|
||||
resolve_dispute as wf_resolve_dispute,
|
||||
resolve_dispute_by_manager as wf_resolve_dispute_by_manager,
|
||||
reopen_test as wf_reopen,
|
||||
handle_remediation_completed as wf_handle_remediation,
|
||||
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"])
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 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
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@router.get("", response_model=list[TestOut])
|
||||
# Define function list_tests
|
||||
def list_tests(
|
||||
def _test_list_filter_params(
|
||||
# Entry: state
|
||||
state: Optional[str] = Query(None, description="Filter by test state"),
|
||||
# Entry: technique_id
|
||||
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
|
||||
platform: Optional[str] = Query(None, description="Filter by platform"),
|
||||
# Entry: created_by
|
||||
@@ -225,6 +198,52 @@ def list_tests(
|
||||
not_in_any_campaign: bool = Query(
|
||||
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),
|
||||
# Entry: limit
|
||||
limit: int = Query(50, ge=1, le=200),
|
||||
@@ -236,12 +255,7 @@ def list_tests(
|
||||
"""Return a paginated list of tests, optionally filtered by state, technique, platform or creator.
|
||||
|
||||
Args:
|
||||
state (Optional[str]): Filter by test state (e.g. ``draft``, ``validated``).
|
||||
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.
|
||||
filters (dict): Shared filter params — see ``_test_list_filter_params``.
|
||||
offset (int): Number of records to skip for pagination.
|
||||
limit (int): Maximum number of records to return.
|
||||
db (Session): SQLAlchemy database session.
|
||||
@@ -253,18 +267,7 @@ def list_tests(
|
||||
# Return crud_list_tests(
|
||||
return crud_list_tests(
|
||||
db,
|
||||
# Keyword argument: state
|
||||
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,
|
||||
reviewer_id=reviewer_id,
|
||||
not_in_any_campaign=not_in_any_campaign,
|
||||
**filters,
|
||||
offset=offset,
|
||||
# Keyword argument: limit
|
||||
limit=limit,
|
||||
@@ -295,7 +298,7 @@ def create_test(
|
||||
# Entry: db
|
||||
db: Session = Depends(get_db),
|
||||
# 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:
|
||||
"""Create a new test linked to an existing technique.
|
||||
|
||||
@@ -375,7 +378,7 @@ def create_test_from_template(
|
||||
# Entry: db
|
||||
db: Session = Depends(get_db),
|
||||
# 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:
|
||||
"""Instantiate a real Test from an existing TestTemplate.
|
||||
|
||||
@@ -406,6 +409,7 @@ def create_test_from_template(
|
||||
platform_override=payload.platform,
|
||||
procedure_text_override=payload.procedure_text,
|
||||
tool_used_override=payload.tool_used,
|
||||
detect_procedure_override=payload.detect_procedure,
|
||||
)
|
||||
# Call log_action()
|
||||
log_action(
|
||||
@@ -468,12 +472,11 @@ def get_test(
|
||||
|
||||
Returns:
|
||||
TestOut: Full test detail including split red/blue evidence lists.
|
||||
Fields belonging to the other team are nulled out while the
|
||||
test is blind (see :func:`_mask_for_team_blindness`).
|
||||
Both teams see each other's fields regardless of state — Red
|
||||
and Blue are meant to see each other's work, not test blind.
|
||||
"""
|
||||
test = crud_get_test_detail(db, test_id)
|
||||
test_out = TestOut.model_validate(test)
|
||||
return _mask_for_team_blindness(test_out, viewer_role=current_user.role)
|
||||
return TestOut.model_validate(test)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -491,11 +494,12 @@ def update_test(
|
||||
# Entry: db
|
||||
db: Session = Depends(get_db),
|
||||
# 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:
|
||||
"""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.
|
||||
|
||||
Args:
|
||||
@@ -544,6 +548,38 @@ def update_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
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -559,8 +595,8 @@ def update_test_classification(
|
||||
# Entry: db
|
||||
db: Session = Depends(get_db),
|
||||
# Entry: current_user
|
||||
current_user: User = Depends(require_any_role(
|
||||
"admin", "manager", "red_tech", "red_lead", "blue_tech", "blue_lead",
|
||||
current_user: User = Depends(require_any_role_strict(
|
||||
"manager", "red_tech", "red_lead", "blue_tech", "blue_lead",
|
||||
)),
|
||||
) -> TestOut:
|
||||
"""Update the data classification label for a test.
|
||||
@@ -622,7 +658,7 @@ def update_test_red(
|
||||
# Entry: db
|
||||
db: Session = Depends(get_db),
|
||||
# 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:
|
||||
"""Red Team updates their fields (allowed in ``draft`` and ``red_executing``).
|
||||
|
||||
@@ -635,11 +671,13 @@ def update_test_red(
|
||||
Returns:
|
||||
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)
|
||||
if (
|
||||
_pre_test.red_tech_assignee is not None
|
||||
and current_user.role == "red_tech"
|
||||
and _pre_test.red_tech_assignee != current_user.id
|
||||
):
|
||||
raise HTTPException(status_code=403, detail="Test is assigned to another operator")
|
||||
@@ -688,7 +726,7 @@ def update_test_blue(
|
||||
# Entry: db
|
||||
db: Session = Depends(get_db),
|
||||
# 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:
|
||||
"""Blue Team updates their fields (allowed only in ``blue_evaluating``).
|
||||
|
||||
@@ -701,11 +739,11 @@ def update_test_blue(
|
||||
Returns:
|
||||
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)
|
||||
if (
|
||||
_pre_test.blue_tech_assignee is not None
|
||||
and current_user.role == "blue_tech"
|
||||
and _pre_test.blue_tech_assignee != current_user.id
|
||||
):
|
||||
raise HTTPException(status_code=403, detail="Test is assigned to another operator")
|
||||
@@ -752,7 +790,7 @@ def start_execution(
|
||||
# Entry: db
|
||||
db: Session = Depends(get_db),
|
||||
# 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:
|
||||
"""Move a test from ``draft`` to ``red_executing``.
|
||||
|
||||
@@ -803,7 +841,7 @@ def submit_red(
|
||||
# Entry: db
|
||||
db: Session = Depends(get_db),
|
||||
# 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:
|
||||
"""Red Team finalises — move from ``red_executing`` to ``blue_evaluating``.
|
||||
|
||||
@@ -851,7 +889,7 @@ def submit_blue(
|
||||
# Entry: db
|
||||
db: Session = Depends(get_db),
|
||||
# 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:
|
||||
"""Blue Team finalises — move from ``blue_evaluating`` to ``in_review``.
|
||||
|
||||
@@ -895,7 +933,7 @@ def submit_blue(
|
||||
def start_blue_work(
|
||||
test_id: uuid.UUID,
|
||||
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."""
|
||||
test = crud_get_test_or_raise(db, test_id)
|
||||
@@ -928,12 +966,12 @@ def review_red(
|
||||
test_id: uuid.UUID,
|
||||
payload: TestRedReview,
|
||||
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:
|
||||
"""Assigned Red Lead approves or reopens a test sitting in red_review."""
|
||||
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")
|
||||
|
||||
with UnitOfWork(db) as uow:
|
||||
@@ -958,12 +996,12 @@ def review_blue(
|
||||
test_id: uuid.UUID,
|
||||
payload: TestBlueReview,
|
||||
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:
|
||||
"""Assigned Blue Lead approves, reopens, or flags a capability gap on a test in blue_review."""
|
||||
test = crud_get_test_or_raise(db, test_id)
|
||||
|
||||
if 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")
|
||||
|
||||
with UnitOfWork(db) as uow:
|
||||
@@ -993,7 +1031,7 @@ def pause_timer(
|
||||
# Entry: db
|
||||
db: Session = Depends(get_db),
|
||||
# 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:
|
||||
"""Pause the running timer for the current phase (red_executing or blue_evaluating).
|
||||
|
||||
@@ -1032,7 +1070,7 @@ def resume_timer(
|
||||
# Entry: db
|
||||
db: Session = Depends(get_db),
|
||||
# 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:
|
||||
"""Resume the paused timer for the current phase.
|
||||
|
||||
@@ -1073,7 +1111,7 @@ def validate_red(
|
||||
# Entry: db
|
||||
db: Session = Depends(get_db),
|
||||
# Entry: current_user
|
||||
current_user: User = Depends(require_any_role("red_lead")),
|
||||
current_user: User = Depends(require_any_role_strict("red_lead")),
|
||||
) -> TestOut:
|
||||
"""Red Lead approves or rejects the red side of a test.
|
||||
|
||||
@@ -1130,7 +1168,7 @@ def validate_blue(
|
||||
# Entry: db
|
||||
db: Session = Depends(get_db),
|
||||
# Entry: current_user
|
||||
current_user: User = Depends(require_any_role("blue_lead")),
|
||||
current_user: User = Depends(require_any_role_strict("blue_lead")),
|
||||
) -> TestOut:
|
||||
"""Blue Lead approves or rejects the blue side of a test.
|
||||
|
||||
@@ -1182,7 +1220,7 @@ def resolve_dispute(
|
||||
test_id: uuid.UUID,
|
||||
payload: TestResolveDispute,
|
||||
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:
|
||||
"""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)
|
||||
@@ -1193,6 +1231,78 @@ def resolve_dispute(
|
||||
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
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -1206,7 +1316,7 @@ def reopen(
|
||||
# Entry: db
|
||||
db: Session = Depends(get_db),
|
||||
# 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:
|
||||
"""Reopen a rejected test, moving it back to ``draft``.
|
||||
|
||||
@@ -1233,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)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@@ -1242,35 +1352,68 @@ def assign_test_operators(
|
||||
test_id: uuid.UUID,
|
||||
payload: TestAssign,
|
||||
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)
|
||||
newly_assigned: list[User] = []
|
||||
|
||||
if payload.red_tech_assignee is not None:
|
||||
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")
|
||||
test.red_tech_assignee = payload.red_tech_assignee
|
||||
newly_assigned.append(u)
|
||||
|
||||
if payload.blue_tech_assignee is not None:
|
||||
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")
|
||||
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
|
||||
if "red_tech_assignee" in payload.model_fields_set and payload.red_tech_assignee is None:
|
||||
test.red_tech_assignee = None
|
||||
if "blue_tech_assignee" in payload.model_fields_set and payload.blue_tech_assignee is 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), {
|
||||
"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,
|
||||
"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.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
|
||||
|
||||
|
||||
@@ -1279,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)
|
||||
def hold_test(
|
||||
test_id: uuid.UUID,
|
||||
payload: TestHold,
|
||||
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."""
|
||||
from datetime import datetime as _dt
|
||||
@@ -1299,6 +1457,8 @@ def hold_test(
|
||||
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:
|
||||
raise HTTPException(status_code=400, detail="Test is already on hold")
|
||||
|
||||
@@ -1306,6 +1466,10 @@ def hold_test(
|
||||
test.hold_reason = payload.reason
|
||||
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})
|
||||
db.commit()
|
||||
db.refresh(test)
|
||||
@@ -1324,9 +1488,10 @@ def hold_test(
|
||||
def resume_test(
|
||||
test_id: uuid.UUID,
|
||||
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."""
|
||||
from datetime import datetime as _dt
|
||||
from app.services.jira_service import push_hold_event
|
||||
|
||||
test = crud_get_test_or_raise(db, test_id)
|
||||
@@ -1334,10 +1499,22 @@ def resume_test(
|
||||
if not test.is_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.hold_reason = 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), {})
|
||||
db.commit()
|
||||
db.refresh(test)
|
||||
@@ -1362,7 +1539,7 @@ def update_remediation(
|
||||
# Entry: db
|
||||
db: Session = Depends(get_db),
|
||||
# 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:
|
||||
"""Update remediation fields on a test.
|
||||
|
||||
@@ -1596,7 +1773,7 @@ def sync_tempo(
|
||||
def request_discussion(
|
||||
test_id: uuid.UUID,
|
||||
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.
|
||||
|
||||
@@ -1615,12 +1792,12 @@ def request_discussion(
|
||||
role = current_user.role
|
||||
|
||||
# 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
|
||||
rejector_id = test.blue_validated_by
|
||||
rejector_label = "Blue 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
|
||||
rejector_id = test.red_validated_by
|
||||
rejector_label = "Red Lead"
|
||||
@@ -1637,6 +1814,7 @@ def request_discussion(
|
||||
if rejector_id else None
|
||||
)
|
||||
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
|
||||
|
||||
# Notify the rejecting lead
|
||||
@@ -1675,6 +1853,7 @@ def request_discussion(
|
||||
"status": "notification_sent",
|
||||
"message": f"Discussion request sent to {rejector_name}",
|
||||
"rejector_username": rejector_name,
|
||||
"rejector_full_name": rejector_full_name,
|
||||
"rejector_email": rejector_email,
|
||||
"rejector_role": rejector_label,
|
||||
}
|
||||
@@ -1716,13 +1895,13 @@ class RTImportPayload(BaseModel):
|
||||
def import_rt(
|
||||
payload: RTImportPayload,
|
||||
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.
|
||||
|
||||
Creates one Test record per technique in ``validated`` state (bypassing
|
||||
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
|
||||
for entry in payload.techniques:
|
||||
|
||||
+183
-13
@@ -1,5 +1,8 @@
|
||||
"""User management router (admin only)."""
|
||||
|
||||
# Import logging
|
||||
import logging
|
||||
|
||||
# Import uuid
|
||||
import uuid
|
||||
|
||||
@@ -13,28 +16,42 @@ from sqlalchemy.orm import Session
|
||||
from app.database import get_db
|
||||
|
||||
# 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
|
||||
from app.domain.unit_of_work import UnitOfWork
|
||||
from app.domain.errors import BusinessRuleViolation
|
||||
|
||||
# Import User from app.models.user
|
||||
from app.models.user import 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
|
||||
|
||||
# Import from app.services.user_service
|
||||
from app.services.user_service import (
|
||||
create_user,
|
||||
create_user_without_password,
|
||||
delete_user,
|
||||
get_user_or_raise,
|
||||
list_users,
|
||||
switch_active_role,
|
||||
update_user,
|
||||
)
|
||||
from app.services.password_setup_service import send_password_setup_email
|
||||
|
||||
# Assign router = APIRouter(prefix="/users", tags=["users"])
|
||||
router = APIRouter(prefix="/users", tags=["users"])
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# PATCH /users/me/preferences — update current user preferences
|
||||
@@ -77,6 +94,39 @@ def get_me(
|
||||
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
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -110,19 +160,17 @@ def create_user_route(
|
||||
# Entry: current_user
|
||||
current_user: User = Depends(require_role("admin")),
|
||||
) -> 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
|
||||
with UnitOfWork(db) as uow:
|
||||
# Assign user = create_user(
|
||||
user = create_user(
|
||||
user = create_user_without_password(
|
||||
db,
|
||||
# Keyword argument: username
|
||||
username=payload.username,
|
||||
# Keyword argument: email
|
||||
full_name=payload.full_name,
|
||||
email=payload.email,
|
||||
# Keyword argument: password
|
||||
password=payload.password,
|
||||
# Keyword argument: role
|
||||
role=payload.role,
|
||||
)
|
||||
# Call log_action()
|
||||
@@ -137,17 +185,68 @@ def create_user_route(
|
||||
# Keyword argument: entity_id
|
||||
entity_id=user.id,
|
||||
# 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()
|
||||
uow.commit()
|
||||
# Reload ORM object attributes from the database
|
||||
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
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 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
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -188,6 +287,16 @@ def update_user_route(
|
||||
"""Update one or more fields of an existing user. **Requires admin role.**."""
|
||||
# Assign 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
|
||||
with UnitOfWork(db) as uow:
|
||||
# Assign user = update_user(db, user_id, **update_data)
|
||||
@@ -213,3 +322,64 @@ def update_user_route(
|
||||
|
||||
# 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
|
||||
# Assign username = 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
|
||||
# Assign entity_type = None
|
||||
|
||||
@@ -39,10 +39,12 @@ class UserOut(BaseModel):
|
||||
id: uuid.UUID
|
||||
# username: str
|
||||
username: str
|
||||
full_name: str | None = None
|
||||
# Assign email = None
|
||||
email: str | None = None
|
||||
# role: str
|
||||
role: str
|
||||
extra_roles: list[str] = []
|
||||
# is_active: bool
|
||||
is_active: bool
|
||||
# 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 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
|
||||
from app.domain.enums import AttackSuccessResult, ContainmentResult, DataClassification
|
||||
from app.models.enums import TestResult, TestState
|
||||
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 ──────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@@ -85,6 +99,8 @@ class TestRedUpdate(BaseModel):
|
||||
execution_start_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 ───────────────────────────────────────────────
|
||||
|
||||
@@ -99,6 +115,40 @@ class TestBlueUpdate(BaseModel):
|
||||
containment_time: datetime | None = None
|
||||
# Assign blue_summary = 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 ────────────────────────────────────────────
|
||||
@@ -135,6 +185,13 @@ class TestResolveDispute(BaseModel):
|
||||
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) ────────────────────────────
|
||||
|
||||
|
||||
@@ -171,10 +228,12 @@ class TestRemediationUpdate(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
|
||||
blue_tech_assignee: uuid.UUID | None = None
|
||||
red_reviewer_assignee: uuid.UUID | None = None
|
||||
blue_reviewer_assignee: uuid.UUID | None = None
|
||||
|
||||
|
||||
class TestHold(BaseModel):
|
||||
@@ -219,6 +278,7 @@ class TestOut(BaseModel):
|
||||
execution_date: datetime | None = None
|
||||
# Assign created_by = None
|
||||
created_by: uuid.UUID | None = None
|
||||
source_template_id: uuid.UUID | None = None
|
||||
# Assign result = None
|
||||
result: TestResult | None = None
|
||||
# Assign state = TestState.draft
|
||||
@@ -243,6 +303,7 @@ class TestOut(BaseModel):
|
||||
|
||||
# Blue Team fields
|
||||
blue_summary: str | None = None
|
||||
detect_procedure: str | None = None
|
||||
# Assign detection_result = None
|
||||
detection_result: TestResult | None = None
|
||||
containment_result: ContainmentResult | None = None
|
||||
@@ -267,6 +328,8 @@ class TestOut(BaseModel):
|
||||
red_paused_seconds: int = 0
|
||||
# Assign blue_paused_seconds = 0
|
||||
blue_paused_seconds: int = 0
|
||||
red_round_number: int = 1
|
||||
blue_round_number: int = 1
|
||||
|
||||
# Remediation fields
|
||||
remediation_steps: str | None = None
|
||||
@@ -278,6 +341,12 @@ class TestOut(BaseModel):
|
||||
# Assignment fields
|
||||
red_tech_assignee: uuid.UUID | None = None
|
||||
blue_tech_assignee: uuid.UUID | None = None
|
||||
# Resolved usernames — the operator (or lead) viewing a test they're not
|
||||
# a lead/manager on can't call GET /users/operators (403), so they have
|
||||
# no other way to resolve who an assignee ID actually is. Populated
|
||||
# from the ORM relationship, same pattern as technique_name below.
|
||||
red_tech_assignee_username: str | None = None
|
||||
blue_tech_assignee_username: str | None = None
|
||||
|
||||
# Review assignment fields
|
||||
red_reviewer_assignee: uuid.UUID | None = None
|
||||
@@ -288,6 +357,8 @@ class TestOut(BaseModel):
|
||||
blue_review_by: uuid.UUID | None = None
|
||||
blue_review_at: datetime | None = None
|
||||
blue_review_notes: str | None = None
|
||||
red_reviewer_assignee_username: str | None = None
|
||||
blue_reviewer_assignee_username: str | None = None
|
||||
system_gaps: str | None = None
|
||||
|
||||
# On-hold fields
|
||||
@@ -299,8 +370,8 @@ class TestOut(BaseModel):
|
||||
retest_of: uuid.UUID | None = None
|
||||
# Assign retest_count = 0
|
||||
retest_count: int = 0
|
||||
# Assign data_classification = "confidential"
|
||||
data_classification: str = "confidential"
|
||||
# Assign data_classification = "internal_use_only"
|
||||
data_classification: str = "internal_use_only"
|
||||
|
||||
# Technique info (populated when joined)
|
||||
technique_mitre_id: str | None = None
|
||||
@@ -311,6 +382,9 @@ class TestOut(BaseModel):
|
||||
red_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_validator(mode="before")
|
||||
@@ -343,6 +417,23 @@ class TestOut(BaseModel):
|
||||
except Exception: # nosec B110
|
||||
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)
|
||||
raw_evs = obj.__dict__.get("evidences")
|
||||
if raw_evs is not None:
|
||||
@@ -367,4 +458,11 @@ class TestOut(BaseModel):
|
||||
obj.__dict__["red_evidences"] = red_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
|
||||
|
||||
@@ -130,3 +130,4 @@ class TestTemplateInstantiate(BaseModel):
|
||||
platform: str | None = None
|
||||
procedure_text: 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 ──────────────────────────────────────────────────────────
|
||||
|
||||
class UserCreate(BaseModel):
|
||||
"""Payload for creating a new user."""
|
||||
"""Payload for creating a new user.
|
||||
|
||||
# username: str
|
||||
username: str
|
||||
# Assign email = None
|
||||
email: str | None = None
|
||||
# password: str
|
||||
password: str
|
||||
No password is set here — the admin creates the user with just a
|
||||
name/email/role, then uses "Send Email" to let the user set their own
|
||||
password via a one-time link. ``username`` is no longer admin-supplied;
|
||||
the service layer derives it from ``email`` since it's still needed
|
||||
internally as the login identifier, but it is never shown in the UI.
|
||||
"""
|
||||
|
||||
full_name: str
|
||||
email: str
|
||||
# Assign role = "viewer"
|
||||
role: str = "viewer"
|
||||
|
||||
# Apply the @field_validator decorator
|
||||
@field_validator("username")
|
||||
# Apply the @classmethod decorator
|
||||
@field_validator("full_name")
|
||||
@classmethod
|
||||
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
|
||||
# Define function username_format
|
||||
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)
|
||||
|
||||
# Apply the @field_validator decorator
|
||||
@field_validator("password")
|
||||
# Apply the @classmethod decorator
|
||||
@classmethod
|
||||
# Define function password_strength
|
||||
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)
|
||||
|
||||
|
||||
@@ -163,13 +171,19 @@ class UserCreate(BaseModel):
|
||||
class UserUpdate(BaseModel):
|
||||
"""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
|
||||
email: str | None = None
|
||||
full_name: str | None = None
|
||||
# Assign role = 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
|
||||
is_active: bool | None = None
|
||||
# Assign password = None
|
||||
@@ -248,10 +262,12 @@ class UserOut(BaseModel):
|
||||
id: uuid.UUID
|
||||
# username: str
|
||||
username: str
|
||||
full_name: str | None = None
|
||||
# Assign email = None
|
||||
email: str | None = None
|
||||
# role: str
|
||||
role: str
|
||||
extra_roles: list[str] = Field(default_factory=list)
|
||||
# is_active: bool
|
||||
is_active: bool
|
||||
# Assign must_change_password = True
|
||||
@@ -284,3 +300,49 @@ class UserOut(BaseModel):
|
||||
self.jira_token_set = bool(self.jira_api_token)
|
||||
self.tempo_token_set = bool(self.tempo_api_token)
|
||||
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.
|
||||
|
||||
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
|
||||
**not set**, a cryptographically random 16-character password is generated
|
||||
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"
|
||||
admin_username = os.environ.get("ADMIN_USERNAME", "admin").strip() or "admin"
|
||||
|
||||
# Assign existing = db.query(User).filter(User.username == admin_username).first()
|
||||
existing = db.query(User).filter(User.username == admin_username).first()
|
||||
# Email is the unique login identifier — prefer ADMIN_EMAIL, then an
|
||||
# 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
|
||||
if existing:
|
||||
# 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
|
||||
|
||||
@@ -78,7 +94,9 @@ def seed_admin() -> None:
|
||||
# Assign admin = User(
|
||||
admin = User(
|
||||
# Keyword argument: username
|
||||
username=admin_username,
|
||||
username=admin_email,
|
||||
# Keyword argument: email
|
||||
email=admin_email,
|
||||
# Keyword argument: hashed_password
|
||||
hashed_password=hash_password(admin_password),
|
||||
# Keyword argument: role
|
||||
@@ -98,7 +116,10 @@ def seed_admin() -> None:
|
||||
# Call print()
|
||||
print("=" * 60)
|
||||
# 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
|
||||
if password_was_generated:
|
||||
# Call print()
|
||||
|
||||
@@ -92,6 +92,7 @@ def list_logs(
|
||||
"user_id": log.user_id,
|
||||
# Literal argument value
|
||||
"username": log.user.username if log.user else None,
|
||||
"full_name": log.user.full_name if log.user else None,
|
||||
# Literal argument value
|
||||
"action": log.action,
|
||||
# Literal argument value
|
||||
|
||||
@@ -19,15 +19,15 @@ _DUMMY_HASH = "$2b$12$LJ3m4ys3Lg3dMO/NpNmOaeVwFpWJMxlB2FLmEAo9fZr.S8H1vC4Wy"
|
||||
|
||||
|
||||
# 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.
|
||||
|
||||
Raises BusinessRuleViolation for invalid credentials.
|
||||
Raises PermissionViolation for disabled account.
|
||||
Uses constant-time comparison to prevent timing attacks.
|
||||
"""
|
||||
# Assign user = db.query(User).filter(User.username == username).first()
|
||||
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.email == email).first()
|
||||
# Assign 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)
|
||||
@@ -36,7 +36,7 @@ def authenticate_user(db: Session, *, username: str, password: str) -> User:
|
||||
# Check: user is None or not password_valid
|
||||
if user is None or not password_valid:
|
||||
# Raise BusinessRuleViolation
|
||||
raise BusinessRuleViolation("Incorrect username or password")
|
||||
raise BusinessRuleViolation("Incorrect email or password")
|
||||
# Check: not user.is_active
|
||||
if not user.is_active:
|
||||
# Raise PermissionViolation
|
||||
|
||||
@@ -281,8 +281,20 @@ def create_campaign(
|
||||
tags: Optional[list[str]] = None,
|
||||
# Entry: scheduled_at
|
||||
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:
|
||||
"""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(
|
||||
campaign = Campaign(
|
||||
# Keyword argument: name
|
||||
@@ -302,6 +314,11 @@ def create_campaign(
|
||||
# Keyword argument: scheduled_at
|
||||
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
|
||||
db.add(campaign)
|
||||
# Flush changes to DB without committing the transaction
|
||||
@@ -350,6 +367,11 @@ def approve_campaign(
|
||||
) -> Campaign:
|
||||
"""Approve a pending campaign: fix its start date and activate it.
|
||||
|
||||
Also allows approving a previously-rejected campaign directly (status
|
||||
``draft`` with a ``rejection_reason`` set) — a manager can edit it via
|
||||
``update_campaign()`` and then approve it themselves, without needing
|
||||
the original lead to resubmit it through the normal queue.
|
||||
|
||||
Raises EntityNotFoundError, BusinessRuleViolation.
|
||||
Does not commit; caller commits.
|
||||
"""
|
||||
@@ -357,8 +379,9 @@ def approve_campaign(
|
||||
if not campaign:
|
||||
raise EntityNotFoundError("Campaign", campaign_id)
|
||||
|
||||
if campaign.status != "pending_approval":
|
||||
raise BusinessRuleViolation("Only campaigns pending approval can be approved")
|
||||
is_previously_rejected_draft = campaign.status == "draft" and campaign.rejection_reason
|
||||
if campaign.status != "pending_approval" and not is_previously_rejected_draft:
|
||||
raise BusinessRuleViolation("Only campaigns pending approval (or previously rejected) can be approved")
|
||||
|
||||
if not start_date:
|
||||
raise BusinessRuleViolation("start_date is required to approve a campaign")
|
||||
@@ -437,7 +460,7 @@ def update_campaign(
|
||||
Does not commit; caller commits.
|
||||
"""
|
||||
# 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
|
||||
if not campaign:
|
||||
# Raise EntityNotFoundError
|
||||
@@ -448,10 +471,16 @@ def update_campaign(
|
||||
# Raise BusinessRuleViolation
|
||||
raise BusinessRuleViolation("Can only update draft or active campaigns")
|
||||
|
||||
# Check: str(campaign.created_by) != str(updater_id) and updater_role != "ad...
|
||||
if str(campaign.created_by) != str(updater_id) and updater_role != "admin":
|
||||
# A manager may edit a campaign they (or another manager) previously
|
||||
# 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("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"]
|
||||
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()
|
||||
# Flush changes to DB without committing the transaction
|
||||
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
|
||||
|
||||
|
||||
@@ -72,32 +72,24 @@ def _clone_campaign(db: Session, original: Campaign) -> Campaign:
|
||||
1. Clone the campaign with a date-stamped name.
|
||||
2. For each ``CampaignTest`` in the original, create a new ``Test``
|
||||
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()
|
||||
# Assign run_label = now.strftime("%Y-%m-%d")
|
||||
run_label = now.strftime("%Y-%m-%d")
|
||||
|
||||
# Assign child = Campaign(
|
||||
child = Campaign(
|
||||
# Keyword argument: name
|
||||
name=f"{original.name} (Run {run_label})",
|
||||
# Keyword argument: description
|
||||
description=original.description,
|
||||
# Keyword argument: type
|
||||
type=original.type,
|
||||
# Keyword argument: threat_actor_id
|
||||
threat_actor_id=original.threat_actor_id,
|
||||
# Keyword argument: status
|
||||
status="active",
|
||||
# Keyword argument: created_by
|
||||
status="pending_approval",
|
||||
created_by=original.created_by,
|
||||
# Keyword argument: target_platform
|
||||
target_platform=original.target_platform,
|
||||
# Keyword argument: tags
|
||||
tags=original.tags or [],
|
||||
# Keyword argument: parent_campaign_id
|
||||
parent_campaign_id=original.id,
|
||||
)
|
||||
# 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
|
||||
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:
|
||||
# Call create_notification()
|
||||
create_notification(
|
||||
db,
|
||||
# Keyword argument: user_id
|
||||
user_id=campaign.created_by,
|
||||
# Keyword argument: type
|
||||
type="recurring_campaign_run",
|
||||
# Keyword argument: title
|
||||
title="Recurring campaign executed",
|
||||
# Keyword argument: message
|
||||
title="Recurring campaign created — awaiting approval",
|
||||
message=(
|
||||
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",
|
||||
# Keyword argument: entity_id
|
||||
entity_id=child.id,
|
||||
)
|
||||
|
||||
# Notify red_tech users
|
||||
red_techs = db.query(User).filter(User.role == "red_tech", User.is_active == True).all() # noqa: E712
|
||||
# Iterate over red_techs
|
||||
for user in red_techs:
|
||||
# Call create_notification()
|
||||
# Notify managers — same approval gate as any other campaign,
|
||||
# recurrence only automates spawning the run, not skipping review.
|
||||
managers = db.query(User).filter(User.role == "manager", User.is_active == True).all() # noqa: E712
|
||||
for user in managers:
|
||||
create_notification(
|
||||
db,
|
||||
# Keyword argument: user_id
|
||||
user_id=user.id,
|
||||
# Keyword argument: type
|
||||
type="campaign_activated",
|
||||
# Keyword argument: title
|
||||
title="New recurring campaign active",
|
||||
# Keyword argument: message
|
||||
message=f'Campaign "{child.name}" is now active and ready for execution.',
|
||||
# Keyword argument: entity_type
|
||||
type="campaign_pending_approval",
|
||||
title="Recurring campaign needs approval",
|
||||
message=f'Campaign "{child.name}" was auto-created from a recurring template and needs your approval.',
|
||||
entity_type="campaign",
|
||||
# Keyword argument: entity_id
|
||||
entity_id=child.id,
|
||||
)
|
||||
|
||||
@@ -296,3 +276,61 @@ def check_and_run_recurring_campaigns(db: Session) -> int:
|
||||
|
||||
# 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
|
||||
|
||||
@@ -57,6 +57,8 @@ ALLOWED_EXTENSIONS: frozenset[str] = frozenset({
|
||||
".zip", ".tar", ".gz", ".7z",
|
||||
# Literal argument value
|
||||
".har", ".eml", ".msg",
|
||||
# Red team artifacts shared with Blue for detection analysis.
|
||||
".exe",
|
||||
})
|
||||
|
||||
|
||||
|
||||
@@ -353,6 +353,17 @@ def build_coverage_layer(
|
||||
layer["techniques"].append({
|
||||
# Literal argument value
|
||||
"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
|
||||
"tactic": _format_tactic(tech.tactic),
|
||||
# Literal argument value
|
||||
@@ -491,6 +502,11 @@ def build_threat_actor_layer(
|
||||
)
|
||||
layer["techniques"].append({
|
||||
"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),
|
||||
"color": _score_to_color(score),
|
||||
"score": score,
|
||||
@@ -586,6 +602,7 @@ def build_detection_rules_layer(
|
||||
layer["techniques"].append({
|
||||
# Literal argument value
|
||||
"techniqueID": tech.mitre_id,
|
||||
"name": tech.name,
|
||||
# Literal argument value
|
||||
"tactic": _format_tactic(tech.tactic),
|
||||
# Literal argument value
|
||||
@@ -754,6 +771,7 @@ def build_campaign_layer(
|
||||
layer["techniques"].append({
|
||||
# Literal argument value
|
||||
"techniqueID": mitre_id,
|
||||
"name": tech.name,
|
||||
# Literal argument value
|
||||
"tactic": _format_tactic(tech.tactic),
|
||||
# Literal argument value
|
||||
|
||||
@@ -31,6 +31,9 @@ from __future__ import annotations
|
||||
# Import logging
|
||||
import logging
|
||||
|
||||
# Import re
|
||||
import re
|
||||
|
||||
# Import datetime from datetime
|
||||
from datetime import datetime
|
||||
|
||||
@@ -79,7 +82,7 @@ JIRA_FIELD_ATTACK_SUCCESS = "customfield_11815"
|
||||
JIRA_FIELD_ATTACK_DETECTED = "customfield_11809"
|
||||
JIRA_FIELD_TIME_TO_DETECT = "customfield_11810"
|
||||
JIRA_FIELD_TIME_TO_CONTAIN = "customfield_11811"
|
||||
JIRA_FIELD_DATA_SENSITIVITY = "customfield_11233"
|
||||
JIRA_FIELD_DATA_SENSITIVITY = "customfield_11814"
|
||||
JIRA_FIELD_ATTACK_CONTAINED = "customfield_11885"
|
||||
|
||||
_ATTACK_SUCCESS_TO_JIRA = {
|
||||
@@ -101,10 +104,12 @@ _CONTAINMENT_TO_JIRA = {
|
||||
}
|
||||
|
||||
_DATA_CLASSIFICATION_TO_JIRA = {
|
||||
"public_release": "Public Release",
|
||||
"public": "Public",
|
||||
"general_use": "General Use",
|
||||
"confidential": "Confidential",
|
||||
"restricted": "Restricted",
|
||||
"internal_use_only": "Internal Use Only",
|
||||
"trusted_people": "Trusted People",
|
||||
"customer_content": "Customer Content",
|
||||
"pii": "PII",
|
||||
}
|
||||
|
||||
# Assign logger = logging.getLogger(__name__)
|
||||
@@ -290,6 +295,9 @@ def get_admin_jira_client(db: Session):
|
||||
def lookup_user_jira_account_id(db: Session, user: User) -> bool:
|
||||
"""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.
|
||||
Returns ``True`` when the value was updated, ``False`` otherwise.
|
||||
Non-fatal — all errors are logged at DEBUG level and swallowed.
|
||||
@@ -297,13 +305,13 @@ def lookup_user_jira_account_id(db: Session, user: User) -> bool:
|
||||
if not has_admin_jira_configured(db):
|
||||
return False
|
||||
|
||||
email = getattr(user, "email", None)
|
||||
email = getattr(user, "jira_email", None) or getattr(user, "email", None)
|
||||
if not email:
|
||||
return False
|
||||
|
||||
try:
|
||||
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
|
||||
for u in results or []:
|
||||
@@ -334,6 +342,28 @@ def lookup_user_jira_account_id(db: Session, user: User) -> bool:
|
||||
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)
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -371,11 +401,11 @@ _STATE_EMOJI: dict[str, str] = {
|
||||
# (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",
|
||||
"draft": "To Do",
|
||||
"red_executing": "In Progress",
|
||||
"red_review": "RT Test Review",
|
||||
"red_review": "Red Team test review",
|
||||
"blue_evaluating": "Queued Blue Team",
|
||||
"blue_review": "Blue Team Test Review",
|
||||
"blue_review": "Blue Team test review",
|
||||
"in_review": "Validation",
|
||||
"validated": "Done",
|
||||
"rejected": "Rejected",
|
||||
@@ -441,15 +471,27 @@ def _build_state_comment(
|
||||
|
||||
elif new_state == "red_review":
|
||||
lines += [
|
||||
"Red Team has submitted evidence and the test is awaiting Red Lead review "
|
||||
"before it queues for Blue Team.",
|
||||
f"Red Team has submitted evidence for Round {test.red_round_number or 1} and the "
|
||||
"test is awaiting Red Lead review before it queues for Blue Team.",
|
||||
"",
|
||||
f"*Procedure:* {test.procedure_text or 'N/A'}",
|
||||
f"*Tool used:* {test.tool_used or 'N/A'}",
|
||||
f"*Attack Success:* {_enum_value(test.attack_success) or 'N/A'}",
|
||||
]
|
||||
if test.red_summary:
|
||||
lines += ["", "h4. Red Team Summary", test.red_summary]
|
||||
|
||||
elif new_state == "blue_review":
|
||||
lines += [
|
||||
"Blue Team has submitted evidence and the test is awaiting Blue Lead review "
|
||||
"before cross-validation.",
|
||||
f"Blue Team has submitted evidence for Round {test.blue_round_number or 1} and the "
|
||||
"test is awaiting Blue Lead review before cross-validation.",
|
||||
"",
|
||||
f"*Detect Procedure:* {test.detect_procedure or 'N/A'}",
|
||||
f"*Detection Result:* {_enum_value(test.detection_result) or 'N/A'}",
|
||||
f"*Containment Result:* {_enum_value(test.containment_result) or 'N/A'}",
|
||||
]
|
||||
if test.blue_summary:
|
||||
lines += ["", "h4. Blue Team Summary", test.blue_summary]
|
||||
|
||||
elif new_state == "blue_evaluating":
|
||||
lines += [
|
||||
@@ -464,16 +506,27 @@ def _build_state_comment(
|
||||
lines += [
|
||||
"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:
|
||||
lines += ["", "h4. Blue Team Summary", test.blue_summary]
|
||||
if test.remediation_steps:
|
||||
lines += ["", "h4. Remediation Steps", test.remediation_steps]
|
||||
|
||||
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 += [
|
||||
"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"*Blue Lead Status:* {test.blue_validation_status or 'N/A'}",
|
||||
@@ -484,8 +537,13 @@ def _build_state_comment(
|
||||
lines += ["", f"*Blue Lead Notes:* {test.blue_validation_notes}"]
|
||||
|
||||
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 += [
|
||||
"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"*Blue Lead Status:* {test.blue_validation_status or 'N/A'}",
|
||||
@@ -647,6 +705,37 @@ def auto_create_campaign_issue(
|
||||
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(
|
||||
db: Session,
|
||||
test: Test,
|
||||
@@ -695,21 +784,30 @@ def auto_create_test_issue(
|
||||
|
||||
poc = test.procedure_text or "N/A"
|
||||
severity = _technique_severity(technique).capitalize()
|
||||
labels = ["aegis", mitre_id.replace(".", "-")]
|
||||
if test.platform:
|
||||
# Jira labels can't contain whitespace — normalize to kebab-case.
|
||||
labels.append(re.sub(r"[^a-z0-9_-]+", "-", test.platform.lower()).strip("-"))
|
||||
# No parent_ticket_override means this test isn't nested under a
|
||||
# campaign ticket — i.e. it's standalone. Tag it so standalone tests
|
||||
# are identifiable in Jira without cross-referencing Aegis.
|
||||
if parent_ticket_override is None:
|
||||
labels.append("standalone-test")
|
||||
fields: dict = {
|
||||
"project": {"key": project_key},
|
||||
"summary": f"[Aegis] {mitre_id} — {test.name}",
|
||||
"description": _build_test_description(test, technique),
|
||||
"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": f"{{code}}{poc}{{code}}",
|
||||
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))
|
||||
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
|
||||
from datetime import date as _date
|
||||
@@ -720,7 +818,23 @@ def auto_create_test_issue(
|
||||
if 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_id = result.get("id", "")
|
||||
|
||||
@@ -800,9 +914,12 @@ def push_test_event(
|
||||
link.jira_issue_key, target_status, exc_t,
|
||||
)
|
||||
|
||||
# When the operator starts execution: assign the ticket to that operator.
|
||||
# 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 = getattr(actor, "jira_account_id", None)
|
||||
jira_account_id = _resolve_jira_account_id(db, assignee or actor)
|
||||
if jira_account_id:
|
||||
try:
|
||||
jira.assign_issue(link.jira_issue_key, account_id=jira_account_id)
|
||||
@@ -817,7 +934,7 @@ def push_test_event(
|
||||
)
|
||||
|
||||
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:
|
||||
try:
|
||||
jira.assign_issue(link.jira_issue_key, account_id=jira_account_id)
|
||||
@@ -831,6 +948,41 @@ def push_test_event(
|
||||
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()
|
||||
db.flush()
|
||||
logger.info(
|
||||
@@ -844,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
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -912,11 +1105,13 @@ def push_hold_event(
|
||||
|
||||
|
||||
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
|
||||
operator stepping away briefly) maps to "On Hold", while an explicit
|
||||
hold (something external blocking progress) maps to "Blocked".
|
||||
Distinct from :func:`push_hold_event` — a short timer pause (the timer
|
||||
sitting idle between rework rounds, or the operator briefly stepping
|
||||
away) is not the same thing as the test actually being blocked, so it
|
||||
must not flip the Jira issue to "On Hold". Only a genuine Hold action
|
||||
(:func:`push_hold_event`) does that. This just leaves a record.
|
||||
Non-fatal — any Jira error is logged and swallowed.
|
||||
"""
|
||||
if not has_admin_jira_configured(db):
|
||||
@@ -939,16 +1134,8 @@ def push_pause_event(db: Session, test, actor, *, resuming: bool = False) -> Non
|
||||
|
||||
if resuming:
|
||||
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:
|
||||
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)
|
||||
link.last_synced_at = datetime.utcnow()
|
||||
@@ -958,6 +1145,73 @@ 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)
|
||||
|
||||
|
||||
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.
|
||||
@@ -990,6 +1244,16 @@ def push_bt_work_started(db: Session, test, actor) -> None:
|
||||
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)
|
||||
@@ -1009,12 +1273,28 @@ def _enum_value(v) -> Optional[str]:
|
||||
return v.value if hasattr(v, "value") else str(v)
|
||||
|
||||
|
||||
def _compute_hours(start: Optional[datetime], end: Optional[datetime]) -> Optional[float]:
|
||||
"""Return the elapsed time between two timestamps in hours, or None if unavailable."""
|
||||
if not start or not end:
|
||||
return None
|
||||
delta_hours = (end - start).total_seconds() / 3600
|
||||
return round(delta_hours, 2) if delta_hours >= 0 else None
|
||||
def _select_field(value: str) -> dict:
|
||||
"""Wrap a value for a Jira single-select custom field.
|
||||
|
||||
Jira's REST API rejects a bare string for select-type custom fields
|
||||
("Specify a valid 'id' or 'name' for <field>") — it must be posted as
|
||||
``{"value": ...}``.
|
||||
"""
|
||||
return {"value": value}
|
||||
|
||||
|
||||
def _jira_datetime(dt: datetime) -> str:
|
||||
"""Format a naive-UTC datetime for a Jira ``datetime``-type custom field.
|
||||
|
||||
RT/BT Start/End Date are genuine Jira datetime fields (confirmed via
|
||||
issue_editmeta — schema type "datetime"), not plain dates. Sending a
|
||||
bare "YYYY-MM-DD" string makes Jira default the time-of-day to
|
||||
midnight, so RT Start Date and RT End Date ended up showing the same
|
||||
meaningless midnight timestamp regardless of when the operator
|
||||
actually started/finished. Jira's REST API expects
|
||||
``yyyy-MM-dd'T'HH:mm:ss.SSSZ`` for this field type.
|
||||
"""
|
||||
return dt.strftime("%Y-%m-%dT%H:%M:%S.000+0000")
|
||||
|
||||
|
||||
def _update_test_fields(db: Session, test: Test, fields: dict) -> None:
|
||||
@@ -1049,48 +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:
|
||||
"""Set the RT End Date + Attack Success fields — called when Red submits for review."""
|
||||
fields: dict = {JIRA_FIELD_RT_END_DATE: datetime.utcnow().strftime("%Y-%m-%d")}
|
||||
"""Set RT Start Date, RT End Date, and Attack Success — called when Red submits for review.
|
||||
|
||||
RT Start Date always reflects the *first* round's execution start (from
|
||||
round_number=1 in round_history if this test has been reopened, else the
|
||||
live field — this is round 1 in that case). RT End Date and Attack
|
||||
Success always reflect the *current* round, since they're read live off
|
||||
the test each time this runs and get pushed again on every resubmit.
|
||||
Previously this pushed ``datetime.utcnow()`` (click time) instead of the
|
||||
operator-entered execution window — fixed to read the real fields.
|
||||
"""
|
||||
from app.models.test_round_history import TestRoundHistory
|
||||
|
||||
fields: dict = {}
|
||||
|
||||
first_round = (
|
||||
db.query(TestRoundHistory)
|
||||
.filter(
|
||||
TestRoundHistory.test_id == test.id,
|
||||
TestRoundHistory.team == "red",
|
||||
TestRoundHistory.round_number == 1,
|
||||
)
|
||||
.first()
|
||||
)
|
||||
first_start = (
|
||||
first_round.execution_start_time
|
||||
if first_round and first_round.execution_start_time
|
||||
else test.execution_start_time
|
||||
)
|
||||
if first_start:
|
||||
fields[JIRA_FIELD_RT_START_DATE] = _jira_datetime(first_start)
|
||||
|
||||
if test.execution_end_time:
|
||||
fields[JIRA_FIELD_RT_END_DATE] = _jira_datetime(test.execution_end_time)
|
||||
|
||||
attack_success = _enum_value(test.attack_success)
|
||||
if attack_success in _ATTACK_SUCCESS_TO_JIRA:
|
||||
fields[JIRA_FIELD_ATTACK_SUCCESS] = _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)
|
||||
|
||||
|
||||
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, {
|
||||
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:
|
||||
"""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")}
|
||||
"""Set BT End Date, Attack Detected/Contained, and Time to Detect/Contain — Blue submits for review.
|
||||
|
||||
Time to Detect / Time to Contain are Jira **datetime** fields (confirmed
|
||||
via issue_editmeta), not numeric duration fields — despite the name.
|
||||
This used to push a computed hour count, which Jira rejected outright;
|
||||
since Jira validates the whole fields payload atomically, that one bad
|
||||
field silently sank BT End Date and Attack Detected too. Now pushes the
|
||||
actual detection_time/containment_time timestamps.
|
||||
"""
|
||||
fields: dict = {JIRA_FIELD_BT_END_DATE: _jira_datetime(datetime.utcnow())}
|
||||
|
||||
detection_result = _enum_value(test.detection_result)
|
||||
if detection_result in _DETECTION_TO_JIRA:
|
||||
fields[JIRA_FIELD_ATTACK_DETECTED] = _DETECTION_TO_JIRA[detection_result]
|
||||
fields[JIRA_FIELD_ATTACK_DETECTED] = _select_field(_DETECTION_TO_JIRA[detection_result])
|
||||
|
||||
containment_result = _enum_value(test.containment_result)
|
||||
if containment_result in _CONTAINMENT_TO_JIRA:
|
||||
fields[JIRA_FIELD_ATTACK_CONTAINED] = _CONTAINMENT_TO_JIRA[containment_result]
|
||||
fields[JIRA_FIELD_ATTACK_CONTAINED] = _select_field(_CONTAINMENT_TO_JIRA[containment_result])
|
||||
|
||||
time_to_detect = _compute_hours(test.execution_end_time, test.detection_time)
|
||||
if time_to_detect is not None:
|
||||
fields[JIRA_FIELD_TIME_TO_DETECT] = time_to_detect
|
||||
if test.detection_time:
|
||||
fields[JIRA_FIELD_TIME_TO_DETECT] = _jira_datetime(test.detection_time)
|
||||
|
||||
time_to_contain = _compute_hours(test.detection_time, test.containment_time)
|
||||
if time_to_contain is not None:
|
||||
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)
|
||||
|
||||
|
||||
@@ -391,5 +391,18 @@ def sync_mitre(db: Session) -> dict:
|
||||
# Commit all pending changes to the database
|
||||
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
|
||||
|
||||
@@ -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(
|
||||
db: Session,
|
||||
*,
|
||||
@@ -316,6 +386,12 @@ def notify_test_state_change(db: Session, test, new_state: str) -> None:
|
||||
# Keyword argument: entity_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"
|
||||
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
|
||||
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"
|
||||
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
|
||||
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
|
||||
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
|
||||
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
|
||||
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
|
||||
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:
|
||||
"""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"}
|
||||
users = db.query(User).filter(
|
||||
@@ -47,6 +47,14 @@ def _dispatch_inapp_notifications(db: Session, rule: AlertRule, instance: AlertI
|
||||
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:
|
||||
"""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()
|
||||
|
||||
# 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"
|
||||
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"
|
||||
|
||||
# Resolve email: try configured attr → Azure email claim URI → NameID
|
||||
@@ -189,9 +190,9 @@ def process_callback(db: Session, request_data: dict) -> User:
|
||||
or ""
|
||||
)
|
||||
|
||||
# Resolve username: keep full email (e.g. user@company.com) to avoid collisions with local accounts
|
||||
raw_username = _first_attr(attrs, username_attr) or email or name_id or ""
|
||||
username = raw_username.strip() or email.split("@")[0] or name_id
|
||||
# Email is the unique login identifier platform-wide — username always
|
||||
# mirrors it (see User model), never a separately-provisioned IdP value.
|
||||
username = email
|
||||
|
||||
# Resolve role: try configured attr → Azure role claim URI → default
|
||||
role = (
|
||||
@@ -202,7 +203,7 @@ def process_callback(db: Session, request_data: dict) -> User:
|
||||
)
|
||||
|
||||
# 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:
|
||||
log.warning("SSO: unknown role '%s' for user '%s', falling back to default", role, username)
|
||||
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.
|
||||
Obtain a Tempo token at: Jira → Apps → Tempo → Settings → API Integration.
|
||||
|
||||
The global ``settings.TEMPO_ENABLED`` flag acts as a kill-switch. When False,
|
||||
all Tempo calls are silently skipped regardless of whether users have tokens.
|
||||
The global ``settings.TEMPO_ENABLED`` env var is an optional hard kill-switch
|
||||
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
|
||||
------------------
|
||||
@@ -223,8 +226,12 @@ def auto_log_test_worklog(
|
||||
logger.debug("Skipping Tempo sync for activity_type=%s", activity_type)
|
||||
return None
|
||||
|
||||
# Global kill-switch
|
||||
if not settings.TEMPO_ENABLED:
|
||||
# Global kill-switch — bypassed once an admin or personal Tempo token
|
||||
# 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
|
||||
|
||||
# Compute duration from test timestamps when not supplied by the caller
|
||||
|
||||
@@ -10,8 +10,8 @@ from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
# Import Session, joinedload from sqlalchemy.orm
|
||||
from sqlalchemy import or_
|
||||
from sqlalchemy.orm import Session, joinedload
|
||||
from sqlalchemy import func, or_
|
||||
from sqlalchemy.orm import Query, Session, joinedload
|
||||
|
||||
# Import from app.domain.errors
|
||||
from app.domain.errors import (
|
||||
@@ -29,7 +29,7 @@ from app.utils import escape_like
|
||||
|
||||
# Tactics whose test data is more likely to touch customer content, PII, or
|
||||
# high-impact operational detail — these get an initial classification of
|
||||
# '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.
|
||||
_RESTRICTED_TACTICS = frozenset({
|
||||
"exfiltration", "collection", "credential-access", "impact",
|
||||
@@ -40,39 +40,40 @@ def determine_initial_classification(technique: Technique | None) -> str:
|
||||
"""Best-effort initial data classification for a new test.
|
||||
|
||||
Every security test reveals internal attack/defense posture, so the
|
||||
baseline is 'confidential' (internal use only) — never public or
|
||||
general use by default. Escalated to 'restricted' when the technique's
|
||||
tactic implies customer data, PII, or destructive impact.
|
||||
baseline is 'internal_use_only' — never public or general use by
|
||||
default. Escalated to 'pii' when the technique's tactic implies
|
||||
customer data, PII, or destructive impact.
|
||||
"""
|
||||
if technique and technique.tactic and technique.tactic.lower() in _RESTRICTED_TACTICS:
|
||||
return DataClassification.restricted.value
|
||||
return DataClassification.confidential.value
|
||||
return DataClassification.pii.value
|
||||
return DataClassification.internal_use_only.value
|
||||
|
||||
|
||||
# Define function list_tests
|
||||
def list_tests(
|
||||
# Entry: db
|
||||
def _build_test_query(
|
||||
db: Session,
|
||||
*,
|
||||
# Entry: state
|
||||
state: str | None = None,
|
||||
# Entry: technique_id
|
||||
technique_id: uuid.UUID | None = None,
|
||||
# Entry: platform
|
||||
technique_search: str | None = None,
|
||||
platform: str | None = None,
|
||||
# Entry: created_by
|
||||
created_by: uuid.UUID | None = None,
|
||||
# Entry: pending_validation_side
|
||||
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,
|
||||
offset: int = 0,
|
||||
# Entry: limit
|
||||
limit: int = 50,
|
||||
) -> list[Test]:
|
||||
"""Return a paginated list of tests with optional filters."""
|
||||
query = db.query(Test).options(joinedload(Test.technique))
|
||||
attack_success: str | None = None,
|
||||
detection_result: str | None = None,
|
||||
validated_from: datetime | None = None,
|
||||
validated_to: datetime | None = None,
|
||||
) -> Query:
|
||||
"""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
|
||||
if state:
|
||||
@@ -82,6 +83,16 @@ def list_tests(
|
||||
if technique_id:
|
||||
# Assign 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
|
||||
if platform:
|
||||
# Assign query = query.filter(Test.platform.ilike(f"%{escape_like(platform)}%"))
|
||||
@@ -125,10 +136,102 @@ def list_tests(
|
||||
linked = db.query(CampaignTest.test_id).distinct().subquery()
|
||||
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()
|
||||
|
||||
|
||||
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
|
||||
def create_test(
|
||||
# Entry: db
|
||||
@@ -201,6 +304,7 @@ def create_test_from_template(
|
||||
platform_override: str | None = None,
|
||||
procedure_text_override: str | None = None,
|
||||
tool_used_override: str | None = None,
|
||||
detect_procedure_override: str | None = None,
|
||||
) -> Test:
|
||||
"""Instantiate a Test from a TestTemplate.
|
||||
|
||||
@@ -261,9 +365,11 @@ def create_test_from_template(
|
||||
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,
|
||||
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,
|
||||
# Keyword argument: created_by
|
||||
created_by=creator_id,
|
||||
source_template_id=template.id,
|
||||
# Keyword argument: state
|
||||
state=TestState.draft,
|
||||
created_at=datetime.utcnow(), # explicit — DB column has no server default
|
||||
@@ -293,7 +399,12 @@ def get_test_detail(db: Session, test_id: uuid.UUID) -> Test:
|
||||
# Assign test = (
|
||||
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)
|
||||
# Chain .first() call
|
||||
.first()
|
||||
@@ -327,6 +438,28 @@ def get_test_or_raise(db: Session, test_id: uuid.UUID) -> 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
|
||||
def get_test_with_technique(db: Session, test_id: uuid.UUID) -> Test:
|
||||
"""Fetch a test with technique joined. Raises EntityNotFoundError if not found.
|
||||
|
||||
@@ -12,7 +12,7 @@ from sqlalchemy import func, or_
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
# 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
|
||||
from app.models.test_template import TestTemplate
|
||||
@@ -23,6 +23,42 @@ from app.models.test import Test
|
||||
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
|
||||
def list_templates(
|
||||
# Entry: db
|
||||
@@ -46,51 +82,16 @@ def list_templates(
|
||||
limit: int = 50,
|
||||
) -> list:
|
||||
"""Return paginated, filterable list of test templates."""
|
||||
# Assign query = db.query(TestTemplate)
|
||||
query = db.query(TestTemplate)
|
||||
# Check: is_active is not None
|
||||
if is_active is not None:
|
||||
# Assign query = query.filter(TestTemplate.is_active == is_active)
|
||||
query = query.filter(TestTemplate.is_active == is_active)
|
||||
query = _build_template_query(
|
||||
db, source=source, platform=platform, severity=severity,
|
||||
mitre_technique_id=mitre_technique_id, search=search, 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 = (
|
||||
query
|
||||
# Chain .order_by() call
|
||||
.order_by(TestTemplate.mitre_technique_id, TestTemplate.name)
|
||||
# Chain .offset() call
|
||||
.offset(offset)
|
||||
# Chain .limit() call
|
||||
.limit(limit)
|
||||
# Chain .all() call
|
||||
.all()
|
||||
)
|
||||
|
||||
@@ -108,10 +109,29 @@ def list_templates(
|
||||
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
|
||||
def get_template_stats(db: Session) -> dict:
|
||||
"""Return catalog statistics: totals by source, platform, active/inactive."""
|
||||
@@ -215,9 +235,22 @@ def get_template_or_raise(db: Session, template_id: uuid.UUID) -> TestTemplate:
|
||||
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
|
||||
def create_template(db: Session, **fields: object) -> TestTemplate:
|
||||
"""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)
|
||||
template = TestTemplate(**fields)
|
||||
# Stage new record(s) for database insertion
|
||||
@@ -229,6 +262,8 @@ def create_template(db: Session, **fields: object) -> TestTemplate:
|
||||
# Define function update_template
|
||||
def update_template(db: Session, template_id: uuid.UUID, **fields: object) -> TestTemplate:
|
||||
"""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)
|
||||
template = get_template_or_raise(db, template_id)
|
||||
# Iterate over fields.items()
|
||||
|
||||
@@ -32,12 +32,14 @@ from app.models.campaign import Campaign, CampaignTest
|
||||
from app.models.enums import TestState, TeamSide
|
||||
from app.models.evidence import Evidence
|
||||
from app.models.test import Test
|
||||
from app.models.test_round_history import TestRoundHistory
|
||||
from app.models.user import User
|
||||
from app.services.audit_service import log_action
|
||||
from app.services.notification_service import (
|
||||
notify_test_state_change,
|
||||
create_notification,
|
||||
notify_role_with_email,
|
||||
notify_roles_by_email,
|
||||
)
|
||||
|
||||
# Assign logger = logging.getLogger(__name__)
|
||||
@@ -242,12 +244,6 @@ def start_execution(db: Session, test: Test, user: User) -> Test:
|
||||
except Exception as e:
|
||||
logger.warning("Jira push failed for test %s: %s", test.id, e, exc_info=True)
|
||||
|
||||
try:
|
||||
from app.services.jira_service import push_rt_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
|
||||
|
||||
|
||||
@@ -345,6 +341,12 @@ def submit_red_evidence(db: Session, test: Test, user: User) -> Test:
|
||||
except Exception as e:
|
||||
logger.warning("Jira RT end date push failed for test %s: %s", test.id, e, exc_info=True)
|
||||
|
||||
try:
|
||||
from app.services.procedure_suggestion_service import create_suggestion_from_submission
|
||||
create_suggestion_from_submission(db, test, "red")
|
||||
except Exception as e:
|
||||
logger.warning("Procedure suggestion extraction failed for test %s: %s", test.id, e, exc_info=True)
|
||||
|
||||
return test
|
||||
|
||||
|
||||
@@ -383,18 +385,43 @@ def reopen_red_review(db: Session, test: Test, user: User, notes: str) -> Test:
|
||||
if not notes or not notes.strip():
|
||||
raise InvalidOperationError("A comment is required when reopening a test for rework")
|
||||
|
||||
# Archive this round's data before resetting the live fields — reopening
|
||||
# is for a fresh attempt, not for erasing what Blue Team already saw
|
||||
# about the first one.
|
||||
archived_round = TestRoundHistory(
|
||||
test_id=test.id, team="red", round_number=test.red_round_number or 1,
|
||||
started_at=test.red_started_at, ended_at=datetime.utcnow(),
|
||||
paused_seconds=test.red_paused_seconds or 0,
|
||||
procedure_text=test.procedure_text, tool_used=test.tool_used,
|
||||
attack_success=test.attack_success, red_summary=test.red_summary,
|
||||
execution_start_time=test.execution_start_time, execution_end_time=test.execution_end_time,
|
||||
review_notes=notes.strip(), reviewed_by=user.id,
|
||||
)
|
||||
db.add(archived_round)
|
||||
|
||||
entity = TestEntity.from_orm(test)
|
||||
entity.reopen_red_review()
|
||||
entity.apply_to(test)
|
||||
test.red_review_by = user.id
|
||||
test.red_review_at = datetime.utcnow()
|
||||
test.red_review_notes = notes.strip()
|
||||
test.red_round_number = (test.red_round_number or 1) + 1
|
||||
# Only the per-round verdict/timing fields get a blank slate — the
|
||||
# operator must explicitly answer "was THIS attempt successful" and
|
||||
# "when did THIS attempt run" rather than silently inheriting round 1's
|
||||
# answer. The old values aren't lost: they're in round_history and
|
||||
# shown on the archived Phase Timeline card. Free-text fields
|
||||
# (procedure, tool, summary) are NOT cleared — the operator edits them
|
||||
# in place for the new round instead of retyping from scratch.
|
||||
test.attack_success = None
|
||||
test.execution_start_time = None
|
||||
test.execution_end_time = None
|
||||
db.flush()
|
||||
|
||||
log_action(
|
||||
db, user_id=user.id, action="reopen_red_review",
|
||||
entity_type="test", entity_id=test.id,
|
||||
details={"notes": notes, "test_name": test.name},
|
||||
details={"notes": notes, "test_name": test.name, "round_number": archived_round.round_number},
|
||||
)
|
||||
|
||||
if test.red_tech_assignee:
|
||||
@@ -409,8 +436,16 @@ def reopen_red_review(db: Session, test: Test, user: User, notes: str) -> Test:
|
||||
logger.warning("Notification failed for test %s: %s", test.id, e, exc_info=True)
|
||||
|
||||
try:
|
||||
from app.services.jira_service import push_test_event
|
||||
push_test_event(db, test, user, "red_executing", extra={"notes": notes.strip()})
|
||||
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)
|
||||
|
||||
@@ -549,6 +584,12 @@ def submit_blue_evidence(db: Session, test: Test, user: User) -> Test:
|
||||
except Exception as e:
|
||||
logger.warning("Jira BT end date push failed for test %s: %s", test.id, e, exc_info=True)
|
||||
|
||||
try:
|
||||
from app.services.procedure_suggestion_service import create_suggestion_from_submission
|
||||
create_suggestion_from_submission(db, test, "blue")
|
||||
except Exception as e:
|
||||
logger.warning("Procedure suggestion extraction failed for test %s: %s", test.id, e, exc_info=True)
|
||||
|
||||
return test
|
||||
|
||||
|
||||
@@ -587,6 +628,19 @@ def reopen_blue_review(db: Session, test: Test, user: User, notes: str) -> Test:
|
||||
if not notes or not notes.strip():
|
||||
raise InvalidOperationError("A comment is required when reopening a test for rework")
|
||||
|
||||
# Archive this round's data before resetting the live fields — same
|
||||
# reasoning as the red side: don't lose what was already evaluated.
|
||||
archived_round = TestRoundHistory(
|
||||
test_id=test.id, team="blue", round_number=test.blue_round_number or 1,
|
||||
started_at=test.blue_work_started_at, ended_at=datetime.utcnow(),
|
||||
paused_seconds=test.blue_paused_seconds or 0,
|
||||
detection_result=test.detection_result, containment_result=test.containment_result,
|
||||
detection_time=test.detection_time, containment_time=test.containment_time,
|
||||
blue_summary=test.blue_summary, detect_procedure=test.detect_procedure,
|
||||
review_notes=notes.strip(), reviewed_by=user.id,
|
||||
)
|
||||
db.add(archived_round)
|
||||
|
||||
entity = TestEntity.from_orm(test)
|
||||
entity.reopen_blue_review()
|
||||
entity.apply_to(test)
|
||||
@@ -594,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_at = datetime.utcnow()
|
||||
test.blue_review_notes = notes.strip()
|
||||
test.blue_round_number = (test.blue_round_number or 1) + 1
|
||||
# Same split as the red side: verdict/timing fields get a blank slate
|
||||
# for this round (old values live on in round_history + the archived
|
||||
# Phase Timeline card), but blue_summary is free text the operator
|
||||
# edits in place rather than retyping.
|
||||
test.detection_result = None
|
||||
test.containment_result = None
|
||||
test.detection_time = None
|
||||
test.containment_time = None
|
||||
db.flush()
|
||||
|
||||
log_action(
|
||||
db, user_id=user.id, action="reopen_blue_review",
|
||||
entity_type="test", entity_id=test.id,
|
||||
details={"notes": notes, "test_name": test.name},
|
||||
details={"notes": notes, "test_name": test.name, "round_number": archived_round.round_number},
|
||||
)
|
||||
|
||||
if test.blue_tech_assignee:
|
||||
@@ -614,7 +677,8 @@ def reopen_blue_review(db: Session, test: Test, user: User, notes: str) -> Test:
|
||||
logger.warning("Notification failed for test %s: %s", test.id, e, exc_info=True)
|
||||
|
||||
try:
|
||||
from app.services.jira_service import push_test_event
|
||||
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)
|
||||
@@ -985,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)
|
||||
return test
|
||||
|
||||
@@ -1050,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)
|
||||
return test
|
||||
|
||||
@@ -1084,9 +1162,14 @@ def check_dual_validation(db: Session, test: Test) -> Test:
|
||||
|
||||
# Define function _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:
|
||||
"""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:
|
||||
# Check: event.name == "dual_validation_approved"
|
||||
if event.name == "dual_validation_approved":
|
||||
@@ -1115,7 +1198,7 @@ def _dispatch_dual_validation_effects(
|
||||
if actor:
|
||||
try:
|
||||
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:
|
||||
logger.warning("Jira push failed for test %s: %s", test.id, e, exc_info=True)
|
||||
|
||||
@@ -1135,7 +1218,7 @@ def _dispatch_dual_validation_effects(
|
||||
if actor:
|
||||
try:
|
||||
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:
|
||||
logger.warning("Jira push failed for test %s: %s", test.id, e, exc_info=True)
|
||||
|
||||
@@ -1228,13 +1311,12 @@ def resolve_dispute(db: Session, test: Test, user: User, target_team: str, notes
|
||||
if target_team not in ("red", "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_blue_approver = user.role == "blue_lead" and test.blue_validation_status == "approved"
|
||||
if not (is_red_approver or is_blue_approver):
|
||||
raise InvalidOperationError(
|
||||
"Only the lead who approved can flip their vote to resolve this dispute"
|
||||
)
|
||||
is_red_approver = user.role == "red_lead" and test.red_validation_status == "approved"
|
||||
is_blue_approver = user.role == "blue_lead" and test.blue_validation_status == "approved"
|
||||
if not (is_red_approver or is_blue_approver):
|
||||
raise InvalidOperationError(
|
||||
"Only the lead who approved can flip their vote to resolve this dispute"
|
||||
)
|
||||
|
||||
entity = TestEntity.from_orm(test)
|
||||
entity.resolve_dispute_reject(target_team)
|
||||
@@ -1279,6 +1361,51 @@ def resolve_dispute(db: Session, test: Test, user: User, target_team: str, notes
|
||||
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
|
||||
def handle_remediation_completed(db: Session, test: Test, user: User) -> Test | None:
|
||||
"""Create a re-test when remediation is completed.
|
||||
|
||||
@@ -10,6 +10,9 @@ from __future__ import annotations
|
||||
# Import uuid
|
||||
import uuid
|
||||
|
||||
# Import secrets
|
||||
import secrets
|
||||
|
||||
# Import Session from sqlalchemy.orm
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
@@ -26,8 +29,8 @@ from app.domain.errors import (
|
||||
# Import User from app.models.user
|
||||
from app.models.user import User
|
||||
|
||||
# Assign VALID_ROLES = {"admin", "red_tech", "blue_tech", "red_lead", "blue_lead", "viewer"}
|
||||
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", "manager", "viewer"}
|
||||
|
||||
|
||||
# Define function list_users
|
||||
@@ -42,27 +45,28 @@ def create_user(
|
||||
# Entry: db
|
||||
db: Session,
|
||||
*,
|
||||
# Entry: username
|
||||
username: str,
|
||||
# Entry: email
|
||||
email: str | None,
|
||||
email: str,
|
||||
# Entry: password
|
||||
password: str,
|
||||
# Entry: role
|
||||
role: str,
|
||||
) -> 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.
|
||||
Does not commit; the router handles that.
|
||||
"""
|
||||
# Assign existing = db.query(User).filter(User.username == username).first()
|
||||
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.email == email).first()
|
||||
# Check: existing
|
||||
if existing:
|
||||
# Raise DuplicateEntityError
|
||||
raise DuplicateEntityError("User", "username", username)
|
||||
raise DuplicateEntityError("User", "email", email)
|
||||
|
||||
# Check: role not in VALID_ROLES
|
||||
if role not in VALID_ROLES:
|
||||
@@ -74,7 +78,7 @@ def create_user(
|
||||
# Assign user = User(
|
||||
user = User(
|
||||
# Keyword argument: username
|
||||
username=username,
|
||||
username=email,
|
||||
# Keyword argument: email
|
||||
email=email,
|
||||
# Keyword argument: hashed_password
|
||||
@@ -88,6 +92,40 @@ def create_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
|
||||
def get_user_or_raise(db: Session, user_id: uuid.UUID) -> User:
|
||||
"""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))}"
|
||||
)
|
||||
|
||||
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
|
||||
if "password" in update_data:
|
||||
# Assign 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()
|
||||
for field, value in update_data.items():
|
||||
# Call setattr()
|
||||
@@ -134,3 +187,92 @@ def update_user(db: Session, user_id: uuid.UUID, **fields: object) -> 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)",
|
||||
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."""
|
||||
response = client.post(
|
||||
"/api/v1/auth/login",
|
||||
data={"username": "admin", "password": "admin123"},
|
||||
data={"username": "admin@test.com", "password": "admin123"},
|
||||
)
|
||||
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."""
|
||||
response = client.post(
|
||||
"/api/v1/auth/login",
|
||||
data={"username": "redtech", "password": "redtech123"},
|
||||
data={"username": "redtech@test.com", "password": "redtech123"},
|
||||
)
|
||||
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."""
|
||||
response = client.post(
|
||||
"/api/v1/auth/login",
|
||||
data={"username": "bluetech", "password": "bluetech123"},
|
||||
data={"username": "bluetech@test.com", "password": "bluetech123"},
|
||||
)
|
||||
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."""
|
||||
response = client.post(
|
||||
"/api/v1/auth/login",
|
||||
data={"username": "redlead", "password": "redlead123"},
|
||||
data={"username": "redlead@test.com", "password": "redlead123"},
|
||||
)
|
||||
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."""
|
||||
response = client.post(
|
||||
"/api/v1/auth/login",
|
||||
data={"username": "bluelead", "password": "bluelead123"},
|
||||
data={"username": "bluelead@test.com", "password": "bluelead123"},
|
||||
)
|
||||
return response.json()["access_token"]
|
||||
|
||||
@@ -319,7 +319,7 @@ def manager_token(client, manager_user):
|
||||
"""Get an auth token for the manager user."""
|
||||
response = client.post(
|
||||
"/api/v1/auth/login",
|
||||
data={"username": "manager", "password": "manager123"},
|
||||
data={"username": "manager@test.com", "password": "manager123"},
|
||||
)
|
||||
return response.json()["access_token"]
|
||||
|
||||
|
||||
@@ -105,24 +105,35 @@ def test_import_config_rejects_invalid_json(client, auth_headers):
|
||||
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", "role": "red_tech", "is_active": True}]},
|
||||
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.username == "imported_user").first()
|
||||
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, "role": "red_lead", "is_active": True}]},
|
||||
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
|
||||
|
||||
@@ -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)
|
||||
@@ -23,7 +23,7 @@ def test_login_success(client, admin_user):
|
||||
"""Test successful login returns a token."""
|
||||
response = client.post(
|
||||
"/api/v1/auth/login",
|
||||
data={"username": "admin", "password": "admin123"},
|
||||
data={"username": "admin@test.com", "password": "admin123"},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
@@ -35,7 +35,7 @@ def test_login_wrong_password(client, admin_user):
|
||||
"""Test login with wrong password returns 400."""
|
||||
response = client.post(
|
||||
"/api/v1/auth/login",
|
||||
data={"username": "admin", "password": "wrongpassword"},
|
||||
data={"username": "admin@test.com", "password": "wrongpassword"},
|
||||
)
|
||||
assert response.status_code == 400
|
||||
|
||||
@@ -56,6 +56,7 @@ def test_login_inactive_user(client, db):
|
||||
|
||||
user = User(
|
||||
username="inactive",
|
||||
email="inactive@test.com",
|
||||
hashed_password=hash_password("password"),
|
||||
role="viewer",
|
||||
is_active=False,
|
||||
@@ -65,7 +66,7 @@ def test_login_inactive_user(client, db):
|
||||
|
||||
response = client.post(
|
||||
"/api/v1/auth/login",
|
||||
data={"username": "inactive", "password": "password"},
|
||||
data={"username": "inactive@test.com", "password": "password"},
|
||||
)
|
||||
assert response.status_code == 403
|
||||
|
||||
@@ -106,7 +107,7 @@ def test_logout_revokes_token(client, admin_user):
|
||||
"""
|
||||
login = client.post(
|
||||
"/api/v1/auth/login",
|
||||
data={"username": "admin", "password": "admin123"},
|
||||
data={"username": "admin@test.com", "password": "admin123"},
|
||||
)
|
||||
assert login.status_code == 200
|
||||
token = login.json()["access_token"]
|
||||
|
||||
@@ -6,7 +6,7 @@ from app.models.audit import AuditLog
|
||||
def test_login_failed_creates_audit_entry(client, admin_user, db):
|
||||
response = client.post(
|
||||
"/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"},
|
||||
)
|
||||
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.entity_type == "auth"
|
||||
assert log.details["username"] == "admin"
|
||||
assert log.details["email"] == "admin@test.com"
|
||||
assert log.details["reason"] == "invalid_credentials"
|
||||
assert log.ip_address == "198.51.100.10"
|
||||
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()
|
||||
response = client.post(
|
||||
"/api/v1/auth/login",
|
||||
data={"username": "admin", "password": "admin123"},
|
||||
data={"username": "admin@test.com", "password": "admin123"},
|
||||
headers={"X-Forwarded-For": "198.51.100.20"},
|
||||
)
|
||||
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
|
||||
draft/red_executing/red_review/blue_evaluating/blue_review. Both sides
|
||||
see everything once the test reaches in_review (and beyond). admin and
|
||||
viewer are never blinded.
|
||||
Blind visibility (hiding the other team's fields until both reviews pass)
|
||||
was a deliberate design in an earlier phase, but the org decided both teams
|
||||
should see each other's work throughout — detection testing isn't meant to
|
||||
be blind here. This confirms neither the frontend nor backend hides
|
||||
anything based on role/state anymore.
|
||||
"""
|
||||
|
||||
import uuid
|
||||
@@ -46,11 +47,11 @@ def technique(api, auth_headers):
|
||||
|
||||
|
||||
@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."""
|
||||
resp = api(
|
||||
"post", "/api/v1/tests", auth_headers,
|
||||
json={"technique_id": technique, "name": "Blind visibility test"},
|
||||
"post", "/api/v1/tests", red_lead_headers,
|
||||
json={"technique_id": technique, "name": "Visibility test"},
|
||||
)
|
||||
test_id = resp.json()["id"]
|
||||
|
||||
@@ -62,32 +63,21 @@ def blind_test(client, db, api, auth_headers, red_tech_headers, technique):
|
||||
return test_id
|
||||
|
||||
|
||||
def test_blue_viewer_cannot_see_red_fields_during_red_executing(
|
||||
client, db, api, blind_test, blue_tech_headers
|
||||
def test_blue_viewer_sees_red_fields_during_red_executing(
|
||||
client, db, api, in_progress_test, blue_tech_headers
|
||||
):
|
||||
resp = api("get", f"/api/v1/tests/{blind_test}", blue_tech_headers)
|
||||
resp = api("get", f"/api/v1/tests/{in_progress_test}", blue_tech_headers)
|
||||
assert resp.status_code == 200
|
||||
body = resp.json()
|
||||
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["tool_used"] == "mimikatz"
|
||||
assert body["red_summary"] == "dumped creds"
|
||||
|
||||
|
||||
def test_admin_sees_everything_during_blind_states(
|
||||
client, db, api, blind_test, auth_headers
|
||||
def test_red_viewer_sees_own_fields_during_red_executing(
|
||||
client, db, api, in_progress_test, red_tech_headers
|
||||
):
|
||||
resp = api("get", f"/api/v1/tests/{blind_test}", auth_headers)
|
||||
resp = api("get", f"/api/v1/tests/{in_progress_test}", red_tech_headers)
|
||||
assert resp.status_code == 200
|
||||
body = resp.json()
|
||||
assert body["procedure_text"] == "run mimikatz"
|
||||
@@ -95,9 +85,9 @@ def test_admin_sees_everything_during_blind_states(
|
||||
|
||||
def test_both_sides_see_everything_once_in_review(
|
||||
client, db, api, auth_headers, red_tech_headers, red_lead_headers,
|
||||
blue_tech_headers, blue_lead_headers, red_lead_user, blue_lead_user, 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)
|
||||
submit_red = api("post", f"/api/v1/tests/{test_id}/submit-red", red_tech_headers)
|
||||
assert submit_red.status_code == 200, submit_red.text
|
||||
|
||||
@@ -45,7 +45,7 @@ def test_manager_can_approve_and_sets_start_date(api, db, red_lead_user, red_lea
|
||||
"post",
|
||||
f"/api/v1/campaigns/{campaign.id}/approve",
|
||||
manager_headers,
|
||||
json={"start_date": "2026-09-01T00:00:00"},
|
||||
json={"start_date": "2020-01-01T00:00:00"},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
body = resp.json()
|
||||
@@ -53,10 +53,13 @@ def test_manager_can_approve_and_sets_start_date(api, db, red_lead_user, red_lea
|
||||
assert body["start_date"] is not None
|
||||
|
||||
|
||||
def test_manager_approval_creates_jira_tickets(api, db, red_lead_user, red_lead_headers, manager_headers):
|
||||
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."""
|
||||
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)
|
||||
|
||||
@@ -68,7 +71,7 @@ def test_manager_approval_creates_jira_tickets(api, db, red_lead_user, red_lead_
|
||||
"post",
|
||||
f"/api/v1/campaigns/{campaign.id}/approve",
|
||||
manager_headers,
|
||||
json={"start_date": "2026-09-01T00:00:00"},
|
||||
json={"start_date": "2020-01-01T00:00:00"},
|
||||
)
|
||||
|
||||
assert resp.status_code == 200
|
||||
@@ -77,6 +80,29 @@ def test_manager_approval_creates_jira_tickets(api, db, red_lead_user, red_lead_
|
||||
mock_create_test.assert_called_once()
|
||||
|
||||
|
||||
def test_manager_approval_skips_jira_tickets_when_start_date_is_future(
|
||||
api, db, red_lead_user, red_lead_headers, manager_headers
|
||||
):
|
||||
"""A campaign scheduled to start in the future must not get its Jira
|
||||
tickets created at approval time — that must wait for the periodic
|
||||
due-campaign sync job, once the scheduled start_date actually arrives."""
|
||||
campaign = _make_draft_campaign(db, red_lead_user.id)
|
||||
api("post", f"/api/v1/campaigns/{campaign.id}/submit", red_lead_headers)
|
||||
|
||||
with patch("app.services.jira_service.get_campaign_jira_key", return_value=None) as mock_get_key, \
|
||||
patch("app.services.jira_service.auto_create_campaign_issue", return_value="PT-100") as mock_create_campaign:
|
||||
resp = api(
|
||||
"post",
|
||||
f"/api/v1/campaigns/{campaign.id}/approve",
|
||||
manager_headers,
|
||||
json={"start_date": "2099-01-01T00:00:00"},
|
||||
)
|
||||
|
||||
assert resp.status_code == 200
|
||||
mock_get_key.assert_not_called()
|
||||
mock_create_campaign.assert_not_called()
|
||||
|
||||
|
||||
def test_lead_cannot_approve(api, db, red_lead_user, red_lead_headers):
|
||||
campaign = _make_draft_campaign(db, red_lead_user.id)
|
||||
api("post", f"/api/v1/campaigns/{campaign.id}/submit", red_lead_headers)
|
||||
@@ -320,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"},
|
||||
)
|
||||
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
|
||||
|
||||
|
||||
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
|
||||
|
||||
@@ -21,6 +21,7 @@ from app.services.campaign_service import (
|
||||
from app.services.campaign_scheduler_service import (
|
||||
calculate_next_run,
|
||||
check_and_run_recurring_campaigns,
|
||||
sync_due_campaign_jira_tickets,
|
||||
)
|
||||
from app.services.snapshot_service import (
|
||||
create_snapshot,
|
||||
@@ -199,7 +200,10 @@ class TestCampaigns:
|
||||
)
|
||||
assert child is not None
|
||||
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)
|
||||
child_cts = (
|
||||
@@ -217,12 +221,92 @@ class TestCampaigns:
|
||||
test = db.query(Test).filter(Test.id == ct.test_id).first()
|
||||
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
|
||||
db.refresh(campaign)
|
||||
assert campaign.last_run_at is not None
|
||||
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
|
||||
# ═══════════════════════════════════════════════════════════════════════
|
||||
|
||||
@@ -34,7 +34,7 @@ def _seed_technique(db, tactic="execution") -> 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
|
||||
still gets a safe classification via the column's server_default."""
|
||||
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.commit()
|
||||
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."""
|
||||
resp = api(
|
||||
"post", "/api/v1/tests", auth_headers,
|
||||
"post", "/api/v1/tests", red_lead_headers,
|
||||
json={"technique_id": technique["id"], "name": "Heuristic test"},
|
||||
)
|
||||
assert resp.status_code == 201, resp.text
|
||||
# The shared `technique` fixture (see conftest/test_tests.py) doesn't set
|
||||
# a restricted-tier tactic, so this should land on the baseline.
|
||||
assert resp.json()["data_classification"] == "confidential"
|
||||
# a pii-tier tactic, so this should land on the baseline.
|
||||
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)
|
||||
test = Test(
|
||||
technique_id=technique.id,
|
||||
@@ -74,14 +76,33 @@ def test_admin_can_update_classification(client, db, admin_user, admin_token, re
|
||||
|
||||
response = client.patch(
|
||||
f"/api/v1/tests/{test.id}/classification",
|
||||
json={"data_classification": "restricted"},
|
||||
json={"data_classification": "pii"},
|
||||
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.json()["data_classification"] == "restricted"
|
||||
assert response.json()["data_classification"] == "pii"
|
||||
|
||||
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):
|
||||
@@ -116,7 +137,7 @@ def test_viewer_cannot_update_classification(client, db, admin_user, red_lead_us
|
||||
)
|
||||
db.add(viewer)
|
||||
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"]
|
||||
|
||||
technique = _seed_technique(db)
|
||||
@@ -130,7 +151,7 @@ def test_viewer_cannot_update_classification(client, db, admin_user, red_lead_us
|
||||
|
||||
response = client.patch(
|
||||
f"/api/v1/tests/{test.id}/classification",
|
||||
json={"data_classification": "restricted"},
|
||||
json={"data_classification": "pii"},
|
||||
headers={"Authorization": f"Bearer {viewer_token}"},
|
||||
)
|
||||
assert response.status_code == 403
|
||||
@@ -142,15 +163,15 @@ def _technique_stub(tactic):
|
||||
return t
|
||||
|
||||
|
||||
def test_determine_initial_classification_defaults_to_confidential():
|
||||
assert determine_initial_classification(_technique_stub("execution")) == "confidential"
|
||||
assert determine_initial_classification(_technique_stub(None)) == "confidential"
|
||||
assert determine_initial_classification(None) == "confidential"
|
||||
def test_determine_initial_classification_defaults_to_internal_use_only():
|
||||
assert determine_initial_classification(_technique_stub("execution")) == "internal_use_only"
|
||||
assert determine_initial_classification(_technique_stub(None)) == "internal_use_only"
|
||||
assert determine_initial_classification(None) == "internal_use_only"
|
||||
|
||||
|
||||
def test_determine_initial_classification_escalates_for_sensitive_tactics():
|
||||
assert determine_initial_classification(_technique_stub("exfiltration")) == "restricted"
|
||||
assert determine_initial_classification(_technique_stub("collection")) == "restricted"
|
||||
assert determine_initial_classification(_technique_stub("credential-access")) == "restricted"
|
||||
assert determine_initial_classification(_technique_stub("impact")) == "restricted"
|
||||
assert determine_initial_classification(_technique_stub("Exfiltration")) == "restricted"
|
||||
assert determine_initial_classification(_technique_stub("exfiltration")) == "pii"
|
||||
assert determine_initial_classification(_technique_stub("collection")) == "pii"
|
||||
assert determine_initial_classification(_technique_stub("credential-access")) == "pii"
|
||||
assert determine_initial_classification(_technique_stub("impact")) == "pii"
|
||||
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")
|
||||
class _FakeBGScheduler:
|
||||
def add_job(self, *a, **kw): pass
|
||||
def add_listener(self, *a, **kw): pass
|
||||
def start(self): pass
|
||||
def shutdown(self, **kw): pass
|
||||
_apsched.BackgroundScheduler = _FakeBGScheduler
|
||||
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:
|
||||
sys.modules["taxii2client"] = ModuleType("taxii2client")
|
||||
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
|
||||
@@ -53,9 +53,14 @@ def _make_test(**overrides):
|
||||
t.attack_success = None
|
||||
t.detection_result = None
|
||||
t.containment_result = None
|
||||
t.execution_start_time = None
|
||||
t.execution_end_time = None
|
||||
t.detection_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
|
||||
@@ -65,6 +70,9 @@ def _make_test(**overrides):
|
||||
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():
|
||||
setattr(t, k, v)
|
||||
return t
|
||||
@@ -72,7 +80,10 @@ def _make_test(**overrides):
|
||||
|
||||
@patch("app.services.jira_service.has_admin_jira_configured", return_value=True)
|
||||
@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_get_client.return_value = mock_jira
|
||||
test = _make_test()
|
||||
@@ -82,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)
|
||||
|
||||
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()
|
||||
|
||||
|
||||
@patch("app.services.jira_service.has_admin_jira_configured", return_value=True)
|
||||
@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_get_client.return_value = mock_jira
|
||||
test = _make_test()
|
||||
@@ -98,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)
|
||||
|
||||
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)
|
||||
@@ -116,14 +128,86 @@ def test_push_hold_event_sets_blocked(mock_get_client, mock_configured, db):
|
||||
mock_jira.set_issue_status.assert_called_once_with(link.jira_issue_key, "Blocked")
|
||||
|
||||
|
||||
@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_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"),
|
||||
("draft", "To Do"),
|
||||
("red_executing", "In Progress"),
|
||||
("red_review", "RT Test Review"),
|
||||
("red_review", "Red Team test review"),
|
||||
("blue_evaluating", "Queued Blue Team"),
|
||||
("blue_review", "Blue Team Test Review"),
|
||||
("blue_review", "Blue Team test review"),
|
||||
("in_review", "Validation"),
|
||||
("validated", "Done"),
|
||||
("rejected", "Rejected"),
|
||||
@@ -149,6 +233,302 @@ def test_push_test_event_maps_every_state_to_jira_status(
|
||||
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):
|
||||
@@ -163,27 +543,46 @@ def test_push_bt_work_started_sets_in_progress(mock_get_client, mock_configured,
|
||||
db.add(link)
|
||||
db.commit()
|
||||
|
||||
jira_service.push_bt_work_started(db, test, MagicMock(username="bob"))
|
||||
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_rt_started_sets_date_field(mock_get_client, mock_configured, db):
|
||||
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()
|
||||
test = _make_test(blue_round_number=1)
|
||||
link = _make_link(test.id)
|
||||
db.add(link)
|
||||
db.commit()
|
||||
|
||||
jira_service.push_rt_started(db, test)
|
||||
jira_service.push_bt_started(db, test)
|
||||
|
||||
mock_jira.update_issue_field.assert_called_once()
|
||||
args, kwargs = mock_jira.update_issue_field.call_args
|
||||
assert args[0] == link.jira_issue_key
|
||||
assert jira_service.JIRA_FIELD_RT_START_DATE in kwargs["fields"]
|
||||
fields = mock_jira.update_issue_field.call_args[1]["fields"]
|
||||
assert jira_service.JIRA_FIELD_BT_START_DATE in 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)
|
||||
@@ -191,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):
|
||||
mock_jira = MagicMock()
|
||||
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)
|
||||
db.add(link)
|
||||
db.commit()
|
||||
@@ -200,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"]
|
||||
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.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_get_client.return_value = mock_jira
|
||||
exec_end = datetime(2026, 1, 1, 10, 0, 0)
|
||||
detect = datetime(2026, 1, 1, 12, 0, 0)
|
||||
contain = datetime(2026, 1, 1, 13, 30, 0)
|
||||
test = _make_test(
|
||||
detection_result=MagicMock(value="detected"),
|
||||
execution_end_time=exec_end,
|
||||
detection_time=detect,
|
||||
containment_time=contain,
|
||||
)
|
||||
@@ -224,9 +694,9 @@ def test_push_bt_submitted_includes_detection_and_hours(mock_get_client, mock_co
|
||||
jira_service.push_bt_submitted(db, test)
|
||||
|
||||
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_TIME_TO_DETECT] == 2.0
|
||||
assert fields[jira_service.JIRA_FIELD_TIME_TO_CONTAIN] == 1.5
|
||||
assert fields[jira_service.JIRA_FIELD_ATTACK_DETECTED] == {"value": "Yes"}
|
||||
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] == "2026-01-01T13:30:00.000+0000"
|
||||
|
||||
|
||||
@patch("app.services.jira_service.has_admin_jira_configured", return_value=True)
|
||||
@@ -242,7 +712,7 @@ def test_push_bt_submitted_includes_attack_contained(mock_get_client, mock_confi
|
||||
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] == "Partial"
|
||||
assert fields[jira_service.JIRA_FIELD_ATTACK_CONTAINED] == {"value": "Partial"}
|
||||
|
||||
|
||||
def test_update_test_fields_noop_when_not_configured(db):
|
||||
@@ -277,3 +747,338 @@ def test_search_jira_issues_maps_fields(mock_get_client):
|
||||
assert results[0]["summary"] == "Test issue"
|
||||
assert results[0]["status"] == "In Progress"
|
||||
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 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.schedulers.background",
|
||||
"apscheduler.triggers", "apscheduler.triggers.cron",
|
||||
"apscheduler.events",
|
||||
]:
|
||||
if _mod not in sys.modules:
|
||||
m = ModuleType(_mod)
|
||||
@@ -85,6 +86,7 @@ for _mod in [
|
||||
elif _mod == "botocore.exceptions": m.ClientError = Exception
|
||||
elif _mod == "apscheduler.schedulers.background": m.BackgroundScheduler = MagicMock
|
||||
elif _mod == "apscheduler.triggers.cron": m.CronTrigger = MagicMock
|
||||
elif _mod == "apscheduler.events": m.EVENT_JOB_ERROR = 1
|
||||
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")
|
||||
@@ -0,0 +1,245 @@
|
||||
"""End-to-end coverage for the passwordless user creation + emailed
|
||||
set-password-link flow (also reused for password resets).
|
||||
"""
|
||||
|
||||
import uuid
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def new_user_id(api, auth_headers):
|
||||
resp = api(
|
||||
"post", "/api/v1/users", auth_headers,
|
||||
json={"full_name": "Set Password User", "email": "setpw@test.com", "role": "viewer"},
|
||||
)
|
||||
assert resp.status_code == 201, resp.text
|
||||
return resp.json()["id"]
|
||||
|
||||
|
||||
def test_create_user_automatically_sends_set_password_email(api, db, auth_headers):
|
||||
api(
|
||||
"patch", "/api/v1/system/email-webhook-config", auth_headers,
|
||||
json={"url": "https://example.com/power-automate-hook"},
|
||||
)
|
||||
|
||||
with patch("app.services.webhook_email_service.requests.post") as mock_post:
|
||||
resp = api(
|
||||
"post", "/api/v1/users", auth_headers,
|
||||
json={"full_name": "Auto Send User", "email": "autosend@test.com", "role": "viewer"},
|
||||
)
|
||||
assert resp.status_code == 201, resp.text
|
||||
mock_post.assert_called_once()
|
||||
payload = mock_post.call_args.kwargs["json"]
|
||||
assert payload["to"] == "autosend@test.com"
|
||||
assert payload["subject"] == "Set Your Password"
|
||||
assert "token=" in payload["body"]
|
||||
|
||||
from app.models.password_setup_token import PasswordSetupToken
|
||||
token_row = db.query(PasswordSetupToken).filter(
|
||||
PasswordSetupToken.user_id == uuid.UUID(resp.json()["id"]),
|
||||
).first()
|
||||
assert token_row is not None
|
||||
|
||||
|
||||
def test_create_user_succeeds_even_if_auto_send_fails(api, auth_headers):
|
||||
"""No webhook configured yet — the automatic first-email attempt fails,
|
||||
but user creation itself must still succeed."""
|
||||
resp = api(
|
||||
"post", "/api/v1/users", auth_headers,
|
||||
json={"full_name": "No Webhook User", "email": "nowebhook@test.com", "role": "viewer"},
|
||||
)
|
||||
assert resp.status_code == 201, resp.text
|
||||
|
||||
|
||||
def test_send_password_email_requires_webhook_configured(api, auth_headers, new_user_id):
|
||||
resp = api("post", f"/api/v1/users/{new_user_id}/send-password-email", auth_headers)
|
||||
assert resp.status_code == 400
|
||||
assert "webhook" in resp.text.lower()
|
||||
|
||||
|
||||
def test_admin_can_configure_password_webhook(api, auth_headers):
|
||||
resp = api(
|
||||
"patch", "/api/v1/system/email-webhook-config", auth_headers,
|
||||
json={"url": "https://example.com/power-automate-hook"},
|
||||
)
|
||||
assert resp.status_code == 200, resp.text
|
||||
assert resp.json()["configured"] is True
|
||||
|
||||
get_resp = api("get", "/api/v1/system/email-webhook-config", auth_headers)
|
||||
assert get_resp.json()["url"] == "https://example.com/power-automate-hook"
|
||||
|
||||
|
||||
def test_admin_can_configure_webhook_api_key(api, auth_headers):
|
||||
resp = api(
|
||||
"patch", "/api/v1/system/email-webhook-config", auth_headers,
|
||||
json={"url": "https://example.com/power-automate-hook", "api_key": "super-secret-key"},
|
||||
)
|
||||
assert resp.status_code == 200, resp.text
|
||||
assert resp.json()["api_key_set"] is True
|
||||
# The key itself is never echoed back.
|
||||
assert "api_key" not in resp.json()
|
||||
assert "super-secret-key" not in resp.text
|
||||
|
||||
|
||||
def test_send_password_email_sends_api_key_header(api, db, auth_headers, new_user_id):
|
||||
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:
|
||||
api("post", f"/api/v1/users/{new_user_id}/send-password-email", auth_headers)
|
||||
call_kwargs = mock_post.call_args
|
||||
assert call_kwargs.kwargs["headers"] == {"x-api-key": "super-secret-key"}
|
||||
|
||||
|
||||
def test_send_password_email_posts_to_webhook_and_issues_token(api, db, auth_headers, new_user_id):
|
||||
api(
|
||||
"patch", "/api/v1/system/email-webhook-config", auth_headers,
|
||||
json={"url": "https://example.com/power-automate-hook"},
|
||||
)
|
||||
|
||||
with patch("app.services.webhook_email_service.requests.post") as mock_post:
|
||||
resp = api("post", f"/api/v1/users/{new_user_id}/send-password-email", auth_headers)
|
||||
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"
|
||||
payload = call_kwargs.kwargs["json"]
|
||||
assert payload["to"] == "setpw@test.com"
|
||||
assert payload["subject"] == "Set Your Password"
|
||||
assert "Hi Set Password User" in payload["body"]
|
||||
assert "token=" in payload["body"]
|
||||
assert "Purple Team Engineering" in payload["body"]
|
||||
|
||||
from app.models.password_setup_token import PasswordSetupToken
|
||||
token_row = db.query(PasswordSetupToken).filter(PasswordSetupToken.user_id == uuid.UUID(new_user_id)).first()
|
||||
assert token_row is not None
|
||||
assert token_row.used_at is None
|
||||
|
||||
|
||||
def test_send_password_email_fails_loudly_when_webhook_rejects(api, auth_headers, new_user_id):
|
||||
"""A webhook that's reachable but rejects the request (bad URL/API key,
|
||||
wrong Power Automate config, etc.) must not be reported as a success —
|
||||
previously the HTTP response status was never checked."""
|
||||
api(
|
||||
"patch", "/api/v1/system/email-webhook-config", auth_headers,
|
||||
json={"url": "https://example.com/power-automate-hook"},
|
||||
)
|
||||
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
fake_response = MagicMock(ok=False, status_code=404, text="not found")
|
||||
with patch("app.services.webhook_email_service.requests.post", return_value=fake_response):
|
||||
resp = api("post", f"/api/v1/users/{new_user_id}/send-password-email", auth_headers)
|
||||
assert resp.status_code == 400
|
||||
assert "failed to send" in resp.text.lower()
|
||||
|
||||
|
||||
def test_send_password_email_resend_after_password_set_is_a_reset_email(api, db, auth_headers, new_user_id):
|
||||
"""Once a user has already set their password, hitting 'Send Email'
|
||||
again must still actually send — as a password-reset email — not
|
||||
silently no-op."""
|
||||
api(
|
||||
"patch", "/api/v1/system/email-webhook-config", auth_headers,
|
||||
json={"url": "https://example.com/power-automate-hook"},
|
||||
)
|
||||
|
||||
with patch("app.services.webhook_email_service.requests.post") as mock_post:
|
||||
resp = api("post", f"/api/v1/users/{new_user_id}/send-password-email", auth_headers)
|
||||
assert resp.status_code == 200, resp.text
|
||||
first_payload = mock_post.call_args.kwargs["json"]
|
||||
assert first_payload["subject"] == "Set Your Password"
|
||||
|
||||
from app.models.password_setup_token import PasswordSetupToken
|
||||
token_row = db.query(PasswordSetupToken).filter(
|
||||
PasswordSetupToken.user_id == uuid.UUID(new_user_id),
|
||||
).first()
|
||||
api(
|
||||
"post", "/api/v1/auth/set-password", {},
|
||||
json={"token": token_row.token, "new_password": "BrandNewPass123!@#"},
|
||||
)
|
||||
|
||||
# Now the user has must_change_password=False — resending must still
|
||||
# go through, this time as a reset email.
|
||||
with patch("app.services.webhook_email_service.requests.post") as mock_post_2:
|
||||
resp2 = api("post", f"/api/v1/users/{new_user_id}/send-password-email", auth_headers)
|
||||
assert resp2.status_code == 200, resp2.text
|
||||
mock_post_2.assert_called_once()
|
||||
second_payload = mock_post_2.call_args.kwargs["json"]
|
||||
assert second_payload["subject"] == "Reset Your Password"
|
||||
|
||||
def test_set_password_with_valid_token_succeeds(api, db, auth_headers, new_user_id):
|
||||
api(
|
||||
"patch", "/api/v1/system/email-webhook-config", auth_headers,
|
||||
json={"url": "https://example.com/power-automate-hook"},
|
||||
)
|
||||
with patch("app.services.webhook_email_service.requests.post"):
|
||||
api("post", f"/api/v1/users/{new_user_id}/send-password-email", auth_headers)
|
||||
|
||||
from app.models.password_setup_token import PasswordSetupToken
|
||||
token_row = db.query(PasswordSetupToken).filter(PasswordSetupToken.user_id == uuid.UUID(new_user_id)).first()
|
||||
|
||||
validate = api("get", f"/api/v1/auth/set-password/validate?token={token_row.token}", {})
|
||||
assert validate.status_code == 200
|
||||
assert validate.json()["valid"] is True
|
||||
assert validate.json()["full_name"] == "Set Password User"
|
||||
|
||||
resp = api(
|
||||
"post", "/api/v1/auth/set-password", {},
|
||||
json={"token": token_row.token, "new_password": "BrandNewPass123!@#"},
|
||||
)
|
||||
assert resp.status_code == 200, resp.text
|
||||
|
||||
login = api(
|
||||
"post", "/api/v1/auth/login", {},
|
||||
data={"username": "setpw@test.com", "password": "BrandNewPass123!@#"},
|
||||
)
|
||||
assert login.status_code == 200, login.text
|
||||
|
||||
|
||||
def test_set_password_token_cannot_be_reused(api, db, auth_headers, new_user_id):
|
||||
api(
|
||||
"patch", "/api/v1/system/email-webhook-config", auth_headers,
|
||||
json={"url": "https://example.com/power-automate-hook"},
|
||||
)
|
||||
with patch("app.services.webhook_email_service.requests.post"):
|
||||
api("post", f"/api/v1/users/{new_user_id}/send-password-email", auth_headers)
|
||||
|
||||
from app.models.password_setup_token import PasswordSetupToken
|
||||
token_row = db.query(PasswordSetupToken).filter(PasswordSetupToken.user_id == uuid.UUID(new_user_id)).first()
|
||||
|
||||
first = api(
|
||||
"post", "/api/v1/auth/set-password", {},
|
||||
json={"token": token_row.token, "new_password": "BrandNewPass123!@#"},
|
||||
)
|
||||
assert first.status_code == 200
|
||||
|
||||
second = api(
|
||||
"post", "/api/v1/auth/set-password", {},
|
||||
json={"token": token_row.token, "new_password": "AnotherPass456!@#"},
|
||||
)
|
||||
assert second.status_code == 400
|
||||
|
||||
|
||||
def test_set_password_invalid_token_rejected(api):
|
||||
validate = api("get", "/api/v1/auth/set-password/validate?token=not-a-real-token", {})
|
||||
assert validate.status_code == 200
|
||||
assert validate.json()["valid"] is False
|
||||
|
||||
resp = api(
|
||||
"post", "/api/v1/auth/set-password", {},
|
||||
json={"token": "not-a-real-token", "new_password": "BrandNewPass123!@#"},
|
||||
)
|
||||
assert resp.status_code == 404
|
||||
|
||||
|
||||
def test_password_webhook_config_requires_admin(api, red_lead_headers):
|
||||
resp = api("get", "/api/v1/system/email-webhook-config", red_lead_headers)
|
||||
assert resp.status_code == 403
|
||||
|
||||
|
||||
def test_send_password_email_requires_admin(api, red_lead_headers, new_user_id):
|
||||
resp = api("post", f"/api/v1/users/{new_user_id}/send-password-email", red_lead_headers)
|
||||
assert resp.status_code == 403
|
||||
@@ -0,0 +1,109 @@
|
||||
"""Tests for the regex-based command/query extraction heuristic used to
|
||||
build procedure-improvement suggestions from free-text procedure fields."""
|
||||
|
||||
import pytest
|
||||
|
||||
from app.services.procedure_extraction_service import extract_commands
|
||||
|
||||
|
||||
@pytest.mark.parametrize("value", [None, "", " ", "\n\n"])
|
||||
def test_returns_none_for_empty_input(value):
|
||||
assert extract_commands(value) is None
|
||||
|
||||
|
||||
def test_returns_none_for_pure_narrative():
|
||||
text = (
|
||||
"I logged into the domain controller and tried a well-known credential "
|
||||
"dumping tool. It worked well and I documented everything in the summary."
|
||||
)
|
||||
assert extract_commands(text) is None
|
||||
|
||||
|
||||
def test_fenced_code_block_takes_priority_over_surrounding_narrative():
|
||||
text = (
|
||||
"Ran the following from an elevated prompt:\n"
|
||||
"```\n"
|
||||
"Invoke-Mimikatz -DumpCreds\n"
|
||||
"```\n"
|
||||
"It successfully extracted plaintext passwords from LSASS memory."
|
||||
)
|
||||
assert extract_commands(text) == "Invoke-Mimikatz -DumpCreds"
|
||||
|
||||
|
||||
def test_tilde_fence_supported():
|
||||
text = "~~~\nGet-Process lsass\n~~~"
|
||||
assert extract_commands(text) == "Get-Process lsass"
|
||||
|
||||
|
||||
def test_multiple_fenced_blocks_are_joined():
|
||||
text = "```\nwhoami /priv\n```\nthen\n```\nGet-Process lsass\n```"
|
||||
assert extract_commands(text) == "whoami /priv\n\nGet-Process lsass"
|
||||
|
||||
|
||||
def test_extracts_command_lines_from_mixed_narrative_no_fence():
|
||||
text = (
|
||||
"First I checked what privileges I had.\n"
|
||||
"whoami /priv\n"
|
||||
"Then I dumped credentials from LSASS.\n"
|
||||
"Invoke-Mimikatz -DumpCreds\n"
|
||||
"This successfully retrieved plaintext passwords, as shown below.\n"
|
||||
"Handles NPM(K) PM(K) WS(K) CPU(s) Id SI ProcessName\n"
|
||||
" 542 27 23012 41564 0.45 1234 1 explorer\n"
|
||||
)
|
||||
result = extract_commands(text)
|
||||
assert result == "whoami /priv\nInvoke-Mimikatz -DumpCreds"
|
||||
|
||||
|
||||
def test_strips_shell_prompt_markers():
|
||||
text = (
|
||||
"Ran this against the target host:\n"
|
||||
"$ curl -X POST http://target/api/exfil -d @creds.txt\n"
|
||||
"Got a 200 OK back.\n"
|
||||
)
|
||||
assert extract_commands(text) == "curl -X POST http://target/api/exfil -d @creds.txt"
|
||||
|
||||
|
||||
def test_strips_powershell_prompt_marker():
|
||||
text = "PS C:\\Users\\op> Get-Process lsass"
|
||||
assert extract_commands(text) == "Get-Process lsass"
|
||||
|
||||
|
||||
def test_extracts_kql_detection_query_mixed_with_narrative():
|
||||
text = (
|
||||
"I built a Sentinel query to catch this technique going forward.\n"
|
||||
"DeviceProcessEvents\n"
|
||||
"| where FileName == \"mimikatz.exe\"\n"
|
||||
"That caught it within a minute of execution in testing.\n"
|
||||
)
|
||||
result = extract_commands(text)
|
||||
assert 'where FileName == "mimikatz.exe"' in result
|
||||
assert "That caught it" not in result
|
||||
|
||||
|
||||
def test_extracts_splunk_query():
|
||||
text = (
|
||||
"Search used for detection:\n"
|
||||
"index=main sourcetype=WinEventLog:Security EventCode=4688\n"
|
||||
"This surfaced the process creation event immediately.\n"
|
||||
)
|
||||
result = extract_commands(text)
|
||||
assert "index=main sourcetype=WinEventLog:Security EventCode=4688" in result
|
||||
assert "immediately" not in result
|
||||
|
||||
|
||||
def test_known_binary_at_start_of_line_is_recognized():
|
||||
text = "Notes: used the following.\nsudo tcpdump -i eth0 -w capture.pcap\nCaptured 500 packets."
|
||||
result = extract_commands(text)
|
||||
assert result == "sudo tcpdump -i eth0 -w capture.pcap"
|
||||
|
||||
|
||||
def test_recognizes_sysmon_as_a_detection_command():
|
||||
text = "launch sysmon to detect\nsysmon.exe\nthen search this query\n\\? mimikatz \\n"
|
||||
result = extract_commands(text)
|
||||
assert result == "sysmon.exe"
|
||||
|
||||
|
||||
def test_recognizes_wevtutil_and_auditpol():
|
||||
text = "Pulled the security log.\nwevtutil qe Security /c:5\nThen checked audit policy.\nauditpol /get /category:*"
|
||||
result = extract_commands(text)
|
||||
assert result == "wevtutil qe Security /c:5\nauditpol /get /category:*"
|
||||
@@ -0,0 +1,372 @@
|
||||
"""End-to-end coverage for the procedure-suggestion review workflow.
|
||||
|
||||
An operator submitting a round with a filled-in procedure field that
|
||||
contains an extractable command should produce a pending
|
||||
ProcedureSuggestion against the originating template; a lead can then
|
||||
approve it (writing it into the template) or reject it (leaving the
|
||||
template untouched). Nothing is ever written to a template automatically.
|
||||
"""
|
||||
|
||||
import uuid
|
||||
|
||||
import pytest
|
||||
|
||||
from app.models.enums import TeamSide
|
||||
from app.models.evidence import Evidence
|
||||
|
||||
|
||||
def _add_evidence(db, test_id, team: TeamSide):
|
||||
ev = Evidence(
|
||||
test_id=uuid.UUID(test_id),
|
||||
file_name="proof.txt",
|
||||
file_path="s3://bucket/proof.txt",
|
||||
sha256_hash="a" * 64,
|
||||
team=team,
|
||||
)
|
||||
db.add(ev)
|
||||
db.commit()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def technique(api, auth_headers):
|
||||
resp = api(
|
||||
"post", "/api/v1/techniques", auth_headers,
|
||||
json={"mitre_id": "T1059.200", "name": "Command Line Suggestions"},
|
||||
)
|
||||
assert resp.status_code == 201, resp.text
|
||||
return resp.json()["id"]
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def template(api, red_lead_headers, technique):
|
||||
resp = api(
|
||||
"post", "/api/v1/test-templates", red_lead_headers,
|
||||
json={
|
||||
"mitre_technique_id": "T1059.200",
|
||||
"name": "Suggestion source template",
|
||||
"attack_procedure": "Run a discovery command on the target.",
|
||||
"expected_detection": "Check process creation logs.",
|
||||
},
|
||||
)
|
||||
assert resp.status_code == 201, resp.text
|
||||
return resp.json()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def test_from_template(api, red_lead_headers, technique, template):
|
||||
resp = api(
|
||||
"post", "/api/v1/tests/from-template", red_lead_headers,
|
||||
json={"template_id": template["id"], "technique_id": technique},
|
||||
)
|
||||
assert resp.status_code == 201, resp.text
|
||||
return resp.json()["id"]
|
||||
|
||||
|
||||
def test_red_submission_with_command_creates_pending_suggestion(
|
||||
client, db, api, test_from_template, red_tech_headers, red_lead_headers,
|
||||
):
|
||||
test_id = test_from_template
|
||||
api("post", f"/api/v1/tests/{test_id}/start-execution", red_tech_headers)
|
||||
api(
|
||||
"patch", f"/api/v1/tests/{test_id}/red", red_tech_headers,
|
||||
json={"procedure_text": "Ran discovery.\nwhoami /priv\nGot back SeDebugPrivilege."},
|
||||
)
|
||||
_add_evidence(db, test_id, TeamSide.red)
|
||||
resp = api("post", f"/api/v1/tests/{test_id}/submit-red", red_tech_headers)
|
||||
assert resp.status_code == 200, resp.text
|
||||
|
||||
listed = api("get", "/api/v1/procedure-suggestions", red_lead_headers)
|
||||
assert listed.status_code == 200, listed.text
|
||||
suggestions = listed.json()
|
||||
assert len(suggestions) == 1
|
||||
assert suggestions[0]["team"] == "red"
|
||||
assert suggestions[0]["suggested_text"] == "whoami /priv"
|
||||
assert suggestions[0]["status"] == "pending"
|
||||
|
||||
|
||||
def test_blue_submission_with_command_creates_pending_suggestion(
|
||||
client, db, api, test_from_template, red_tech_headers, red_lead_headers,
|
||||
blue_tech_headers, blue_lead_headers,
|
||||
):
|
||||
test_id = test_from_template
|
||||
api("post", f"/api/v1/tests/{test_id}/start-execution", red_tech_headers)
|
||||
_add_evidence(db, test_id, TeamSide.red)
|
||||
api("post", f"/api/v1/tests/{test_id}/submit-red", red_tech_headers)
|
||||
api("post", f"/api/v1/tests/{test_id}/review-red", red_lead_headers, json={"decision": "approve"})
|
||||
|
||||
api("post", f"/api/v1/tests/{test_id}/start-blue-work", blue_tech_headers)
|
||||
api(
|
||||
"patch", f"/api/v1/tests/{test_id}/blue", blue_tech_headers,
|
||||
json={
|
||||
"detection_result": "detected",
|
||||
"detect_procedure": "Checked logs.\nGet-WinEvent -LogName Security | where Id -eq 4688\nFound it.",
|
||||
},
|
||||
)
|
||||
_add_evidence(db, test_id, TeamSide.blue)
|
||||
resp = api("post", f"/api/v1/tests/{test_id}/submit-blue", blue_tech_headers)
|
||||
assert resp.status_code == 200, resp.text
|
||||
|
||||
listed = api("get", "/api/v1/procedure-suggestions", blue_lead_headers)
|
||||
assert listed.status_code == 200, listed.text
|
||||
suggestions = listed.json()
|
||||
assert len(suggestions) == 1
|
||||
assert suggestions[0]["team"] == "blue"
|
||||
assert "Get-WinEvent" in suggestions[0]["suggested_text"]
|
||||
|
||||
|
||||
def test_submission_without_extractable_command_creates_no_suggestion(
|
||||
client, db, api, test_from_template, red_tech_headers, red_lead_headers,
|
||||
):
|
||||
test_id = test_from_template
|
||||
api("post", f"/api/v1/tests/{test_id}/start-execution", red_tech_headers)
|
||||
api(
|
||||
"patch", f"/api/v1/tests/{test_id}/red", red_tech_headers,
|
||||
json={"procedure_text": "Just talked to the target and it agreed to be compromised."},
|
||||
)
|
||||
_add_evidence(db, test_id, TeamSide.red)
|
||||
resp = api("post", f"/api/v1/tests/{test_id}/submit-red", red_tech_headers)
|
||||
assert resp.status_code == 200, resp.text
|
||||
|
||||
listed = api("get", "/api/v1/procedure-suggestions", red_lead_headers)
|
||||
assert listed.json() == []
|
||||
|
||||
|
||||
def test_approving_a_suggestion_writes_it_into_the_template(
|
||||
client, db, api, test_from_template, template, red_tech_headers, red_lead_headers,
|
||||
):
|
||||
test_id = test_from_template
|
||||
api("post", f"/api/v1/tests/{test_id}/start-execution", red_tech_headers)
|
||||
api(
|
||||
"patch", f"/api/v1/tests/{test_id}/red", red_tech_headers,
|
||||
json={"procedure_text": "Ran discovery.\nwhoami /priv\nDone."},
|
||||
)
|
||||
_add_evidence(db, test_id, TeamSide.red)
|
||||
api("post", f"/api/v1/tests/{test_id}/submit-red", red_tech_headers)
|
||||
|
||||
suggestion = api("get", "/api/v1/procedure-suggestions", red_lead_headers).json()[0]
|
||||
approve = api(
|
||||
"post", f"/api/v1/procedure-suggestions/{suggestion['id']}/approve", red_lead_headers,
|
||||
)
|
||||
assert approve.status_code == 200, approve.text
|
||||
assert approve.json()["status"] == "approved"
|
||||
|
||||
reloaded = api("get", f"/api/v1/test-templates/{template['id']}", red_lead_headers)
|
||||
assert reloaded.json()["attack_procedure"] == "Run a discovery command on the target.\nwhoami /priv"
|
||||
|
||||
listed = api("get", "/api/v1/procedure-suggestions", red_lead_headers)
|
||||
assert listed.json() == []
|
||||
|
||||
|
||||
def test_rejecting_a_suggestion_leaves_the_template_untouched(
|
||||
client, db, api, test_from_template, template, red_tech_headers, red_lead_headers,
|
||||
):
|
||||
test_id = test_from_template
|
||||
api("post", f"/api/v1/tests/{test_id}/start-execution", red_tech_headers)
|
||||
api(
|
||||
"patch", f"/api/v1/tests/{test_id}/red", red_tech_headers,
|
||||
json={"procedure_text": "Ran discovery.\nwhoami /priv\nDone."},
|
||||
)
|
||||
_add_evidence(db, test_id, TeamSide.red)
|
||||
api("post", f"/api/v1/tests/{test_id}/submit-red", red_tech_headers)
|
||||
|
||||
suggestion = api("get", "/api/v1/procedure-suggestions", red_lead_headers).json()[0]
|
||||
reject = api(
|
||||
"post", f"/api/v1/procedure-suggestions/{suggestion['id']}/reject", red_lead_headers,
|
||||
)
|
||||
assert reject.status_code == 200, reject.text
|
||||
assert reject.json()["status"] == "rejected"
|
||||
|
||||
reloaded = api("get", f"/api/v1/test-templates/{template['id']}", red_lead_headers)
|
||||
assert reloaded.json()["attack_procedure"] == "Run a discovery command on the target."
|
||||
|
||||
|
||||
def test_approving_a_later_suggestion_does_not_erase_an_earlier_approved_one(
|
||||
client, db, api, test_from_template, template, red_tech_headers, red_lead_headers,
|
||||
):
|
||||
"""Two separate rounds, each contributing a different command, must
|
||||
both survive in the template once their suggestions are approved —
|
||||
approving round 2's suggestion must not wipe out what round 1 added."""
|
||||
test_id = test_from_template
|
||||
api("post", f"/api/v1/tests/{test_id}/start-execution", red_tech_headers)
|
||||
api(
|
||||
"patch", f"/api/v1/tests/{test_id}/red", red_tech_headers,
|
||||
json={"procedure_text": "Ran discovery.\nwhoami /priv\nDone."},
|
||||
)
|
||||
_add_evidence(db, test_id, TeamSide.red)
|
||||
api("post", f"/api/v1/tests/{test_id}/submit-red", red_tech_headers)
|
||||
|
||||
first_suggestion = api("get", "/api/v1/procedure-suggestions", red_lead_headers).json()[0]
|
||||
api("post", f"/api/v1/procedure-suggestions/{first_suggestion['id']}/approve", red_lead_headers)
|
||||
|
||||
api(
|
||||
"post", f"/api/v1/tests/{test_id}/review-red", red_lead_headers,
|
||||
json={"decision": "reopen", "notes": "one more pass"},
|
||||
)
|
||||
api(
|
||||
"patch", f"/api/v1/tests/{test_id}/red", red_tech_headers,
|
||||
json={"procedure_text": "Ran a follow-up check.\nnltest /dsgetdc:corp\nDone."},
|
||||
)
|
||||
_add_evidence(db, test_id, TeamSide.red)
|
||||
api("post", f"/api/v1/tests/{test_id}/submit-red", red_tech_headers)
|
||||
|
||||
second_suggestion = api("get", "/api/v1/procedure-suggestions", red_lead_headers).json()[0]
|
||||
assert second_suggestion["suggested_text"] == "nltest /dsgetdc:corp"
|
||||
approve_second = api(
|
||||
"post", f"/api/v1/procedure-suggestions/{second_suggestion['id']}/approve", red_lead_headers,
|
||||
)
|
||||
assert approve_second.status_code == 200, approve_second.text
|
||||
|
||||
reloaded = api("get", f"/api/v1/test-templates/{template['id']}", red_lead_headers)
|
||||
assert reloaded.json()["attack_procedure"] == (
|
||||
"Run a discovery command on the target.\nwhoami /priv\nnltest /dsgetdc:corp"
|
||||
)
|
||||
|
||||
|
||||
def test_blue_lead_cannot_review_a_red_suggestion(
|
||||
client, db, api, test_from_template, red_tech_headers, red_lead_headers, blue_lead_headers,
|
||||
):
|
||||
test_id = test_from_template
|
||||
api("post", f"/api/v1/tests/{test_id}/start-execution", red_tech_headers)
|
||||
api(
|
||||
"patch", f"/api/v1/tests/{test_id}/red", red_tech_headers,
|
||||
json={"procedure_text": "Ran discovery.\nwhoami /priv\nDone."},
|
||||
)
|
||||
_add_evidence(db, test_id, TeamSide.red)
|
||||
api("post", f"/api/v1/tests/{test_id}/submit-red", red_tech_headers)
|
||||
|
||||
suggestion = api("get", "/api/v1/procedure-suggestions", red_lead_headers).json()[0]
|
||||
|
||||
# A blue_lead sees no red suggestions in their own queue...
|
||||
blue_view = api("get", "/api/v1/procedure-suggestions", blue_lead_headers)
|
||||
assert blue_view.json() == []
|
||||
|
||||
# ...and is forbidden from acting on one directly by id.
|
||||
forbidden = api(
|
||||
"post", f"/api/v1/procedure-suggestions/{suggestion['id']}/approve", blue_lead_headers,
|
||||
)
|
||||
assert forbidden.status_code == 403
|
||||
|
||||
|
||||
def test_duplicate_submission_does_not_create_a_second_pending_suggestion(
|
||||
client, db, api, test_from_template, red_tech_headers, red_lead_headers,
|
||||
):
|
||||
test_id = test_from_template
|
||||
api("post", f"/api/v1/tests/{test_id}/start-execution", red_tech_headers)
|
||||
api(
|
||||
"patch", f"/api/v1/tests/{test_id}/red", red_tech_headers,
|
||||
json={"procedure_text": "Ran discovery.\nwhoami /priv\nDone."},
|
||||
)
|
||||
_add_evidence(db, test_id, TeamSide.red)
|
||||
api("post", f"/api/v1/tests/{test_id}/submit-red", red_tech_headers)
|
||||
api(
|
||||
"post", f"/api/v1/tests/{test_id}/review-red", red_lead_headers,
|
||||
json={"decision": "reopen", "notes": "please redo this round"},
|
||||
)
|
||||
|
||||
api(
|
||||
"patch", f"/api/v1/tests/{test_id}/red", red_tech_headers,
|
||||
json={"procedure_text": "Ran discovery again.\nwhoami /priv\nStill works."},
|
||||
)
|
||||
_add_evidence(db, test_id, TeamSide.red)
|
||||
api("post", f"/api/v1/tests/{test_id}/submit-red", red_tech_headers)
|
||||
|
||||
listed = api("get", "/api/v1/procedure-suggestions", red_lead_headers).json()
|
||||
assert len(listed) == 1
|
||||
|
||||
|
||||
def test_lead_can_override_detect_procedure_when_creating_from_template(
|
||||
client, db, api, red_lead_headers, technique, template,
|
||||
):
|
||||
"""A lead editing Expected Detection in the create-from-template form
|
||||
seeds the new test's detect_procedure with their edit — same mechanism
|
||||
as procedure_text_override on the red side — without touching the
|
||||
template itself. The template only updates later, through the normal
|
||||
procedure-suggestion approval flow once a round is actually submitted."""
|
||||
resp = api(
|
||||
"post", "/api/v1/tests/from-template", red_lead_headers,
|
||||
json={
|
||||
"template_id": template["id"],
|
||||
"technique_id": technique,
|
||||
"detect_procedure": "Check Sysmon Event ID 1 for mimikatz.exe process creation.",
|
||||
},
|
||||
)
|
||||
assert resp.status_code == 201, resp.text
|
||||
assert resp.json()["detect_procedure"] == "Check Sysmon Event ID 1 for mimikatz.exe process creation."
|
||||
|
||||
reloaded_template = api("get", f"/api/v1/test-templates/{template['id']}", red_lead_headers)
|
||||
assert reloaded_template.json()["expected_detection"] == "Check process creation logs."
|
||||
|
||||
|
||||
def test_detect_procedure_defaults_to_template_expected_detection(
|
||||
client, db, api, red_lead_headers, technique, template,
|
||||
):
|
||||
resp = api(
|
||||
"post", "/api/v1/tests/from-template", red_lead_headers,
|
||||
json={"template_id": template["id"], "technique_id": technique},
|
||||
)
|
||||
assert resp.status_code == 201, resp.text
|
||||
assert resp.json()["detect_procedure"] == "Check process creation logs."
|
||||
|
||||
|
||||
class TestSuggestionsForTest:
|
||||
"""GET /procedure-suggestions/for-test/{test_id} — powers the blocking
|
||||
review popup a lead sees when opening a test with a pending suggestion."""
|
||||
|
||||
def test_returns_pending_suggestion_for_the_right_test(
|
||||
self, client, db, api, test_from_template, red_tech_headers, red_lead_headers,
|
||||
):
|
||||
test_id = test_from_template
|
||||
api("post", f"/api/v1/tests/{test_id}/start-execution", red_tech_headers)
|
||||
api(
|
||||
"patch", f"/api/v1/tests/{test_id}/red", red_tech_headers,
|
||||
json={"procedure_text": "Ran discovery.\nwhoami /priv\nDone."},
|
||||
)
|
||||
_add_evidence(db, test_id, TeamSide.red)
|
||||
api("post", f"/api/v1/tests/{test_id}/submit-red", red_tech_headers)
|
||||
|
||||
resp = api("get", f"/api/v1/procedure-suggestions/for-test/{test_id}", red_lead_headers)
|
||||
assert resp.status_code == 200, resp.text
|
||||
suggestions = resp.json()
|
||||
assert len(suggestions) == 1
|
||||
assert suggestions[0]["suggested_text"] == "whoami /priv"
|
||||
|
||||
def test_returns_empty_for_a_test_with_no_suggestion(
|
||||
self, client, db, api, test_from_template, red_lead_headers,
|
||||
):
|
||||
resp = api("get", f"/api/v1/procedure-suggestions/for-test/{test_from_template}", red_lead_headers)
|
||||
assert resp.status_code == 200, resp.text
|
||||
assert resp.json() == []
|
||||
|
||||
def test_blue_lead_does_not_see_a_red_suggestion_for_the_test(
|
||||
self, client, db, api, test_from_template, red_tech_headers, blue_lead_headers,
|
||||
):
|
||||
test_id = test_from_template
|
||||
api("post", f"/api/v1/tests/{test_id}/start-execution", red_tech_headers)
|
||||
api(
|
||||
"patch", f"/api/v1/tests/{test_id}/red", red_tech_headers,
|
||||
json={"procedure_text": "Ran discovery.\nwhoami /priv\nDone."},
|
||||
)
|
||||
_add_evidence(db, test_id, TeamSide.red)
|
||||
api("post", f"/api/v1/tests/{test_id}/submit-red", red_tech_headers)
|
||||
|
||||
resp = api("get", f"/api/v1/procedure-suggestions/for-test/{test_id}", blue_lead_headers)
|
||||
assert resp.status_code == 200, resp.text
|
||||
assert resp.json() == []
|
||||
|
||||
def test_suggestion_disappears_from_for_test_once_approved(
|
||||
self, client, db, api, test_from_template, red_tech_headers, red_lead_headers,
|
||||
):
|
||||
test_id = test_from_template
|
||||
api("post", f"/api/v1/tests/{test_id}/start-execution", red_tech_headers)
|
||||
api(
|
||||
"patch", f"/api/v1/tests/{test_id}/red", red_tech_headers,
|
||||
json={"procedure_text": "Ran discovery.\nwhoami /priv\nDone."},
|
||||
)
|
||||
_add_evidence(db, test_id, TeamSide.red)
|
||||
api("post", f"/api/v1/tests/{test_id}/submit-red", red_tech_headers)
|
||||
|
||||
suggestion = api("get", f"/api/v1/procedure-suggestions/for-test/{test_id}", red_lead_headers).json()[0]
|
||||
api("post", f"/api/v1/procedure-suggestions/{suggestion['id']}/approve", red_lead_headers)
|
||||
|
||||
resp = api("get", f"/api/v1/procedure-suggestions/for-test/{test_id}", red_lead_headers)
|
||||
assert resp.json() == []
|
||||
@@ -16,7 +16,8 @@ def test_purple_campaign_pdf_download(mock_gen, client, auth_headers, db):
|
||||
f.write(b"%PDF-1.4 fake")
|
||||
mock_gen.return_value = fake_pdf
|
||||
|
||||
with patch.object(settings, "REPORT_OUTPUT_DIR", tmpdir):
|
||||
with patch.object(settings, "REPORT_OUTPUT_DIR", tmpdir), \
|
||||
patch.object(settings, "REPORTS_ENABLED", True):
|
||||
campaign = Campaign(name="Export Camp", status="active")
|
||||
db.add(campaign)
|
||||
db.commit()
|
||||
@@ -38,7 +39,8 @@ def test_coverage_summary_html(mock_gen, client, auth_headers):
|
||||
f.write("<html><body>ok</body></html>")
|
||||
mock_gen.return_value = fake_html
|
||||
|
||||
with patch.object(settings, "REPORT_OUTPUT_DIR", tmpdir):
|
||||
with patch.object(settings, "REPORT_OUTPUT_DIR", tmpdir), \
|
||||
patch.object(settings, "REPORTS_ENABLED", True):
|
||||
r = client.get(
|
||||
"/api/v1/reports/generate/coverage-summary",
|
||||
params={"format": "html"},
|
||||
@@ -46,3 +48,15 @@ def test_coverage_summary_html(mock_gen, client, auth_headers):
|
||||
)
|
||||
assert r.status_code == 200
|
||||
assert "text/html" in r.headers["content-type"]
|
||||
|
||||
|
||||
def test_reports_disabled_by_default_returns_503(client, auth_headers):
|
||||
"""Reports are unfinished — the frontend hides the UI entirely, and the
|
||||
backend must refuse cleanly rather than let a half-working render
|
||||
pipeline (e.g. missing weasyprint system deps) surface a raw 500."""
|
||||
r = client.get(
|
||||
"/api/v1/reports/generate/coverage-summary",
|
||||
params={"format": "pdf"},
|
||||
headers=auth_headers,
|
||||
)
|
||||
assert r.status_code == 503
|
||||
|
||||
@@ -36,7 +36,7 @@ def _reach_disputed(client, db, api, auth_headers, red_tech_headers, red_lead_he
|
||||
blue_tech_headers, blue_lead_headers, technique):
|
||||
"""Drive a fresh test all the way to disputed: red approves, blue rejects."""
|
||||
resp = api(
|
||||
"post", "/api/v1/tests", auth_headers,
|
||||
"post", "/api/v1/tests", red_lead_headers,
|
||||
json={"technique_id": technique, "name": "Dispute test"},
|
||||
)
|
||||
test_id = resp.json()["id"]
|
||||
@@ -112,3 +112,96 @@ def test_resolve_dispute_routes_to_red_queue(
|
||||
assert body["state"] == "red_executing"
|
||||
assert body["red_validation_status"] is None
|
||||
assert body["blue_validation_status"] is None
|
||||
|
||||
|
||||
def test_escalate_to_manager_notifies_managers(
|
||||
client, db, api, auth_headers, red_tech_headers, red_lead_headers,
|
||||
blue_tech_headers, blue_lead_headers, manager_headers, manager_user, technique,
|
||||
):
|
||||
test_id = _reach_disputed(client, db, api, auth_headers, red_tech_headers, red_lead_headers,
|
||||
blue_tech_headers, blue_lead_headers, technique)
|
||||
|
||||
resp = api("post", f"/api/v1/tests/{test_id}/escalate-to-manager", red_lead_headers)
|
||||
assert resp.status_code == 200, resp.text
|
||||
assert resp.json()["status"] == "escalated"
|
||||
|
||||
notifications = api("get", "/api/v1/notifications", manager_headers).json()
|
||||
assert any("escalated" in (n.get("title") or "").lower() for n in notifications)
|
||||
|
||||
|
||||
def test_escalate_to_manager_requires_disputed_state(
|
||||
client, db, api, auth_headers, red_tech_headers, red_lead_headers, technique,
|
||||
):
|
||||
resp = api(
|
||||
"post", "/api/v1/tests", red_lead_headers,
|
||||
json={"technique_id": technique, "name": "Not disputed"},
|
||||
)
|
||||
test_id = resp.json()["id"]
|
||||
|
||||
resp = api("post", f"/api/v1/tests/{test_id}/escalate-to-manager", red_lead_headers)
|
||||
assert resp.status_code == 400
|
||||
|
||||
|
||||
def test_manager_resolve_dispute_as_validated(
|
||||
client, db, api, auth_headers, red_tech_headers, red_lead_headers,
|
||||
blue_tech_headers, blue_lead_headers, manager_headers, technique,
|
||||
):
|
||||
test_id = _reach_disputed(client, db, api, auth_headers, red_tech_headers, red_lead_headers,
|
||||
blue_tech_headers, blue_lead_headers, technique)
|
||||
|
||||
resp = api(
|
||||
"post", f"/api/v1/tests/{test_id}/manager-resolve-dispute", manager_headers,
|
||||
json={"outcome": "validated", "notes": "Detection was sufficient after all"},
|
||||
)
|
||||
assert resp.status_code == 200, resp.text
|
||||
assert resp.json()["state"] == "validated"
|
||||
|
||||
red_notifs = api("get", "/api/v1/notifications", red_lead_headers).json()
|
||||
assert any(n["type"] == "manager_dispute_resolution" for n in red_notifs)
|
||||
blue_notifs = api("get", "/api/v1/notifications", blue_lead_headers).json()
|
||||
assert any(n["type"] == "manager_dispute_resolution" for n in blue_notifs)
|
||||
|
||||
|
||||
def test_manager_resolve_dispute_as_rejected(
|
||||
client, db, api, auth_headers, red_tech_headers, red_lead_headers,
|
||||
blue_tech_headers, blue_lead_headers, manager_headers, technique,
|
||||
):
|
||||
test_id = _reach_disputed(client, db, api, auth_headers, red_tech_headers, red_lead_headers,
|
||||
blue_tech_headers, blue_lead_headers, technique)
|
||||
|
||||
resp = api(
|
||||
"post", f"/api/v1/tests/{test_id}/manager-resolve-dispute", manager_headers,
|
||||
json={"outcome": "rejected"},
|
||||
)
|
||||
assert resp.status_code == 200, resp.text
|
||||
assert resp.json()["state"] == "rejected"
|
||||
|
||||
|
||||
def test_manager_resolve_dispute_forbidden_for_lead(
|
||||
client, db, api, auth_headers, red_tech_headers, red_lead_headers,
|
||||
blue_tech_headers, blue_lead_headers, technique,
|
||||
):
|
||||
test_id = _reach_disputed(client, db, api, auth_headers, red_tech_headers, red_lead_headers,
|
||||
blue_tech_headers, blue_lead_headers, technique)
|
||||
|
||||
resp = api(
|
||||
"post", f"/api/v1/tests/{test_id}/manager-resolve-dispute", red_lead_headers,
|
||||
json={"outcome": "validated"},
|
||||
)
|
||||
assert resp.status_code == 403
|
||||
|
||||
|
||||
def test_manager_resolve_dispute_requires_disputed_state(
|
||||
client, db, api, red_lead_headers, manager_headers, technique,
|
||||
):
|
||||
resp = api(
|
||||
"post", "/api/v1/tests", red_lead_headers,
|
||||
json={"technique_id": technique, "name": "Not disputed either"},
|
||||
)
|
||||
test_id = resp.json()["id"]
|
||||
|
||||
resp = api(
|
||||
"post", f"/api/v1/tests/{test_id}/manager-resolve-dispute", manager_headers,
|
||||
json={"outcome": "validated"},
|
||||
)
|
||||
assert resp.status_code == 400
|
||||
|
||||
@@ -86,7 +86,7 @@ def test_review_red_forbidden_for_non_assigned_lead(
|
||||
client, db, api, auth_headers, red_tech_headers, red_lead_headers, red_lead_user, technique
|
||||
):
|
||||
"""A red_lead who isn't the assigned reviewer gets 403."""
|
||||
test_id = _reach_red_review(client, db, api, auth_headers, red_tech_headers, technique)
|
||||
test_id = _reach_red_review(client, db, api, red_lead_headers, red_tech_headers, technique)
|
||||
# red_lead_user IS the only red_lead fixture, so it WILL be auto-assigned
|
||||
# as reviewer (single-candidate load balancing). To exercise the 403
|
||||
# path we need a second, non-assigned red_lead.
|
||||
@@ -101,7 +101,7 @@ def test_review_red_forbidden_for_non_assigned_lead(
|
||||
db.add(other_lead)
|
||||
db.commit()
|
||||
|
||||
login = client.post("/api/v1/auth/login", data={"username": "otherredlead", "password": "x"})
|
||||
login = client.post("/api/v1/auth/login", data={"username": "otherredlead@test.com", "password": "x"})
|
||||
assert login.status_code == 200
|
||||
other_headers = {"Authorization": f"Bearer {login.json()['access_token']}"}
|
||||
|
||||
@@ -112,7 +112,7 @@ def test_review_red_forbidden_for_non_assigned_lead(
|
||||
def test_review_red_approve_moves_to_blue_evaluating(
|
||||
client, db, api, auth_headers, red_tech_headers, red_lead_headers, red_lead_user, technique
|
||||
):
|
||||
test_id = _reach_red_review(client, db, api, auth_headers, red_tech_headers, technique)
|
||||
test_id = _reach_red_review(client, db, api, red_lead_headers, red_tech_headers, technique)
|
||||
|
||||
resp = api("post", f"/api/v1/tests/{test_id}/review-red", red_lead_headers, json={"decision": "approve", "notes": "LGTM"})
|
||||
assert resp.status_code == 200, resp.text
|
||||
@@ -125,7 +125,7 @@ def test_review_red_approve_moves_to_blue_evaluating(
|
||||
def test_review_red_reopen_requires_notes(
|
||||
client, db, api, auth_headers, red_tech_headers, red_lead_headers, red_lead_user, technique
|
||||
):
|
||||
test_id = _reach_red_review(client, db, api, auth_headers, red_tech_headers, technique)
|
||||
test_id = _reach_red_review(client, db, api, red_lead_headers, red_tech_headers, technique)
|
||||
|
||||
resp = api("post", f"/api/v1/tests/{test_id}/review-red", red_lead_headers, json={"decision": "reopen"})
|
||||
assert resp.status_code == 400
|
||||
@@ -134,7 +134,7 @@ def test_review_red_reopen_requires_notes(
|
||||
def test_review_red_reopen_moves_to_red_executing(
|
||||
client, db, api, auth_headers, red_tech_headers, red_lead_headers, red_lead_user, technique
|
||||
):
|
||||
test_id = _reach_red_review(client, db, api, auth_headers, red_tech_headers, technique)
|
||||
test_id = _reach_red_review(client, db, api, red_lead_headers, red_tech_headers, technique)
|
||||
|
||||
resp = api("post", f"/api/v1/tests/{test_id}/review-red", red_lead_headers, json={"decision": "reopen", "notes": "add more detail"})
|
||||
assert resp.status_code == 200, resp.text
|
||||
@@ -143,14 +143,15 @@ def test_review_red_reopen_moves_to_red_executing(
|
||||
assert body["red_review_notes"] == "add more detail"
|
||||
|
||||
|
||||
def test_review_red_admin_can_always_act(
|
||||
client, db, api, auth_headers, red_tech_headers, red_lead_user, technique
|
||||
def test_review_red_forbidden_for_admin(
|
||||
client, db, api, auth_headers, red_tech_headers, red_lead_headers, red_lead_user, technique
|
||||
):
|
||||
test_id = _reach_red_review(client, db, api, auth_headers, red_tech_headers, technique)
|
||||
"""Admin administers the site, not the test workflow — reviewing is a
|
||||
red_lead-only action now, with no admin override."""
|
||||
test_id = _reach_red_review(client, db, api, red_lead_headers, red_tech_headers, technique)
|
||||
|
||||
resp = api("post", f"/api/v1/tests/{test_id}/review-red", auth_headers, json={"decision": "approve"})
|
||||
assert resp.status_code == 200, resp.text
|
||||
assert resp.json()["state"] == "blue_evaluating"
|
||||
assert resp.status_code == 403
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -162,7 +163,7 @@ def test_review_blue_forbidden_for_non_assigned_lead(
|
||||
client, db, api, auth_headers, red_tech_headers, red_lead_headers, blue_tech_headers, blue_lead_headers,
|
||||
red_lead_user, blue_lead_user, technique,
|
||||
):
|
||||
test_id = _reach_blue_review(client, db, api, auth_headers, red_tech_headers, red_lead_headers, blue_tech_headers, technique)
|
||||
test_id = _reach_blue_review(client, db, api, red_lead_headers, red_tech_headers, red_lead_headers, blue_tech_headers, technique)
|
||||
|
||||
from app.auth import hash_password
|
||||
from app.models.user import User
|
||||
@@ -175,7 +176,7 @@ def test_review_blue_forbidden_for_non_assigned_lead(
|
||||
db.add(other_lead)
|
||||
db.commit()
|
||||
|
||||
login = client.post("/api/v1/auth/login", data={"username": "otherbluelead", "password": "x"})
|
||||
login = client.post("/api/v1/auth/login", data={"username": "otherbluelead@test.com", "password": "x"})
|
||||
other_headers = {"Authorization": f"Bearer {login.json()['access_token']}"}
|
||||
|
||||
resp = api("post", f"/api/v1/tests/{test_id}/review-blue", other_headers, json={"decision": "approve"})
|
||||
@@ -186,7 +187,7 @@ def test_review_blue_approve_moves_to_in_review(
|
||||
client, db, api, auth_headers, red_tech_headers, red_lead_headers, blue_tech_headers, blue_lead_headers,
|
||||
red_lead_user, blue_lead_user, technique,
|
||||
):
|
||||
test_id = _reach_blue_review(client, db, api, auth_headers, red_tech_headers, red_lead_headers, blue_tech_headers, technique)
|
||||
test_id = _reach_blue_review(client, db, api, red_lead_headers, red_tech_headers, red_lead_headers, blue_tech_headers, technique)
|
||||
|
||||
resp = api("post", f"/api/v1/tests/{test_id}/review-blue", blue_lead_headers, json={"decision": "approve"})
|
||||
assert resp.status_code == 200, resp.text
|
||||
@@ -197,7 +198,7 @@ def test_review_blue_reopen_requires_notes(
|
||||
client, db, api, auth_headers, red_tech_headers, red_lead_headers, blue_tech_headers, blue_lead_headers,
|
||||
red_lead_user, blue_lead_user, technique,
|
||||
):
|
||||
test_id = _reach_blue_review(client, db, api, auth_headers, red_tech_headers, red_lead_headers, blue_tech_headers, technique)
|
||||
test_id = _reach_blue_review(client, db, api, red_lead_headers, red_tech_headers, red_lead_headers, blue_tech_headers, technique)
|
||||
|
||||
resp = api("post", f"/api/v1/tests/{test_id}/review-blue", blue_lead_headers, json={"decision": "reopen"})
|
||||
assert resp.status_code == 400
|
||||
@@ -207,7 +208,7 @@ def test_review_blue_reopen_moves_to_blue_evaluating(
|
||||
client, db, api, auth_headers, red_tech_headers, red_lead_headers, blue_tech_headers, blue_lead_headers,
|
||||
red_lead_user, blue_lead_user, technique,
|
||||
):
|
||||
test_id = _reach_blue_review(client, db, api, auth_headers, red_tech_headers, red_lead_headers, blue_tech_headers, technique)
|
||||
test_id = _reach_blue_review(client, db, api, red_lead_headers, red_tech_headers, red_lead_headers, blue_tech_headers, technique)
|
||||
|
||||
resp = api("post", f"/api/v1/tests/{test_id}/review-blue", blue_lead_headers, json={"decision": "reopen", "notes": "redo detection"})
|
||||
assert resp.status_code == 200, resp.text
|
||||
@@ -220,7 +221,7 @@ def test_review_blue_gap_requires_system_gaps(
|
||||
client, db, api, auth_headers, red_tech_headers, red_lead_headers, blue_tech_headers, blue_lead_headers,
|
||||
red_lead_user, blue_lead_user, technique,
|
||||
):
|
||||
test_id = _reach_blue_review(client, db, api, auth_headers, red_tech_headers, red_lead_headers, blue_tech_headers, technique)
|
||||
test_id = _reach_blue_review(client, db, api, red_lead_headers, red_tech_headers, red_lead_headers, blue_tech_headers, technique)
|
||||
|
||||
resp = api("post", f"/api/v1/tests/{test_id}/review-blue", blue_lead_headers, json={"decision": "gap"})
|
||||
assert resp.status_code == 400
|
||||
@@ -230,7 +231,7 @@ def test_review_blue_gap_moves_to_in_review_with_system_gaps(
|
||||
client, db, api, auth_headers, red_tech_headers, red_lead_headers, blue_tech_headers, blue_lead_headers,
|
||||
red_lead_user, blue_lead_user, technique,
|
||||
):
|
||||
test_id = _reach_blue_review(client, db, api, auth_headers, red_tech_headers, red_lead_headers, blue_tech_headers, technique)
|
||||
test_id = _reach_blue_review(client, db, api, red_lead_headers, red_tech_headers, red_lead_headers, blue_tech_headers, technique)
|
||||
|
||||
resp = api(
|
||||
"post", f"/api/v1/tests/{test_id}/review-blue", blue_lead_headers,
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
"""Manager must have the same Review Queue access as red_lead/blue_lead —
|
||||
mark_reviewed was previously red_lead/blue_lead (and admin) only."""
|
||||
|
||||
|
||||
def test_manager_can_mark_technique_reviewed(client, db, api, auth_headers, manager_headers):
|
||||
resp = api(
|
||||
"post", "/api/v1/techniques", auth_headers,
|
||||
json={"mitre_id": "T1059.400", "name": "Manager Review Access Technique"},
|
||||
)
|
||||
assert resp.status_code == 201, resp.text
|
||||
|
||||
resp = api("patch", "/api/v1/techniques/T1059.400/review", manager_headers)
|
||||
assert resp.status_code == 200, resp.text
|
||||
assert resp.json()["review_required"] is False
|
||||
|
||||
|
||||
def test_red_tech_cannot_mark_technique_reviewed(client, db, api, auth_headers, red_tech_headers):
|
||||
resp = api(
|
||||
"post", "/api/v1/techniques", auth_headers,
|
||||
json={"mitre_id": "T1059.401", "name": "Red Tech No Access Technique"},
|
||||
)
|
||||
assert resp.status_code == 201, resp.text
|
||||
|
||||
resp = api("patch", "/api/v1/techniques/T1059.401/review", red_tech_headers)
|
||||
assert resp.status_code == 403
|
||||
@@ -0,0 +1,19 @@
|
||||
"""GET /scores/history returns a list of weekly data points — regression
|
||||
test for a response-model mismatch (endpoint annotated -> dict while the
|
||||
service actually returns a list, causing a raw 500 on serialization)."""
|
||||
|
||||
|
||||
def test_scores_history_returns_a_list(client, auth_headers):
|
||||
resp = client.get("/api/v1/scores/history", headers=auth_headers)
|
||||
assert resp.status_code == 200, resp.text
|
||||
body = resp.json()
|
||||
assert isinstance(body, list)
|
||||
|
||||
|
||||
def test_scores_history_accepts_all_periods(client, auth_headers):
|
||||
for period in ("30d", "90d", "1y"):
|
||||
resp = client.get(
|
||||
"/api/v1/scores/history", params={"period": period}, headers=auth_headers,
|
||||
)
|
||||
assert resp.status_code == 200, resp.text
|
||||
assert isinstance(resp.json(), list)
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user