Compare commits
37 Commits
8fcd733d4a
...
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 |
@@ -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,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."
|
||||
)
|
||||
|
||||
@@ -672,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), "
|
||||
|
||||
@@ -44,6 +44,7 @@ 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
|
||||
@@ -215,6 +216,7 @@ 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()
|
||||
|
||||
@@ -46,7 +46,10 @@ 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__ = [
|
||||
@@ -90,4 +93,7 @@ __all__ = [
|
||||
"AlertInstance",
|
||||
"TestRoundHistory",
|
||||
"ProcedureSuggestion",
|
||||
"TemplateSuggestion",
|
||||
"PasswordSetupToken",
|
||||
"EvaluationImport",
|
||||
]
|
||||
|
||||
@@ -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,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])
|
||||
@@ -41,13 +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
|
||||
# Suggested detection procedure — Blue's counterpart to attack_procedure.
|
||||
# Only ever filled in via an approved procedure suggestion or a lead
|
||||
# editing the template directly; external syncs never touch it (those
|
||||
# only insert brand-new rows, never update existing ones).
|
||||
detect_suggested_procedure = Column(Text, nullable=True)
|
||||
# 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.
|
||||
|
||||
|
||||
@@ -52,7 +52,7 @@ def _to_out(suggestion, template_by_id: dict) -> ProcedureSuggestionOut:
|
||||
if template is not None:
|
||||
out.template_name = template.name
|
||||
out.template_current_text = (
|
||||
template.attack_procedure if suggestion.team == "red" else template.detect_suggested_procedure
|
||||
template.attack_procedure if suggestion.team == "red" else template.expected_detection
|
||||
)
|
||||
return out
|
||||
|
||||
@@ -73,6 +73,25 @@ def list_suggestions(
|
||||
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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -344,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")
|
||||
@@ -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)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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,
|
||||
@@ -152,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,
|
||||
@@ -371,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_strict("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.
|
||||
|
||||
@@ -402,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(
|
||||
@@ -540,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
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -1191,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
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -1310,6 +1422,21 @@ 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,
|
||||
@@ -1330,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")
|
||||
|
||||
@@ -1370,6 +1499,8 @@ 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
|
||||
@@ -1683,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
|
||||
@@ -1721,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,
|
||||
}
|
||||
|
||||
+154
-12
@@ -1,5 +1,8 @@
|
||||
"""User management router (admin only)."""
|
||||
|
||||
# Import logging
|
||||
import logging
|
||||
|
||||
# Import uuid
|
||||
import uuid
|
||||
|
||||
@@ -17,24 +20,38 @@ 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, UserOperatorOut
|
||||
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,13 +185,36 @@ 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
|
||||
|
||||
@@ -216,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)
|
||||
@@ -241,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,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)
|
||||
@@ -185,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) ────────────────────────────
|
||||
|
||||
|
||||
|
||||
@@ -31,7 +31,6 @@ class TestTemplateOut(BaseModel):
|
||||
attack_procedure: str | None = None
|
||||
# Assign expected_detection = None
|
||||
expected_detection: str | None = None
|
||||
detect_suggested_procedure: str | None = None
|
||||
# Assign platform = None
|
||||
platform: str | None = None
|
||||
# Assign tool_suggested = None
|
||||
@@ -71,7 +70,6 @@ class TestTemplateCreate(BaseModel):
|
||||
attack_procedure: str | None = None
|
||||
# Assign expected_detection = None
|
||||
expected_detection: str | None = None
|
||||
detect_suggested_procedure: str | None = None
|
||||
# Assign platform = None
|
||||
platform: str | None = None
|
||||
# Assign tool_suggested = None
|
||||
@@ -132,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
|
||||
|
||||
+85
-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
|
||||
@@ -291,6 +307,42 @@ class UserOperatorOut(BaseModel):
|
||||
|
||||
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",
|
||||
})
|
||||
|
||||
|
||||
|
||||
@@ -506,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'}",
|
||||
@@ -526,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'}",
|
||||
@@ -689,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,
|
||||
@@ -933,6 +980,8 @@ def push_test_event(
|
||||
"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()
|
||||
|
||||
@@ -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
|
||||
@@ -48,7 +48,9 @@ _COMMAND_LINE_RE = re.compile(
|
||||
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)\b
|
||||
Invoke-\S+|iex|dsquery|nltest|
|
||||
sysmon\S*|wevtutil|auditpol|tcpdump|tshark|procmon\S*|autorunsc\S*|
|
||||
volatility\S*)\b
|
||||
|
|
||||
\bSELECT\s+.+\s+FROM\b # SQL
|
||||
|
|
||||
|
||||
@@ -28,7 +28,7 @@ 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": "detect_suggested_procedure"}
|
||||
_TEMPLATE_FIELD = {"red": "attack_procedure", "blue": "expected_detection"}
|
||||
_LEAD_ROLE = {"red": "red_lead", "blue": "blue_lead"}
|
||||
|
||||
|
||||
@@ -131,11 +131,17 @@ def create_suggestion_from_submission(db: Session, test: Test, team: str) -> Pro
|
||||
return suggestion
|
||||
|
||||
|
||||
def list_pending_suggestions(db: Session, *, team: str | None = None) -> list[ProcedureSuggestion]:
|
||||
"""List pending suggestions, optionally filtered to one team."""
|
||||
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()
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -304,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.
|
||||
|
||||
@@ -364,7 +365,7 @@ 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=template.detect_suggested_procedure,
|
||||
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,
|
||||
@@ -437,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)
|
||||
|
||||
# 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),
|
||||
)
|
||||
query = _build_template_query(
|
||||
db, source=source, platform=platform, severity=severity,
|
||||
mitre_technique_id=mitre_technique_id, search=search, is_active=is_active,
|
||||
)
|
||||
|
||||
# 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()
|
||||
|
||||
@@ -39,6 +39,7 @@ 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__)
|
||||
@@ -1048,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
|
||||
|
||||
@@ -1113,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
|
||||
|
||||
@@ -1147,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":
|
||||
@@ -1178,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)
|
||||
|
||||
@@ -1198,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)
|
||||
|
||||
@@ -1341,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
|
||||
|
||||
@@ -39,6 +39,8 @@ DUMMY_ID = str(uuid.uuid4())
|
||||
("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):
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
# ═══════════════════════════════════════════════════════════════════════
|
||||
|
||||
@@ -137,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)
|
||||
|
||||
@@ -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)
|
||||
@@ -31,6 +31,20 @@ def _seed_executing_test(db, technique, created_by) -> 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)
|
||||
@@ -88,3 +102,49 @@ def test_hold_does_not_double_pause_an_already_paused_timer(client, db, red_tech
|
||||
|
||||
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:
|
||||
|
||||
@@ -72,6 +72,7 @@ def _make_test(**overrides):
|
||||
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
|
||||
@@ -232,6 +233,68 @@ 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):
|
||||
@@ -302,6 +365,45 @@ def test_push_test_event_clears_assignee_on_in_review(mock_get_client, mock_conf
|
||||
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):
|
||||
|
||||
@@ -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
|
||||
@@ -95,3 +95,15 @@ 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:*"
|
||||
|
||||
@@ -45,7 +45,7 @@ def template(api, red_lead_headers, technique):
|
||||
"mitre_technique_id": "T1059.200",
|
||||
"name": "Suggestion source template",
|
||||
"attack_procedure": "Run a discovery command on the target.",
|
||||
"detect_suggested_procedure": "Check process creation logs.",
|
||||
"expected_detection": "Check process creation logs.",
|
||||
},
|
||||
)
|
||||
assert resp.status_code == 201, resp.text
|
||||
@@ -272,3 +272,101 @@ def test_duplicate_submission_does_not_create_a_second_pending_suggestion(
|
||||
|
||||
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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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']}"}
|
||||
|
||||
@@ -176,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"})
|
||||
|
||||
@@ -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)
|
||||
@@ -10,7 +10,7 @@ if backend_dir not in sys.path:
|
||||
import pytest
|
||||
from pydantic import ValidationError
|
||||
|
||||
from app.schemas.user import UserCreate
|
||||
from app.schemas.user import LegacyUserCreateWithPassword as UserCreate
|
||||
|
||||
|
||||
# ── Username validation ──────────────────────────────────────────────
|
||||
|
||||
@@ -103,6 +103,8 @@ if "apscheduler" not in sys.modules:
|
||||
sys.modules["apscheduler.triggers"] = ModuleType("apscheduler.triggers")
|
||||
sys.modules["apscheduler.triggers.cron"] = ModuleType("apscheduler.triggers.cron")
|
||||
sys.modules["apscheduler.triggers.cron"].CronTrigger = MagicMock
|
||||
sys.modules["apscheduler.events"] = ModuleType("apscheduler.events")
|
||||
sys.modules["apscheduler.events"].EVENT_JOB_ERROR = 1
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Imports
|
||||
|
||||
@@ -100,6 +100,8 @@ if "apscheduler" not in sys.modules:
|
||||
sys.modules["apscheduler.triggers"] = ModuleType("apscheduler.triggers")
|
||||
sys.modules["apscheduler.triggers.cron"] = ModuleType("apscheduler.triggers.cron")
|
||||
sys.modules["apscheduler.triggers.cron"].CronTrigger = MagicMock
|
||||
sys.modules["apscheduler.events"] = ModuleType("apscheduler.events")
|
||||
sys.modules["apscheduler.events"].EVENT_JOB_ERROR = 1
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Imports
|
||||
|
||||
@@ -76,6 +76,7 @@ for mod_name in [
|
||||
"apscheduler", "apscheduler.schedulers",
|
||||
"apscheduler.schedulers.background",
|
||||
"apscheduler.triggers", "apscheduler.triggers.cron",
|
||||
"apscheduler.events",
|
||||
]:
|
||||
if mod_name not in sys.modules:
|
||||
m = ModuleType(mod_name)
|
||||
@@ -92,6 +93,8 @@ for mod_name in [
|
||||
m.BackgroundScheduler = MagicMock
|
||||
elif mod_name == "apscheduler.triggers.cron":
|
||||
m.CronTrigger = MagicMock
|
||||
elif mod_name == "apscheduler.events":
|
||||
m.EVENT_JOB_ERROR = 1
|
||||
sys.modules[mod_name] = m
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -74,6 +74,7 @@ for mod_name in [
|
||||
"apscheduler", "apscheduler.schedulers",
|
||||
"apscheduler.schedulers.background",
|
||||
"apscheduler.triggers", "apscheduler.triggers.cron",
|
||||
"apscheduler.events",
|
||||
]:
|
||||
if mod_name not in sys.modules:
|
||||
m = ModuleType(mod_name)
|
||||
@@ -83,6 +84,7 @@ for mod_name in [
|
||||
elif mod_name == "botocore.exceptions": m.ClientError = Exception
|
||||
elif mod_name == "apscheduler.schedulers.background": m.BackgroundScheduler = MagicMock
|
||||
elif mod_name == "apscheduler.triggers.cron": m.CronTrigger = MagicMock
|
||||
elif mod_name == "apscheduler.events": m.EVENT_JOB_ERROR = 1
|
||||
sys.modules[mod_name] = m
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -74,6 +74,7 @@ for mod_name in [
|
||||
"apscheduler", "apscheduler.schedulers",
|
||||
"apscheduler.schedulers.background",
|
||||
"apscheduler.triggers", "apscheduler.triggers.cron",
|
||||
"apscheduler.events",
|
||||
]:
|
||||
if mod_name not in sys.modules:
|
||||
m = ModuleType(mod_name)
|
||||
@@ -83,6 +84,7 @@ for mod_name in [
|
||||
elif mod_name == "botocore.exceptions": m.ClientError = Exception
|
||||
elif mod_name == "apscheduler.schedulers.background": m.BackgroundScheduler = MagicMock
|
||||
elif mod_name == "apscheduler.triggers.cron": m.CronTrigger = MagicMock
|
||||
elif mod_name == "apscheduler.events": m.EVENT_JOB_ERROR = 1
|
||||
sys.modules[mod_name] = m
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -182,11 +184,14 @@ def test_soft_delete():
|
||||
|
||||
|
||||
def test_search_filter():
|
||||
from app.routers.test_templates import list_templates
|
||||
source = inspect.getsource(list_templates)
|
||||
from app.services.test_template_service import _build_template_query, list_templates
|
||||
# The actual filter-building (including search) now lives in the shared
|
||||
# _build_template_query() helper, reused by both list and count.
|
||||
source = inspect.getsource(_build_template_query)
|
||||
assert "search" in source
|
||||
assert "name" in source and "description" in source
|
||||
assert "ilike" in source
|
||||
assert "_build_template_query" in inspect.getsource(list_templates)
|
||||
print(" [PASS] Search filter searches in name and description")
|
||||
|
||||
|
||||
|
||||
@@ -78,6 +78,7 @@ for mod_name in [
|
||||
"apscheduler", "apscheduler.schedulers",
|
||||
"apscheduler.schedulers.background",
|
||||
"apscheduler.triggers", "apscheduler.triggers.cron",
|
||||
"apscheduler.events",
|
||||
]:
|
||||
if mod_name not in sys.modules:
|
||||
m = ModuleType(mod_name)
|
||||
@@ -87,6 +88,7 @@ for mod_name in [
|
||||
elif mod_name == "botocore.exceptions": m.ClientError = Exception
|
||||
elif mod_name == "apscheduler.schedulers.background": m.BackgroundScheduler = MagicMock
|
||||
elif mod_name == "apscheduler.triggers.cron": m.CronTrigger = MagicMock
|
||||
elif mod_name == "apscheduler.events": m.EVENT_JOB_ERROR = 1
|
||||
sys.modules[mod_name] = m
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
"""GET /test-templates/count — total matching the same filters as the list
|
||||
endpoint, so the catalog UI can compute a true page count."""
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def technique(api, auth_headers):
|
||||
resp = api(
|
||||
"post", "/api/v1/techniques", auth_headers,
|
||||
json={"mitre_id": "T1059.300", "name": "Count Endpoint Technique"},
|
||||
)
|
||||
assert resp.status_code == 201, resp.text
|
||||
return resp.json()["id"]
|
||||
|
||||
|
||||
def _create_template(api, headers, mitre_technique_id, name):
|
||||
resp = api(
|
||||
"post", "/api/v1/test-templates", headers,
|
||||
json={"mitre_technique_id": mitre_technique_id, "name": name},
|
||||
)
|
||||
assert resp.status_code == 201, resp.text
|
||||
return resp.json()["id"]
|
||||
|
||||
|
||||
def test_count_matches_number_of_matching_templates(
|
||||
client, db, api, auth_headers, red_lead_headers, technique,
|
||||
):
|
||||
_create_template(api, red_lead_headers, "T1059.300", "Count test template A")
|
||||
_create_template(api, red_lead_headers, "T1059.300", "Count test template B")
|
||||
|
||||
resp = api(
|
||||
"get", "/api/v1/test-templates/count", auth_headers,
|
||||
params={"mitre_technique_id": "T1059.300"},
|
||||
)
|
||||
assert resp.status_code == 200, resp.text
|
||||
assert resp.json()["total"] == 2
|
||||
|
||||
|
||||
def test_count_respects_search_filter(client, db, api, auth_headers, red_lead_headers, technique):
|
||||
_create_template(api, red_lead_headers, "T1059.300", "Unique Zebra Template")
|
||||
_create_template(api, red_lead_headers, "T1059.300", "Something else entirely")
|
||||
|
||||
resp = api("get", "/api/v1/test-templates/count", auth_headers, params={"search": "Zebra"})
|
||||
assert resp.status_code == 200, resp.text
|
||||
assert resp.json()["total"] == 1
|
||||
|
||||
|
||||
def test_count_matches_list_length_when_under_page_size(
|
||||
client, db, api, auth_headers, red_lead_headers, technique,
|
||||
):
|
||||
_create_template(api, red_lead_headers, "T1059.300", "Parity check template")
|
||||
|
||||
listed = api(
|
||||
"get", "/api/v1/test-templates", auth_headers,
|
||||
params={"mitre_technique_id": "T1059.300"},
|
||||
)
|
||||
counted = api(
|
||||
"get", "/api/v1/test-templates/count", auth_headers,
|
||||
params={"mitre_technique_id": "T1059.300"},
|
||||
)
|
||||
assert len(listed.json()) == counted.json()["total"]
|
||||
|
||||
|
||||
def test_count_accessible_to_any_authenticated_role(client, db, api, red_tech_headers):
|
||||
"""Pagination is a basic UX need, not a lead-only privilege like /stats."""
|
||||
resp = api("get", "/api/v1/test-templates/count", red_tech_headers)
|
||||
assert resp.status_code == 200, resp.text
|
||||
@@ -0,0 +1,145 @@
|
||||
"""End-to-end coverage for the operator template-proposal review workflow.
|
||||
|
||||
Leads create test templates directly via POST /test-templates. An operator
|
||||
(red_tech/blue_tech) gets the same "propose a template" action, but their
|
||||
submission lands in the review queue instead of the live catalog — a lead
|
||||
on their team approves (optionally editing fields) or discards it.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
def _payload(**overrides):
|
||||
base = {
|
||||
"mitre_technique_id": "T1059.300",
|
||||
"name": "Operator-proposed template",
|
||||
"attack_procedure": "Run a discovery command.",
|
||||
"expected_detection": "Check process creation logs.",
|
||||
}
|
||||
base.update(overrides)
|
||||
return base
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def technique(api, auth_headers):
|
||||
resp = api(
|
||||
"post", "/api/v1/techniques", auth_headers,
|
||||
json={"mitre_id": "T1059.300", "name": "Command Line Proposals"},
|
||||
)
|
||||
assert resp.status_code == 201, resp.text
|
||||
return resp.json()["id"]
|
||||
|
||||
|
||||
def test_red_tech_can_propose_a_template(api, technique, red_tech_headers):
|
||||
resp = api("post", "/api/v1/template-suggestions", red_tech_headers, json=_payload())
|
||||
assert resp.status_code == 201, resp.text
|
||||
body = resp.json()
|
||||
assert body["status"] == "pending"
|
||||
assert body["team"] == "red"
|
||||
assert body["name"] == "Operator-proposed template"
|
||||
|
||||
|
||||
def test_blue_tech_can_propose_a_template(api, technique, blue_tech_headers):
|
||||
resp = api("post", "/api/v1/template-suggestions", blue_tech_headers, json=_payload())
|
||||
assert resp.status_code == 201, resp.text
|
||||
assert resp.json()["team"] == "blue"
|
||||
|
||||
|
||||
def test_lead_cannot_propose_a_template_via_suggestion_endpoint(api, technique, red_lead_headers):
|
||||
"""Leads use the direct POST /test-templates endpoint instead."""
|
||||
resp = api("post", "/api/v1/template-suggestions", red_lead_headers, json=_payload())
|
||||
assert resp.status_code == 403
|
||||
|
||||
|
||||
def test_proposed_template_does_not_appear_in_catalog(api, technique, red_tech_headers, red_lead_headers):
|
||||
api("post", "/api/v1/template-suggestions", red_tech_headers, json=_payload())
|
||||
|
||||
catalog = api("get", "/api/v1/test-templates?search=Operator-proposed", red_lead_headers)
|
||||
assert catalog.json() == []
|
||||
|
||||
|
||||
def test_red_lead_sees_pending_red_suggestion(api, technique, red_tech_headers, red_lead_headers):
|
||||
api("post", "/api/v1/template-suggestions", red_tech_headers, json=_payload())
|
||||
|
||||
listed = api("get", "/api/v1/template-suggestions", red_lead_headers)
|
||||
assert listed.status_code == 200, listed.text
|
||||
assert len(listed.json()) == 1
|
||||
assert listed.json()[0]["status"] == "pending"
|
||||
|
||||
|
||||
def test_blue_lead_does_not_see_red_suggestion(api, technique, red_tech_headers, blue_lead_headers):
|
||||
api("post", "/api/v1/template-suggestions", red_tech_headers, json=_payload())
|
||||
|
||||
listed = api("get", "/api/v1/template-suggestions", blue_lead_headers)
|
||||
assert listed.json() == []
|
||||
|
||||
|
||||
def test_blue_lead_cannot_approve_a_red_suggestion(api, technique, red_tech_headers, blue_lead_headers):
|
||||
created = api("post", "/api/v1/template-suggestions", red_tech_headers, json=_payload())
|
||||
suggestion_id = created.json()["id"]
|
||||
|
||||
resp = api("post", f"/api/v1/template-suggestions/{suggestion_id}/approve", blue_lead_headers)
|
||||
assert resp.status_code == 403
|
||||
|
||||
|
||||
def test_lead_approves_suggestion_as_is_creates_template(api, technique, red_tech_headers, red_lead_headers):
|
||||
created = api("post", "/api/v1/template-suggestions", red_tech_headers, json=_payload())
|
||||
suggestion_id = created.json()["id"]
|
||||
|
||||
resp = api("post", f"/api/v1/template-suggestions/{suggestion_id}/approve", red_lead_headers)
|
||||
assert resp.status_code == 200, resp.text
|
||||
body = resp.json()
|
||||
assert body["status"] == "approved"
|
||||
assert body["created_template_id"] is not None
|
||||
|
||||
template = api("get", f"/api/v1/test-templates/{body['created_template_id']}", red_lead_headers)
|
||||
assert template.status_code == 200
|
||||
assert template.json()["name"] == "Operator-proposed template"
|
||||
|
||||
listed = api("get", "/api/v1/template-suggestions", red_lead_headers)
|
||||
assert listed.json() == []
|
||||
|
||||
|
||||
def test_lead_approves_suggestion_with_edits(api, technique, red_tech_headers, red_lead_headers):
|
||||
"""The lead can add/adjust information before it goes live — the created
|
||||
template reflects the lead's edits, not just the operator's original text."""
|
||||
created = api("post", "/api/v1/template-suggestions", red_tech_headers, json=_payload())
|
||||
suggestion_id = created.json()["id"]
|
||||
|
||||
resp = api(
|
||||
"post", f"/api/v1/template-suggestions/{suggestion_id}/approve", red_lead_headers,
|
||||
json={"attack_procedure": "Run a discovery command, then pivot via WMI.", "severity": "high"},
|
||||
)
|
||||
assert resp.status_code == 200, resp.text
|
||||
template_id = resp.json()["created_template_id"]
|
||||
|
||||
template = api("get", f"/api/v1/test-templates/{template_id}", red_lead_headers)
|
||||
assert template.json()["attack_procedure"] == "Run a discovery command, then pivot via WMI."
|
||||
assert template.json()["severity"] == "high"
|
||||
# Untouched fields still come from the operator's original submission.
|
||||
assert template.json()["expected_detection"] == "Check process creation logs."
|
||||
|
||||
|
||||
def test_lead_discards_suggestion_without_creating_template(api, technique, red_tech_headers, red_lead_headers):
|
||||
created = api("post", "/api/v1/template-suggestions", red_tech_headers, json=_payload())
|
||||
suggestion_id = created.json()["id"]
|
||||
|
||||
resp = api("post", f"/api/v1/template-suggestions/{suggestion_id}/reject", red_lead_headers)
|
||||
assert resp.status_code == 200, resp.text
|
||||
assert resp.json()["status"] == "rejected"
|
||||
assert resp.json()["created_template_id"] is None
|
||||
|
||||
catalog = api("get", "/api/v1/test-templates?search=Operator-proposed", red_lead_headers)
|
||||
assert catalog.json() == []
|
||||
|
||||
listed = api("get", "/api/v1/template-suggestions", red_lead_headers)
|
||||
assert listed.json() == []
|
||||
|
||||
|
||||
def test_cannot_review_an_already_reviewed_suggestion_twice(api, technique, red_tech_headers, red_lead_headers):
|
||||
created = api("post", "/api/v1/template-suggestions", red_tech_headers, json=_payload())
|
||||
suggestion_id = created.json()["id"]
|
||||
|
||||
api("post", f"/api/v1/template-suggestions/{suggestion_id}/approve", red_lead_headers)
|
||||
resp = api("post", f"/api/v1/template-suggestions/{suggestion_id}/reject", red_lead_headers)
|
||||
assert resp.status_code == 400
|
||||
@@ -77,6 +77,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)
|
||||
@@ -86,6 +87,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
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -147,8 +149,11 @@ def test_list_templates_with_filters():
|
||||
assert "search" in source, "List must accept search filter"
|
||||
assert "mitre_technique_id" in source, "List must accept mitre_technique_id filter"
|
||||
|
||||
# Verify ilike is used for search
|
||||
assert "ilike" in source, "Search should use ilike for case-insensitive matching"
|
||||
# Verify ilike is used for search — lives in the shared
|
||||
# _build_template_query() helper now, reused by list and count.
|
||||
from app.services.test_template_service import _build_template_query
|
||||
assert "ilike" in inspect.getsource(_build_template_query), \
|
||||
"Search should use ilike for case-insensitive matching"
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
|
||||
@@ -102,7 +102,7 @@ def test_start_execution_twice_returns_invalid_transition(
|
||||
|
||||
rl = client.post(
|
||||
"/api/v1/auth/login",
|
||||
data={"username": "redtech", "password": "redtech123"},
|
||||
data={"username": "redtech@test.com", "password": "redtech123"},
|
||||
)
|
||||
assert rl.status_code == 200
|
||||
red_headers = {"Authorization": f"Bearer {rl.json()['access_token']}"}
|
||||
|
||||
@@ -1,28 +1,30 @@
|
||||
"""API-level validation tests for user creation (SEC-004, SEC-007)."""
|
||||
"""API-level validation tests for user creation (SEC-004, SEC-007).
|
||||
|
||||
Users are created with a name + email — no password (see
|
||||
password_setup_service for the emailed set-password link flow) and no
|
||||
admin-supplied username (username is auto-derived from email).
|
||||
"""
|
||||
|
||||
|
||||
def test_create_user_weak_password_rejected(client, admin_user, admin_token):
|
||||
def test_create_user_missing_full_name_rejected(client, admin_user, admin_token):
|
||||
response = client.post(
|
||||
"/api/v1/users",
|
||||
json={
|
||||
"username": "newuser",
|
||||
"password": "123",
|
||||
"full_name": "",
|
||||
"email": "new@test.com",
|
||||
"role": "viewer",
|
||||
},
|
||||
headers={"Authorization": f"Bearer {admin_token}"},
|
||||
)
|
||||
assert response.status_code == 422
|
||||
assert "password" in response.text.lower()
|
||||
|
||||
|
||||
def test_create_user_reserved_username(client, admin_user, admin_token):
|
||||
def test_create_user_invalid_email_rejected(client, admin_user, admin_token):
|
||||
response = client.post(
|
||||
"/api/v1/users",
|
||||
json={
|
||||
"username": "system",
|
||||
"password": "SecurePass123!@#",
|
||||
"email": "sys@test.com",
|
||||
"full_name": "New User",
|
||||
"email": "not-an-email",
|
||||
"role": "viewer",
|
||||
},
|
||||
headers={"Authorization": f"Bearer {admin_token}"},
|
||||
@@ -30,30 +32,65 @@ def test_create_user_reserved_username(client, admin_user, admin_token):
|
||||
assert response.status_code == 422
|
||||
|
||||
|
||||
def test_create_user_invalid_username_chars(client, admin_user, admin_token):
|
||||
def test_create_user_valid_request_accepted(client, admin_user, admin_token):
|
||||
response = client.post(
|
||||
"/api/v1/users",
|
||||
json={
|
||||
"username": "../admin",
|
||||
"password": "SecurePass123!@#",
|
||||
"email": "bad@test.com",
|
||||
"role": "viewer",
|
||||
},
|
||||
headers={"Authorization": f"Bearer {admin_token}"},
|
||||
)
|
||||
assert response.status_code == 422
|
||||
|
||||
|
||||
def test_create_user_valid_password_accepted(client, admin_user, admin_token):
|
||||
response = client.post(
|
||||
"/api/v1/users",
|
||||
json={
|
||||
"username": "validuser99",
|
||||
"password": "ValidPass123!@#",
|
||||
"full_name": "Valid User",
|
||||
"email": "valid@test.com",
|
||||
"role": "viewer",
|
||||
},
|
||||
headers={"Authorization": f"Bearer {admin_token}"},
|
||||
)
|
||||
assert response.status_code == 201
|
||||
assert response.json()["username"] == "validuser99"
|
||||
assert response.status_code == 201, response.text
|
||||
body = response.json()
|
||||
assert body["full_name"] == "Valid User"
|
||||
assert body["username"] == "valid@test.com"
|
||||
assert body["must_change_password"] is True
|
||||
|
||||
|
||||
def test_create_user_with_manager_role_accepted(client, admin_user, admin_token):
|
||||
"""Regression: 'manager' is a real, actively-used role (dispute mediation,
|
||||
operator assignment) but was missing from the creation whitelist."""
|
||||
response = client.post(
|
||||
"/api/v1/users",
|
||||
json={
|
||||
"full_name": "New Manager",
|
||||
"email": "newmanager@test.com",
|
||||
"role": "manager",
|
||||
},
|
||||
headers={"Authorization": f"Bearer {admin_token}"},
|
||||
)
|
||||
assert response.status_code == 201, response.text
|
||||
assert response.json()["role"] == "manager"
|
||||
|
||||
|
||||
def test_create_user_invalid_role_rejected(client, admin_user, admin_token):
|
||||
response = client.post(
|
||||
"/api/v1/users",
|
||||
json={
|
||||
"full_name": "Bad Role User",
|
||||
"email": "badrole@test.com",
|
||||
"role": "superuser",
|
||||
},
|
||||
headers={"Authorization": f"Bearer {admin_token}"},
|
||||
)
|
||||
assert response.status_code == 400
|
||||
assert "Invalid role" in response.text
|
||||
|
||||
|
||||
def test_create_user_duplicate_email_rejected(client, admin_user, admin_token):
|
||||
payload = {
|
||||
"full_name": "Dup User",
|
||||
"email": "dup@test.com",
|
||||
"role": "viewer",
|
||||
}
|
||||
first = client.post(
|
||||
"/api/v1/users", json=payload, headers={"Authorization": f"Bearer {admin_token}"},
|
||||
)
|
||||
assert first.status_code == 201, first.text
|
||||
|
||||
second = client.post(
|
||||
"/api/v1/users", json=payload, headers={"Authorization": f"Bearer {admin_token}"},
|
||||
)
|
||||
assert second.status_code == 409
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
"""Admins can permanently delete users with no activity footprint —
|
||||
anyone with tests/evidence/worklogs/etc must be deactivated instead, to
|
||||
keep the audit trail intact.
|
||||
"""
|
||||
|
||||
import uuid
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def technique(client, auth_headers):
|
||||
response = client.post(
|
||||
"/api/v1/techniques",
|
||||
json={"mitre_id": "T1059", "name": "Test Technique"},
|
||||
headers=auth_headers,
|
||||
)
|
||||
return response.json()
|
||||
|
||||
|
||||
def test_admin_can_delete_user_with_no_footprint(api, auth_headers):
|
||||
created = api(
|
||||
"post", "/api/v1/users", auth_headers,
|
||||
json={"full_name": "Disposable User", "email": "disposable@test.com", "role": "viewer"},
|
||||
)
|
||||
assert created.status_code == 201, created.text
|
||||
user_id = created.json()["id"]
|
||||
|
||||
resp = api("delete", f"/api/v1/users/{user_id}", auth_headers)
|
||||
assert resp.status_code == 204, resp.text
|
||||
|
||||
get_resp = api("get", f"/api/v1/users/{user_id}", auth_headers)
|
||||
assert get_resp.status_code == 404
|
||||
|
||||
|
||||
def test_admin_cannot_delete_own_account(api, auth_headers, admin_user):
|
||||
resp = api("delete", f"/api/v1/users/{admin_user.id}", auth_headers)
|
||||
assert resp.status_code == 400
|
||||
assert "own account" in resp.text.lower()
|
||||
|
||||
|
||||
def test_admin_cannot_delete_user_with_test_footprint(api, auth_headers, technique, red_lead_headers, red_lead_user, client):
|
||||
client.cookies.clear()
|
||||
test_resp = api(
|
||||
"post", "/api/v1/tests", red_lead_headers,
|
||||
json={
|
||||
"technique_id": technique["id"],
|
||||
"name": "Footprint Test",
|
||||
"description": "x",
|
||||
"platform": "windows",
|
||||
},
|
||||
)
|
||||
assert test_resp.status_code == 201, test_resp.text
|
||||
|
||||
resp = api("delete", f"/api/v1/users/{red_lead_user.id}", auth_headers)
|
||||
assert resp.status_code == 400
|
||||
assert "activity history" in resp.text.lower() or "deactivate" in resp.text.lower()
|
||||
|
||||
# The user must still be there — deactivating is the only allowed path.
|
||||
get_resp = api("get", f"/api/v1/users/{red_lead_user.id}", auth_headers)
|
||||
assert get_resp.status_code == 200
|
||||
|
||||
|
||||
def test_delete_user_requires_admin(api, red_lead_headers, red_lead_user):
|
||||
resp = api("delete", f"/api/v1/users/{red_lead_user.id}", red_lead_headers)
|
||||
assert resp.status_code == 403
|
||||
|
||||
|
||||
def test_delete_user_not_found(api, auth_headers):
|
||||
resp = api("delete", f"/api/v1/users/{uuid.uuid4()}", auth_headers)
|
||||
assert resp.status_code == 404
|
||||
|
||||
|
||||
def test_delete_cleans_up_notifications_and_tokens(api, auth_headers, db):
|
||||
created = api(
|
||||
"post", "/api/v1/users", auth_headers,
|
||||
json={"full_name": "Notifiable User", "email": "notifiable@test.com", "role": "viewer"},
|
||||
)
|
||||
user_id = uuid.UUID(created.json()["id"])
|
||||
|
||||
from app.models.notification import Notification
|
||||
from app.models.password_setup_token import PasswordSetupToken
|
||||
from app.services.notification_service import create_notification
|
||||
|
||||
create_notification(
|
||||
db, user_id=user_id, type="test", title="Hi", message="hi",
|
||||
entity_type="test", entity_id=uuid.uuid4(),
|
||||
)
|
||||
db.add(PasswordSetupToken(user_id=user_id, token="tok-abc", expires_at=__import__("datetime").datetime.utcnow()))
|
||||
db.commit()
|
||||
|
||||
resp = api("delete", f"/api/v1/users/{user_id}", auth_headers)
|
||||
assert resp.status_code == 204, resp.text
|
||||
|
||||
assert db.query(Notification).filter(Notification.user_id == user_id).count() == 0
|
||||
assert db.query(PasswordSetupToken).filter(PasswordSetupToken.user_id == user_id).count() == 0
|
||||
@@ -83,6 +83,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)
|
||||
@@ -92,6 +93,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
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -951,7 +953,7 @@ class TestReviewerSelection:
|
||||
|
||||
def _make_lead(self, db, username, role="red_lead"):
|
||||
from app.models.user import User
|
||||
u = User(username=username, role=role, hashed_password="x", is_active=True)
|
||||
u = User(username=username, email=f"{username}@test.com", role=role, hashed_password="x", is_active=True)
|
||||
db.add(u)
|
||||
db.flush()
|
||||
return u
|
||||
|
||||
@@ -90,9 +90,15 @@ services:
|
||||
REDIS_TOKEN_BLACKLIST_DB: ${REDIS_TOKEN_BLACKLIST_DB:-1}
|
||||
REDIS_CACHE_DB: ${REDIS_CACHE_DB:-2}
|
||||
CORS_ORIGINS: ${CORS_ORIGINS:-}
|
||||
# Base URL used to build links in outbound emails (set-password, etc).
|
||||
# No default on purpose — every deployment has a different domain;
|
||||
# the backend refuses to start in production without this set.
|
||||
PLATFORM_URL: ${PLATFORM_URL:?Set PLATFORM_URL in your .env file to this deployment's real public frontend URL}
|
||||
AEGIS_ENV: ${AEGIS_ENV:-production}
|
||||
SECURE_COOKIES: ${SECURE_COOKIES:-false}
|
||||
ADMIN_USERNAME: ${ADMIN_USERNAME:-admin}
|
||||
# Email is the admin account's unique login identifier.
|
||||
ADMIN_EMAIL: ${ADMIN_EMAIL:-}
|
||||
ADMIN_PASSWORD: ${ADMIN_PASSWORD:-}
|
||||
# ── Tempo time-tracking (optional) ────────────────────────────────────
|
||||
TEMPO_ENABLED: ${TEMPO_ENABLED:-false}
|
||||
|
||||
@@ -6,6 +6,7 @@ import ProtectedRoute from "./components/ProtectedRoute";
|
||||
|
||||
/* ── Eagerly loaded (core pages) ──────────────────────────────────── */
|
||||
import LoginPage from "./pages/LoginPage";
|
||||
import SetPasswordPage from "./pages/SetPasswordPage";
|
||||
import DashboardPage from "./pages/DashboardPage";
|
||||
|
||||
/* ── Lazy loaded (V1-V3 pages) ────────────────────────────────────── */
|
||||
@@ -20,7 +21,9 @@ const TestCatalogPage = React.lazy(() => import("./pages/TestCatalogPage"));
|
||||
const ValidatedTestsPage = React.lazy(() => import("./pages/ValidatedTestsPage"));
|
||||
const ReviewQueuePage = React.lazy(() => import("./pages/ReviewQueuePage"));
|
||||
const ImportRTPage = React.lazy(() => import("./pages/ImportRTPage"));
|
||||
const ReportsPage = React.lazy(() => import("./pages/ReportsPage"));
|
||||
// Reports is unfinished — hidden platform-wide (route + nav link removed
|
||||
// below) until it's ready. Left unimported/unrouted rather than deleted.
|
||||
// const ReportsPage = React.lazy(() => import("./pages/ReportsPage"));
|
||||
const SystemPage = React.lazy(() => import("./pages/SystemPage"));
|
||||
const UsersPage = React.lazy(() => import("./pages/UsersPage"));
|
||||
const AuditLogPage = React.lazy(() => import("./pages/AuditLogPage"));
|
||||
@@ -37,6 +40,7 @@ export default function App() {
|
||||
<Routes>
|
||||
{/* Public */}
|
||||
<Route path="/login" element={<LoginPage />} />
|
||||
<Route path="/set-password" element={<SetPasswordPage />} />
|
||||
|
||||
{/* Protected — wrapped in shared Layout */}
|
||||
<Route
|
||||
@@ -57,7 +61,7 @@ export default function App() {
|
||||
<Route
|
||||
path="/techniques/review-queue"
|
||||
element={
|
||||
<ProtectedRoute roles={["admin", "red_lead", "blue_lead"]}>
|
||||
<ProtectedRoute roles={["admin", "red_lead", "blue_lead", "manager"]}>
|
||||
<Suspense fallback={<LoadingSpinner text="Loading…" />}><ReviewQueuePage /></Suspense>
|
||||
</ProtectedRoute>
|
||||
}
|
||||
@@ -110,8 +114,7 @@ export default function App() {
|
||||
}
|
||||
/>
|
||||
|
||||
{/* ── Reports ──────────────────────────────────────────── */}
|
||||
<Route path="/reports" element={<Suspense fallback={<LoadingSpinner text="Loading…" />}><ReportsPage /></Suspense>} />
|
||||
{/* ── Reports (hidden — unfinished, see import comment above) ── */}
|
||||
|
||||
{/* ── Settings (all authenticated users) ───────────────── */}
|
||||
<Route path="/settings" element={<Suspense fallback={<LoadingSpinner text="Loading…" />}><SettingsPage /></Suspense>} />
|
||||
|
||||
@@ -4,6 +4,7 @@ export interface AuditLogOut {
|
||||
id: string;
|
||||
user_id: string | null;
|
||||
username: string | null;
|
||||
full_name: string | null;
|
||||
action: string;
|
||||
entity_type: string | null;
|
||||
entity_id: string | null;
|
||||
|
||||
@@ -2,17 +2,20 @@ import client from "./client";
|
||||
import type { User } from "../types/models";
|
||||
|
||||
/**
|
||||
* Authenticate the user.
|
||||
* Authenticate the user with their email (the unique login identifier)
|
||||
* and password.
|
||||
*
|
||||
* The backend sets an HttpOnly cookie with the JWT — no token is stored
|
||||
* in JavaScript memory or localStorage.
|
||||
* in JavaScript memory or localStorage. The form field is still named
|
||||
* "username" on the wire — that's the OAuth2PasswordRequestForm spec's
|
||||
* fixed field name, not a statement about what value it holds.
|
||||
*/
|
||||
export async function login(
|
||||
username: string,
|
||||
email: string,
|
||||
password: string,
|
||||
): Promise<void> {
|
||||
const params = new URLSearchParams();
|
||||
params.append("username", username);
|
||||
params.append("username", email);
|
||||
params.append("password", password);
|
||||
|
||||
await client.post("/auth/login", params, {
|
||||
@@ -50,3 +53,19 @@ export async function changePassword(
|
||||
new_password: newPassword,
|
||||
});
|
||||
}
|
||||
|
||||
/** Check a set-password token before rendering the form. Public — no auth. */
|
||||
export async function validateSetPasswordToken(
|
||||
token: string,
|
||||
): Promise<{ valid: boolean; full_name: string | null }> {
|
||||
const { data } = await client.get<{ valid: boolean; full_name: string | null }>(
|
||||
"/auth/set-password/validate",
|
||||
{ params: { token } },
|
||||
);
|
||||
return data;
|
||||
}
|
||||
|
||||
/** Complete a password setup/reset using a one-time token. Public — no auth. */
|
||||
export async function setPassword(token: string, newPassword: string): Promise<void> {
|
||||
await client.post("/auth/set-password", { token, new_password: newPassword });
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user