Compare commits

4 Commits

Author SHA1 Message Date
kitos 4e378540af fix(security): replace python-jose with PyJWT to eliminate ecdsa CVEs
Snyk scan found 3 High severity vulns: two in ecdsa (pulled by python-jose)
and one in diskcache (pulled by pySigma, never imported). Remove both
vulnerable dependencies and migrate JWT handling to PyJWT. Fix
test_logout_revokes_token which broke because test stubs sys.modules[jose]
with a MagicMock at collection time; test now uses PyJWT directly.
2026-06-11 09:06:16 +02:00
kitos c99cc4946a refactor(docs+comments): add Google-style docstrings and inline comments across backend
Task D — Google-style docstrings (Args/Returns) on every public function,
method, and class across all 158 Python files in the backend. Zero ruff D
violations (pydocstyle Google convention).

Task E — Explanatory one-line comment before every code line (~11600 new
comments). ruff check passes clean after isort re-sort.
2026-06-10 13:25:14 +02:00
kitos 394d5d9056 refactor(types): add comprehensive type annotations across backend Python codebase
Enable ANN rules in ruff.toml (flake8-annotations) and resolve all 221 violations:

ANN201/ANN202 — return types on 168 public/private functions:
- All 28 FastAPI routers: endpoints annotated with dict/list/specific schema/
  StreamingResponse/FileResponse/JSONResponse as appropriate
- main.py: lifespan→AsyncGenerator[None,None], exception handlers→JSONResponse
- database.py: get_db→Generator[Session,None,None], proxy methods→correct types
- middleware/request_context.py: dispatch→Response with Callable call_next type

ANN001/ANN002/ANN003 — 32 missing argument types:
- seed_demo.py: all db parameters typed as Session
- domain/unit_of_work.py: __aexit__ exc_type/exc_val/exc_tb typed with TracebackType
- services: audit_service user_id→UUID|None, heatmap_service query/model/builder,
  notification_service test→Test, tempo_service test→Test/user→User,
  test_workflow_service test_id→UUID, campaign_crud **fields→object,
  test_crud **fields→object (4 sites)

ANN401 — 16 Any usages resolved:
- Domain entities (campaign/technique/threat_actor/test_entity): replaced Any with
  actual ORM types via TYPE_CHECKING guards to avoid circular imports
- detection_rule_service: test_id/detection_rule_id/evaluator_id→UUID
- score_cache: kept Any with # noqa: ANN401 (genuinely generic cache)
- jira_service/tempo_service: kept Any with # noqa: ANN401 (lazy optional deps)
- d3fend_import_service: _to_str(v: Any) kept with # noqa: ANN401

ANN204/ANN205/ANN206 — special/static/class methods:
- database.py proxy __call__/__getattr__: *args: object/**kwargs: object
- schemas/test.py model_validate: obj→object, **kwargs→object
- sa_technique_repository._int_type→type

All 439 unit tests pass. ruff check app/ → All checks passed!

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-09 17:04:51 +02:00
kitos ec26183e2e refactor(pep8): enforce full PEP8 compliance across backend Python codebase
- ruff.toml: select E/W/F/I/N rules, line-length=120, drop legacy ignores
- Auto-fix: sort 82 import blocks (isort), remove 29 unused imports,
  strip 6 trailing-whitespace blank lines in docstrings
- main.py: move setup_logging and settings imports to top (E402)
- errors.py: noqa N818 on DDD exception names (96 call sites, safe)
- intel_service.py: noqa N817 for universal ET alias
- atomic/elastic/sigma import services: move _MAX_UNCOMPRESSED_SIZE and
  _MAX_ENTRIES to module level (N806)
- compliance_import_service.py: move SAMPLE_CONTROLS / CIS_CONTROLS to
  module level; wrap long description strings (N806 + E501)
- snapshot_service.py: move STATUS_ORDER dict to module level (N806)
- sigma_import_service.py: remove dead dedup_key expression (F841)
- threat_actor_import_service.py: remove dead stix_to_actor expression (F841)
- data_source.py, seed_demo.py, campaign_scheduler_service.py,
  lolbas_import_service.py: wrap lines exceeding 120 chars (E501)
- d3fend_import_service.py: per-file E501 ignore (data file with long strings)

All 439 unit tests pass. ruff check app/ → All checks passed!

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-09 16:40:14 +02:00
360 changed files with 4261 additions and 57761 deletions
+189
View File
@@ -0,0 +1,189 @@
---
description: Aegis backend Clean Architecture rules. Apply when working on any backend Python file under backend/app/ or backend/tests/.
globs: backend/**/*.py
---
# Aegis — Clean Modular Monolith Architecture
## Architecture Overview
Aegis follows a **Clean Architecture** pattern inside a modular monolith. The backend has four layers with strict dependency rules:
```
Presentation → Application → Domain ← Infrastructure
```
**The golden rule:** dependencies only point towards the Domain layer. Infrastructure implements the ports (interfaces) defined in Domain.
## Layer Structure and Rules
### Domain Layer (`backend/app/domain/`)
The innermost layer. **ZERO** imports from FastAPI, SQLAlchemy, Pydantic, or any framework.
| Directory | Purpose |
|-----------|---------|
| `domain/enums.py` | Canonical domain enums (TechniqueStatus, TestState, TeamSide, TestResult) |
| `domain/errors.py` | Exception hierarchy (DomainError → EntityNotFoundError, InvalidStateTransition, etc.) |
| `domain/exceptions.py` | Backward-compatible re-exports from errors.py |
| `domain/test_entity.py` | TestEntity — pure state machine with domain events |
| `domain/entities/` | Rich domain entities (TechniqueEntity, etc.) with business behavior |
| `domain/value_objects/` | Immutable value types (MitreId, ScoringWeights) |
| `domain/ports/repositories/` | Protocol interfaces defining data access contracts |
| `domain/ports/services/` | Protocol interfaces for external capabilities (storage, events) |
| `domain/unit_of_work.py` | UnitOfWork wrapping SQLAlchemy session |
**NEVER** import from `app.models`, `app.routers`, `app.infrastructure`, `fastapi`, or `sqlalchemy` inside `domain/`.
### Application Layer (`backend/app/application/` — future)
Use case orchestrators. Depends only on Domain.
| Directory | Purpose |
|-----------|---------|
| `application/use_cases/` | One class per business operation |
| `application/dto/` | Plain data containers for use case input/output |
| `application/interfaces/` | Application-level contracts (UnitOfWork protocol) |
### Infrastructure Layer (`backend/app/infrastructure/`)
Implements ports defined in Domain. Depends on Domain and Application.
| Directory | Purpose |
|-----------|---------|
| `infrastructure/redis_client.py` | Redis connection singleton |
| `infrastructure/persistence/repositories/` | SQLAlchemy implementations of repository ports |
| `infrastructure/persistence/mappers/` | ORM model ↔ domain entity converters |
### Presentation Layer (routers, schemas, dependencies)
HTTP boundary. Depends on Application and Domain (for exceptions).
| Directory | Purpose |
|-----------|---------|
| `routers/` | FastAPI routers — HTTP mapping only |
| `schemas/` | Pydantic request/response models |
| `dependencies/` | FastAPI `Depends()` wiring (auth, repositories) |
| `middleware/` | Error handler mapping domain exceptions → HTTP responses |
## Import Rules (Strict)
| From \ To | domain/ | application/ | infrastructure/ | presentation/ |
|-----------|---------|-------------|----------------|--------------|
| **domain/** | Self only | FORBIDDEN | FORBIDDEN | FORBIDDEN |
| **application/** | ALLOWED | Self only | FORBIDDEN | FORBIDDEN |
| **infrastructure/** | ALLOWED (ports) | ALLOWED (UoW) | Self only | FORBIDDEN |
| **presentation/** | ALLOWED (exceptions) | ALLOWED (use cases) | ALLOWED (wiring in dependencies/) | Self only |
## How to Add a New Feature
### 1. Start from the Domain
- Define or reuse domain entities in `domain/entities/`
- Add value objects if needed in `domain/value_objects/`
- Define repository port if a new aggregate root in `domain/ports/repositories/`
- Domain exceptions go in `domain/errors.py`
- Business rules live IN the entity, not in services or routers
### 2. Implement Infrastructure
- Create SQLAlchemy repository implementation in `infrastructure/persistence/repositories/`
- Create mapper if converting between ORM model and domain entity
- Repository does NOT call `commit()` — only `flush()`
- Transaction control belongs to the Unit of Work
### 3. Wire in Presentation
- Add FastAPI `Depends()` provider in `dependencies/repositories.py`
- Keep routers thin: parse request → call service/use case → return response
- Map domain exceptions to HTTP via the error handler middleware (automatic)
### 4. Tests (Mandatory)
Every change MUST include tests:
- **Domain entities/value objects**: pure unit tests, no DB, no mocking frameworks
- **Repositories**: integration tests using the `db` fixture from conftest
- **Routers**: API tests using the `client` fixture
- At least one success test + one failure/edge-case test per behavior
Before committing, run: `scripts/agent_validate_backend.sh`
## Existing Patterns to Follow
### Domain Entity Pattern (see `domain/test_entity.py`)
```python
@dataclass
class SomeEntity:
id: uuid.UUID
# fields...
_events: list[DomainEvent] = field(default_factory=list, repr=False)
@classmethod
def from_orm(cls, model: Any) -> "SomeEntity":
"""Build from SQLAlchemy model."""
...
def apply_to(self, model: Any) -> None:
"""Copy mutable fields back onto the ORM model."""
...
def some_business_method(self) -> None:
"""Business logic lives HERE, not in services."""
...
self._events.append(DomainEvent("something_happened"))
```
### Repository Port Pattern (Protocol)
```python
from typing import Protocol, runtime_checkable
@runtime_checkable
class SomeRepository(Protocol):
def find_by_id(self, id: uuid.UUID) -> SomeEntity | None: ...
def save(self, entity: SomeEntity) -> SomeEntity: ...
```
### Repository Implementation Pattern
```python
class SASomeRepository:
def __init__(self, session: Session) -> None:
self._session = session
def find_by_id(self, id: uuid.UUID) -> SomeEntity | None:
model = self._session.query(SomeModel).filter(SomeModel.id == id).first()
return SomeMapper.to_entity(model) if model else None
def save(self, entity: SomeEntity) -> SomeEntity:
model = SomeMapper.to_model(entity)
merged = self._session.merge(model)
self._session.flush() # NO commit — UoW does that
return SomeMapper.to_entity(merged)
```
### Error Handling (automatic via middleware)
Services raise domain exceptions → middleware maps to HTTP:
- `EntityNotFoundError` → 404
- `DuplicateEntityError` → 409
- `InvalidStateTransition` → 400
- `BusinessRuleViolation` → 400
- `PermissionViolation` → 403
### Coexistence Strategy
Old code (direct `db.query()` in routers) and new code (repositories) coexist. Migration is incremental:
1. New endpoints use repositories
2. Existing endpoints are migrated one at a time
3. Both access the same DB, same session, same tables
## Key Conventions
- **Enums**: canonical source is `domain/enums.py`, `models/enums.py` re-exports
- **Exceptions**: raise from `domain/errors.py`, never raise `HTTPException` from services
- **Commits**: only via `UnitOfWork.commit()` or at the router level, never inside services/repos
- **IDs**: UUID everywhere (primary keys, foreign keys)
- **Tests**: SQLite in-memory for unit/integration, PostgreSQL in CI
- **Validation**: Pydantic in schemas (presentation), domain rules in entities (domain)
-10
View File
@@ -23,10 +23,7 @@ TOKEN_EXPIRE_MINUTES=60
# ── Initial Admin Account ──────────────────────────────────────────────────── # ── Initial Admin Account ────────────────────────────────────────────────────
# If ADMIN_PASSWORD is empty, a random password is auto-generated and # If ADMIN_PASSWORD is empty, a random password is auto-generated and
# printed to the backend container logs on first startup. # printed to the backend container logs on first startup.
# ADMIN_EMAIL is what you actually log in with (email is the unique
# identifier) — set it to a real address you control.
ADMIN_USERNAME=admin ADMIN_USERNAME=admin
ADMIN_EMAIL=
ADMIN_PASSWORD= ADMIN_PASSWORD=
# ── MinIO Object Storage ───────────────────────────────────────────────────── # ── MinIO Object Storage ─────────────────────────────────────────────────────
@@ -42,13 +39,6 @@ CORS_ORIGINS=https://your-domain.com
# ── Frontend ───────────────────────────────────────────────────────────────── # ── Frontend ─────────────────────────────────────────────────────────────────
FRONTEND_PORT=80 FRONTEND_PORT=80
# ── Emails ────────────────────────────────────────────────────────────────────
# Base URL used to build links in outbound emails (set-password, etc).
# REQUIRED in production — must be THIS deployment's real public frontend
# URL. There is no safe default (it's unique per deployment); the backend
# refuses to start without it when AEGIS_ENV=production.
PLATFORM_URL=https://your-domain.com
# ── Environment flag ───────────────────────────────────────────────────────── # ── Environment flag ─────────────────────────────────────────────────────────
# Set to "production" for production deployments (enforces SECRET_KEY, etc.) # Set to "production" for production deployments (enforces SECRET_KEY, etc.)
AEGIS_ENV=production AEGIS_ENV=production
-71
View File
@@ -1,71 +0,0 @@
name: Snyk Security Scan
on:
push:
branches: [main, develop]
pull_request:
branches: [main]
schedule:
- cron: '0 6 * * 1' # Weekly on Monday 06:00 UTC
jobs:
snyk-backend:
name: Python vulnerabilities (backend)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.11"
- name: Install backend dependencies
run: pip install -r backend/requirements-lock.txt
- name: Snyk — scan Python packages
uses: snyk/actions/python@master
env:
SNYK_TOKEN: ${{ secrets.SNYK_TOKEN }}
with:
args: --file=backend/requirements-lock.txt --severity-threshold=high
continue-on-error: true # report without blocking CI during initial cleanup
snyk-frontend:
name: npm vulnerabilities (frontend)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
- name: Install frontend dependencies
run: npm ci
working-directory: frontend
- name: Snyk — scan npm packages
uses: snyk/actions/node@master
env:
SNYK_TOKEN: ${{ secrets.SNYK_TOKEN }}
with:
args: --file=frontend/package.json --severity-threshold=high
continue-on-error: true
snyk-docker-backend:
name: Docker image vulnerabilities (backend)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Build backend image for scanning
run: docker build -t aegis-backend:scan backend/
- name: Snyk — scan Docker image
uses: snyk/actions/docker@master
env:
SNYK_TOKEN: ${{ secrets.SNYK_TOKEN }}
with:
image: aegis-backend:scan
args: --severity-threshold=high
continue-on-error: true
-9
View File
@@ -60,12 +60,3 @@ Thumbs.db
# Local development # Local development
*.local *.local
# Documentation drafts — never commit, delivered directly in chat
docs/confluence/
docs/drafts/
# Editor / AI assistant working files — never commit
.claude/
.cursor/
CLAUDE.md
-11
View File
@@ -1,11 +0,0 @@
# Snyk (https://snyk.io) policy file
version: v1.25.0
# Snyk Code — exclude paths from SAST scanning
exclude:
code:
# Test fixtures use hardcoded credentials against an in-memory SQLite DB.
# These are test-only and carry no production risk.
- backend/tests/**
# Utility scripts use env-var credentials with safe defaults for local dev only.
- scripts/**
-2
View File
@@ -1,2 +0,0 @@
skips:
- B311
+1 -5
View File
@@ -3,14 +3,10 @@ FROM python:3.11-slim
WORKDIR /app WORKDIR /app
# Install system dependencies # Install system dependencies
RUN apt-get update && apt-get upgrade -y && apt-get install -y \ RUN apt-get update && apt-get install -y \
gcc \ gcc \
libpq-dev \ libpq-dev \
curl \ curl \
pkg-config \
libxml2-dev \
libxmlsec1-dev \
libxmlsec1-openssl \
&& rm -rf /var/lib/apt/lists/* && rm -rf /var/lib/apt/lists/*
# Copy requirements first for better caching # Copy requirements first for better caching
@@ -1,32 +0,0 @@
"""Phase 6.1: webhook_configs table.
Revision ID: b031phase6
Revises: b030phase5
"""
from typing import Sequence, Union
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
from alembic import op
revision: str = "b031phase6"
down_revision: Union[str, None] = "b030phase5"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.create_table(
"webhook_configs",
sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True),
sa.Column("name", sa.String(200), nullable=False),
sa.Column("url", sa.Text, nullable=False),
sa.Column("secret", sa.String(256), nullable=True),
sa.Column("events", postgresql.JSONB, nullable=False, server_default="[]"),
sa.Column("is_active", sa.Boolean, nullable=False, server_default="true"),
sa.Column("created_by", postgresql.UUID(as_uuid=True), sa.ForeignKey("users.id", ondelete="SET NULL"), nullable=True),
sa.Column("last_triggered_at", sa.DateTime, nullable=True),
sa.Column("failure_count", sa.Integer, nullable=False, server_default="0"),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
)
def downgrade() -> None:
op.drop_table("webhook_configs")
@@ -1,41 +0,0 @@
"""Phase 7.2: user notification_preferences and jira_account_id columns.
Revision ID: b032phase7
Revises: b031phase6
"""
from typing import Sequence, Union
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
from alembic import op
revision: str = "b032phase7"
down_revision: Union[str, None] = "b031phase6"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
_DEFAULT_PREFS = '{"email_on_test_validated": true, "email_on_campaign_completed": true, "email_on_new_mitre_techniques": false, "in_app_all": true}'
def _column_names(table: str) -> set[str]:
bind = op.get_bind()
insp = sa.inspect(bind)
return {c["name"] for c in insp.get_columns(table)}
def upgrade() -> None:
user_cols = _column_names("users")
if "notification_preferences" not in user_cols:
op.add_column(
"users",
sa.Column("notification_preferences", postgresql.JSONB, nullable=True, server_default=_DEFAULT_PREFS),
)
if "jira_account_id" not in user_cols:
op.add_column(
"users",
sa.Column("jira_account_id", sa.String(100), nullable=True),
)
def downgrade() -> None:
user_cols = _column_names("users")
if "jira_account_id" in user_cols:
op.drop_column("users", "jira_account_id")
if "notification_preferences" in user_cols:
op.drop_column("users", "notification_preferences")
@@ -1,43 +0,0 @@
"""Phase 8: system_configs table for runtime configuration.
Revision ID: b033syscfg
Revises: b032phase7
"""
from typing import Sequence, Union
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
from alembic import op
revision: str = "b033syscfg"
down_revision: Union[str, None] = "b032phase7"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def _table_exists(name: str) -> bool:
bind = op.get_bind()
insp = sa.inspect(bind)
return name in insp.get_table_names()
def upgrade() -> None:
if not _table_exists("system_configs"):
op.create_table(
"system_configs",
sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True),
sa.Column("key", sa.String(200), unique=True, nullable=False),
sa.Column("value", sa.Text, nullable=True),
sa.Column("description", sa.String(500), nullable=True),
sa.Column(
"updated_at",
sa.DateTime(timezone=True),
server_default=sa.text("now()"),
),
)
op.create_index("ix_system_configs_key", "system_configs", ["key"])
def downgrade() -> None:
if _table_exists("system_configs"):
op.drop_index("ix_system_configs_key", table_name="system_configs")
op.drop_table("system_configs")
@@ -1,174 +0,0 @@
"""Phase 8: Detection Lifecycle Management tables.
Revision ID: b034dlm
Revises: b033syscfg
"""
from typing import Sequence, Union
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
from alembic import op
revision: str = "b034dlm"
down_revision: Union[str, None] = "b033syscfg"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def _table_exists(name: str) -> bool:
bind = op.get_bind()
insp = sa.inspect(bind)
return name in insp.get_table_names()
def upgrade() -> None:
if not _table_exists("detection_assets"):
op.create_table(
"detection_assets",
sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True),
sa.Column("name", sa.String(500), nullable=False),
sa.Column("description", sa.Text),
sa.Column("asset_type", sa.String(50), nullable=False),
sa.Column("platform", sa.String(100)),
sa.Column("rule_content", sa.Text),
sa.Column("rule_language", sa.String(50)),
sa.Column("rule_repository_url", sa.Text),
sa.Column("rule_file_path", sa.String(500)),
sa.Column("rule_version", sa.String(50)),
sa.Column("rule_hash", sa.String(64)),
sa.Column("last_rule_change_at", sa.DateTime),
sa.Column("log_source_name", sa.String(200)),
sa.Column("log_source_version", sa.String(50)),
sa.Column("log_source_config", postgresql.JSONB, server_default="{}"),
sa.Column("infrastructure_hash", sa.String(64)),
sa.Column("infrastructure_details", postgresql.JSONB, server_default="{}"),
sa.Column("health_status", sa.String(20), server_default="untested", nullable=False),
sa.Column("last_alert_at", sa.DateTime),
sa.Column("alert_count_30d", sa.Integer, server_default="0"),
sa.Column("false_positive_rate", sa.Float),
sa.Column("expected_alert_frequency", sa.String(50)),
sa.Column("owner_id", postgresql.UUID(as_uuid=True), sa.ForeignKey("users.id", ondelete="SET NULL")),
sa.Column("backup_owner_id", postgresql.UUID(as_uuid=True), sa.ForeignKey("users.id", ondelete="SET NULL")),
sa.Column("team", sa.String(100)),
sa.Column("is_active", sa.Boolean, server_default="true", nullable=False),
sa.Column("tags", postgresql.JSONB, server_default="[]"),
sa.Column("asset_metadata", postgresql.JSONB, server_default="{}"),
sa.Column("created_by", postgresql.UUID(as_uuid=True), sa.ForeignKey("users.id", ondelete="SET NULL")),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("now()")),
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.text("now()")),
)
op.create_index("ix_detection_assets_platform", "detection_assets", ["platform"])
op.create_index("ix_detection_assets_health_status", "detection_assets", ["health_status"])
op.create_index("ix_detection_assets_owner_id", "detection_assets", ["owner_id"])
if not _table_exists("detection_technique_mappings"):
op.create_table(
"detection_technique_mappings",
sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True),
sa.Column("detection_asset_id", postgresql.UUID(as_uuid=True), sa.ForeignKey("detection_assets.id", ondelete="CASCADE"), nullable=False),
sa.Column("technique_id", postgresql.UUID(as_uuid=True), sa.ForeignKey("techniques.id", ondelete="CASCADE"), nullable=False),
sa.Column("coverage_type", sa.String(50), server_default="detect"),
sa.Column("confidence_level", sa.String(20), server_default="medium"),
sa.Column("notes", sa.Text),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("now()")),
)
op.create_index("ix_detection_technique_mappings_technique_id", "detection_technique_mappings", ["technique_id"])
op.create_index("ix_detection_technique_mappings_asset_id", "detection_technique_mappings", ["detection_asset_id"])
if not _table_exists("detection_validations"):
op.create_table(
"detection_validations",
sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True),
sa.Column("detection_asset_id", postgresql.UUID(as_uuid=True), sa.ForeignKey("detection_assets.id", ondelete="CASCADE"), nullable=False),
sa.Column("technique_id", postgresql.UUID(as_uuid=True), sa.ForeignKey("techniques.id", ondelete="SET NULL")),
sa.Column("test_id", postgresql.UUID(as_uuid=True), sa.ForeignKey("tests.id", ondelete="SET NULL")),
sa.Column("validated_at", sa.DateTime),
sa.Column("expires_at", sa.DateTime, nullable=False),
sa.Column("is_valid", sa.Boolean, server_default="true", nullable=False),
sa.Column("validation_result", sa.String(50)),
sa.Column("validation_method", sa.String(100)),
sa.Column("rule_hash_at_validation", sa.String(64)),
sa.Column("log_source_version_at_validation", sa.String(50)),
sa.Column("infrastructure_hash_at_validation", sa.String(64)),
sa.Column("environment_snapshot", postgresql.JSONB, server_default="{}"),
sa.Column("invalidated_at", sa.DateTime),
sa.Column("invalidation_reason", sa.String(50)),
sa.Column("invalidation_details", sa.Text),
sa.Column("invalidated_by", postgresql.UUID(as_uuid=True), sa.ForeignKey("users.id", ondelete="SET NULL")),
sa.Column("validated_by", postgresql.UUID(as_uuid=True), sa.ForeignKey("users.id", ondelete="SET NULL"), nullable=False),
sa.Column("integrity_hash", sa.String(64)),
sa.Column("notes", sa.Text),
sa.Column("evidence_ids", postgresql.JSONB, server_default="[]"),
)
op.create_index("ix_detection_validations_asset_id_valid", "detection_validations", ["detection_asset_id", "is_valid"])
op.create_index("ix_detection_validations_expires_at", "detection_validations", ["expires_at"])
if not _table_exists("technique_confidence_scores"):
op.create_table(
"technique_confidence_scores",
sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True),
sa.Column("technique_id", postgresql.UUID(as_uuid=True), sa.ForeignKey("techniques.id", ondelete="CASCADE"), nullable=False, unique=True),
sa.Column("confidence_level", sa.String(20), server_default="unknown"),
sa.Column("confidence_score", sa.Float, server_default="0.0"),
sa.Column("detection_count", sa.Integer, server_default="0"),
sa.Column("valid_detection_count", sa.Integer, server_default="0"),
sa.Column("last_validated_at", sa.DateTime),
sa.Column("next_validation_due", sa.DateTime),
sa.Column("last_recalculated_at", sa.DateTime),
sa.Column("recency_factor", sa.Float, server_default="0.0"),
sa.Column("coverage_factor", sa.Float, server_default="0.0"),
sa.Column("health_factor", sa.Float, server_default="0.0"),
sa.Column("diversity_factor", sa.Float, server_default="0.0"),
sa.Column("score_breakdown", postgresql.JSONB, server_default="{}"),
sa.Column("risk_factors", postgresql.JSONB, server_default="[]"),
sa.Column("updated_at", sa.DateTime),
)
op.create_index("ix_technique_confidence_scores_technique_id", "technique_confidence_scores", ["technique_id"])
op.create_index("ix_technique_confidence_scores_confidence_level", "technique_confidence_scores", ["confidence_level"])
if not _table_exists("infrastructure_change_logs"):
op.create_table(
"infrastructure_change_logs",
sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True),
sa.Column("change_type", sa.String(100), nullable=False),
sa.Column("description", sa.Text, nullable=False),
sa.Column("affected_platforms", postgresql.JSONB, server_default="[]"),
sa.Column("affected_log_sources", postgresql.JSONB, server_default="[]"),
sa.Column("change_date", sa.DateTime),
sa.Column("reported_by", postgresql.UUID(as_uuid=True), sa.ForeignKey("users.id", ondelete="SET NULL")),
sa.Column("auto_invalidate", sa.Boolean, server_default="true"),
sa.Column("invalidated_count", sa.Integer, server_default="0"),
sa.Column("change_metadata", postgresql.JSONB, server_default="{}"),
sa.Column("created_at", sa.DateTime),
)
op.create_index("ix_infrastructure_change_logs_change_date", "infrastructure_change_logs", ["change_date"])
if not _table_exists("decay_policies"):
op.create_table(
"decay_policies",
sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True),
sa.Column("name", sa.String(200), nullable=False),
sa.Column("description", sa.Text),
sa.Column("applies_to_platform", sa.String(100)),
sa.Column("applies_to_asset_type", sa.String(50)),
sa.Column("applies_to_tactic", sa.String(100)),
sa.Column("fresh_days", sa.Integer, server_default="90"),
sa.Column("aging_days", sa.Integer, server_default="180"),
sa.Column("stale_days", sa.Integer, server_default="365"),
sa.Column("default_validity_days", sa.Integer, server_default="180"),
sa.Column("silent_threshold_days", sa.Integer, server_default="30"),
sa.Column("noisy_threshold_daily", sa.Integer, server_default="100"),
sa.Column("recency_weight", sa.Float, server_default="0.3"),
sa.Column("coverage_weight", sa.Float, server_default="0.3"),
sa.Column("health_weight", sa.Float, server_default="0.25"),
sa.Column("diversity_weight", sa.Float, server_default="0.15"),
sa.Column("is_default", sa.Boolean, server_default="false"),
sa.Column("is_active", sa.Boolean, server_default="true"),
sa.Column("created_at", sa.DateTime),
sa.Column("updated_at", sa.DateTime),
)
def downgrade() -> None:
for table in ["decay_policies", "infrastructure_change_logs", "technique_confidence_scores", "detection_validations", "detection_technique_mappings", "detection_assets"]:
if _table_exists(table):
op.drop_table(table)
@@ -1,118 +0,0 @@
"""Phase 9: Ownership & Revalidation Queue
Revision ID: b035ownerq
Revises: b034dlm
Create Date: 2026-05-19
Uses raw SQL for all DDL to avoid SQLAlchemy before_create hook issues
with existing enum types.
"""
from typing import Union
from alembic import op
import sqlalchemy as sa
revision: str = "b035ownerq"
down_revision: Union[str, None] = "b034dlm"
branch_labels = None
depends_on = None
def upgrade() -> None:
conn = op.get_bind()
# ── Enums (idempotent) ────────────────────────────────────────────────────
conn.execute(sa.text("""
DO $$ BEGIN
CREATE TYPE queue_priority AS ENUM ('critical', 'high', 'medium', 'low');
EXCEPTION WHEN duplicate_object THEN NULL;
END $$
"""))
conn.execute(sa.text("""
DO $$ BEGIN
CREATE TYPE queue_status AS ENUM ('pending', 'in_progress', 'completed', 'dismissed');
EXCEPTION WHEN duplicate_object THEN NULL;
END $$
"""))
conn.execute(sa.text("""
DO $$ BEGIN
CREATE TYPE queue_reason AS ENUM (
'validation_expired', 'infra_change', 'osint_alert',
'mitre_update', 'rule_modified', 'low_confidence', 'manual');
EXCEPTION WHEN duplicate_object THEN NULL;
END $$
"""))
# ── technique_ownerships ──────────────────────────────────────────────────
conn.execute(sa.text("""
CREATE TABLE IF NOT EXISTS technique_ownerships (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
technique_id UUID NOT NULL UNIQUE
REFERENCES techniques(id) ON DELETE CASCADE,
owner_id UUID
REFERENCES users(id) ON DELETE SET NULL,
backup_owner_id UUID
REFERENCES users(id) ON DELETE SET NULL,
team VARCHAR(200),
notes TEXT,
assigned_at TIMESTAMP,
assigned_by UUID
REFERENCES users(id) ON DELETE SET NULL,
created_at TIMESTAMP DEFAULT now(),
updated_at TIMESTAMP DEFAULT now()
)
"""))
conn.execute(sa.text(
"CREATE INDEX IF NOT EXISTS ix_techown_owner_id ON technique_ownerships (owner_id)"
))
conn.execute(sa.text(
"CREATE INDEX IF NOT EXISTS ix_techown_technique_id ON technique_ownerships (technique_id)"
))
# ── revalidation_queue_items ──────────────────────────────────────────────
conn.execute(sa.text("""
CREATE TABLE IF NOT EXISTS revalidation_queue_items (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
technique_id UUID
REFERENCES techniques(id) ON DELETE CASCADE,
detection_asset_id UUID
REFERENCES detection_assets(id) ON DELETE CASCADE,
priority queue_priority NOT NULL DEFAULT 'medium',
reason queue_reason NOT NULL,
reason_detail TEXT,
status queue_status NOT NULL DEFAULT 'pending',
assigned_to UUID
REFERENCES users(id) ON DELETE SET NULL,
due_date TIMESTAMP,
created_at TIMESTAMP DEFAULT now(),
completed_at TIMESTAMP,
dismissed_at TIMESTAMP,
completed_by UUID
REFERENCES users(id) ON DELETE SET NULL,
extra JSONB
)
"""))
conn.execute(sa.text(
"CREATE INDEX IF NOT EXISTS ix_rqueue_status ON revalidation_queue_items (status)"
))
conn.execute(sa.text(
"CREATE INDEX IF NOT EXISTS ix_rqueue_priority ON revalidation_queue_items (priority)"
))
conn.execute(sa.text(
"CREATE INDEX IF NOT EXISTS ix_rqueue_assigned_to ON revalidation_queue_items (assigned_to)"
))
conn.execute(sa.text(
"CREATE INDEX IF NOT EXISTS ix_rqueue_technique_id ON revalidation_queue_items (technique_id)"
))
conn.execute(sa.text(
"CREATE INDEX IF NOT EXISTS ix_rqueue_asset_id ON revalidation_queue_items (detection_asset_id)"
))
def downgrade() -> None:
conn = op.get_bind()
conn.execute(sa.text("DROP TABLE IF EXISTS revalidation_queue_items"))
conn.execute(sa.text("DROP TABLE IF EXISTS technique_ownerships"))
conn.execute(sa.text("DROP TYPE IF EXISTS queue_reason"))
conn.execute(sa.text("DROP TYPE IF EXISTS queue_status"))
conn.execute(sa.text("DROP TYPE IF EXISTS queue_priority"))
@@ -1,184 +0,0 @@
"""Phase 10: Attack Paths & Advanced Purple Team
Revision ID: b036atk
Revises: b035ownerq
Create Date: 2026-05-19
Uses raw SQL to avoid SQLAlchemy DDL hook issues with enum types.
"""
from typing import Union
from alembic import op
import sqlalchemy as sa
revision: str = "b036atk"
down_revision: Union[str, None] = "b035ownerq"
branch_labels = None
depends_on = None
def upgrade() -> None:
conn = op.get_bind()
# ── Enums ─────────────────────────────────────────────────────────────────
conn.execute(sa.text("""
DO $$ BEGIN
CREATE TYPE execution_status AS ENUM
('planned','in_progress','completed','aborted');
EXCEPTION WHEN duplicate_object THEN NULL; END $$
"""))
conn.execute(sa.text("""
DO $$ BEGIN
CREATE TYPE step_result_status AS ENUM
('pending','executing','detected','not_detected','skipped');
EXCEPTION WHEN duplicate_object THEN NULL; END $$
"""))
conn.execute(sa.text("""
DO $$ BEGIN
CREATE TYPE timeline_actor_side AS ENUM ('red','blue','system');
EXCEPTION WHEN duplicate_object THEN NULL; END $$
"""))
conn.execute(sa.text("""
DO $$ BEGIN
CREATE TYPE timeline_entry_type AS ENUM
('action','detection','note','phase_transition','flag');
EXCEPTION WHEN duplicate_object THEN NULL; END $$
"""))
# ── attack_paths ──────────────────────────────────────────────────────────
conn.execute(sa.text("""
CREATE TABLE IF NOT EXISTS attack_paths (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name VARCHAR(300) NOT NULL,
description TEXT,
objective TEXT,
is_template BOOLEAN DEFAULT FALSE,
threat_actor_id UUID REFERENCES threat_actors(id) ON DELETE SET NULL,
created_by UUID REFERENCES users(id) ON DELETE SET NULL,
tags JSONB,
is_active BOOLEAN DEFAULT TRUE,
created_at TIMESTAMP DEFAULT now(),
updated_at TIMESTAMP DEFAULT now()
)
"""))
conn.execute(sa.text(
"CREATE INDEX IF NOT EXISTS ix_attack_paths_created_by ON attack_paths (created_by)"
))
conn.execute(sa.text(
"CREATE INDEX IF NOT EXISTS ix_attack_paths_is_template ON attack_paths (is_template)"
))
# ── attack_path_steps ─────────────────────────────────────────────────────
conn.execute(sa.text("""
CREATE TABLE IF NOT EXISTS attack_path_steps (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
attack_path_id UUID NOT NULL REFERENCES attack_paths(id) ON DELETE CASCADE,
order_index INTEGER NOT NULL DEFAULT 0,
kill_chain_phase VARCHAR(60),
technique_id UUID REFERENCES techniques(id) ON DELETE SET NULL,
test_id UUID REFERENCES tests(id) ON DELETE SET NULL,
name VARCHAR(300),
description TEXT,
expected_detection BOOLEAN DEFAULT TRUE,
notes TEXT
)
"""))
conn.execute(sa.text(
"CREATE INDEX IF NOT EXISTS ix_ap_steps_path_id ON attack_path_steps (attack_path_id)"
))
conn.execute(sa.text(
"CREATE INDEX IF NOT EXISTS ix_ap_steps_technique_id ON attack_path_steps (technique_id)"
))
# ── attack_path_executions ────────────────────────────────────────────────
conn.execute(sa.text("""
CREATE TABLE IF NOT EXISTS attack_path_executions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
attack_path_id UUID NOT NULL REFERENCES attack_paths(id) ON DELETE CASCADE,
status execution_status NOT NULL DEFAULT 'planned',
environment VARCHAR(100),
red_team_lead UUID REFERENCES users(id) ON DELETE SET NULL,
blue_team_lead UUID REFERENCES users(id) ON DELETE SET NULL,
started_by UUID REFERENCES users(id) ON DELETE SET NULL,
started_at TIMESTAMP,
completed_at TIMESTAMP,
notes TEXT,
created_at TIMESTAMP DEFAULT now(),
-- kill-chain metrics (populated on completion)
total_steps INTEGER,
detected_steps INTEGER,
not_detected_steps INTEGER,
skipped_steps INTEGER,
detection_rate FLOAT,
mttd_seconds FLOAT,
furthest_undetected_step INTEGER
)
"""))
conn.execute(sa.text(
"CREATE INDEX IF NOT EXISTS ix_ap_exec_path_id ON attack_path_executions (attack_path_id)"
))
conn.execute(sa.text(
"CREATE INDEX IF NOT EXISTS ix_ap_exec_status ON attack_path_executions (status)"
))
# ── attack_path_step_results ──────────────────────────────────────────────
conn.execute(sa.text("""
CREATE TABLE IF NOT EXISTS attack_path_step_results (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
execution_id UUID NOT NULL
REFERENCES attack_path_executions(id) ON DELETE CASCADE,
step_id UUID NOT NULL
REFERENCES attack_path_steps(id) ON DELETE CASCADE,
step_order INTEGER NOT NULL DEFAULT 0,
status step_result_status NOT NULL DEFAULT 'pending',
executed_by UUID REFERENCES users(id) ON DELETE SET NULL,
executed_at TIMESTAMP,
detected_at TIMESTAMP,
time_to_detect_seconds FLOAT,
detection_asset_id UUID
REFERENCES detection_assets(id) ON DELETE SET NULL,
notes TEXT,
evidence_ids JSONB
)
"""))
conn.execute(sa.text(
"CREATE INDEX IF NOT EXISTS ix_ap_stepres_exec ON attack_path_step_results (execution_id)"
))
conn.execute(sa.text(
"CREATE INDEX IF NOT EXISTS ix_ap_stepres_step ON attack_path_step_results (step_id)"
))
# ── attack_path_timeline ──────────────────────────────────────────────────
conn.execute(sa.text("""
CREATE TABLE IF NOT EXISTS attack_path_timeline (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
execution_id UUID NOT NULL
REFERENCES attack_path_executions(id) ON DELETE CASCADE,
step_id UUID REFERENCES attack_path_steps(id) ON DELETE SET NULL,
timestamp TIMESTAMP NOT NULL DEFAULT now(),
actor_side timeline_actor_side NOT NULL,
actor_id UUID REFERENCES users(id) ON DELETE SET NULL,
entry_type timeline_entry_type NOT NULL,
content TEXT NOT NULL,
extra JSONB
)
"""))
conn.execute(sa.text(
"CREATE INDEX IF NOT EXISTS ix_timeline_execution_id ON attack_path_timeline (execution_id)"
))
conn.execute(sa.text(
"CREATE INDEX IF NOT EXISTS ix_timeline_timestamp ON attack_path_timeline (timestamp)"
))
def downgrade() -> None:
conn = op.get_bind()
conn.execute(sa.text("DROP TABLE IF EXISTS attack_path_timeline"))
conn.execute(sa.text("DROP TABLE IF EXISTS attack_path_step_results"))
conn.execute(sa.text("DROP TABLE IF EXISTS attack_path_executions"))
conn.execute(sa.text("DROP TABLE IF EXISTS attack_path_steps"))
conn.execute(sa.text("DROP TABLE IF EXISTS attack_paths"))
conn.execute(sa.text("DROP TYPE IF EXISTS timeline_entry_type"))
conn.execute(sa.text("DROP TYPE IF EXISTS timeline_actor_side"))
conn.execute(sa.text("DROP TYPE IF EXISTS step_result_status"))
conn.execute(sa.text("DROP TYPE IF EXISTS execution_status"))
-106
View File
@@ -1,106 +0,0 @@
"""Phase 11: Knowledge Management — Playbooks + Lessons Learned
Revision ID: b037know
Revises: b036atk
Create Date: 2026-05-20
Uses raw SQL to bypass SQLAlchemy DDL hooks (no enum types — string columns
with Pydantic-layer validation instead, so no PostgreSQL enums needed).
"""
from typing import Union
from alembic import op
import sqlalchemy as sa
revision: str = "b037know"
down_revision: Union[str, None] = "b036atk"
branch_labels = None
depends_on = None
def upgrade() -> None:
conn = op.get_bind()
# ── playbooks ──────────────────────────────────────────────────────────────
conn.execute(sa.text("""
CREATE TABLE IF NOT EXISTS playbooks (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
technique_id UUID NOT NULL REFERENCES techniques(id) ON DELETE CASCADE,
playbook_type VARCHAR(32) NOT NULL,
title VARCHAR(255) NOT NULL,
content TEXT NOT NULL DEFAULT '',
version INTEGER NOT NULL DEFAULT 1,
tools JSONB,
prerequisites JSONB,
created_by UUID REFERENCES users(id) ON DELETE SET NULL,
updated_by UUID REFERENCES users(id) ON DELETE SET NULL,
created_at TIMESTAMP DEFAULT now(),
updated_at TIMESTAMP DEFAULT now(),
is_active BOOLEAN DEFAULT TRUE,
CONSTRAINT uq_playbook_technique_type UNIQUE (technique_id, playbook_type)
)
"""))
conn.execute(sa.text(
"CREATE INDEX IF NOT EXISTS ix_playbooks_technique_id ON playbooks (technique_id)"
))
conn.execute(sa.text(
"CREATE INDEX IF NOT EXISTS ix_playbooks_type ON playbooks (playbook_type)"
))
# ── playbook_versions ──────────────────────────────────────────────────────
conn.execute(sa.text("""
CREATE TABLE IF NOT EXISTS playbook_versions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
playbook_id UUID NOT NULL REFERENCES playbooks(id) ON DELETE CASCADE,
version INTEGER NOT NULL,
title VARCHAR(255) NOT NULL,
content TEXT NOT NULL DEFAULT '',
tools JSONB,
prerequisites JSONB,
changed_by UUID REFERENCES users(id) ON DELETE SET NULL,
change_note VARCHAR(500),
created_at TIMESTAMP DEFAULT now()
)
"""))
conn.execute(sa.text(
"CREATE INDEX IF NOT EXISTS ix_pb_versions_playbook_id ON playbook_versions (playbook_id)"
))
conn.execute(sa.text(
"CREATE INDEX IF NOT EXISTS ix_pb_versions_version ON playbook_versions (playbook_id, version)"
))
# ── lessons_learned ────────────────────────────────────────────────────────
conn.execute(sa.text("""
CREATE TABLE IF NOT EXISTS lessons_learned (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
title VARCHAR(255) NOT NULL,
what_happened TEXT NOT NULL DEFAULT '',
root_cause TEXT NOT NULL DEFAULT '',
fix_applied TEXT,
severity VARCHAR(16) NOT NULL DEFAULT 'medium',
entity_type VARCHAR(32) NOT NULL DEFAULT 'manual',
entity_id UUID,
technique_ids JSONB,
tags JSONB,
created_by UUID REFERENCES users(id) ON DELETE SET NULL,
created_at TIMESTAMP DEFAULT now(),
updated_at TIMESTAMP DEFAULT now(),
is_active BOOLEAN DEFAULT TRUE
)
"""))
conn.execute(sa.text(
"CREATE INDEX IF NOT EXISTS ix_ll_entity ON lessons_learned (entity_type, entity_id)"
))
conn.execute(sa.text(
"CREATE INDEX IF NOT EXISTS ix_ll_severity ON lessons_learned (severity)"
))
conn.execute(sa.text(
"CREATE INDEX IF NOT EXISTS ix_ll_created_by ON lessons_learned (created_by)"
))
def downgrade() -> None:
conn = op.get_bind()
conn.execute(sa.text("DROP TABLE IF EXISTS lessons_learned"))
conn.execute(sa.text("DROP TABLE IF EXISTS playbook_versions"))
conn.execute(sa.text("DROP TABLE IF EXISTS playbooks"))
@@ -1,62 +0,0 @@
"""Phase 12: Risk Intelligence — technique_risk_profiles table
Revision ID: b038risk
Revises: b037know
Create Date: 2026-05-20
Uses raw SQL to bypass SQLAlchemy DDL hooks.
"""
from typing import Union
from alembic import op
import sqlalchemy as sa
revision: str = "b038risk"
down_revision: Union[str, None] = "b037know"
branch_labels = None
depends_on = None
def upgrade() -> None:
conn = op.get_bind()
conn.execute(sa.text("""
CREATE TABLE IF NOT EXISTS technique_risk_profiles (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
technique_id UUID NOT NULL REFERENCES techniques(id) ON DELETE CASCADE,
risk_score FLOAT NOT NULL DEFAULT 0.0,
likelihood FLOAT NOT NULL DEFAULT 0.0,
impact FLOAT NOT NULL DEFAULT 0.0,
risk_level VARCHAR(16) NOT NULL DEFAULT 'info',
detection_gap FLOAT NOT NULL DEFAULT 1.0,
threat_actor_count INTEGER NOT NULL DEFAULT 0,
osint_signal_count INTEGER NOT NULL DEFAULT 0,
test_fail_count INTEGER NOT NULL DEFAULT 0,
test_total_count INTEGER NOT NULL DEFAULT 0,
test_failure_rate FLOAT NOT NULL DEFAULT 0.0,
confidence_level FLOAT NOT NULL DEFAULT 0.0,
scoring_breakdown JSONB,
recommendations JSONB,
computed_at TIMESTAMP DEFAULT now(),
is_stale BOOLEAN DEFAULT TRUE,
CONSTRAINT uq_risk_profile_technique UNIQUE (technique_id)
)
"""))
conn.execute(sa.text(
"CREATE INDEX IF NOT EXISTS ix_risk_profiles_risk_score "
"ON technique_risk_profiles (risk_score)"
))
conn.execute(sa.text(
"CREATE INDEX IF NOT EXISTS ix_risk_profiles_risk_level "
"ON technique_risk_profiles (risk_level)"
))
conn.execute(sa.text(
"CREATE INDEX IF NOT EXISTS ix_risk_profiles_stale "
"ON technique_risk_profiles (is_stale)"
))
def downgrade() -> None:
conn = op.get_bind()
conn.execute(sa.text("DROP TABLE IF EXISTS technique_risk_profiles"))
@@ -1,77 +0,0 @@
"""Phase 13: Executive Dashboard — posture_snapshots table.
Revision ID: b039exec
Revises: b038risk
Create Date: 2026-05-20
"""
from alembic import op
import sqlalchemy as sa
revision = "b039exec"
down_revision = "b038risk"
branch_labels = None
depends_on = None
def upgrade() -> None:
conn = op.get_bind()
conn.execute(sa.text("""
CREATE TABLE IF NOT EXISTS posture_snapshots (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
snapshot_date DATE NOT NULL,
-- Coverage
total_techniques INTEGER NOT NULL DEFAULT 0,
validated_count INTEGER NOT NULL DEFAULT 0,
partial_count INTEGER NOT NULL DEFAULT 0,
not_covered_count INTEGER NOT NULL DEFAULT 0,
coverage_pct FLOAT NOT NULL DEFAULT 0.0,
-- Risk
avg_risk_score FLOAT NOT NULL DEFAULT 0.0,
critical_count INTEGER NOT NULL DEFAULT 0,
high_count INTEGER NOT NULL DEFAULT 0,
medium_count INTEGER NOT NULL DEFAULT 0,
low_count INTEGER NOT NULL DEFAULT 0,
-- Operations
open_queue_items INTEGER NOT NULL DEFAULT 0,
orphan_techniques INTEGER NOT NULL DEFAULT 0,
-- Knowledge
playbook_count INTEGER NOT NULL DEFAULT 0,
lesson_count INTEGER NOT NULL DEFAULT 0,
-- MTTD
mttd_avg_seconds FLOAT,
executions_30d INTEGER NOT NULL DEFAULT 0,
detection_rate_30d FLOAT,
-- Meta
created_by UUID REFERENCES users(id) ON DELETE SET NULL,
created_at TIMESTAMP WITHOUT TIME ZONE DEFAULT now(),
extra JSONB
)
"""))
# Unique constraint: one snapshot per calendar day
conn.execute(sa.text("""
DO $$ BEGIN
ALTER TABLE posture_snapshots
ADD CONSTRAINT uq_posture_snapshot_date UNIQUE (snapshot_date);
EXCEPTION WHEN duplicate_table THEN NULL;
WHEN duplicate_object THEN NULL;
END $$
"""))
# Index for date-range trend queries
conn.execute(sa.text("""
CREATE INDEX IF NOT EXISTS ix_posture_snapshots_date
ON posture_snapshots (snapshot_date)
"""))
def downgrade() -> None:
conn = op.get_bind()
conn.execute(sa.text("DROP TABLE IF EXISTS posture_snapshots CASCADE"))
@@ -1,75 +0,0 @@
"""Phase 14: Enterprise Readiness — api_keys and sso_configs tables.
Revision ID: b040ent
Revises: b039exec
Create Date: 2026-05-20
"""
from alembic import op
import sqlalchemy as sa
revision = "b040ent"
down_revision = "b039exec"
branch_labels = None
depends_on = None
def upgrade() -> None:
conn = op.get_bind()
# ── api_keys ──────────────────────────────────────────────────────────────
conn.execute(sa.text("""
CREATE TABLE IF NOT EXISTS api_keys (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name VARCHAR(200) NOT NULL,
description TEXT,
key_prefix VARCHAR(13) NOT NULL,
key_hash VARCHAR(64) NOT NULL UNIQUE,
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
scopes JSONB NOT NULL DEFAULT '["read"]',
last_used_at TIMESTAMP WITHOUT TIME ZONE,
expires_at TIMESTAMP WITHOUT TIME ZONE,
is_active BOOLEAN NOT NULL DEFAULT TRUE,
created_at TIMESTAMP WITHOUT TIME ZONE DEFAULT now()
)
"""))
conn.execute(sa.text(
"CREATE INDEX IF NOT EXISTS ix_api_keys_user_id ON api_keys (user_id)"
))
conn.execute(sa.text(
"CREATE INDEX IF NOT EXISTS ix_api_keys_key_hash ON api_keys (key_hash)"
))
conn.execute(sa.text(
"CREATE INDEX IF NOT EXISTS ix_api_keys_active ON api_keys (is_active)"
))
# ── sso_configs ───────────────────────────────────────────────────────────
conn.execute(sa.text("""
CREATE TABLE IF NOT EXISTS sso_configs (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
is_enabled BOOLEAN NOT NULL DEFAULT FALSE,
provider_name VARCHAR(200),
sp_entity_id VARCHAR(500),
sp_acs_url VARCHAR(500),
sp_slo_url VARCHAR(500),
sp_certificate TEXT,
sp_private_key TEXT,
idp_entity_id VARCHAR(500),
idp_sso_url VARCHAR(500),
idp_slo_url VARCHAR(500),
idp_certificate TEXT,
attr_email VARCHAR(200) DEFAULT 'email',
attr_username VARCHAR(200) DEFAULT 'username',
attr_role VARCHAR(200) DEFAULT 'role',
default_role VARCHAR(50) DEFAULT 'viewer',
auto_provision BOOLEAN NOT NULL DEFAULT TRUE,
created_at TIMESTAMP WITHOUT TIME ZONE DEFAULT now(),
updated_at TIMESTAMP WITHOUT TIME ZONE DEFAULT now()
)
"""))
def downgrade() -> None:
conn = op.get_bind()
conn.execute(sa.text("DROP TABLE IF EXISTS api_keys CASCADE"))
conn.execute(sa.text("DROP TABLE IF EXISTS sso_configs CASCADE"))
@@ -1,82 +0,0 @@
"""Phase 13: Operational Alerts — alert_rules and alert_instances tables.
Revision ID: b041alerts
Revises: b040ent
Create Date: 2026-05-21
"""
from alembic import op
import sqlalchemy as sa
revision = "b041alerts"
down_revision = "b040ent"
branch_labels = None
depends_on = None
def upgrade() -> None:
conn = op.get_bind()
# ── alert_rules ───────────────────────────────────────────────────────────
conn.execute(sa.text("""
CREATE TABLE IF NOT EXISTS alert_rules (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name VARCHAR(300) NOT NULL,
description TEXT,
rule_type VARCHAR(50) NOT NULL,
severity VARCHAR(20) NOT NULL DEFAULT 'medium',
is_enabled BOOLEAN NOT NULL DEFAULT TRUE,
is_system BOOLEAN NOT NULL DEFAULT FALSE,
config JSONB NOT NULL DEFAULT '{}',
notify_in_app BOOLEAN NOT NULL DEFAULT TRUE,
notify_webhook BOOLEAN NOT NULL DEFAULT FALSE,
webhook_id UUID REFERENCES webhook_configs(id) ON DELETE SET NULL,
cooldown_hours INTEGER NOT NULL DEFAULT 24,
created_by UUID REFERENCES users(id) ON DELETE SET NULL,
created_at TIMESTAMP WITHOUT TIME ZONE DEFAULT now(),
last_fired_at TIMESTAMP WITHOUT TIME ZONE
)
"""))
conn.execute(sa.text(
"CREATE INDEX IF NOT EXISTS ix_alert_rules_type ON alert_rules (rule_type)"
))
conn.execute(sa.text(
"CREATE INDEX IF NOT EXISTS ix_alert_rules_enabled ON alert_rules (is_enabled)"
))
# ── alert_instances ───────────────────────────────────────────────────────
conn.execute(sa.text("""
CREATE TABLE IF NOT EXISTS alert_instances (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
rule_id UUID REFERENCES alert_rules(id) ON DELETE SET NULL,
rule_name VARCHAR(300) NOT NULL,
rule_type VARCHAR(50) NOT NULL,
severity VARCHAR(20) NOT NULL,
title VARCHAR(500) NOT NULL,
message TEXT NOT NULL,
details JSONB,
status VARCHAR(20) NOT NULL DEFAULT 'open',
acknowledged_by UUID REFERENCES users(id) ON DELETE SET NULL,
acknowledged_at TIMESTAMP WITHOUT TIME ZONE,
resolved_at TIMESTAMP WITHOUT TIME ZONE,
created_at TIMESTAMP WITHOUT TIME ZONE DEFAULT now()
)
"""))
conn.execute(sa.text(
"CREATE INDEX IF NOT EXISTS ix_alert_instances_rule_id ON alert_instances (rule_id)"
))
conn.execute(sa.text(
"CREATE INDEX IF NOT EXISTS ix_alert_instances_status ON alert_instances (status)"
))
conn.execute(sa.text(
"CREATE INDEX IF NOT EXISTS ix_alert_instances_severity ON alert_instances (severity)"
))
conn.execute(sa.text(
"CREATE INDEX IF NOT EXISTS ix_alert_instances_created ON alert_instances (created_at)"
))
def downgrade() -> None:
conn = op.get_bind()
conn.execute(sa.text("DROP TABLE IF EXISTS alert_instances CASCADE"))
conn.execute(sa.text("DROP TABLE IF EXISTS alert_rules CASCADE"))
@@ -1,25 +0,0 @@
"""Add jira_api_token to users table.
Revision ID: b042
Revises: b041_operational_alerts
Create Date: 2026-05-26
"""
from alembic import op
import sqlalchemy as sa
revision = "b042"
down_revision = "b041alerts"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column(
"users",
sa.Column("jira_api_token", sa.String(500), nullable=True),
)
def downgrade() -> None:
op.drop_column("users", "jira_api_token")
@@ -1,28 +0,0 @@
"""Add jira_email to users table.
Allows each user to specify a separate email for Jira authentication,
independent of their Aegis account email.
Revision ID: b043
Revises: b042
Create Date: 2026-05-26
"""
from alembic import op
import sqlalchemy as sa
revision = "b043"
down_revision = "b042"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column(
"users",
sa.Column("jira_email", sa.String(255), nullable=True),
)
def downgrade() -> None:
op.drop_column("users", "jira_email")
@@ -1,25 +0,0 @@
"""add tempo_api_token to users
Revision ID: b044
Revises: b043
Create Date: 2026-05-27
"""
from alembic import op
import sqlalchemy as sa
revision = "b044"
down_revision = "b043"
branch_labels = None
depends_on = None
def upgrade():
op.add_column(
"users",
sa.Column("tempo_api_token", sa.String(500), nullable=True),
)
def downgrade():
op.drop_column("users", "tempo_api_token")
@@ -1,16 +0,0 @@
"""Add blue_work_started_at to tests table."""
from alembic import op
import sqlalchemy as sa
revision = "b045"
down_revision = "b044"
branch_labels = None
depends_on = None
def upgrade():
op.add_column("tests", sa.Column("blue_work_started_at", sa.DateTime(), nullable=True))
def downgrade():
op.drop_column("tests", "blue_work_started_at")
@@ -1,22 +0,0 @@
"""Add 'disputed' value to teststate enum.
Revision ID: b046
Revises: b045
Create Date: 2026-06-03
"""
from alembic import op
revision = "b046"
down_revision = "b045"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.execute("ALTER TYPE teststate ADD VALUE IF NOT EXISTS 'disputed'")
def downgrade() -> None:
# PostgreSQL does not support removing enum values; downgrade is a no-op.
pass
@@ -1,27 +0,0 @@
"""Add start_date to campaigns.
Revision ID: b047
Revises: b046
Create Date: 2026-06-03
"""
from alembic import op
import sqlalchemy as sa
revision = "b047"
down_revision = "b046"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column(
"campaigns",
sa.Column("start_date", sa.DateTime(), nullable=True),
)
op.create_index("ix_campaigns_start_date", "campaigns", ["start_date"])
def downgrade() -> None:
op.drop_index("ix_campaigns_start_date", table_name="campaigns")
op.drop_column("campaigns", "start_date")
@@ -1,39 +0,0 @@
"""Add evaluation_imports table.
Revision ID: b048
Revises: b047
Create Date: 2026-06-05
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects.postgresql import UUID
revision = "b048"
down_revision = "b047"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.create_table(
"evaluation_imports",
sa.Column("id", UUID(as_uuid=True), primary_key=True),
sa.Column("adversary_name", sa.String, nullable=False),
sa.Column("adversary_display", sa.String, nullable=False),
sa.Column("eval_round", sa.Integer, nullable=False),
sa.Column("imported_at", sa.DateTime, nullable=False),
sa.Column("imported_by", UUID(as_uuid=True), sa.ForeignKey("users.id"), nullable=True),
sa.Column("tests_created", sa.Integer, default=0),
sa.Column("techniques_covered", sa.Integer, default=0),
sa.Column("status", sa.String, default="completed"),
sa.Column("notes", sa.Text, nullable=True),
)
op.create_index("ix_evaluation_imports_adversary", "evaluation_imports", ["adversary_name"])
op.create_index("ix_evaluation_imports_round", "evaluation_imports", ["eval_round"])
def downgrade() -> None:
op.drop_index("ix_evaluation_imports_round", table_name="evaluation_imports")
op.drop_index("ix_evaluation_imports_adversary", table_name="evaluation_imports")
op.drop_table("evaluation_imports")
@@ -1,26 +0,0 @@
"""Add external_references column to techniques table.
Revision ID: b049
Revises: b048
Create Date: 2026-06-18
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects.postgresql import JSONB
revision = "b049"
down_revision = "b048"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column(
"techniques",
sa.Column("external_references", JSONB, nullable=True),
)
def downgrade() -> None:
op.drop_column("techniques", "external_references")
@@ -1,24 +0,0 @@
"""Add red_tech_assignee and blue_tech_assignee to tests.
Revision ID: b050
Revises: b049
Create Date: 2026-06-19
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects.postgresql import UUID
revision = "b050"
down_revision = "b049"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column("tests", sa.Column("red_tech_assignee", UUID(as_uuid=True), sa.ForeignKey("users.id"), nullable=True))
op.add_column("tests", sa.Column("blue_tech_assignee", UUID(as_uuid=True), sa.ForeignKey("users.id"), nullable=True))
def downgrade() -> None:
op.drop_column("tests", "blue_tech_assignee")
op.drop_column("tests", "red_tech_assignee")
@@ -1,25 +0,0 @@
"""Add is_on_hold, hold_reason, held_at to tests.
Revision ID: b051
Revises: b050
Create Date: 2026-06-19
"""
from alembic import op
import sqlalchemy as sa
revision = "b051"
down_revision = "b050"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column("tests", sa.Column("is_on_hold", sa.Boolean(), nullable=False, server_default="false"))
op.add_column("tests", sa.Column("hold_reason", sa.Text(), nullable=True))
op.add_column("tests", sa.Column("held_at", sa.DateTime(), nullable=True))
def downgrade() -> None:
op.drop_column("tests", "held_at")
op.drop_column("tests", "hold_reason")
op.drop_column("tests", "is_on_hold")
@@ -1,31 +0,0 @@
"""Add containment_result to tests.
Revision ID: b052
Revises: b051
Create Date: 2026-06-24
"""
from alembic import op
import sqlalchemy as sa
revision = "b052"
down_revision = "b051"
branch_labels = None
depends_on = None
containment_enum = sa.Enum(
"contained", "partially_contained", "not_contained",
name="containmentresult",
)
def upgrade() -> None:
containment_enum.create(op.get_bind(), checkfirst=True)
op.add_column(
"tests",
sa.Column("containment_result", containment_enum, nullable=True),
)
def downgrade() -> None:
op.drop_column("tests", "containment_result")
containment_enum.drop(op.get_bind(), checkfirst=True)
@@ -1,27 +0,0 @@
"""Add execution_start_time, execution_end_time, detection_time, containment_time to tests.
Revision ID: b053
Revises: b052
Create Date: 2026-06-24
"""
from alembic import op
import sqlalchemy as sa
revision = "b053"
down_revision = "b052"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column("tests", sa.Column("execution_start_time", sa.DateTime(), nullable=True))
op.add_column("tests", sa.Column("execution_end_time", sa.DateTime(), nullable=True))
op.add_column("tests", sa.Column("detection_time", sa.DateTime(), nullable=True))
op.add_column("tests", sa.Column("containment_time", sa.DateTime(), nullable=True))
def downgrade() -> None:
op.drop_column("tests", "containment_time")
op.drop_column("tests", "detection_time")
op.drop_column("tests", "execution_end_time")
op.drop_column("tests", "execution_start_time")
@@ -1,36 +0,0 @@
"""Add approved_by, approved_at, rejection_reason to campaigns.
Revision ID: b054
Revises: b053
Create Date: 2026-07-02
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
revision = "b054"
down_revision = "b053"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column(
"campaigns",
sa.Column("approved_by", postgresql.UUID(as_uuid=True), nullable=True),
)
op.add_column("campaigns", sa.Column("approved_at", sa.DateTime(), nullable=True))
op.add_column("campaigns", sa.Column("rejection_reason", sa.Text(), nullable=True))
op.create_foreign_key(
"fk_campaigns_approved_by_users",
"campaigns", "users",
["approved_by"], ["id"],
ondelete="SET NULL",
)
def downgrade() -> None:
op.drop_constraint("fk_campaigns_approved_by_users", "campaigns", type_="foreignkey")
op.drop_column("campaigns", "rejection_reason")
op.drop_column("campaigns", "approved_at")
op.drop_column("campaigns", "approved_by")
@@ -1,59 +0,0 @@
"""Add campaign_modification_requests table.
Revision ID: b055
Revises: b054
Create Date: 2026-07-02
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
revision = "b055"
down_revision = "b054"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.create_table(
"campaign_modification_requests",
sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True),
sa.Column(
"campaign_id", postgresql.UUID(as_uuid=True),
sa.ForeignKey("campaigns.id", ondelete="CASCADE"), nullable=False,
),
sa.Column(
"requested_by", postgresql.UUID(as_uuid=True),
sa.ForeignKey("users.id", ondelete="SET NULL"), nullable=True,
),
sa.Column("action", sa.String(), nullable=False),
sa.Column(
"test_id", postgresql.UUID(as_uuid=True),
sa.ForeignKey("tests.id", ondelete="CASCADE"), nullable=False,
),
sa.Column("order_index", sa.Integer(), nullable=True),
sa.Column("phase", sa.String(), nullable=True),
sa.Column("justification", sa.Text(), nullable=False),
sa.Column("status", sa.String(), nullable=False, server_default="pending"),
sa.Column(
"reviewed_by", postgresql.UUID(as_uuid=True),
sa.ForeignKey("users.id", ondelete="SET NULL"), nullable=True,
),
sa.Column("reviewed_at", sa.DateTime(), nullable=True),
sa.Column("review_notes", sa.Text(), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
)
op.create_index(
"ix_campaign_mod_requests_campaign",
"campaign_modification_requests", ["campaign_id"],
)
op.create_index(
"ix_campaign_mod_requests_status",
"campaign_modification_requests", ["status"],
)
def downgrade() -> None:
op.drop_index("ix_campaign_mod_requests_status", table_name="campaign_modification_requests")
op.drop_index("ix_campaign_mod_requests_campaign", table_name="campaign_modification_requests")
op.drop_table("campaign_modification_requests")
@@ -1,50 +0,0 @@
"""Change campaign_modification_requests.test_id to nullable with ON DELETE SET NULL.
Approving a "remove_test" modification request deletes the underlying Test
row. With the original ON DELETE CASCADE, that delete cascaded and wiped
out the modification-request row itself, destroying the audit record
(justification, reviewer, decision) the request exists to preserve.
Revision ID: b056
Revises: b055
Create Date: 2026-07-03
"""
from alembic import op
import sqlalchemy as sa
revision = "b056"
down_revision = "b055"
branch_labels = None
depends_on = None
_FK_NAME = "campaign_modification_requests_test_id_fkey"
def upgrade() -> None:
op.alter_column(
"campaign_modification_requests", "test_id",
existing_type=sa.dialects.postgresql.UUID(as_uuid=True),
nullable=True,
)
op.drop_constraint(_FK_NAME, "campaign_modification_requests", type_="foreignkey")
op.create_foreign_key(
_FK_NAME,
"campaign_modification_requests", "tests",
["test_id"], ["id"],
ondelete="SET NULL",
)
def downgrade() -> None:
op.drop_constraint(_FK_NAME, "campaign_modification_requests", type_="foreignkey")
op.create_foreign_key(
_FK_NAME,
"campaign_modification_requests", "tests",
["test_id"], ["id"],
ondelete="CASCADE",
)
op.alter_column(
"campaign_modification_requests", "test_id",
existing_type=sa.dialects.postgresql.UUID(as_uuid=True),
nullable=False,
)
@@ -1,44 +0,0 @@
"""Add red_review, blue_review states and review-assignment columns to tests.
Revision ID: b057
Revises: b056
Create Date: 2026-07-06
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
revision = "b057"
down_revision = "b056"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.execute("ALTER TYPE teststate ADD VALUE IF NOT EXISTS 'red_review'")
op.execute("ALTER TYPE teststate ADD VALUE IF NOT EXISTS 'blue_review'")
op.add_column("tests", sa.Column("red_reviewer_assignee", postgresql.UUID(as_uuid=True), sa.ForeignKey("users.id"), nullable=True))
op.add_column("tests", sa.Column("red_review_by", postgresql.UUID(as_uuid=True), sa.ForeignKey("users.id"), nullable=True))
op.add_column("tests", sa.Column("red_review_at", sa.DateTime(), nullable=True))
op.add_column("tests", sa.Column("red_review_notes", sa.Text(), nullable=True))
op.add_column("tests", sa.Column("blue_reviewer_assignee", postgresql.UUID(as_uuid=True), sa.ForeignKey("users.id"), nullable=True))
op.add_column("tests", sa.Column("blue_review_by", postgresql.UUID(as_uuid=True), sa.ForeignKey("users.id"), nullable=True))
op.add_column("tests", sa.Column("blue_review_at", sa.DateTime(), nullable=True))
op.add_column("tests", sa.Column("blue_review_notes", sa.Text(), nullable=True))
op.add_column("tests", sa.Column("system_gaps", sa.Text(), nullable=True))
def downgrade() -> None:
op.drop_column("tests", "system_gaps")
op.drop_column("tests", "blue_review_notes")
op.drop_column("tests", "blue_review_at")
op.drop_column("tests", "blue_review_by")
op.drop_column("tests", "blue_reviewer_assignee")
op.drop_column("tests", "red_review_notes")
op.drop_column("tests", "red_review_at")
op.drop_column("tests", "red_review_by")
op.drop_column("tests", "red_reviewer_assignee")
# PostgreSQL does not support removing enum values; downgrade is a no-op
# for the enum part, matching b046_add_disputed_test_state.py.
@@ -1,48 +0,0 @@
"""Convert tests.attack_success from Boolean to a 3-value enum.
Revision ID: b058
Revises: b057
Create Date: 2026-07-06
"""
from alembic import op
import sqlalchemy as sa
revision = "b058"
down_revision = "b057"
branch_labels = None
depends_on = None
_ENUM_NAME = "attacksuccessresult"
def upgrade() -> None:
attack_success_enum = sa.Enum(
"successful", "not_successful", "partially_successful", name=_ENUM_NAME
)
attack_success_enum.create(op.get_bind(), checkfirst=True)
op.add_column("tests", sa.Column("attack_success_new", attack_success_enum, nullable=True))
op.execute(
"UPDATE tests SET attack_success_new = (CASE "
"WHEN attack_success = true THEN 'successful' "
"WHEN attack_success = false THEN 'not_successful' "
f"ELSE NULL END)::{_ENUM_NAME}"
)
op.drop_column("tests", "attack_success")
op.alter_column("tests", "attack_success_new", new_column_name="attack_success")
def downgrade() -> None:
op.add_column("tests", sa.Column("attack_success_bool", sa.Boolean(), nullable=True))
op.execute(
"UPDATE tests SET attack_success_bool = CASE "
"WHEN attack_success = 'successful' THEN true "
"WHEN attack_success = 'not_successful' THEN false "
"ELSE NULL END"
)
op.drop_column("tests", "attack_success")
op.alter_column("tests", "attack_success_bool", new_column_name="attack_success")
sa.Enum(name=_ENUM_NAME).drop(op.get_bind(), checkfirst=True)
@@ -1,41 +0,0 @@
"""Rename data_classification values to match the org's real scheme.
public -> public_release, internal -> general_use, sensitive -> confidential.
restricted is unchanged. Applies to tests, campaigns, and evidences tables
(all plain String(20) columns — no native enum type to alter).
Revision ID: b059
Revises: b058
Create Date: 2026-07-07
"""
from alembic import op
revision = "b059"
down_revision = "b058"
branch_labels = None
depends_on = None
_TABLES = ("tests", "campaigns", "evidences")
_RENAMES_UP = {
"public": "public_release",
"internal": "general_use",
"sensitive": "confidential",
}
_RENAMES_DOWN = {v: k for k, v in _RENAMES_UP.items()}
def upgrade() -> None:
for table in _TABLES:
for old, new in _RENAMES_UP.items():
op.execute(f"UPDATE {table} SET data_classification = '{new}' WHERE data_classification = '{old}'")
op.alter_column(table, "data_classification", server_default="confidential")
def downgrade() -> None:
for table in _TABLES:
for new, old in _RENAMES_DOWN.items():
op.execute(f"UPDATE {table} SET data_classification = '{old}' WHERE data_classification = '{new}'")
op.alter_column(table, "data_classification", server_default="internal")
@@ -1,47 +0,0 @@
"""Rename data_classification values to match Jira's real Data Sensitivity field.
Jira's actual "Data Sensitivity" custom field (customfield_11814) has 6
options — Public, General Use, Internal Use Only, Trusted People, Customer
Content, PII — not the 4-tier scheme Aegis invented independently. This
migration replicates Jira's scheme so ticket sync stops guessing at a
mapping. Applies to tests, campaigns, and evidences tables (all plain
String(20) columns — no native enum type to alter).
public_release -> public, confidential -> internal_use_only,
restricted -> pii. general_use is unchanged.
Revision ID: b060
Revises: b059
Create Date: 2026-07-09
"""
from alembic import op
revision = "b060"
down_revision = "b059"
branch_labels = None
depends_on = None
_TABLES = ("tests", "campaigns", "evidences")
_RENAMES_UP = {
"public_release": "public",
"confidential": "internal_use_only",
"restricted": "pii",
}
_RENAMES_DOWN = {v: k for k, v in _RENAMES_UP.items()}
def upgrade() -> None:
for table in _TABLES:
for old, new in _RENAMES_UP.items():
op.execute(f"UPDATE {table} SET data_classification = '{new}' WHERE data_classification = '{old}'")
op.alter_column(table, "data_classification", server_default="internal_use_only")
def downgrade() -> None:
for table in _TABLES:
for new, old in _RENAMES_DOWN.items():
op.execute(f"UPDATE {table} SET data_classification = '{old}' WHERE data_classification = '{new}'")
op.alter_column(table, "data_classification", server_default="confidential")
@@ -1,64 +0,0 @@
"""Archive round data on reopen instead of overwriting it.
Reopening a test for rework used to reset the live procedure/result fields
in place, losing the prior round's data (and clobbering the Phase Timeline,
which read those same mutable columns). This adds an append-only
``test_round_history`` table that snapshots a round's fields right before
they get reset, plus per-team round counters on ``tests`` so each round is
numbered.
Revision ID: b061
Revises: b060
Create Date: 2026-07-13
"""
import sqlalchemy as sa
from alembic import op
from sqlalchemy.dialects import postgresql
revision = "b061"
down_revision = "b060"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column("tests", sa.Column("red_round_number", sa.Integer(), nullable=False, server_default="1"))
op.add_column("tests", sa.Column("blue_round_number", sa.Integer(), nullable=False, server_default="1"))
op.create_table(
"test_round_history",
sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True),
sa.Column("test_id", postgresql.UUID(as_uuid=True), sa.ForeignKey("tests.id"), nullable=False),
sa.Column("team", sa.String(10), nullable=False),
sa.Column("round_number", sa.Integer(), nullable=False),
sa.Column("started_at", sa.DateTime(), nullable=True),
sa.Column("ended_at", sa.DateTime(), nullable=True),
sa.Column("paused_seconds", sa.Integer(), nullable=True),
sa.Column("procedure_text", sa.Text(), nullable=True),
sa.Column("tool_used", sa.String(), nullable=True),
# Plain strings, not the shared Postgres enum types tests.* uses —
# this is an archive table with no DB-level constraint needs, and
# reusing those native type names here would make Alembic try to
# (re)create them.
sa.Column("attack_success", sa.String(), nullable=True),
sa.Column("red_summary", sa.Text(), nullable=True),
sa.Column("execution_start_time", sa.DateTime(), nullable=True),
sa.Column("execution_end_time", sa.DateTime(), nullable=True),
sa.Column("detection_result", sa.String(), nullable=True),
sa.Column("containment_result", sa.String(), nullable=True),
sa.Column("detection_time", sa.DateTime(), nullable=True),
sa.Column("containment_time", sa.DateTime(), nullable=True),
sa.Column("blue_summary", sa.Text(), nullable=True),
sa.Column("review_notes", sa.Text(), nullable=True),
sa.Column("reviewed_by", postgresql.UUID(as_uuid=True), sa.ForeignKey("users.id"), nullable=True),
sa.Column("archived_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
)
op.create_index("ix_test_round_history_test_id", "test_round_history", ["test_id"])
def downgrade() -> None:
op.drop_index("ix_test_round_history_test_id", table_name="test_round_history")
op.drop_table("test_round_history")
op.drop_column("tests", "blue_round_number")
op.drop_column("tests", "red_round_number")
@@ -1,58 +0,0 @@
"""Add detect_procedure fields and procedure_suggestions review table.
Blue Team gets a "Detect Procedure" field (what they actually did to
detect the attack) mirroring Red's procedure_text, plus a matching
"suggested" field on test_templates. Commands extracted from either
side's procedure text are proposed as template improvements via a new
procedure_suggestions table, reviewed and approved/rejected by a lead
rather than written automatically.
Revision ID: b062
Revises: b061
Create Date: 2026-07-14
"""
import sqlalchemy as sa
from alembic import op
from sqlalchemy.dialects import postgresql
revision = "b062"
down_revision = "b061"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column("tests", sa.Column("detect_procedure", sa.Text(), nullable=True))
op.add_column(
"tests",
sa.Column("source_template_id", postgresql.UUID(as_uuid=True), sa.ForeignKey("test_templates.id"), nullable=True),
)
op.add_column("test_templates", sa.Column("detect_suggested_procedure", sa.Text(), nullable=True))
op.add_column("test_round_history", sa.Column("detect_procedure", sa.Text(), nullable=True))
op.create_table(
"procedure_suggestions",
sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True),
sa.Column("template_id", postgresql.UUID(as_uuid=True), sa.ForeignKey("test_templates.id"), nullable=False),
sa.Column("team", sa.String(10), nullable=False),
sa.Column("suggested_text", sa.Text(), nullable=False),
sa.Column("source_test_id", postgresql.UUID(as_uuid=True), sa.ForeignKey("tests.id"), nullable=True),
sa.Column("submitted_by", postgresql.UUID(as_uuid=True), sa.ForeignKey("users.id"), nullable=True),
sa.Column("status", sa.String(10), nullable=False, server_default="pending"),
sa.Column("reviewed_by", postgresql.UUID(as_uuid=True), sa.ForeignKey("users.id"), nullable=True),
sa.Column("reviewed_at", sa.DateTime(), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
)
op.create_index("ix_procedure_suggestions_template_id", "procedure_suggestions", ["template_id"])
op.create_index("ix_procedure_suggestions_status", "procedure_suggestions", ["status"])
def downgrade() -> None:
op.drop_index("ix_procedure_suggestions_status", table_name="procedure_suggestions")
op.drop_index("ix_procedure_suggestions_template_id", table_name="procedure_suggestions")
op.drop_table("procedure_suggestions")
op.drop_column("test_round_history", "detect_procedure")
op.drop_column("test_templates", "detect_suggested_procedure")
op.drop_column("tests", "source_template_id")
op.drop_column("tests", "detect_procedure")
@@ -1,40 +0,0 @@
"""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))
@@ -1,54 +0,0 @@
"""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")
@@ -1,43 +0,0 @@
"""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")
@@ -1,32 +0,0 @@
"""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")
@@ -1,33 +0,0 @@
"""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.

Before

Width:  |  Height:  |  Size: 22 KiB

+8 -40
View File
@@ -39,7 +39,8 @@ class Settings(BaseSettings):
SECRET_KEY: str = "" SECRET_KEY: str = ""
# Assign ALGORITHM = "HS256" # Assign ALGORITHM = "HS256"
ALGORITHM: str = "HS256" ALGORITHM: str = "HS256"
ACCESS_TOKEN_EXPIRE_MINUTES: int = 480 # 8 hours — /auth/refresh extends active sessions # Assign ACCESS_TOKEN_EXPIRE_MINUTES = 15 # short-lived for security; configurable via env
ACCESS_TOKEN_EXPIRE_MINUTES: int = 15 # short-lived for security; configurable via env
# ── Redis ───────────────────────────────────────────────────────── # ── Redis ─────────────────────────────────────────────────────────
REDIS_URL: str = "redis://redis:6379/0" REDIS_URL: str = "redis://redis:6379/0"
@@ -56,10 +57,7 @@ class Settings(BaseSettings):
# ── MinIO / S3 ─────────────────────────────────────────────────── # ── MinIO / S3 ───────────────────────────────────────────────────
MINIO_ENDPOINT: str = "minio:9000" MINIO_ENDPOINT: str = "minio:9000"
# Public hostname used in presigned URLs returned to browsers. # Assign MINIO_ACCESS_KEY = "minioadmin"
# In production set this to <server-ip>:9000 (or a public FQDN) so
# the browser can reach MinIO directly. Defaults to MINIO_ENDPOINT.
MINIO_PUBLIC_ENDPOINT: str = ""
MINIO_ACCESS_KEY: str = "minioadmin" MINIO_ACCESS_KEY: str = "minioadmin"
# Assign MINIO_SECRET_KEY = "minioadmin" # Assign MINIO_SECRET_KEY = "minioadmin"
MINIO_SECRET_KEY: str = "minioadmin" MINIO_SECRET_KEY: str = "minioadmin"
@@ -83,11 +81,10 @@ class Settings(BaseSettings):
JIRA_IS_CLOUD: bool = True JIRA_IS_CLOUD: bool = True
# Assign JIRA_DEFAULT_PROJECT = "" # Assign JIRA_DEFAULT_PROJECT = ""
JIRA_DEFAULT_PROJECT: str = "" JIRA_DEFAULT_PROJECT: str = ""
JIRA_ISSUE_TYPE_TEST: str = "Task" # tests (campaign or standalone) # Assign JIRA_ISSUE_TYPE_TEST = "Task"
JIRA_ISSUE_TYPE_CAMPAIGN: str = "Epic" # campaigns (under Initiative) JIRA_ISSUE_TYPE_TEST: str = "Task"
# Jira custom field ID for "Start date" — Jira Cloud team-managed: customfield_10015 # Assign JIRA_ISSUE_TYPE_CAMPAIGN = "Epic"
# Override with the correct field ID for your Jira instance if different. JIRA_ISSUE_TYPE_CAMPAIGN: str = "Epic"
JIRA_START_DATE_FIELD: str = "customfield_10015"
# ── Tempo Integration ───────────────────────────────────────────── # ── Tempo Integration ─────────────────────────────────────────────
TEMPO_ENABLED: bool = False TEMPO_ENABLED: bool = False
@@ -97,9 +94,6 @@ class Settings(BaseSettings):
TEMPO_API_VERSION: int = 4 TEMPO_API_VERSION: int = 4
# Assign TEMPO_DEFAULT_WORK_TYPE = "Red Team" # Assign TEMPO_DEFAULT_WORK_TYPE = "Red Team"
TEMPO_DEFAULT_WORK_TYPE: str = "Red Team" TEMPO_DEFAULT_WORK_TYPE: str = "Red Team"
# Tempo API base URL — use https://api.eu.tempo.io/4 for EU workspaces.
# Can also be set via system_configs key "tempo.base_url" at runtime.
TEMPO_BASE_URL: str = "" # empty → falls back to https://api.tempo.io/4
# ── OSINT / Intelligence ──────────────────────────────────────── # ── OSINT / Intelligence ────────────────────────────────────────
NVD_API_KEY: str = "" # optional; increases NVD rate limit from 5/30s to 50/30s NVD_API_KEY: str = "" # optional; increases NVD rate limit from 5/30s to 50/30s
@@ -107,29 +101,14 @@ class Settings(BaseSettings):
STALE_THRESHOLD_DAYS: int = 365 # days before coverage is considered stale STALE_THRESHOLD_DAYS: int = 365 # days before coverage is considered stale
# ── Reporting ───────────────────────────────────────────────────── # ── Reporting ─────────────────────────────────────────────────────
# PDF/DOCX/HTML report generation (professional_reports router) is
# unfinished — the frontend hides the Reports UI entirely. Defaults
# off so a fresh deploy returns a clean 503 instead of a raw 500 from
# a half-working render pipeline (e.g. missing weasyprint system deps).
REPORTS_ENABLED: bool = False
REPORT_TEMPLATES_DIR: str = "app/templates/reports" REPORT_TEMPLATES_DIR: str = "app/templates/reports"
# Assign REPORT_OUTPUT_DIR = "/tmp/aegis_reports" # Assign REPORT_OUTPUT_DIR = "/tmp/aegis_reports"
REPORT_OUTPUT_DIR: str = "/app/reports" REPORT_OUTPUT_DIR: str = "/tmp/aegis_reports"
# Assign COMPANY_NAME = "Organization" # Assign COMPANY_NAME = "Organization"
COMPANY_NAME: str = "Organization" COMPANY_NAME: str = "Organization"
# Assign COMPANY_LOGO_PATH = "app/templates/reports/assets/logo.png" # Assign COMPANY_LOGO_PATH = "app/templates/reports/assets/logo.png"
COMPANY_LOGO_PATH: str = "app/templates/reports/assets/logo.png" COMPANY_LOGO_PATH: str = "app/templates/reports/assets/logo.png"
# ── Email / SMTP ──────────────────────────────────────────────────
SMTP_ENABLED: bool = False
SMTP_HOST: str = ""
SMTP_PORT: int = 587
SMTP_USERNAME: str = ""
SMTP_PASSWORD: str = ""
SMTP_FROM_EMAIL: str = "aegis@company.com"
SMTP_USE_TLS: bool = True
PLATFORM_URL: str = "http://localhost:5173" # base URL for links in emails
# ── Scoring weights (must sum to 100) ──────────────────────────── # ── Scoring weights (must sum to 100) ────────────────────────────
SCORING_WEIGHT_TESTS: int = 40 SCORING_WEIGHT_TESTS: int = 40
# Assign SCORING_WEIGHT_DETECTION_RULES = 25 # Assign SCORING_WEIGHT_DETECTION_RULES = 25
@@ -216,14 +195,3 @@ if _is_production:
f"Set a strong value via the {name} environment variable " f"Set a strong value via the {name} environment variable "
f"before running in production." f"before running in production."
) )
# PLATFORM_URL has no safe production default — it's baked into every
# emailed link (set-password, notifications). Falling back silently to
# the dev value (or to some other deployment's hardcoded domain) would
# ship broken/wrong links without anyone noticing.
if settings.PLATFORM_URL == "http://localhost:5173":
raise RuntimeError(
"CRITICAL: PLATFORM_URL is not configured. Set it to this "
"deployment's real public frontend URL via the PLATFORM_URL "
"environment variable before running in production."
)
+6 -80
View File
@@ -3,7 +3,6 @@
Provides: Provides:
- ``get_current_user``: decodes JWT from HttpOnly cookie (preferred) or - ``get_current_user``: decodes JWT from HttpOnly cookie (preferred) or
Authorization header (fallback), fetches user from DB, raises 401 on failure. Authorization header (fallback), fetches user from DB, raises 401 on failure.
Also accepts Aegis API keys (``aegis_…`` prefix) as Bearer tokens.
- ``require_role``: factory that returns a dependency enforcing a specific role - ``require_role``: factory that returns a dependency enforcing a specific role
(admins always pass). (admins always pass).
""" """
@@ -37,7 +36,6 @@ from app.database import get_db
# Import User from app.models.user # Import User from app.models.user
from app.models.user import User from app.models.user import User
from app.models.api_key import KEY_PREFIX
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# OAuth2 scheme (reads Authorization header — used as fallback / Swagger UI) # OAuth2 scheme (reads Authorization header — used as fallback / Swagger UI)
@@ -100,15 +98,7 @@ async def get_current_user(
# Raise credentials_exception # Raise credentials_exception
raise credentials_exception raise credentials_exception
# ── API Key path (Bearer token starts with "aegis_") ────────────────── # Attempt the following; catch errors below
if token.startswith(KEY_PREFIX):
from app.services.api_key_service import authenticate_raw_key
user = authenticate_raw_key(db, token)
if user is None:
raise credentials_exception
return user
# ── JWT path ──────────────────────────────────────────────────────────
try: try:
# Assign payload = jwt.decode( # Assign payload = jwt.decode(
payload = jwt.decode( payload = jwt.decode(
@@ -172,27 +162,12 @@ async def require_password_changed(
return current_user return current_user
def _check_api_key_scope(user: User, required_scope: str) -> None: # Define function require_role
"""Raise 403 if the request was authenticated via an API key that lacks *required_scope*. def require_role(required_role: str) -> Callable[..., object]:
When authenticated via JWT (browser session), ``_api_key_scopes`` is not set
and the check is skipped — full access is granted based on role alone.
"""
key_scopes = getattr(user, "_api_key_scopes", None)
if key_scopes is not None and required_scope not in key_scopes:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=f"API key scope '{required_scope}' required for this operation",
)
def require_role(required_role: str):
"""Return a FastAPI dependency that enforces *required_role*. """Return a FastAPI dependency that enforces *required_role*.
The dependency allows the request to proceed when The dependency allows the request to proceed when
``user.role == required_role`` **or** ``user.role == "admin"``. ``user.role == required_role`` **or** ``user.role == "admin"``.
Also enforces API key scopes: admin-role endpoints require the ``admin``
scope; all other role-restricted endpoints require ``write``.
Otherwise it raises :class:`~fastapi.HTTPException` **403**. Otherwise it raises :class:`~fastapi.HTTPException` **403**.
""" """
@@ -210,8 +185,7 @@ def require_role(required_role: str):
# Keyword argument: detail # Keyword argument: detail
detail="Not enough permissions", detail="Not enough permissions",
) )
scope = "admin" if required_role == "admin" else "write" # Return current_user
_check_api_key_scope(current_user, scope)
return current_user return current_user
# Return role_checker # Return role_checker
@@ -222,11 +196,7 @@ def require_role(required_role: str):
def require_any_role(*roles: str) -> Callable[..., object]: def require_any_role(*roles: str) -> Callable[..., object]:
"""Return a FastAPI dependency that enforces **any** of the given *roles*. """Return a FastAPI dependency that enforces **any** of the given *roles*.
Admins always pass. Also enforces API key scopes: if the only accepted Admins always pass. Usage example::
role is ``admin``, the key must carry the ``admin`` scope; otherwise the
``write`` scope is required.
Usage example::
@router.patch("/resource", dependencies=[Depends(require_any_role("red_lead", "blue_lead"))]) @router.patch("/resource", dependencies=[Depends(require_any_role("red_lead", "blue_lead"))])
""" """
@@ -245,52 +215,8 @@ def require_any_role(*roles: str) -> Callable[..., object]:
# Keyword argument: detail # Keyword argument: detail
detail="Not enough permissions", detail="Not enough permissions",
) )
scope = "admin" if set(roles) == {"admin"} else "write" # Return current_user
_check_api_key_scope(current_user, scope)
return current_user return current_user
# Return role_checker # Return role_checker
return role_checker return role_checker
def require_any_role_strict(*roles: str) -> Callable[..., object]:
"""Return a FastAPI dependency that enforces **any** of the given *roles*.
Unlike ``require_any_role``, admins do **not** automatically pass — use
this for actions that belong exclusively to Red/Blue operators, leads,
or managers (e.g. executing, reviewing, validating, or resolving a
disputed test), where "admin" must mean site administration only, not
a backdoor into the test workflow itself.
"""
async def role_checker(
current_user: User = Depends(get_current_user),
) -> User:
if current_user.role not in roles:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Not enough permissions",
)
_check_api_key_scope(current_user, "write")
return current_user
return role_checker
def require_scope(scope: str):
"""Return a dependency that enforces the API key carries *scope*.
JWT-authenticated requests (browser sessions) bypass this check entirely.
Use on mutation endpoints that don't already use ``require_role`` /
``require_any_role``::
@router.post("/resource", dependencies=[Depends(require_scope("write"))])
"""
async def scope_checker(
current_user: User = Depends(get_current_user),
) -> User:
_check_api_key_scope(current_user, scope)
return current_user
return scope_checker
+1 -47
View File
@@ -33,8 +33,6 @@ class CampaignStatus(str, enum.Enum):
# Assign draft = "draft" # Assign draft = "draft"
draft = "draft" draft = "draft"
# Assign pending_approval = "pending_approval"
pending_approval = "pending_approval"
# Assign active = "active" # Assign active = "active"
active = "active" active = "active"
# Assign completed = "completed" # Assign completed = "completed"
@@ -59,8 +57,7 @@ class CampaignType(str, enum.Enum):
# Assign VALID_TRANSITIONS = { # Assign VALID_TRANSITIONS = {
VALID_TRANSITIONS: dict[CampaignStatus, list[CampaignStatus]] = { VALID_TRANSITIONS: dict[CampaignStatus, list[CampaignStatus]] = {
CampaignStatus.draft: [CampaignStatus.pending_approval, CampaignStatus.active], CampaignStatus.draft: [CampaignStatus.active],
CampaignStatus.pending_approval: [CampaignStatus.active, CampaignStatus.draft],
CampaignStatus.active: [CampaignStatus.completed], CampaignStatus.active: [CampaignStatus.completed],
CampaignStatus.completed: [CampaignStatus.archived], CampaignStatus.completed: [CampaignStatus.archived],
CampaignStatus.archived: [], CampaignStatus.archived: [],
@@ -135,49 +132,6 @@ class CampaignEntity:
# Assign self.status = CampaignStatus.active # Assign self.status = CampaignStatus.active
self.status = CampaignStatus.active self.status = CampaignStatus.active
def submit_for_approval(self) -> None:
"""Transition the campaign from ``draft`` to ``pending_approval``.
Returns:
None
"""
if not self.can_transition_to(CampaignStatus.pending_approval):
raise InvalidStateTransition(
self.status.value, CampaignStatus.pending_approval.value,
[s.value for s in VALID_TRANSITIONS[self.status]],
)
if self.test_count == 0:
raise BusinessRuleViolation(
"Campaign must have at least one test to submit for approval"
)
self.status = CampaignStatus.pending_approval
def approve(self) -> None:
"""Transition the campaign from ``pending_approval`` to ``active``.
Returns:
None
"""
if self.status != CampaignStatus.pending_approval:
raise InvalidStateTransition(
self.status.value, CampaignStatus.active.value,
[CampaignStatus.pending_approval.value],
)
self.status = CampaignStatus.active
def reject(self) -> None:
"""Transition the campaign from ``pending_approval`` back to ``draft``.
Returns:
None
"""
if not self.can_transition_to(CampaignStatus.draft):
raise InvalidStateTransition(
self.status.value, CampaignStatus.draft.value,
[s.value for s in VALID_TRANSITIONS[self.status]],
)
self.status = CampaignStatus.draft
# Define function complete # Define function complete
def complete(self) -> None: def complete(self) -> None:
"""Transition the campaign from ``active`` to ``completed``. """Transition the campaign from ``active`` to ``completed``.
+16 -22
View File
@@ -221,17 +221,14 @@ class TechniqueEntity:
) -> TechniqueStatus: ) -> TechniqueStatus:
"""Recompute ``status_global`` from a list of (state, detection_result) pairs. """Recompute ``status_global`` from a list of (state, detection_result) pairs.
Rules (v3): Rules (v2):
1. No tests -> not_evaluated 1. No tests -> not_evaluated
2. All tests validated -> inspect detection results: 2. All validated -> inspect detection results:
a. All detected AND ≥ 1 validated test -> validated - All detected -> validated
b. Any partially_detected -> partial - Any partially_detected -> partial
d. Otherwise (no detected results) -> not_covered - Otherwise -> not_covered
3. Some validated, others in intermediate states -> partial 3. Some validated, others in progress -> partial
4. All tests in intermediate states (draft/executing/evaluating/review/rejected) 4. All in intermediate states -> in_progress
-> in_progress
Minimum validated count for "validated": 1 test.
Args: Args:
test_snapshots (list[tuple[str, str | None]]): Each element is a test_snapshots (list[tuple[str, str | None]]): Each element is a
@@ -243,8 +240,7 @@ class TechniqueEntity:
TechniqueStatus: The newly computed status, which is also stored on TechniqueStatus: The newly computed status, which is also stored on
the entity's ``status_global`` field. the entity's ``status_global`` field.
""" """
min_validated_for_full = 1 # require ≥ N validated tests for "validated" # Assign tests = [
tests = [ tests = [
_TestSnapshot( _TestSnapshot(
# Keyword argument: state # Keyword argument: state
@@ -261,23 +257,21 @@ class TechniqueEntity:
self.status_global = TechniqueStatus.not_evaluated self.status_global = TechniqueStatus.not_evaluated
# Alternative: all(t.state == TestState.validated for t in tests) # Alternative: all(t.state == TestState.validated for t in tests)
elif all(t.state == TestState.validated for t in tests): elif all(t.state == TestState.validated for t in tests):
validated_count = len(tests) # Assign results = [t.detection_result for t in tests if t.detection_result]
results = [t.detection_result for t in tests if t.detection_result] results = [t.detection_result for t in tests if t.detection_result]
# Check: results and all(r == TestResult.detected or r == "detected" for r i... # Check: results and all(r == TestResult.detected or r == "detected" for r i...
if results and all(r == TestResult.detected or r == "detected" for r in results): if results and all(r == TestResult.detected or r == "detected" for r in results):
# Need at least min_validated_for_full tests for "validated" # Assign self.status_global = TechniqueStatus.validated
if validated_count >= min_validated_for_full: self.status_global = TechniqueStatus.validated
self.status_global = TechniqueStatus.validated # elif any(
else:
self.status_global = TechniqueStatus.partial
elif any( elif any(
r in (TestResult.detected, "detected", # Keyword argument: r
TestResult.partially_detected, "partially_detected") r == TestResult.partially_detected or r == "partially_detected"
for r in results for r in results
): ):
# Mix of detected + not_detected, or any partially_detected → partial # Assign self.status_global = TechniqueStatus.partial
self.status_global = TechniqueStatus.partial self.status_global = TechniqueStatus.partial
# Fallback: handle remaining cases (all not_detected) # Fallback: handle remaining cases
else: else:
# Assign self.status_global = TechniqueStatus.not_covered # Assign self.status_global = TechniqueStatus.not_covered
self.status_global = TechniqueStatus.not_covered self.status_global = TechniqueStatus.not_covered
+8 -38
View File
@@ -35,19 +35,14 @@ class TestState(str, enum.Enum):
draft = "draft" draft = "draft"
# Assign red_executing = "red_executing" # Assign red_executing = "red_executing"
red_executing = "red_executing" red_executing = "red_executing"
# Red Lead reviews the operator's work before it queues for Blue Team
red_review = "red_review"
# Assign blue_evaluating = "blue_evaluating" # Assign blue_evaluating = "blue_evaluating"
blue_evaluating = "blue_evaluating" blue_evaluating = "blue_evaluating"
# Blue Lead reviews the operator's work before cross-validation
blue_review = "blue_review"
# Assign in_review = "in_review" # Assign in_review = "in_review"
in_review = "in_review" in_review = "in_review"
# Assign validated = "validated" # Assign validated = "validated"
validated = "validated" validated = "validated"
# Assign rejected = "rejected" # Assign rejected = "rejected"
rejected = "rejected" rejected = "rejected"
disputed = "disputed" # one lead approved, the other rejected
# Define class TeamSide # Define class TeamSide
@@ -72,40 +67,15 @@ class TestResult(str, enum.Enum):
partially_detected = "partially_detected" partially_detected = "partially_detected"
# Define class ContainmentResult
class ContainmentResult(str, enum.Enum):
"""Outcome of the blue team's containment effort."""
contained = "contained"
partially_contained = "partially_contained"
not_contained = "not_contained"
class AttackSuccessResult(str, enum.Enum):
"""Outcome of a red-team attack from a success perspective."""
successful = "successful"
not_successful = "not_successful"
partially_successful = "partially_successful"
# Define class DataClassification # Define class DataClassification
class DataClassification(str, enum.Enum): class DataClassification(str, enum.Enum):
"""Data sensitivity classification levels for compliance and retention policies. """Data sensitivity classification levels for compliance and retention policies."""
Matches Jira's "Data Sensitivity" field (customfield_11814) exactly, since
that is the organization's actual data protection scheme:
- public: approved for external/public release
- general_use: general internal information, no special restriction
- internal_use_only: internal use only
- trusted_people: shared only with a limited/trusted audience
- customer_content: contains customer data
- pii: contains personally identifiable information
"""
# Assign public = "public"
public = "public" public = "public"
general_use = "general_use" # Assign internal = "internal"
internal_use_only = "internal_use_only" internal = "internal"
trusted_people = "trusted_people" # Assign sensitive = "sensitive"
customer_content = "customer_content" sensitive = "sensitive"
pii = "pii" # Assign restricted = "restricted"
restricted = "restricted"
+46 -169
View File
@@ -60,33 +60,22 @@ class TestState(str, enum.Enum):
draft = "draft" draft = "draft"
# Assign red_executing = "red_executing" # Assign red_executing = "red_executing"
red_executing = "red_executing" red_executing = "red_executing"
# Red Lead reviews the operator's work before it queues for Blue Team
red_review = "red_review"
# Assign blue_evaluating = "blue_evaluating" # Assign blue_evaluating = "blue_evaluating"
blue_evaluating = "blue_evaluating" blue_evaluating = "blue_evaluating"
# Blue Lead reviews the operator's work before cross-validation
blue_review = "blue_review"
# Assign in_review = "in_review" # Assign in_review = "in_review"
in_review = "in_review" in_review = "in_review"
# Assign validated = "validated" # Assign validated = "validated"
validated = "validated" validated = "validated"
# Assign rejected = "rejected" # Assign rejected = "rejected"
rejected = "rejected" rejected = "rejected"
disputed = "disputed" # one lead approved, the other rejected
# Assign VALID_TRANSITIONS = { # Assign VALID_TRANSITIONS = {
VALID_TRANSITIONS: dict[TestState, list[TestState]] = { VALID_TRANSITIONS: dict[TestState, list[TestState]] = {
TestState.draft: [TestState.red_executing], TestState.draft: [TestState.red_executing],
TestState.red_executing: [TestState.red_review], TestState.red_executing: [TestState.blue_evaluating],
TestState.red_review: [TestState.blue_evaluating, TestState.red_executing], TestState.blue_evaluating: [TestState.in_review],
TestState.blue_evaluating: [TestState.blue_review], TestState.in_review: [TestState.validated, TestState.rejected],
TestState.blue_review: [TestState.in_review, TestState.blue_evaluating],
TestState.in_review: [TestState.validated, TestState.rejected, TestState.disputed],
TestState.disputed: [
TestState.validated, TestState.rejected,
TestState.red_executing, TestState.blue_evaluating,
],
TestState.rejected: [TestState.draft], TestState.rejected: [TestState.draft],
TestState.validated: [], TestState.validated: [],
} }
@@ -372,12 +361,10 @@ class TestEntity:
# Define function submit_red_evidence # Define function submit_red_evidence
def submit_red_evidence(self) -> int: def submit_red_evidence(self) -> int:
"""Transition the test from ``red_executing`` to ``red_review``. """Transition the test from ``red_executing`` to ``blue_evaluating``.
Auto-resumes if paused. Returns paused seconds accumulated Auto-resumes if paused. Returns paused seconds accumulated
during this phase (for worklog calculation). The Blue Team queue during this phase (for worklog calculation).
timer does not start yet — that happens in ``approve_red_review``,
once the Red Lead actually releases the test to Blue Team.
Returns: Returns:
int: Total seconds the red phase was paused. int: Total seconds the red phase was paused.
@@ -385,9 +372,13 @@ class TestEntity:
# Assign paused_extra = self._auto_resume() # Assign paused_extra = self._auto_resume()
paused_extra = self._auto_resume() paused_extra = self._auto_resume()
# Call self._transition() # Call self._transition()
self._transition(TestState.red_review) self._transition(TestState.blue_evaluating)
# Assign total_paused = self.red_paused_seconds + paused_extra # Assign total_paused = self.red_paused_seconds + paused_extra
total_paused = self.red_paused_seconds + paused_extra total_paused = self.red_paused_seconds + paused_extra
# Assign self.blue_started_at = datetime.utcnow()
self.blue_started_at = datetime.utcnow()
# Assign self.blue_paused_seconds = 0
self.blue_paused_seconds = 0
# Call self._events.append() # Call self._events.append()
self._events.append(DomainEvent( self._events.append(DomainEvent(
# Literal argument value # Literal argument value
@@ -397,36 +388,9 @@ class TestEntity:
# Return total_paused # Return total_paused
return total_paused return total_paused
def approve_red_review(self) -> None:
"""Transition the test from ``red_review`` to ``blue_evaluating``.
Called when the assigned Red Lead approves the operator's work.
Starts the Blue Team queue timer.
"""
self._transition(TestState.blue_evaluating)
self.blue_started_at = datetime.utcnow()
self.blue_paused_seconds = 0
self._events.append(DomainEvent("red_review_approved"))
def reopen_red_review(self) -> None:
"""Transition the test from ``red_review`` back to ``red_executing``.
Called when the assigned Red Lead sends the work back for rework.
Resets the red-phase timer for a fresh attempt (the first attempt's
worklog was already recorded at submit time, so this loses nothing).
Starts the timer PAUSED — the operator hasn't resumed working yet,
just because the lead reopened it. It only starts counting once they
explicitly resume it, same as coming back from a hold.
"""
self._transition(TestState.red_executing)
self.red_started_at = datetime.utcnow()
self.red_paused_seconds = 0
self.paused_at = self.red_started_at
self._events.append(DomainEvent("red_review_reopened"))
# Define function submit_blue_evidence # Define function submit_blue_evidence
def submit_blue_evidence(self) -> int: def submit_blue_evidence(self) -> int:
"""Transition the test from ``blue_evaluating`` to ``blue_review``. """Transition the test from ``blue_evaluating`` to ``in_review``.
Auto-resumes if paused. Returns paused seconds accumulated Auto-resumes if paused. Returns paused seconds accumulated
during this phase (for worklog calculation). during this phase (for worklog calculation).
@@ -437,7 +401,7 @@ class TestEntity:
# Assign paused_extra = self._auto_resume() # Assign paused_extra = self._auto_resume()
paused_extra = self._auto_resume() paused_extra = self._auto_resume()
# Call self._transition() # Call self._transition()
self._transition(TestState.blue_review) self._transition(TestState.in_review)
# Assign total_paused = self.blue_paused_seconds + paused_extra # Assign total_paused = self.blue_paused_seconds + paused_extra
total_paused = self.blue_paused_seconds + paused_extra total_paused = self.blue_paused_seconds + paused_extra
# Call self._events.append() # Call self._events.append()
@@ -449,39 +413,6 @@ class TestEntity:
# Return total_paused # Return total_paused
return total_paused return total_paused
def approve_blue_review(self) -> None:
"""Transition the test from ``blue_review`` to ``in_review``.
Called when the assigned Blue Lead approves the operator's work.
"""
self._transition(TestState.in_review)
self._events.append(DomainEvent("blue_review_approved"))
def reopen_blue_review(self) -> None:
"""Transition the test from ``blue_review`` back to ``blue_evaluating``.
Called when the assigned Blue Lead sends the work back for rework.
Resets the blue-phase timer for a fresh attempt. Note:
``blue_work_started_at`` (the pickup timestamp) lives only on the
ORM model, not on this entity — the service layer resets it there.
"""
self._transition(TestState.blue_evaluating)
self.blue_started_at = datetime.utcnow()
self.blue_paused_seconds = 0
self._events.append(DomainEvent("blue_review_reopened"))
def flag_blue_review_gap(self) -> None:
"""Transition the test from ``blue_review`` to ``in_review``.
Called when the assigned Blue Lead determines the shortfall is a
missing capability, not operator error — the test still proceeds
to cross-validation since a retry can't fix a capability gap. The
gap description itself is stored on the ORM model
(``Test.system_gaps``), not on this entity.
"""
self._transition(TestState.in_review)
self._events.append(DomainEvent("blue_review_gap_flagged"))
# Define function pause_timer # Define function pause_timer
def pause_timer(self) -> None: def pause_timer(self) -> None:
"""Pause the active phase timer. """Pause the active phase timer.
@@ -634,65 +565,6 @@ class TestEntity:
# Call self._events.append() # Call self._events.append()
self._events.append(DomainEvent("test_reopened")) self._events.append(DomainEvent("test_reopened"))
def resolve_dispute_reject(self, target: str) -> None:
"""Resolve a ``disputed`` test by routing rework to the team at fault.
Called when the lead who originally approved flips their vote to
agree with the rejection. Rather than the generic terminal
``rejected`` state (which forces a full draft restart for both
teams), the flipping lead identifies WHICH side's work needs
redoing — the test goes straight back to that team's active queue
so the other team's work is left untouched.
Args:
target (str): ``"red"`` or ``"blue"`` — which team must redo work.
Returns:
None
"""
target_state = TestState.red_executing if target == "red" else TestState.blue_evaluating
self._transition(target_state)
# Both leads must re-vote once the test reaches in_review again —
# clear decisions but keep notes (context for the team doing rework).
self.red_validation_status = None
self.red_validated_by = None
self.red_validated_at = None
self.blue_validation_status = None
self.blue_validated_by = None
self.blue_validated_at = None
self.paused_at = None
if target == "red":
self.red_started_at = datetime.utcnow()
self.red_paused_seconds = 0
else:
self.blue_started_at = datetime.utcnow()
self.blue_paused_seconds = 0
self._events.append(DomainEvent("dispute_resolved_to_rework", {"target": target}))
def resolve_dispute_by_manager(self, outcome: str) -> None:
"""A manager makes the final call on a disputed test, ending the standoff.
Unlike ``resolve_dispute_reject`` (a lead flipping their own vote),
this bypasses both leads entirely — the manager's decision is final
and terminal, going straight to ``validated`` or the normal
``rejected`` dead-end (which any lead can later reopen via the
standard reopen-to-draft flow, same as any other rejection).
Args:
outcome (str): ``"validated"`` or ``"rejected"``.
"""
if outcome not in ("validated", "rejected"):
raise InvalidOperationError("outcome must be 'validated' or 'rejected'")
target_state = TestState.validated if outcome == "validated" else TestState.rejected
self._transition(target_state)
self._events.append(
DomainEvent("dual_validation_approved" if outcome == "validated" else "dual_validation_rejected")
)
# -- Private ------------------------------------------------------- # -- Private -------------------------------------------------------
def _auto_resume(self) -> int: def _auto_resume(self) -> int:
@@ -719,23 +591,37 @@ class TestEntity:
def check_dual_validation(self) -> None: def check_dual_validation(self) -> None:
"""Evaluate both leads' votes and advance state if appropriate. """Evaluate both leads' votes and advance state if appropriate.
Rules (v2 — consensus required): - Both **approved** -> ``validated``
- Both **approved** -> ``validated`` - Either **rejected** -> ``rejected``
- Both **rejected** -> ``rejected`` - Otherwise no change (waiting for the other lead).
- One approved + one rejected -> ``disputed`` (conflict, needs discussion)
- Otherwise (one or both still pending) -> no change
Called automatically by :meth:`validate_red` and :meth:`validate_blue`. Called automatically by :meth:`validate_red` and :meth:`validate_blue`.
Also available as a standalone entry point for backward compatibility
when validation fields are set externally.
Returns:
None
""" """
# Call self._check_dual_validation() # Call self._check_dual_validation()
self._check_dual_validation() self._check_dual_validation()
# Define function _assert_in_review # Define function _assert_in_review
def _assert_in_review(self, side: str) -> None: def _assert_in_review(self, side: str) -> None:
if self.state not in (TestState.in_review, TestState.disputed): """Raise InvalidOperationError unless the test is in ``in_review`` state.
Args:
side (str): The team side being validated (``"red"`` or ``"blue"``),
used in the error message.
Returns:
None
"""
# Check: self.state != TestState.in_review
if self.state != TestState.in_review:
# Raise InvalidOperationError
raise InvalidOperationError( raise InvalidOperationError(
f"Cannot validate {side} side while test is in " f"Cannot validate {side} side while test is in "
f"'{self.state.value}' state (must be in_review or disputed)" f"'{self.state.value}' state (must be in_review)"
) )
# Apply the @staticmethod decorator # Apply the @staticmethod decorator
@@ -760,31 +646,22 @@ class TestEntity:
# Define function _check_dual_validation # Define function _check_dual_validation
def _check_dual_validation(self) -> None: def _check_dual_validation(self) -> None:
"""Advance the test state once enough leads have voted. """Advance to ``validated`` or ``rejected`` once both leads have voted.
A genuine conflict (one lead's *recorded* decision disagreeing with Returns:
the other's) routes to ``disputed`` — there are two opinions to None
reconcile. A lone rejection while the other side hasn't voted yet
isn't a conflict (nothing to disagree with), so it still vetoes
straight to ``rejected`` without waiting for the second vote.
""" """
# r, b = self.red_validation_status, self.blue_validation_status
r, b = self.red_validation_status, self.blue_validation_status r, b = self.red_validation_status, self.blue_validation_status
# Check: r == "rejected" or b == "rejected"
if r == "approved" and b == "approved": if r == "rejected" or b == "rejected":
# Assign self.state = TestState.rejected
self.state = TestState.rejected
# Call self._events.append()
self._events.append(DomainEvent("dual_validation_rejected"))
# Alternative: r == "approved" and b == "approved"
elif r == "approved" and b == "approved":
# Assign self.state = TestState.validated
self.state = TestState.validated self.state = TestState.validated
# Call self._events.append() # Call self._events.append()
self._events.append(DomainEvent("dual_validation_approved")) self._events.append(DomainEvent("dual_validation_approved"))
elif r == "rejected" and b == "rejected":
self.state = TestState.rejected
self._events.append(DomainEvent("dual_validation_rejected"))
elif (r == "approved" and b == "rejected") or (r == "rejected" and b == "approved"):
self.state = TestState.disputed
self._events.append(DomainEvent("dual_validation_disputed"))
elif r == "rejected" or b == "rejected":
# One side rejected while the other hasn't voted yet — no
# disagreement to dispute yet, just a straightforward veto.
self.state = TestState.rejected
self._events.append(DomainEvent("dual_validation_rejected"))
+11 -277
View File
@@ -12,11 +12,9 @@ sessions.
# Import logging # Import logging
import logging import logging
from datetime import datetime, timedelta, timezone
# Import BackgroundScheduler from apscheduler.schedulers.background # Import BackgroundScheduler from apscheduler.schedulers.background
from apscheduler.schedulers.background import BackgroundScheduler from apscheduler.schedulers.background import BackgroundScheduler
from apscheduler.events import EVENT_JOB_ERROR
# Import SessionLocal from app.database # Import SessionLocal from app.database
from app.database import SessionLocal from app.database import SessionLocal
@@ -28,10 +26,7 @@ from app.jobs.jira_sync_job import sync_all_jira_links
from app.jobs.retention_job import run_retention_job from app.jobs.retention_job import run_retention_job
# Import check_and_run_recurring_campaigns from app.services.campaign_scheduler_service # Import check_and_run_recurring_campaigns from app.services.campaign_scheduler_service
from app.services.campaign_scheduler_service import ( from app.services.campaign_scheduler_service import check_and_run_recurring_campaigns
check_and_run_recurring_campaigns,
sync_due_campaign_jira_tickets,
)
# Import scan_intel from app.services.intel_service # Import scan_intel from app.services.intel_service
from app.services.intel_service import scan_intel from app.services.intel_service import scan_intel
@@ -61,27 +56,6 @@ logger = logging.getLogger(__name__)
scheduler = BackgroundScheduler() scheduler = BackgroundScheduler()
def _on_job_error(event) -> None:
"""Email admins (opted-in) whenever a scheduled background job raises."""
db = SessionLocal()
try:
from app.services.notification_service import notify_roles_by_email
notify_roles_by_email(
db, roles=["admin"],
preference_key="email_on_system_errors",
subject=f"Aegis Background Job Failed: {event.job_id}",
message=(
f'The scheduled job "{event.job_id}" raised an exception and did '
f"not complete:\n\n{event.exception}"
),
)
db.commit()
except Exception:
logger.exception("Failed to dispatch system-error notification")
finally:
db.close()
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Job functions # Job functions
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -89,7 +63,7 @@ def _on_job_error(event) -> None:
def _run_mitre_sync() -> None: def _run_mitre_sync() -> None:
"""Execute a MITRE sync inside its own DB session.""" """Execute a MITRE sync inside its own DB session."""
from app.services.webhook_service import dispatch_webhook # Log info: "Scheduled MITRE sync job starting..."
logger.info("Scheduled MITRE sync job starting...") logger.info("Scheduled MITRE sync job starting...")
# Assign db = SessionLocal() # Assign db = SessionLocal()
db = SessionLocal() db = SessionLocal()
@@ -99,7 +73,7 @@ def _run_mitre_sync() -> None:
summary = sync_mitre(db) summary = sync_mitre(db)
# Log info: "Scheduled MITRE sync job finished — %s", summary # Log info: "Scheduled MITRE sync job finished — %s", summary
logger.info("Scheduled MITRE sync job finished — %s", summary) logger.info("Scheduled MITRE sync job finished — %s", summary)
dispatch_webhook("mitre.synced", {"created": summary.get("created", 0), "updated": summary.get("updated", 0)}) # Handle Exception
except Exception: except Exception:
# Log exception: "Scheduled MITRE sync job failed" # Log exception: "Scheduled MITRE sync job failed"
logger.exception("Scheduled MITRE sync job failed") logger.exception("Scheduled MITRE sync job failed")
@@ -189,19 +163,7 @@ def _run_recurring_campaigns() -> None:
db.close() db.close()
def _run_due_campaign_jira_sync() -> None: # Define function _run_intel_scan
"""Create Jira tickets for campaigns whose scheduled start_date has arrived."""
logger.info("Scheduled due-campaign Jira sync starting...")
db = SessionLocal()
try:
processed = sync_due_campaign_jira_tickets(db)
logger.info("Due-campaign Jira sync finished — processed %d campaigns", processed)
except Exception:
logger.exception("Due-campaign Jira sync failed")
finally:
db.close()
def _run_intel_scan() -> None: def _run_intel_scan() -> None:
"""Execute an intel scan inside its own DB session.""" """Execute an intel scan inside its own DB session."""
# Log info: "Scheduled intel scan job starting..." # Log info: "Scheduled intel scan job starting..."
@@ -224,83 +186,7 @@ def _run_intel_scan() -> None:
db.close() db.close()
def _run_evaluation_round_check() -> None: # Define function _run_osint_enrichment
"""Weekly job: check if a new ATT&CK Evaluation round is available.
If a new round is found it is imported automatically and an admin
notification is created so the team knows new baseline data is available.
"""
logger.info("ATT&CK Evaluations new-round check starting...")
db = SessionLocal()
try:
from app.services.attck_evaluations_service import check_for_new_round, import_evaluation_round
from app.models.user import User as UserModel
result = check_for_new_round(db)
if result.get("error"):
logger.warning("ATT&CK Evaluations check failed: %s", result["error"])
return
if not result.get("new_round_available"):
logger.info(
"ATT&CK Evaluations check — latest round '%s' already imported",
result.get("latest_round", {}).get("display_name", "?"),
)
return
latest = result["latest_round"]
logger.info(
"New ATT&CK Evaluation round detected: %s (round %d) — starting auto-import",
latest["display_name"], latest["eval_round"],
)
# Use the first admin user as the importer (system action)
admin = db.query(UserModel).filter(UserModel.role == "admin").first()
if not admin:
logger.warning("ATT&CK Evaluations auto-import: no admin user found — skipping")
return
summary = import_evaluation_round(
db,
latest["name"],
latest["display_name"],
latest["eval_round"],
admin,
)
logger.info(
"ATT&CK Evaluations auto-import complete — round %d (%s): %d tests created",
latest["eval_round"], latest["display_name"], summary["created"],
)
# Notify all admins
try:
from app.services.notification_service import create_notification
admins = db.query(UserModel).filter(UserModel.role == "admin").all()
for adm in admins:
create_notification(
db,
user_id=adm.id,
title="New ATT&CK Evaluation round imported",
message=(
f"Round {latest['eval_round']}{latest['display_name']}"
f"has been automatically imported. "
f"{summary['created']} tests created in In Review state. "
f"Blue Leads must validate each result before it counts as coverage."
),
notification_type="eval_import",
entity_type="evaluation",
entity_id=None,
)
db.commit()
except Exception:
logger.warning("Failed to send eval import notifications", exc_info=True)
except Exception:
logger.exception("ATT&CK Evaluations round check job failed")
finally:
db.close()
def _run_osint_enrichment() -> None: def _run_osint_enrichment() -> None:
"""Execute weekly OSINT enrichment inside its own DB session.""" """Execute weekly OSINT enrichment inside its own DB session."""
# Log info: "Scheduled OSINT enrichment job starting..." # Log info: "Scheduled OSINT enrichment job starting..."
@@ -323,61 +209,7 @@ def _run_osint_enrichment() -> None:
db.close() db.close()
_FREQUENCY_INTERVALS: dict[str, timedelta] = { # Define function _run_stale_detection
"daily": timedelta(days=1),
"weekly": timedelta(weeks=1),
"monthly": timedelta(days=30),
}
def _run_data_sources_sync() -> None:
"""Check all enabled data sources and sync those that are overdue."""
logger.info("Scheduled data sources sync check starting...")
db = SessionLocal()
try:
from app.models.data_source import DataSource
from app.services.data_source_service import sync_source
now = datetime.now(timezone.utc)
sources = (
db.query(DataSource)
.filter(DataSource.is_enabled == True) # noqa: E712
.all()
)
synced = 0
for ds in sources:
freq = ds.sync_frequency
if not freq or freq == "manual":
continue
interval = _FREQUENCY_INTERVALS.get(freq)
if interval is None:
continue
last = ds.last_sync_at
if last is None:
# Never synced — run it now
overdue = True
else:
# Make last timezone-aware if needed
if last.tzinfo is None:
last = last.replace(tzinfo=timezone.utc)
overdue = now - last >= interval
if overdue:
logger.info(
"Data source '%s' is overdue (freq=%s, last=%s) — syncing",
ds.name, freq, last,
)
try:
sync_source(db, str(ds.id))
synced += 1
except Exception:
logger.exception("Failed to sync data source '%s'", ds.name)
logger.info("Data sources sync check finished — %d source(s) synced", synced)
except Exception:
logger.exception("Data sources sync check failed")
finally:
db.close()
def _run_stale_detection() -> None: def _run_stale_detection() -> None:
"""Execute daily stale coverage detection inside its own DB session.""" """Execute daily stale coverage detection inside its own DB session."""
# Log info: "Scheduled stale coverage detection starting..." # Log info: "Scheduled stale coverage detection starting..."
@@ -400,53 +232,6 @@ def _run_stale_detection() -> None:
db.close() db.close()
def _run_decay_engine() -> None:
"""Execute the decay engine inside its own DB session."""
logger.info("Scheduled decay engine job starting...")
db = SessionLocal()
try:
from app.services.decay_engine_service import run_decay_engine
results = run_decay_engine(db)
logger.info("Decay engine job finished — %s", results)
except Exception:
logger.exception("Decay engine job failed")
finally:
db.close()
def _run_queue_generation() -> None:
"""Generate revalidation queue items for analysts — runs after decay engine."""
logger.info("Scheduled revalidation queue generation starting...")
db = SessionLocal()
try:
from app.services.revalidation_queue_service import generate_queue_items
results = generate_queue_items(db)
logger.info("Queue generation finished — %s", results)
except Exception:
logger.exception("Queue generation job failed")
finally:
db.close()
def _run_alert_evaluation() -> None:
"""Evaluate all enabled operational alert rules (hourly)."""
logger.info("Scheduled alert evaluation job starting...")
db = SessionLocal()
try:
from app.services.operational_alert_service import evaluate_all_rules
result = evaluate_all_rules(db)
logger.info(
"Alert evaluation finished — %d rules, %d alerts fired in %.3fs",
result["rules_evaluated"],
result["alerts_fired"],
result["duration_seconds"],
)
except Exception:
logger.exception("Alert evaluation job failed")
finally:
db.close()
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Scheduler bootstrap # Scheduler bootstrap
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -462,7 +247,6 @@ def start_scheduler() -> None:
Neither job fires immediately on startup. Neither job fires immediately on startup.
""" """
scheduler.add_listener(_on_job_error, EVENT_JOB_ERROR)
# Call scheduler.add_job() # Call scheduler.add_job()
scheduler.add_job( scheduler.add_job(
_run_mitre_sync, _run_mitre_sync,
@@ -523,6 +307,7 @@ def start_scheduler() -> None:
# Keyword argument: replace_existing # Keyword argument: replace_existing
replace_existing=True, replace_existing=True,
) )
# Call scheduler.add_job()
scheduler.add_job( scheduler.add_job(
_run_recurring_campaigns, _run_recurring_campaigns,
# Keyword argument: trigger # Keyword argument: trigger
@@ -536,14 +321,6 @@ def start_scheduler() -> None:
# Keyword argument: replace_existing # Keyword argument: replace_existing
replace_existing=True, replace_existing=True,
) )
scheduler.add_job(
_run_due_campaign_jira_sync,
trigger="interval",
minutes=15,
id="due_campaign_jira_sync",
name="Due campaign Jira ticket sync (every 15 min)",
replace_existing=True,
)
# Call scheduler.add_job() # Call scheduler.add_job()
scheduler.add_job( scheduler.add_job(
sync_all_jira_links, sync_all_jira_links,
@@ -600,50 +377,7 @@ def start_scheduler() -> None:
# Keyword argument: replace_existing # Keyword argument: replace_existing
replace_existing=True, replace_existing=True,
) )
scheduler.add_job( # Call scheduler.start()
_run_data_sources_sync,
trigger="interval",
hours=6,
id="data_sources_sync",
name="Data sources auto-sync (every 6h)",
replace_existing=True,
)
scheduler.add_job(
_run_decay_engine,
trigger="cron",
hour=2,
minute=0,
id="decay_engine",
name="Detection decay engine (daily 02:00)",
replace_existing=True,
)
scheduler.add_job(
_run_queue_generation,
trigger="cron",
hour=2,
minute=30,
id="queue_generation",
name="Revalidation queue generation (daily 02:30)",
replace_existing=True,
)
scheduler.add_job(
_run_alert_evaluation,
trigger="interval",
hours=1,
id="alert_evaluation",
name="Operational alert evaluation (hourly)",
replace_existing=True,
)
scheduler.add_job(
_run_evaluation_round_check,
trigger="cron",
day_of_week="mon",
hour=6,
minute=0,
id="attck_evaluation_check",
name="ATT&CK Evaluations new-round check (Mondays 06:00)",
replace_existing=True,
)
scheduler.start() scheduler.start()
# Log info: # Log info:
logger.info( logger.info(
@@ -652,9 +386,9 @@ def start_scheduler() -> None:
# Literal argument value # Literal argument value
"notification_cleanup (24h), weekly_snapshot (Sundays 00:00), " "notification_cleanup (24h), weekly_snapshot (Sundays 00:00), "
# Literal argument value # Literal argument value
"recurring_campaigns (daily), due_campaign_jira_sync (15m), jira_sync (1h), " "recurring_campaigns (daily), jira_sync (1h), "
# Literal argument value # Literal argument value
"osint_enrichment (weekly), stale_detection (daily), " "osint_enrichment (weekly), stale_detection (daily), "
"retention_policies (daily), data_sources_sync (6h), " # Literal argument value
"alert_evaluation (1h), attck_evaluation_check (Mondays 06:00)" "retention_policies (daily)"
) )
+89 -93
View File
@@ -38,47 +38,10 @@ from slowapi.errors import RateLimitExceeded
# Import SQLAlchemyError from sqlalchemy.exc # Import SQLAlchemyError from sqlalchemy.exc
from sqlalchemy.exc import SQLAlchemyError from sqlalchemy.exc import SQLAlchemyError
from app.routers import auth as auth_router # Import settings as _settings from app.config
from app.routers import techniques as techniques_router from app.config import settings as _settings
from app.routers import tests as tests_router
from app.routers import evidence as evidence_router # Import DomainError from app.domain.errors
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
from app.routers import audit as audit_router
from app.routers import notifications as notifications_router
from app.routers import reports as reports_router
from app.routers import data_sources as data_sources_router
from app.routers import threat_actors as threat_actors_router
from app.routers import d3fend as d3fend_router
from app.routers import detection_rules as detection_rules_router
from app.routers import campaigns as campaigns_router
from app.routers import heatmap as heatmap_router
from app.routers import scores as scores_router
from app.routers import operational_metrics as operational_metrics_router
from app.routers import compliance as compliance_router
from app.routers import snapshots as snapshots_router
from app.routers import jira as jira_router
from app.routers import worklogs as worklogs_router
from app.routers import professional_reports as professional_reports_router
from app.routers import analytics as analytics_router
from app.routers import advanced_metrics as advanced_metrics_router
from app.routers import osint as osint_router
from app.routers import webhooks as webhooks_router
from app.routers import detection_lifecycle as detection_lifecycle_router
from app.routers import intel as intel_router
from app.routers import admin_config as admin_config_router
from app.routers import ownership as ownership_router
from app.routers import attack_paths as attack_paths_router
from app.routers import knowledge as knowledge_router
from app.routers import risk_intelligence as risk_router
from app.routers import executive_dashboard as dashboard_router
from app.routers import api_keys as api_keys_router
from app.routers import sso as sso_router
from app.routers import operational_alerts as alerts_router
from app.domain.errors import DomainError from app.domain.errors import DomainError
# Import scheduler, start_scheduler from app.jobs.mitre_sync_job # Import scheduler, start_scheduler from app.jobs.mitre_sync_job
@@ -95,15 +58,94 @@ from app.middleware.error_handler import domain_exception_handler
# Import RequestContextMiddleware from app.middleware.request_context # Import RequestContextMiddleware from app.middleware.request_context
from app.middleware.request_context import RequestContextMiddleware from app.middleware.request_context import RequestContextMiddleware
# Import advanced_metrics as advanced_metrics_router from app.routers
from app.routers import advanced_metrics as advanced_metrics_router
# Import analytics as analytics_router from app.routers
from app.routers import analytics as analytics_router
# Import audit as audit_router from app.routers
from app.routers import audit as audit_router
# Import auth as auth_router from app.routers
from app.routers import auth as auth_router
# Import campaigns as campaigns_router from app.routers
from app.routers import campaigns as campaigns_router
# Import compliance as compliance_router from app.routers
from app.routers import compliance as compliance_router
# Import d3fend as d3fend_router from app.routers
from app.routers import d3fend as d3fend_router
# Import data_sources as data_sources_router from app.routers
from app.routers import data_sources as data_sources_router
# Import detection_rules as detection_rules_router from app.routers
from app.routers import detection_rules as detection_rules_router
# Import evidence as evidence_router from app.routers
from app.routers import evidence as evidence_router
# Import heatmap as heatmap_router from app.routers
from app.routers import heatmap as heatmap_router
# Import jira as jira_router from app.routers
from app.routers import jira as jira_router
# Import metrics as metrics_router from app.routers
from app.routers import metrics as metrics_router
# Import notifications as notifications_router from app.routers
from app.routers import notifications as notifications_router
# Import operational_metrics as operational_metrics_router from app.routers
from app.routers import operational_metrics as operational_metrics_router
# Import osint as osint_router from app.routers
from app.routers import osint as osint_router
# Import professional_reports as professional_reports_ro... from app.routers
from app.routers import professional_reports as professional_reports_router
# Import reports as reports_router from app.routers
from app.routers import reports as reports_router
# Import scores as scores_router from app.routers
from app.routers import scores as scores_router
# Import snapshots as snapshots_router from app.routers
from app.routers import snapshots as snapshots_router
# Import system as system_router from app.routers
from app.routers import system as system_router
# Import techniques as techniques_router from app.routers
from app.routers import techniques as techniques_router
# Import test_templates as test_templates_router from app.routers
from app.routers import test_templates as test_templates_router
# Import tests as tests_router from app.routers
from app.routers import tests as tests_router
# Import threat_actors as threat_actors_router from app.routers
from app.routers import threat_actors as threat_actors_router
# Import users as users_router from app.routers
from app.routers import users as users_router
# Import worklogs as worklogs_router from app.routers
from app.routers import worklogs as worklogs_router
# Import ensure_bucket_exists from app.storage
from app.storage import ensure_bucket_exists from app.storage import ensure_bucket_exists
from app.config import settings as _settings
from starlette.middleware.base import BaseHTTPMiddleware
# Configure structured logging before any module initialises its own logger # Configure structured logging before any module initialises its own logger
setup_logging() setup_logging()
logger = logging.getLogger(__name__)
# ── Environment detection ───────────────────────────────────────────────── # ── Environment detection ─────────────────────────────────────────────────
_IS_PRODUCTION = os.environ.get("AEGIS_ENV", "").lower() == "production" _IS_PRODUCTION = os.environ.get("AEGIS_ENV", "").lower() == "production"
@@ -123,25 +165,7 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
ensure_bucket_exists() ensure_bucket_exists()
# Call start_scheduler() # Call start_scheduler()
start_scheduler() start_scheduler()
# Seed decay policies # Yield value
from app.database import SessionLocal
from app.seed_decay_policies import seed_decay_policies
db = SessionLocal()
try:
seed_decay_policies(db)
except Exception as e:
logger.warning("seed_decay_policies failed at startup: %s", e)
finally:
db.close()
# Seed operational alert system rules
db2 = SessionLocal()
try:
from app.services.operational_alert_service import seed_system_rules
seed_system_rules(db2)
except Exception as e:
logger.warning("seed_system_rules failed at startup: %s", e)
finally:
db2.close()
yield yield
# Graceful shutdown of the background scheduler # Graceful shutdown of the background scheduler
scheduler.shutdown(wait=False) scheduler.shutdown(wait=False)
@@ -169,21 +193,6 @@ app.add_exception_handler(RateLimitExceeded, _rate_limit_exceeded_handler)
# Call app.add_middleware() # Call app.add_middleware()
app.add_middleware(RequestContextMiddleware) app.add_middleware(RequestContextMiddleware)
# ── No-cache middleware for all /api/ responses ───────────────────────────
# Prevents Cloudflare and browser caches from storing API responses,
# which would cause stale/empty data to be served after backend restarts.
class NoCacheAPIMiddleware(BaseHTTPMiddleware):
async def dispatch(self, request: Request, call_next):
response = await call_next(request)
if request.url.path.startswith("/api/"):
response.headers["Cache-Control"] = "no-store, no-cache, must-revalidate"
response.headers["Pragma"] = "no-cache"
return response
app.add_middleware(NoCacheAPIMiddleware)
# ── Domain exception → HTTP mapping ────────────────────────────────────── # ── Domain exception → HTTP mapping ──────────────────────────────────────
app.add_exception_handler(DomainError, domain_exception_handler) app.add_exception_handler(DomainError, domain_exception_handler)
@@ -215,8 +224,6 @@ app.include_router(tests_router.router, prefix="/api/v1")
app.include_router(evidence_router.router, prefix="/api/v1") app.include_router(evidence_router.router, prefix="/api/v1")
# Call app.include_router() # Call app.include_router()
app.include_router(test_templates_router.router, prefix="/api/v1") app.include_router(test_templates_router.router, prefix="/api/v1")
app.include_router(procedure_suggestions_router.router, prefix="/api/v1")
app.include_router(template_suggestions_router.router, prefix="/api/v1")
# Call app.include_router() # Call app.include_router()
app.include_router(system_router.router, prefix="/api/v1") app.include_router(system_router.router, prefix="/api/v1")
# Call app.include_router() # Call app.include_router()
@@ -247,8 +254,7 @@ app.include_router(scores_router.router, prefix="/api/v1")
app.include_router(operational_metrics_router.router, prefix="/api/v1") app.include_router(operational_metrics_router.router, prefix="/api/v1")
# Call app.include_router() # Call app.include_router()
app.include_router(compliance_router.router, prefix="/api/v1") app.include_router(compliance_router.router, prefix="/api/v1")
app.include_router(intel_router.router, prefix="/api/v1") # Call app.include_router()
app.include_router(admin_config_router.router, prefix="/api/v1")
app.include_router(snapshots_router.router, prefix="/api/v1") app.include_router(snapshots_router.router, prefix="/api/v1")
# Call app.include_router() # Call app.include_router()
app.include_router(jira_router.router, prefix="/api/v1") app.include_router(jira_router.router, prefix="/api/v1")
@@ -262,16 +268,6 @@ app.include_router(analytics_router.router, prefix="/api/v1")
app.include_router(advanced_metrics_router.router, prefix="/api/v1") app.include_router(advanced_metrics_router.router, prefix="/api/v1")
# Call app.include_router() # Call app.include_router()
app.include_router(osint_router.router, prefix="/api/v1") app.include_router(osint_router.router, prefix="/api/v1")
app.include_router(webhooks_router.router, prefix="/api/v1")
app.include_router(detection_lifecycle_router.router, prefix="/api/v1")
app.include_router(ownership_router.router, prefix="/api/v1")
app.include_router(attack_paths_router.router, prefix="/api/v1")
app.include_router(knowledge_router.router, prefix="/api/v1")
app.include_router(risk_router.router, prefix="/api/v1")
app.include_router(dashboard_router.router, prefix="/api/v1")
app.include_router(api_keys_router.router, prefix="/api/v1")
app.include_router(sso_router.router, prefix="/api/v1")
app.include_router(alerts_router.router, prefix="/api/v1")
# Apply the @app.get decorator # Apply the @app.get decorator
+61 -64
View File
@@ -1,55 +1,73 @@
"""SQLAlchemy ORM model definitions for all database tables.""" """SQLAlchemy ORM model definitions for all database tables."""
# Import all models here so Alembic can detect them # Import all models here so Alembic can detect them
from app.models.audit import AuditLog from app.models.audit import AuditLog
from app.models.notification import Notification
from app.models.data_source import DataSource # Import Campaign, CampaignTest from app.models.campaign
from app.models.detection_rule import DetectionRule from app.models.campaign import Campaign, CampaignTest
from app.models.threat_actor import ThreatActor, ThreatActorTechnique
from app.models.defensive_technique import DefensiveTechnique, DefensiveTechniqueMapping # Import from app.models.compliance
from app.models.test_template_detection_rule import TestTemplateDetectionRule from app.models.compliance import (
from app.models.test_detection_result import TestDetectionResult ComplianceControl,
from app.models.campaign import Campaign, CampaignTest, CampaignModificationRequest ComplianceControlMapping,
from app.models.compliance import ComplianceFramework, ComplianceControl, ComplianceControlMapping ComplianceFramework,
)
# Import CoverageSnapshot, SnapshotTechniqueState from app.models.coverage_snapshot
from app.models.coverage_snapshot import CoverageSnapshot, SnapshotTechniqueState from app.models.coverage_snapshot import CoverageSnapshot, SnapshotTechniqueState
from app.models.jira_link import JiraLink, JiraLinkEntityType, JiraSyncDirection
from app.models.worklog import Worklog # Import DataSource from app.models.data_source
from app.models.osint_item import OsintItem from app.models.data_source import DataSource
from app.models.scoring_config import ScoringConfig
from app.models.enums import TechniqueStatus, TestState, TestResult, TeamSide # Import DefensiveTechnique, DefensiveTechniqueMapping from app.models.defensive_technique
from app.models.webhook_config import WebhookConfig from app.models.defensive_technique import DefensiveTechnique, DefensiveTechniqueMapping
from app.models.system_config import SystemConfig
from app.models.detection_lifecycle import ( # Import DetectionRule from app.models.detection_rule
DetectionAsset, DetectionTechniqueMapping, DetectionValidation, from app.models.detection_rule import DetectionRule
TechniqueConfidenceScore, InfrastructureChangeLog,
DetectionConfidence, DetectionHealthStatus, InvalidationReason, # Import TeamSide, TechniqueStatus, TestResult, TestState from app.models.enums
) from app.models.enums import TeamSide, TechniqueStatus, TestResult, TestState
from app.models.decay_policy import DecayPolicy
from app.models.ownership_queue import ( # Import Evidence from app.models.evidence
TechniqueOwnership, RevalidationQueueItem,
QueuePriority, QueueStatus, QueueReason,
)
from app.models.attack_path import (
AttackPath, AttackPathStep, AttackPathExecution,
AttackPathStepResult, TimelineEntry,
ExecutionStatus, StepResultStatus, TimelineActorSide, TimelineEntryType,
)
from app.models.knowledge import Playbook, PlaybookVersion, LessonLearned
from app.models.risk_intelligence import TechniqueRiskProfile
from app.models.executive_dashboard import PostureSnapshot
from app.models.api_key import ApiKey
from app.models.sso_config import SsoConfig
from app.models.operational_alert import AlertRule, AlertInstance
from app.models.evidence import Evidence from app.models.evidence import Evidence
# Import IntelItem from app.models.intel
from app.models.intel import IntelItem from app.models.intel import IntelItem
# Import JiraLink, JiraLinkEntityType, JiraSyncDirection from app.models.jira_link
from app.models.jira_link import JiraLink, JiraLinkEntityType, JiraSyncDirection
# Import Notification from app.models.notification
from app.models.notification import Notification
# Import OsintItem from app.models.osint_item
from app.models.osint_item import OsintItem
# Import ScoringConfig from app.models.scoring_config
from app.models.scoring_config import ScoringConfig
# Import Technique from app.models.technique
from app.models.technique import Technique from app.models.technique import Technique
# Import Test from app.models.test
from app.models.test import Test from app.models.test import Test
from app.models.test_round_history import TestRoundHistory
# Import TestDetectionResult from app.models.test_detection_result
from app.models.test_detection_result import TestDetectionResult
# Import TestTemplate from app.models.test_template
from app.models.test_template import TestTemplate from app.models.test_template import TestTemplate
from app.models.procedure_suggestion import ProcedureSuggestion
from app.models.template_suggestion import TemplateSuggestion # Import TestTemplateDetectionRule from app.models.test_template_detection_rule
from app.models.test_template_detection_rule import TestTemplateDetectionRule
# Import ThreatActor, ThreatActorTechnique from app.models.threat_actor
from app.models.threat_actor import ThreatActor, ThreatActorTechnique
# Import User from app.models.user
from app.models.user import User from app.models.user import User
from app.models.password_setup_token import PasswordSetupToken
from app.models.evaluation_import import EvaluationImport # Import Worklog from app.models.worklog
from app.models.worklog import Worklog
# Assign __all__ = [ # Assign __all__ = [
__all__ = [ __all__ = [
@@ -64,7 +82,7 @@ __all__ = [
# Literal argument value # Literal argument value
"TestTemplateDetectionRule", "TestDetectionResult", "TestTemplateDetectionRule", "TestDetectionResult",
# Literal argument value # Literal argument value
"Campaign", "CampaignTest", "CampaignModificationRequest", "Campaign", "CampaignTest",
# Literal argument value # Literal argument value
"ComplianceFramework", "ComplianceControl", "ComplianceControlMapping", "ComplianceFramework", "ComplianceControl", "ComplianceControlMapping",
# Literal argument value # Literal argument value
@@ -75,25 +93,4 @@ __all__ = [
"Worklog", "OsintItem", "ScoringConfig", "Worklog", "OsintItem", "ScoringConfig",
# Literal argument value # Literal argument value
"TechniqueStatus", "TestState", "TestResult", "TeamSide", "TechniqueStatus", "TestState", "TestResult", "TeamSide",
"WebhookConfig", "SystemConfig",
"DetectionAsset", "DetectionTechniqueMapping", "DetectionValidation",
"TechniqueConfidenceScore", "InfrastructureChangeLog",
"DetectionConfidence", "DetectionHealthStatus", "InvalidationReason", "DecayPolicy",
"TechniqueOwnership", "RevalidationQueueItem",
"QueuePriority", "QueueStatus", "QueueReason",
"AttackPath", "AttackPathStep", "AttackPathExecution",
"AttackPathStepResult", "TimelineEntry",
"ExecutionStatus", "StepResultStatus", "TimelineActorSide", "TimelineEntryType",
"Playbook", "PlaybookVersion", "LessonLearned",
"TechniqueRiskProfile",
"PostureSnapshot",
"ApiKey",
"SsoConfig",
"AlertRule",
"AlertInstance",
"TestRoundHistory",
"ProcedureSuggestion",
"TemplateSuggestion",
"PasswordSetupToken",
"EvaluationImport",
] ]
-81
View File
@@ -1,81 +0,0 @@
"""Phase 14: API Key model for programmatic access."""
import hashlib
import secrets
import uuid
from datetime import datetime
from sqlalchemy import Boolean, Column, DateTime, ForeignKey, Index, String, Text
from sqlalchemy.dialects.postgresql import JSONB, UUID
from sqlalchemy.orm import relationship
from app.database import Base
# ── Key generation constants ──────────────────────────────────────────────────
KEY_PREFIX = "aegis_"
KEY_BYTES = 32 # 32 random bytes → 64 hex chars → 70-char key total
DISPLAY_LEN = 12 # chars stored as prefix for UI display
def generate_raw_key() -> str:
"""Generate a fresh raw API key (must be shown to user only once)."""
return KEY_PREFIX + secrets.token_hex(KEY_BYTES)
def hash_key(raw_key: str) -> str:
"""SHA-256 hash of a raw API key for secure storage."""
return hashlib.sha256(raw_key.encode()).hexdigest()
def key_prefix_display(raw_key: str) -> str:
"""First DISPLAY_LEN characters of the raw key (safe for UI)."""
return raw_key[:DISPLAY_LEN]
# ── Valid scopes ──────────────────────────────────────────────────────────────
VALID_SCOPES = {"read", "write", "admin"}
class ApiKey(Base):
"""
Scoped API key for programmatic / BI / SOAR access.
The full raw key is **never stored** — only a SHA-256 hash.
The first 12 characters (``key_prefix``) are retained for display.
"""
__tablename__ = "api_keys"
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
name = Column(String(200), nullable=False)
description = Column(Text, nullable=True)
# Display only — never use for auth
key_prefix = Column(String(DISPLAY_LEN + 1), nullable=False)
# Auth token — SHA-256 of the full raw key
key_hash = Column(String(64), nullable=False, unique=True)
# Owner
user_id = Column(
UUID(as_uuid=True),
ForeignKey("users.id", ondelete="CASCADE"),
nullable=False,
)
# Permissions
scopes = Column(JSONB, nullable=False, default=["read"]) # ["read","write","admin"]
# Lifecycle
last_used_at = Column(DateTime, nullable=True)
expires_at = Column(DateTime, nullable=True)
is_active = Column(Boolean, nullable=False, default=True)
created_at = Column(DateTime, default=datetime.utcnow)
user = relationship("User", foreign_keys=[user_id])
__table_args__ = (
Index("ix_api_keys_user_id", "user_id"),
Index("ix_api_keys_key_hash", "key_hash"),
Index("ix_api_keys_active", "is_active"),
)
-253
View File
@@ -1,253 +0,0 @@
"""Phase 10: Attack Paths & Advanced Purple Team models."""
import enum
import uuid
from datetime import datetime
from sqlalchemy import (
Boolean, Column, DateTime, Enum, Float, ForeignKey,
Index, Integer, String, Text,
)
from sqlalchemy.dialects.postgresql import UUID, JSONB
from sqlalchemy.orm import relationship
from app.database import Base
class ExecutionStatus(str, enum.Enum):
planned = "planned"
in_progress = "in_progress"
completed = "completed"
aborted = "aborted"
class StepResultStatus(str, enum.Enum):
pending = "pending"
executing = "executing"
detected = "detected"
not_detected = "not_detected"
skipped = "skipped"
class TimelineActorSide(str, enum.Enum):
red = "red"
blue = "blue"
system = "system"
class TimelineEntryType(str, enum.Enum):
action = "action"
detection = "detection"
note = "note"
phase_transition = "phase_transition"
flag = "flag"
# ---------------------------------------------------------------------------
class AttackPath(Base):
"""
A reusable attack scenario composed of ordered kill-chain steps.
Can be a template (shared) or a one-off scenario.
"""
__tablename__ = "attack_paths"
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
name = Column(String(300), nullable=False)
description = Column(Text, nullable=True)
objective = Column(Text, nullable=True) # what the attacker aims to achieve
is_template = Column(Boolean, default=False) # reusable template flag
threat_actor_id = Column(
UUID(as_uuid=True), ForeignKey("threat_actors.id", ondelete="SET NULL"), nullable=True
)
created_by = Column(
UUID(as_uuid=True), ForeignKey("users.id", ondelete="SET NULL"), nullable=True
)
tags = Column(JSONB, nullable=True, default=list)
is_active = Column(Boolean, default=True)
created_at = Column(DateTime, default=datetime.utcnow)
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
steps = relationship(
"AttackPathStep", back_populates="attack_path",
cascade="all, delete-orphan",
order_by="AttackPathStep.order_index",
)
executions = relationship("AttackPathExecution", back_populates="attack_path")
creator = relationship("User", foreign_keys=[created_by])
threat_actor = relationship("ThreatActor", foreign_keys=[threat_actor_id])
__table_args__ = (
Index("ix_attack_paths_created_by", "created_by"),
Index("ix_attack_paths_is_template", "is_template"),
)
class AttackPathStep(Base):
"""One step in an attack path — maps to a kill-chain phase + technique."""
__tablename__ = "attack_path_steps"
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
attack_path_id = Column(
UUID(as_uuid=True), ForeignKey("attack_paths.id", ondelete="CASCADE"), nullable=False
)
order_index = Column(Integer, nullable=False, default=0)
kill_chain_phase = Column(String(60), nullable=True) # initial_access, execution, …
technique_id = Column(
UUID(as_uuid=True), ForeignKey("techniques.id", ondelete="SET NULL"), nullable=True
)
test_id = Column(
UUID(as_uuid=True), ForeignKey("tests.id", ondelete="SET NULL"), nullable=True
)
name = Column(String(300), nullable=True) # human label for the step
description = Column(Text, nullable=True)
expected_detection = Column(Boolean, default=True) # do we expect blue to detect this?
notes = Column(Text, nullable=True)
attack_path = relationship("AttackPath", back_populates="steps")
technique = relationship("Technique", foreign_keys=[technique_id])
test = relationship("Test", foreign_keys=[test_id])
__table_args__ = (
Index("ix_ap_steps_path_id", "attack_path_id"),
Index("ix_ap_steps_technique_id", "technique_id"),
)
class AttackPathExecution(Base):
"""
A single run of an attack path.
Tracks Red/Blue participants, timing, and aggregated kill-chain metrics.
"""
__tablename__ = "attack_path_executions"
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
attack_path_id = Column(
UUID(as_uuid=True), ForeignKey("attack_paths.id", ondelete="CASCADE"), nullable=False
)
status = Column(
Enum(ExecutionStatus, name="execution_status"), nullable=False,
default=ExecutionStatus.planned,
)
environment = Column(String(100), nullable=True) # prod, staging, lab
red_team_lead = Column(
UUID(as_uuid=True), ForeignKey("users.id", ondelete="SET NULL"), nullable=True
)
blue_team_lead = Column(
UUID(as_uuid=True), ForeignKey("users.id", ondelete="SET NULL"), nullable=True
)
started_by = Column(
UUID(as_uuid=True), ForeignKey("users.id", ondelete="SET NULL"), nullable=True
)
started_at = Column(DateTime, nullable=True)
completed_at = Column(DateTime, nullable=True)
notes = Column(Text, nullable=True)
created_at = Column(DateTime, default=datetime.utcnow)
# ── Computed kill-chain metrics (written on complete) ─────────────────
total_steps = Column(Integer, nullable=True)
detected_steps = Column(Integer, nullable=True)
not_detected_steps = Column(Integer, nullable=True)
skipped_steps = Column(Integer, nullable=True)
detection_rate = Column(Float, nullable=True) # 0.01.0
mttd_seconds = Column(Float, nullable=True) # mean time to detect (avg across detected)
furthest_undetected_step = Column(Integer, nullable=True) # order_index of deepest undetected step
attack_path = relationship("AttackPath", back_populates="executions")
step_results = relationship(
"AttackPathStepResult", back_populates="execution",
cascade="all, delete-orphan",
order_by="AttackPathStepResult.step_order",
)
timeline = relationship(
"TimelineEntry", back_populates="execution",
cascade="all, delete-orphan",
order_by="TimelineEntry.timestamp",
)
red_lead_user = relationship("User", foreign_keys=[red_team_lead])
blue_lead_user = relationship("User", foreign_keys=[blue_team_lead])
__table_args__ = (
Index("ix_ap_exec_path_id", "attack_path_id"),
Index("ix_ap_exec_status", "status"),
)
class AttackPathStepResult(Base):
"""Result of executing one step in an attack path execution."""
__tablename__ = "attack_path_step_results"
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
execution_id = Column(
UUID(as_uuid=True), ForeignKey("attack_path_executions.id", ondelete="CASCADE"),
nullable=False,
)
step_id = Column(
UUID(as_uuid=True), ForeignKey("attack_path_steps.id", ondelete="CASCADE"),
nullable=False,
)
step_order = Column(Integer, nullable=False, default=0) # denormalized for sorting
status = Column(
Enum(StepResultStatus, name="step_result_status"), nullable=False,
default=StepResultStatus.pending,
)
executed_by = Column(
UUID(as_uuid=True), ForeignKey("users.id", ondelete="SET NULL"), nullable=True
)
executed_at = Column(DateTime, nullable=True)
detected_at = Column(DateTime, nullable=True)
time_to_detect_seconds = Column(Float, nullable=True)
detection_asset_id = Column(
UUID(as_uuid=True),
ForeignKey("detection_assets.id", ondelete="SET NULL"), nullable=True
)
notes = Column(Text, nullable=True)
evidence_ids = Column(JSONB, nullable=True, default=list)
execution = relationship("AttackPathExecution", back_populates="step_results")
step = relationship("AttackPathStep")
detection_asset = relationship("DetectionAsset", foreign_keys=[detection_asset_id])
executor = relationship("User", foreign_keys=[executed_by])
__table_args__ = (
Index("ix_ap_stepres_execution_id", "execution_id"),
Index("ix_ap_stepres_step_id", "step_id"),
)
class TimelineEntry(Base):
"""Timestamped Red/Blue action during an execution — used for MTTD/MTTR."""
__tablename__ = "attack_path_timeline"
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
execution_id = Column(
UUID(as_uuid=True), ForeignKey("attack_path_executions.id", ondelete="CASCADE"),
nullable=False,
)
step_id = Column(
UUID(as_uuid=True), ForeignKey("attack_path_steps.id", ondelete="SET NULL"),
nullable=True,
)
timestamp = Column(DateTime, nullable=False, default=datetime.utcnow)
actor_side = Column(
Enum(TimelineActorSide, name="timeline_actor_side"), nullable=False,
)
actor_id = Column(
UUID(as_uuid=True), ForeignKey("users.id", ondelete="SET NULL"), nullable=True
)
entry_type = Column(
Enum(TimelineEntryType, name="timeline_entry_type"), nullable=False,
)
content = Column(Text, nullable=False)
extra = Column(JSONB, nullable=True)
execution = relationship("AttackPathExecution", back_populates="timeline")
actor = relationship("User", foreign_keys=[actor_id])
__table_args__ = (
Index("ix_timeline_execution_id", "execution_id"),
Index("ix_timeline_timestamp", "timestamp"),
)
+3 -64
View File
@@ -73,15 +73,8 @@ class Campaign(Base):
# Keyword argument: nullable # Keyword argument: nullable
nullable=True, nullable=True,
) )
start_date = Column(DateTime, nullable=True) # campaign won't activate before this date # Assign scheduled_at = Column(DateTime, nullable=True)
scheduled_at = Column(DateTime, nullable=True) scheduled_at = Column(DateTime, nullable=True)
approved_by = Column(
UUID(as_uuid=True),
ForeignKey("users.id", ondelete="SET NULL"),
nullable=True,
)
approved_at = Column(DateTime, nullable=True)
rejection_reason = Column(Text, nullable=True)
# Assign completed_at = Column(DateTime, nullable=True) # Assign completed_at = Column(DateTime, nullable=True)
completed_at = Column(DateTime, nullable=True) completed_at = Column(DateTime, nullable=True)
# Assign target_platform = Column(String, nullable=True) # Assign target_platform = Column(String, nullable=True)
@@ -90,8 +83,8 @@ class Campaign(Base):
tags = Column(JSONB, nullable=True, default=[]) tags = Column(JSONB, nullable=True, default=[])
# Assign created_at = Column(DateTime(timezone=True), server_default=func.now()) # Assign created_at = Column(DateTime(timezone=True), server_default=func.now())
created_at = Column(DateTime(timezone=True), server_default=func.now()) created_at = Column(DateTime(timezone=True), server_default=func.now())
# Assign data_classification = Column(String(20), nullable=False, server_default="internal_use_only") # Assign data_classification = Column(String(20), nullable=False, server_default="internal")
data_classification = Column(String(20), nullable=False, server_default="internal_use_only") data_classification = Column(String(20), nullable=False, server_default="internal")
# Recurring scheduling fields # Recurring scheduling fields
is_recurring = Column(Boolean, default=False) is_recurring = Column(Boolean, default=False)
@@ -236,57 +229,3 @@ class CampaignTest(Base):
Index('ix_campaign_tests_campaign', 'campaign_id'), Index('ix_campaign_tests_campaign', 'campaign_id'),
Index('ix_campaign_tests_test', 'test_id'), Index('ix_campaign_tests_test', 'test_id'),
) )
class CampaignModificationRequest(Base):
"""A lead's request to add or remove a test from an already-active campaign.
The underlying ``CampaignTest`` row is only created/deleted once a
manager approves the request — see ``approve_modification_request``
in ``campaign_crud_service.py``.
"""
__tablename__ = "campaign_modification_requests"
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
campaign_id = Column(
UUID(as_uuid=True),
ForeignKey("campaigns.id", ondelete="CASCADE"),
nullable=False,
)
requested_by = Column(
UUID(as_uuid=True),
ForeignKey("users.id", ondelete="SET NULL"),
nullable=True,
)
action = Column(String, nullable=False) # add_test, remove_test
# SET NULL (not CASCADE): approving a "remove_test" request deletes the
# underlying Test row (see remove_test_from_campaign), and this request
# row is the audit record of that decision — it must survive the test's
# deletion, not be wiped out along with it.
test_id = Column(
UUID(as_uuid=True),
ForeignKey("tests.id", ondelete="SET NULL"),
nullable=True,
)
order_index = Column(Integer, nullable=True)
phase = Column(String, nullable=True)
justification = Column(Text, nullable=False)
status = Column(String, nullable=False, default="pending") # pending, approved, rejected
reviewed_by = Column(
UUID(as_uuid=True),
ForeignKey("users.id", ondelete="SET NULL"),
nullable=True,
)
reviewed_at = Column(DateTime, nullable=True)
review_notes = Column(Text, nullable=True)
created_at = Column(DateTime(timezone=True), server_default=func.now())
campaign = relationship("Campaign")
test = relationship("Test")
requester = relationship("User", foreign_keys=[requested_by])
reviewer = relationship("User", foreign_keys=[reviewed_by])
__table_args__ = (
Index("ix_campaign_mod_requests_campaign", "campaign_id"),
Index("ix_campaign_mod_requests_status", "status"),
)
-32
View File
@@ -1,32 +0,0 @@
"""Decay Policy model — configurable detection validity rules."""
import uuid
from datetime import datetime
from sqlalchemy import Column, String, Integer, Float, Boolean, DateTime, Text
from sqlalchemy.dialects.postgresql import UUID
from app.database import Base
class DecayPolicy(Base):
__tablename__ = "decay_policies"
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
name = Column(String(200), nullable=False)
description = Column(Text)
applies_to_platform = Column(String(100))
applies_to_asset_type = Column(String(50))
applies_to_tactic = Column(String(100))
fresh_days = Column(Integer, default=90, server_default='90')
aging_days = Column(Integer, default=180, server_default='180')
stale_days = Column(Integer, default=365, server_default='365')
default_validity_days = Column(Integer, default=180, server_default='180')
silent_threshold_days = Column(Integer, default=30, server_default='30')
noisy_threshold_daily = Column(Integer, default=100, server_default='100')
recency_weight = Column(Float, default=0.3, server_default='0.3')
coverage_weight = Column(Float, default=0.3, server_default='0.3')
health_weight = Column(Float, default=0.25, server_default='0.25')
diversity_weight = Column(Float, default=0.15, server_default='0.15')
is_default = Column(Boolean, default=False, server_default='false')
is_active = Column(Boolean, default=True, server_default='true')
created_at = Column(DateTime, default=datetime.utcnow)
updated_at = Column(DateTime, default=datetime.utcnow)
-168
View File
@@ -1,168 +0,0 @@
"""Detection Lifecycle Management models."""
import uuid
import enum
from datetime import datetime
from sqlalchemy import (
Column, String, Integer, Float, Boolean, DateTime,
ForeignKey, Text, Enum as SQLEnum
)
from sqlalchemy.dialects.postgresql import UUID, JSONB
from sqlalchemy.orm import relationship
from app.database import Base
class DetectionConfidence(str, enum.Enum):
fresh = "fresh"
aging = "aging"
stale = "stale"
broken = "broken"
unknown = "unknown"
class DetectionHealthStatus(str, enum.Enum):
healthy = "healthy"
silent = "silent"
noisy = "noisy"
orphan = "orphan"
deprecated = "deprecated"
untested = "untested"
class InvalidationReason(str, enum.Enum):
time_decay = "time_decay"
mitre_update = "mitre_update"
log_source_change = "log_source_change"
siem_update = "siem_update"
edr_update = "edr_update"
infrastructure_change = "infrastructure_change"
parser_change = "parser_change"
manual = "manual"
rule_modified = "rule_modified"
class DetectionAsset(Base):
__tablename__ = "detection_assets"
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
name = Column(String(500), nullable=False)
description = Column(Text)
asset_type = Column(String(50), nullable=False)
platform = Column(String(100))
rule_content = Column(Text)
rule_language = Column(String(50))
rule_repository_url = Column(Text)
rule_file_path = Column(String(500))
rule_version = Column(String(50))
rule_hash = Column(String(64))
last_rule_change_at = Column(DateTime)
log_source_name = Column(String(200))
log_source_version = Column(String(50))
log_source_config = Column(JSONB, server_default='{}')
infrastructure_hash = Column(String(64))
infrastructure_details = Column(JSONB, server_default='{}')
health_status = Column(
SQLEnum(DetectionHealthStatus, name="detectionhealthstatus"),
default=DetectionHealthStatus.untested,
nullable=False,
server_default="untested",
)
last_alert_at = Column(DateTime)
alert_count_30d = Column(Integer, default=0, server_default='0')
false_positive_rate = Column(Float)
expected_alert_frequency = Column(String(50))
owner_id = Column(UUID(as_uuid=True), ForeignKey("users.id", ondelete="SET NULL"), nullable=True)
backup_owner_id = Column(UUID(as_uuid=True), ForeignKey("users.id", ondelete="SET NULL"), nullable=True)
team = Column(String(100))
is_active = Column(Boolean, default=True, nullable=False, server_default='true')
tags = Column(JSONB, server_default='[]')
asset_metadata = Column(JSONB, server_default='{}')
created_by = Column(UUID(as_uuid=True), ForeignKey("users.id", ondelete="SET NULL"), nullable=True)
created_at = Column(DateTime(timezone=True), server_default='now()')
updated_at = Column(DateTime(timezone=True), server_default='now()')
technique_mappings = relationship("DetectionTechniqueMapping", back_populates="detection_asset", cascade="all, delete-orphan")
validations = relationship("DetectionValidation", back_populates="detection_asset", cascade="all, delete-orphan")
class DetectionTechniqueMapping(Base):
__tablename__ = "detection_technique_mappings"
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
detection_asset_id = Column(UUID(as_uuid=True), ForeignKey("detection_assets.id", ondelete="CASCADE"), nullable=False)
technique_id = Column(UUID(as_uuid=True), ForeignKey("techniques.id", ondelete="CASCADE"), nullable=False)
coverage_type = Column(String(50), default="detect", server_default="detect")
confidence_level = Column(String(20), default="medium", server_default="medium")
notes = Column(Text)
created_at = Column(DateTime(timezone=True), server_default='now()')
detection_asset = relationship("DetectionAsset", back_populates="technique_mappings")
class DetectionValidation(Base):
__tablename__ = "detection_validations"
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
detection_asset_id = Column(UUID(as_uuid=True), ForeignKey("detection_assets.id", ondelete="CASCADE"), nullable=False)
technique_id = Column(UUID(as_uuid=True), ForeignKey("techniques.id", ondelete="SET NULL"), nullable=True)
test_id = Column(UUID(as_uuid=True), ForeignKey("tests.id", ondelete="SET NULL"), nullable=True)
validated_at = Column(DateTime, default=datetime.utcnow)
expires_at = Column(DateTime, nullable=False)
is_valid = Column(Boolean, default=True, nullable=False, server_default='true')
validation_result = Column(String(50))
validation_method = Column(String(100))
rule_hash_at_validation = Column(String(64))
log_source_version_at_validation = Column(String(50))
infrastructure_hash_at_validation = Column(String(64))
environment_snapshot = Column(JSONB, server_default='{}')
invalidated_at = Column(DateTime)
invalidation_reason = Column(SQLEnum(InvalidationReason, name="invalidationreason"), nullable=True)
invalidation_details = Column(Text)
invalidated_by = Column(UUID(as_uuid=True), ForeignKey("users.id", ondelete="SET NULL"), nullable=True)
validated_by = Column(UUID(as_uuid=True), ForeignKey("users.id", ondelete="SET NULL"), nullable=False)
integrity_hash = Column(String(64))
notes = Column(Text)
evidence_ids = Column(JSONB, server_default='[]')
detection_asset = relationship("DetectionAsset", back_populates="validations")
class TechniqueConfidenceScore(Base):
__tablename__ = "technique_confidence_scores"
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
technique_id = Column(UUID(as_uuid=True), ForeignKey("techniques.id", ondelete="CASCADE"), nullable=False, unique=True)
confidence_level = Column(
SQLEnum(DetectionConfidence, name="detectionconfidence"),
default=DetectionConfidence.unknown,
server_default="unknown",
)
confidence_score = Column(Float, default=0.0, server_default='0.0')
detection_count = Column(Integer, default=0, server_default='0')
valid_detection_count = Column(Integer, default=0, server_default='0')
last_validated_at = Column(DateTime)
next_validation_due = Column(DateTime)
last_recalculated_at = Column(DateTime, default=datetime.utcnow)
recency_factor = Column(Float, default=0.0, server_default='0.0')
coverage_factor = Column(Float, default=0.0, server_default='0.0')
health_factor = Column(Float, default=0.0, server_default='0.0')
diversity_factor = Column(Float, default=0.0, server_default='0.0')
score_breakdown = Column(JSONB, server_default='{}')
risk_factors = Column(JSONB, server_default='[]')
updated_at = Column(DateTime, default=datetime.utcnow)
class InfrastructureChangeLog(Base):
__tablename__ = "infrastructure_change_logs"
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
change_type = Column(String(100), nullable=False)
description = Column(Text, nullable=False)
affected_platforms = Column(JSONB, server_default='[]')
affected_log_sources = Column(JSONB, server_default='[]')
change_date = Column(DateTime, default=datetime.utcnow)
reported_by = Column(UUID(as_uuid=True), ForeignKey("users.id", ondelete="SET NULL"), nullable=True)
auto_invalidate = Column(Boolean, default=True, server_default='true')
invalidated_count = Column(Integer, default=0, server_default='0')
change_metadata = Column(JSONB, server_default='{}')
created_at = Column(DateTime, default=datetime.utcnow)
-2
View File
@@ -7,8 +7,6 @@ working with ``from app.models.enums import ...``.
# Import # noqa: F401 from app.domain.enums # Import # noqa: F401 from app.domain.enums
from app.domain.enums import ( # noqa: F401 from app.domain.enums import ( # noqa: F401
AttackSuccessResult,
ContainmentResult,
DataClassification, DataClassification,
TeamSide, TeamSide,
TechniqueStatus, TechniqueStatus,
-34
View File
@@ -1,34 +0,0 @@
"""SQLAlchemy model for tracking imported ATT&CK Evaluation rounds."""
import uuid
from datetime import datetime
from sqlalchemy import Column, String, Integer, DateTime, Text, ForeignKey, Index
from sqlalchemy.dialects.postgresql import UUID
from app.database import Base
class EvaluationImport(Base):
"""Tracks which ATT&CK Evaluation rounds have been imported into the platform.
Each row represents one vendor+adversary combination that has been processed
and turned into Test records. Used to avoid duplicate imports and to show
the admin panel which rounds are available vs imported.
"""
__tablename__ = "evaluation_imports"
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
adversary_name = Column(String, nullable=False) # "apt29", "turla"
adversary_display = Column(String, nullable=False) # "APT29", "Turla"
eval_round = Column(Integer, nullable=False) # 1, 2, 3 …
imported_at = Column(DateTime, nullable=False, default=datetime.utcnow)
imported_by = Column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=True)
tests_created = Column(Integer, default=0)
techniques_covered = Column(Integer, default=0)
status = Column(String, default="completed") # "completed" | "failed"
notes = Column(Text, nullable=True)
__table_args__ = (
Index("ix_evaluation_imports_adversary", "adversary_name"),
Index("ix_evaluation_imports_round", "eval_round"),
)
+2 -2
View File
@@ -50,8 +50,8 @@ class Evidence(Base):
team = Column(Enum(TeamSide, name="teamside"), nullable=False, default=TeamSide.red) team = Column(Enum(TeamSide, name="teamside"), nullable=False, default=TeamSide.red)
# Assign notes = Column(Text, nullable=True) # Assign notes = Column(Text, nullable=True)
notes = Column(Text, nullable=True) notes = Column(Text, nullable=True)
# Assign data_classification = Column(String(20), nullable=False, server_default="internal_use_only") # Assign data_classification = Column(String(20), nullable=False, server_default="internal")
data_classification = Column(String(20), nullable=False, server_default="internal_use_only") data_classification = Column(String(20), nullable=False, server_default="internal")
# Relationships # Relationships
test = relationship("Test", back_populates="evidences") test = relationship("Test", back_populates="evidences")
-68
View File
@@ -1,68 +0,0 @@
"""Phase 13: Executive Dashboard — PostureSnapshot model."""
import uuid
from datetime import datetime
from sqlalchemy import (
Column, Date, DateTime, Float, ForeignKey,
Index, Integer, UniqueConstraint,
)
from sqlalchemy.dialects.postgresql import UUID, JSONB
from sqlalchemy.orm import relationship
from app.database import Base
class PostureSnapshot(Base):
"""
Daily point-in-time capture of the organisation's security posture.
Aggregates data from all phases (coverage, risk, ownership, knowledge,
attack-paths) into a single row that can be trended over time.
"""
__tablename__ = "posture_snapshots"
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
snapshot_date = Column(Date, nullable=False) # one per calendar day
# ── Coverage ──────────────────────────────────────────────────────────────
total_techniques = Column(Integer, nullable=False, default=0)
validated_count = Column(Integer, nullable=False, default=0)
partial_count = Column(Integer, nullable=False, default=0)
not_covered_count = Column(Integer, nullable=False, default=0)
coverage_pct = Column(Float, nullable=False, default=0.0) # 0100
# ── Risk ─────────────────────────────────────────────────────────────────
avg_risk_score = Column(Float, nullable=False, default=0.0)
critical_count = Column(Integer, nullable=False, default=0)
high_count = Column(Integer, nullable=False, default=0)
medium_count = Column(Integer, nullable=False, default=0)
low_count = Column(Integer, nullable=False, default=0)
# ── Operations ────────────────────────────────────────────────────────────
open_queue_items = Column(Integer, nullable=False, default=0)
orphan_techniques = Column(Integer, nullable=False, default=0)
# ── Knowledge ─────────────────────────────────────────────────────────────
playbook_count = Column(Integer, nullable=False, default=0)
lesson_count = Column(Integer, nullable=False, default=0)
# ── MTTD (from attack-path executions completed in last 30 d) ────────────
mttd_avg_seconds = Column(Float, nullable=True) # None if no data
executions_30d = Column(Integer, nullable=False, default=0)
detection_rate_30d = Column(Float, nullable=True) # avg across executions
# ── Meta ─────────────────────────────────────────────────────────────────
created_by = Column(
UUID(as_uuid=True), ForeignKey("users.id", ondelete="SET NULL"), nullable=True
)
created_at = Column(DateTime, default=datetime.utcnow)
extra = Column(JSONB, nullable=True) # full breakdown / by-tactic
creator = relationship("User", foreign_keys=[created_by])
__table_args__ = (
UniqueConstraint("snapshot_date", name="uq_posture_snapshot_date"),
Index("ix_posture_snapshots_date", "snapshot_date"),
)
-129
View File
@@ -1,129 +0,0 @@
"""Phase 11: Knowledge Management models — Playbooks + Lessons Learned."""
import uuid
from datetime import datetime
from sqlalchemy import (
Boolean, Column, DateTime, ForeignKey,
Index, Integer, String, Text, UniqueConstraint,
)
from sqlalchemy.dialects.postgresql import UUID, JSONB
from sqlalchemy.orm import relationship
from app.database import Base
# ── Playbooks ──────────────────────────────────────────────────────────────────
class Playbook(Base):
"""
Structured runbook for a specific technique and playbook type.
playbook_type: attack | detect | investigate | respond | hunt
One playbook per (technique, type). Edits increment ``version``
and save a snapshot to ``PlaybookVersion``.
"""
__tablename__ = "playbooks"
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
technique_id = Column(
UUID(as_uuid=True), ForeignKey("techniques.id", ondelete="CASCADE"), nullable=False
)
playbook_type = Column(String(32), nullable=False) # attack/detect/investigate/respond/hunt
title = Column(String(255), nullable=False)
content = Column(Text, nullable=False, default="")
version = Column(Integer, default=1, nullable=False)
tools = Column(JSONB, default=list) # list of tool name strings
prerequisites = Column(JSONB, default=list) # list of prerequisite strings
created_by = Column(
UUID(as_uuid=True), ForeignKey("users.id", ondelete="SET NULL"), nullable=True
)
updated_by = Column(
UUID(as_uuid=True), ForeignKey("users.id", ondelete="SET NULL"), nullable=True
)
created_at = Column(DateTime, default=datetime.utcnow)
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
is_active = Column(Boolean, default=True)
# Relationships
technique = relationship("Technique", foreign_keys=[technique_id])
creator = relationship("User", foreign_keys=[created_by])
updater = relationship("User", foreign_keys=[updated_by])
versions = relationship(
"PlaybookVersion", back_populates="playbook",
cascade="all, delete-orphan",
order_by="PlaybookVersion.version.desc()",
)
__table_args__ = (
UniqueConstraint("technique_id", "playbook_type", name="uq_playbook_technique_type"),
Index("ix_playbooks_technique_id", "technique_id"),
Index("ix_playbooks_type", "playbook_type"),
)
class PlaybookVersion(Base):
"""Immutable snapshot of a playbook at a given version number."""
__tablename__ = "playbook_versions"
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
playbook_id = Column(
UUID(as_uuid=True), ForeignKey("playbooks.id", ondelete="CASCADE"), nullable=False
)
version = Column(Integer, nullable=False)
title = Column(String(255), nullable=False)
content = Column(Text, nullable=False, default="")
tools = Column(JSONB, default=list)
prerequisites = Column(JSONB, default=list)
changed_by = Column(
UUID(as_uuid=True), ForeignKey("users.id", ondelete="SET NULL"), nullable=True
)
change_note = Column(String(500), nullable=True)
created_at = Column(DateTime, default=datetime.utcnow)
playbook = relationship("Playbook", back_populates="versions")
changer = relationship("User", foreign_keys=[changed_by])
__table_args__ = (
Index("ix_pb_versions_playbook_id", "playbook_id"),
Index("ix_pb_versions_version", "playbook_id", "version"),
)
# ── Lessons Learned ────────────────────────────────────────────────────────────
class LessonLearned(Base):
"""
Immutable post-mortem record linked to a test, campaign, attack-path or
created manually.
severity: critical | high | medium | low | info
entity_type: test | campaign | attack_path | manual
"""
__tablename__ = "lessons_learned"
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
title = Column(String(255), nullable=False)
what_happened = Column(Text, nullable=False, default="")
root_cause = Column(Text, nullable=False, default="")
fix_applied = Column(Text, nullable=True)
severity = Column(String(16), nullable=False, default="medium")
entity_type = Column(String(32), nullable=False, default="manual")
entity_id = Column(UUID(as_uuid=True), nullable=True)
technique_ids = Column(JSONB, default=list) # list of UUID strings
tags = Column(JSONB, default=list)
created_by = Column(
UUID(as_uuid=True), ForeignKey("users.id", ondelete="SET NULL"), nullable=True
)
created_at = Column(DateTime, default=datetime.utcnow)
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
is_active = Column(Boolean, default=True) # soft-delete (admin only)
creator = relationship("User", foreign_keys=[created_by])
__table_args__ = (
Index("ix_ll_entity", "entity_type", "entity_id"),
Index("ix_ll_severity", "severity"),
Index("ix_ll_created_by", "created_by"),
)
-144
View File
@@ -1,144 +0,0 @@
"""Phase 13: Operational Alerts — AlertRule and AlertInstance models."""
import enum
import uuid
from datetime import datetime
from sqlalchemy import (
Boolean, Column, DateTime, ForeignKey,
Index, Integer, String, Text,
)
from sqlalchemy.dialects.postgresql import JSONB, UUID
from sqlalchemy.orm import relationship
from app.database import Base
# ── Enumerations ──────────────────────────────────────────────────────────────
class AlertSeverity(str, enum.Enum):
critical = "critical"
high = "high"
medium = "medium"
low = "low"
info = "info"
class AlertStatus(str, enum.Enum):
open = "open"
acknowledged = "acknowledged"
resolved = "resolved"
dismissed = "dismissed"
class AlertRuleType(str, enum.Enum):
high_risk = "high_risk" # risk_score >= threshold
stale_technique = "stale_technique" # not validated in N days
coverage_regression = "coverage_regression" # coverage_pct dropped
low_coverage = "low_coverage" # coverage below min
expiry_wave = "expiry_wave" # many pending queue items
new_technique = "new_technique" # new MITRE techniques added
orphan_spike = "orphan_spike" # many unowned techniques
custom = "custom" # future extension placeholder
# ── AlertRule ─────────────────────────────────────────────────────────────────
class AlertRule(Base):
"""
Defines a condition that, when satisfied, fires an AlertInstance.
System rules (is_system=True) are seeded at startup and cannot be deleted.
Custom rules (is_system=False) can be created by admins.
"""
__tablename__ = "alert_rules"
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
name = Column(String(300), nullable=False)
description = Column(Text, nullable=True)
rule_type = Column(String(50), nullable=False)
severity = Column(String(20), nullable=False, default=AlertSeverity.medium.value)
is_enabled = Column(Boolean, nullable=False, default=True)
is_system = Column(Boolean, nullable=False, default=False) # seeded, not deletable
# Rule-specific thresholds/config (varies by rule_type)
config = Column(JSONB, nullable=False, default={})
# Delivery
notify_in_app = Column(Boolean, nullable=False, default=True)
notify_webhook = Column(Boolean, nullable=False, default=False)
webhook_id = Column(
UUID(as_uuid=True),
ForeignKey("webhook_configs.id", ondelete="SET NULL"),
nullable=True,
)
# Cooldown — don't re-fire within N hours of last firing
cooldown_hours = Column(Integer, nullable=False, default=24)
# Meta
created_by = Column(
UUID(as_uuid=True),
ForeignKey("users.id", ondelete="SET NULL"),
nullable=True,
)
created_at = Column(DateTime, default=datetime.utcnow)
last_fired_at = Column(DateTime, nullable=True)
creator = relationship("User", foreign_keys=[created_by])
instances = relationship("AlertInstance", back_populates="rule",
cascade="all, delete-orphan")
__table_args__ = (
Index("ix_alert_rules_type", "rule_type"),
Index("ix_alert_rules_enabled", "is_enabled"),
)
# ── AlertInstance ─────────────────────────────────────────────────────────────
class AlertInstance(Base):
"""
A single firing of an AlertRule.
Transitions: open → acknowledged → resolved
open → dismissed
"""
__tablename__ = "alert_instances"
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
rule_id = Column(
UUID(as_uuid=True),
ForeignKey("alert_rules.id", ondelete="SET NULL"),
nullable=True,
)
# Denormalised fields kept for history even after rule deletion
rule_name = Column(String(300), nullable=False)
rule_type = Column(String(50), nullable=False)
severity = Column(String(20), nullable=False)
title = Column(String(500), nullable=False)
message = Column(Text, nullable=False)
details = Column(JSONB, nullable=True) # structured context
status = Column(String(20), nullable=False, default=AlertStatus.open.value)
acknowledged_by = Column(
UUID(as_uuid=True),
ForeignKey("users.id", ondelete="SET NULL"),
nullable=True,
)
acknowledged_at = Column(DateTime, nullable=True)
resolved_at = Column(DateTime, nullable=True)
created_at = Column(DateTime, default=datetime.utcnow)
rule = relationship("AlertRule", back_populates="instances")
acknowledger = relationship("User", foreign_keys=[acknowledged_by])
__table_args__ = (
Index("ix_alert_instances_rule_id", "rule_id"),
Index("ix_alert_instances_status", "status"),
Index("ix_alert_instances_severity", "severity"),
Index("ix_alert_instances_created", "created_at"),
)
-136
View File
@@ -1,136 +0,0 @@
"""Phase 9: Ownership & Revalidation Queue models."""
import enum
import uuid
from datetime import datetime
from sqlalchemy import Column, DateTime, Enum, ForeignKey, Index, String, Text
from sqlalchemy.dialects.postgresql import UUID, JSONB
from sqlalchemy.orm import relationship
from app.database import Base
class QueuePriority(str, enum.Enum):
critical = "critical"
high = "high"
medium = "medium"
low = "low"
class QueueStatus(str, enum.Enum):
pending = "pending"
in_progress = "in_progress"
completed = "completed"
dismissed = "dismissed"
class QueueReason(str, enum.Enum):
validation_expired = "validation_expired"
infra_change = "infra_change"
osint_alert = "osint_alert"
mitre_update = "mitre_update"
rule_modified = "rule_modified"
low_confidence = "low_confidence"
manual = "manual"
class TechniqueOwnership(Base):
"""Ownership assignment for a MITRE technique."""
__tablename__ = "technique_ownerships"
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
technique_id = Column(
UUID(as_uuid=True),
ForeignKey("techniques.id", ondelete="CASCADE"),
nullable=False,
unique=True,
)
owner_id = Column(
UUID(as_uuid=True),
ForeignKey("users.id", ondelete="SET NULL"),
nullable=True,
)
backup_owner_id = Column(
UUID(as_uuid=True),
ForeignKey("users.id", ondelete="SET NULL"),
nullable=True,
)
team = Column(String(200), nullable=True)
notes = Column(Text, nullable=True)
assigned_at = Column(DateTime, nullable=True)
assigned_by = Column(
UUID(as_uuid=True),
ForeignKey("users.id", ondelete="SET NULL"),
nullable=True,
)
created_at = Column(DateTime, default=datetime.utcnow)
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
technique = relationship("Technique", foreign_keys=[technique_id])
owner = relationship("User", foreign_keys=[owner_id])
backup_owner = relationship("User", foreign_keys=[backup_owner_id])
class RevalidationQueueItem(Base):
"""A prioritised work item for the analyst's daily queue."""
__tablename__ = "revalidation_queue_items"
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
technique_id = Column(
UUID(as_uuid=True),
ForeignKey("techniques.id", ondelete="CASCADE"),
nullable=True,
)
detection_asset_id = Column(
UUID(as_uuid=True),
ForeignKey("detection_assets.id", ondelete="CASCADE"),
nullable=True,
)
priority = Column(
Enum(QueuePriority, name="queue_priority"),
nullable=False,
default=QueuePriority.medium,
)
reason = Column(
Enum(QueueReason, name="queue_reason"),
nullable=False,
)
reason_detail = Column(Text, nullable=True)
status = Column(
Enum(QueueStatus, name="queue_status"),
nullable=False,
default=QueueStatus.pending,
)
assigned_to = Column(
UUID(as_uuid=True),
ForeignKey("users.id", ondelete="SET NULL"),
nullable=True,
)
due_date = Column(DateTime, nullable=True)
created_at = Column(DateTime, default=datetime.utcnow)
completed_at = Column(DateTime, nullable=True)
dismissed_at = Column(DateTime, nullable=True)
completed_by = Column(
UUID(as_uuid=True),
ForeignKey("users.id", ondelete="SET NULL"),
nullable=True,
)
extra = Column(JSONB, nullable=True) # arbitrary metadata
technique = relationship("Technique", foreign_keys=[technique_id])
detection_asset = relationship("DetectionAsset", foreign_keys=[detection_asset_id])
assignee = relationship("User", foreign_keys=[assigned_to])
# Indexes
Index("ix_rqueue_status", RevalidationQueueItem.status)
Index("ix_rqueue_priority", RevalidationQueueItem.priority)
Index("ix_rqueue_assigned_to", RevalidationQueueItem.assigned_to)
Index("ix_rqueue_technique_id", RevalidationQueueItem.technique_id)
Index("ix_rqueue_asset_id", RevalidationQueueItem.detection_asset_id)
Index("ix_techown_owner_id", TechniqueOwnership.owner_id)
@@ -1,30 +0,0 @@
"""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])
@@ -1,41 +0,0 @@
"""SQLAlchemy model for procedure-improvement suggestions.
When an operator submits a round with a filled-in procedure field
(``procedure_text`` for Red, ``detect_procedure`` for Blue), the command(s)
in it are extracted heuristically and proposed as an update to the
originating template's suggested-procedure field. Nothing is written to
the template automatically — a lead reviews and approves or rejects each
suggestion, so a junior who later picks up the same template only ever
sees vetted guidance.
"""
import uuid
from sqlalchemy import Column, DateTime, ForeignKey, String, Text, func
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import relationship
from app.database import Base
class ProcedureSuggestion(Base):
"""A proposed update to a TestTemplate's suggested procedure, pending lead review."""
__tablename__ = "procedure_suggestions"
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
template_id = Column(UUID(as_uuid=True), ForeignKey("test_templates.id"), nullable=False)
team = Column(String(10), nullable=False) # "red" or "blue"
suggested_text = Column(Text, nullable=False)
source_test_id = Column(UUID(as_uuid=True), ForeignKey("tests.id"), nullable=True)
submitted_by = Column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=True)
status = Column(String(10), nullable=False, default="pending", server_default="pending") # pending/approved/rejected
reviewed_by = Column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=True)
reviewed_at = Column(DateTime, nullable=True)
created_at = Column(DateTime(timezone=True), server_default=func.now())
template = relationship("TestTemplate", foreign_keys=[template_id])
source_test = relationship("Test", foreign_keys=[source_test_id])
submitter = relationship("User", foreign_keys=[submitted_by])
reviewer = relationship("User", foreign_keys=[reviewed_by])
-69
View File
@@ -1,69 +0,0 @@
"""Phase 12: Risk Intelligence model — per-technique risk scoring."""
import uuid
from datetime import datetime
from sqlalchemy import (
Boolean, Column, DateTime, Float, ForeignKey,
Index, Integer, String, UniqueConstraint,
)
from sqlalchemy.dialects.postgresql import UUID, JSONB
from sqlalchemy.orm import relationship
from app.database import Base
class TechniqueRiskProfile(Base):
"""
Aggregated risk profile for one technique.
Combines four weighted factors:
• detection_gap (35 %) — 0=fully covered → 1=no coverage
• threat_actor_rel (30 %) — normalised actor count
• osint_signals (20 %) — normalised recent OSINT items (30 d)
• test_failure_rate (15 %) — proportion of tests where blue didn't detect
risk_score = weighted sum × 100 → 0100
risk_level: critical ≥75 | high ≥50 | medium ≥25 | low ≥10 | info
"""
__tablename__ = "technique_risk_profiles"
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
technique_id = Column(
UUID(as_uuid=True),
ForeignKey("techniques.id", ondelete="CASCADE"),
nullable=False,
)
# ── Computed scores ───────────────────────────────────────────────────────
risk_score = Column(Float, nullable=False, default=0.0) # 0100
likelihood = Column(Float, nullable=False, default=0.0) # 0100
impact = Column(Float, nullable=False, default=0.0) # 0100
risk_level = Column(String(16), nullable=False, default="info")
# ── Raw factor values ─────────────────────────────────────────────────────
detection_gap = Column(Float, nullable=False, default=1.0) # 01
threat_actor_count = Column(Integer, nullable=False, default=0)
osint_signal_count = Column(Integer, nullable=False, default=0) # last 30 d
test_fail_count = Column(Integer, nullable=False, default=0)
test_total_count = Column(Integer, nullable=False, default=0)
test_failure_rate = Column(Float, nullable=False, default=0.0) # 01
confidence_level = Column(Float, nullable=False, default=0.0) # DLC 01
# ── Rich detail ──────────────────────────────────────────────────────────
scoring_breakdown = Column(JSONB, nullable=True) # per-factor contributions
recommendations = Column(JSONB, nullable=True) # list[str]
# ── Meta ─────────────────────────────────────────────────────────────────
computed_at = Column(DateTime, default=datetime.utcnow)
is_stale = Column(Boolean, default=True)
technique = relationship("Technique", foreign_keys=[technique_id])
__table_args__ = (
UniqueConstraint("technique_id", name="uq_risk_profile_technique"),
Index("ix_risk_profiles_risk_score", "risk_score"),
Index("ix_risk_profiles_risk_level", "risk_level"),
Index("ix_risk_profiles_stale", "is_stale"),
)
-49
View File
@@ -1,49 +0,0 @@
"""Phase 14: SSO / SAML 2.0 configuration model."""
import uuid
from datetime import datetime
from sqlalchemy import Boolean, Column, DateTime, String, Text
from sqlalchemy.dialects.postgresql import UUID
from app.database import Base
class SsoConfig(Base):
"""
SAML 2.0 Identity Provider configuration.
Exactly one row is expected (use upsert). The SP metadata endpoint
reads from this row to generate XML for IdP registration.
"""
__tablename__ = "sso_configs"
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
is_enabled = Column(Boolean, nullable=False, default=False)
provider_name = Column(String(200), nullable=True) # e.g., "Okta", "Azure AD"
# ── Service Provider (Aegis) settings ────────────────────────────────────
sp_entity_id = Column(String(500), nullable=True) # e.g., https://aegis.co/api/v1/sso/metadata
sp_acs_url = Column(String(500), nullable=True) # Assertion Consumer Service URL
sp_slo_url = Column(String(500), nullable=True) # Single Logout URL (optional)
sp_certificate = Column(Text, nullable=True) # SP public cert for signed requests
sp_private_key = Column(Text, nullable=True) # SP private key (stored encrypted in future)
# ── Identity Provider settings ────────────────────────────────────────────
idp_entity_id = Column(String(500), nullable=True)
idp_sso_url = Column(String(500), nullable=True) # IdP redirect/POST binding URL
idp_slo_url = Column(String(500), nullable=True) # IdP SLO URL
idp_certificate = Column(Text, nullable=True) # IdP X.509 cert for response validation
# ── Attribute mapping ─────────────────────────────────────────────────────
# SAML attribute name → Aegis field
attr_email = Column(String(200), nullable=True, default="email")
attr_username = Column(String(200), nullable=True, default="username")
attr_role = Column(String(200), nullable=True, default="role")
default_role = Column(String(50), nullable=True, default="viewer")
auto_provision = Column(Boolean, nullable=False, default=True) # create user on first login
# ── Meta ─────────────────────────────────────────────────────────────────
created_at = Column(DateTime, default=datetime.utcnow)
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
-26
View File
@@ -1,26 +0,0 @@
"""SystemConfig model — runtime key-value configuration store."""
import uuid
from sqlalchemy import Column, String, Text, DateTime, func
from sqlalchemy.dialects.postgresql import UUID
from app.database import Base
class SystemConfig(Base):
"""Generic key-value store for runtime system configuration.
Currently used for:
- SMTP email settings (overrides .env values when present)
Keys are namespaced by convention: ``smtp.host``, ``smtp.port``, etc.
"""
__tablename__ = "system_configs"
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
key = Column(String(200), unique=True, nullable=False, index=True)
value = Column(Text, nullable=True)
description = Column(String(500), nullable=True)
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
-2
View File
@@ -59,8 +59,6 @@ class Technique(Base):
review_required = Column(Boolean, default=False) review_required = Column(Boolean, default=False)
# Assign last_review_date = Column(DateTime, nullable=True) # Assign last_review_date = Column(DateTime, nullable=True)
last_review_date = Column(DateTime, nullable=True) last_review_date = Column(DateTime, nullable=True)
# Assign external_references = Column(JSONB, nullable=True, default=list)
external_references = Column(JSONB, nullable=True, default=list)
# Relationships # Relationships
tests = relationship("Test", back_populates="technique") tests = relationship("Test", back_populates="technique")
-53
View File
@@ -1,53 +0,0 @@
"""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])
+6 -58
View File
@@ -26,8 +26,8 @@ from sqlalchemy.orm import relationship
# Import Base from app.database # Import Base from app.database
from app.database import Base from app.database import Base
# Import ContainmentResult, TestResult, TestState from app.models.enums # Import TestResult, TestState from app.models.enums
from app.models.enums import AttackSuccessResult, ContainmentResult, TestResult, TestState from app.models.enums import TestResult, TestState
# Define class Test # Define class Test
@@ -59,12 +59,6 @@ class Test(Base):
execution_date = Column(DateTime, nullable=True) execution_date = Column(DateTime, nullable=True)
# Assign created_by = Column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=True) # Assign created_by = Column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=True)
created_by = Column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=True) created_by = Column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=True)
# The template this test was instantiated from, if any (null for
# standalone/manually-created/RT-imported tests). Lets a procedure
# suggestion know exactly which template to propose improving —
# matching purely by technique would be ambiguous when a technique has
# multiple templates.
source_template_id = Column(UUID(as_uuid=True), ForeignKey("test_templates.id"), nullable=True)
# Assign result = Column(Enum(TestResult, name="testresult"), nullable=True) # Assign result = Column(Enum(TestResult, name="testresult"), nullable=True)
result = Column(Enum(TestResult, name="testresult"), nullable=True) result = Column(Enum(TestResult, name="testresult"), nullable=True)
# Assign state = Column(Enum(TestState, name="teststate"), default=TestState.draft) # Assign state = Column(Enum(TestState, name="teststate"), default=TestState.draft)
@@ -75,9 +69,7 @@ class Test(Base):
# ── Red Team fields ───────────────────────────────────────────── # ── Red Team fields ─────────────────────────────────────────────
red_summary = Column(Text, nullable=True) red_summary = Column(Text, nullable=True)
# Assign attack_success = Column(Boolean, nullable=True) # Assign attack_success = Column(Boolean, nullable=True)
attack_success = Column(Enum(AttackSuccessResult, name="attacksuccessresult"), nullable=True) attack_success = Column(Boolean, nullable=True)
execution_start_time = Column(DateTime, nullable=True)
execution_end_time = Column(DateTime, nullable=True)
# Assign red_validated_by = Column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=True) # Assign red_validated_by = Column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=True)
red_validated_by = Column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=True) red_validated_by = Column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=True)
# Assign red_validated_at = Column(DateTime, nullable=True) # Assign red_validated_at = Column(DateTime, nullable=True)
@@ -89,14 +81,8 @@ class Test(Base):
# ── Blue Team fields ──────────────────────────────────────────── # ── Blue Team fields ────────────────────────────────────────────
blue_summary = Column(Text, nullable=True) blue_summary = Column(Text, nullable=True)
# What Blue actually did to detect the attack — Blue's counterpart to
# procedure_text. Free text; may be parsed for a procedure suggestion.
detect_procedure = Column(Text, nullable=True)
# Assign detection_result = Column(Enum(TestResult, name="testresult"), nullable=True) # Assign detection_result = Column(Enum(TestResult, name="testresult"), nullable=True)
detection_result = Column(Enum(TestResult, name="testresult"), nullable=True) detection_result = Column(Enum(TestResult, name="testresult"), nullable=True)
containment_result = Column(Enum(ContainmentResult, name="containmentresult"), nullable=True)
detection_time = Column(DateTime, nullable=True)
containment_time = Column(DateTime, nullable=True)
# Assign blue_validated_by = Column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=True) # Assign blue_validated_by = Column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=True)
blue_validated_by = Column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=True) blue_validated_by = Column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=True)
# Assign blue_validated_at = Column(DateTime, nullable=True) # Assign blue_validated_at = Column(DateTime, nullable=True)
@@ -110,17 +96,13 @@ class Test(Base):
red_started_at = Column(DateTime, nullable=True) red_started_at = Column(DateTime, nullable=True)
# Assign blue_started_at = Column(DateTime, nullable=True) # Assign blue_started_at = Column(DateTime, nullable=True)
blue_started_at = Column(DateTime, nullable=True) blue_started_at = Column(DateTime, nullable=True)
blue_work_started_at = Column(DateTime, nullable=True) # when blue tech picks up (Tempo start) # Assign paused_at = Column(DateTime, nullable=True)
paused_at = Column(DateTime, nullable=True) paused_at = Column(DateTime, nullable=True)
# Assign red_paused_seconds = Column(Integer, default=0) # Assign red_paused_seconds = Column(Integer, default=0)
red_paused_seconds = Column(Integer, default=0) red_paused_seconds = Column(Integer, default=0)
# Assign blue_paused_seconds = Column(Integer, default=0) # Assign blue_paused_seconds = Column(Integer, default=0)
blue_paused_seconds = Column(Integer, default=0) blue_paused_seconds = Column(Integer, default=0)
# ── Round tracking (bumped on each reopen-for-rework) ────────────
red_round_number = Column(Integer, nullable=False, default=1, server_default="1")
blue_round_number = Column(Integer, nullable=False, default=1, server_default="1")
# ── Remediation fields ─────────────────────────────────────────── # ── Remediation fields ───────────────────────────────────────────
remediation_steps = Column(Text, nullable=True) remediation_steps = Column(Text, nullable=True)
# Assign remediation_status = Column(String, nullable=True) # pending / in_progress / completed ... # Assign remediation_status = Column(String, nullable=True) # pending / in_progress / completed ...
@@ -132,17 +114,13 @@ class Test(Base):
retest_of = Column(UUID(as_uuid=True), ForeignKey("tests.id"), nullable=True) retest_of = Column(UUID(as_uuid=True), ForeignKey("tests.id"), nullable=True)
# Assign retest_count = Column(Integer, default=0) # Assign retest_count = Column(Integer, default=0)
retest_count = Column(Integer, default=0) retest_count = Column(Integer, default=0)
# Assign data_classification = Column(String(20), nullable=False, server_default="internal_use_only") # Assign data_classification = Column(String(20), nullable=False, server_default="internal")
data_classification = Column(String(20), nullable=False, server_default="internal_use_only") data_classification = Column(String(20), nullable=False, server_default="internal")
# ── Relationships ─────────────────────────────────────────────── # ── Relationships ───────────────────────────────────────────────
technique = relationship("Technique", back_populates="tests") technique = relationship("Technique", back_populates="tests")
# Assign evidences = relationship("Evidence", back_populates="test") # Assign evidences = relationship("Evidence", back_populates="test")
evidences = relationship("Evidence", back_populates="test") evidences = relationship("Evidence", back_populates="test")
round_history = relationship(
"TestRoundHistory", back_populates="test",
order_by="TestRoundHistory.round_number", cascade="all, delete-orphan",
)
# Assign creator = relationship("User", foreign_keys=[created_by]) # Assign creator = relationship("User", foreign_keys=[created_by])
creator = relationship("User", foreign_keys=[created_by]) creator = relationship("User", foreign_keys=[created_by])
# Assign red_validator = relationship("User", foreign_keys=[red_validated_by]) # Assign red_validator = relationship("User", foreign_keys=[red_validated_by])
@@ -151,36 +129,6 @@ class Test(Base):
blue_validator = relationship("User", foreign_keys=[blue_validated_by]) blue_validator = relationship("User", foreign_keys=[blue_validated_by])
# Assign remediation_user = relationship("User", foreign_keys=[remediation_assignee]) # Assign remediation_user = relationship("User", foreign_keys=[remediation_assignee])
remediation_user = relationship("User", foreign_keys=[remediation_assignee]) remediation_user = relationship("User", foreign_keys=[remediation_assignee])
# ── Assignment fields ──────────────────────────────────────────
red_tech_assignee = Column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=True)
blue_tech_assignee = Column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=True)
# ── Review assignment fields ────────────────────────────────────
red_reviewer_assignee = Column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=True)
red_review_by = Column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=True)
red_review_at = Column(DateTime, nullable=True)
red_review_notes = Column(Text, nullable=True)
blue_reviewer_assignee = Column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=True)
blue_review_by = Column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=True)
blue_review_at = Column(DateTime, nullable=True)
blue_review_notes = Column(Text, nullable=True)
system_gaps = Column(Text, nullable=True)
# ── On-hold fields ─────────────────────────────────────────────
is_on_hold = Column(Boolean, default=False, nullable=False, server_default="false")
hold_reason = Column(Text, nullable=True)
held_at = Column(DateTime, nullable=True)
red_tech_assigned_user = relationship("User", foreign_keys=[red_tech_assignee])
blue_tech_assigned_user = relationship("User", foreign_keys=[blue_tech_assignee])
red_reviewer = relationship("User", foreign_keys=[red_reviewer_assignee])
red_review_actor = relationship("User", foreign_keys=[red_review_by])
blue_reviewer = relationship("User", foreign_keys=[blue_reviewer_assignee])
blue_review_actor = relationship("User", foreign_keys=[blue_review_by])
# Assign original_test = relationship("Test", remote_side="Test.id", foreign_keys=[retest_of]) # Assign original_test = relationship("Test", remote_side="Test.id", foreign_keys=[retest_of])
original_test = relationship("Test", remote_side="Test.id", foreign_keys=[retest_of]) original_test = relationship("Test", remote_side="Test.id", foreign_keys=[retest_of])
# Assign retests = relationship("Test", foreign_keys=[retest_of], back_populates="orig... # Assign retests = relationship("Test", foreign_keys=[retest_of], back_populates="orig...
-61
View File
@@ -1,61 +0,0 @@
"""SQLAlchemy model for archived test rounds.
Each row is a snapshot of one team's round of work (procedure/results for
Red, detection/containment for Blue) taken right before a lead reopens the
test for rework. Reopening resets the live fields on ``Test`` for a fresh
attempt, but the prior attempt's data must not be lost — it's archived here
so the full history stays visible (e.g. to Blue Team, who need to see what
Red actually did on earlier rounds, not just the latest one).
"""
import uuid
from sqlalchemy import Column, DateTime, Enum, ForeignKey, Integer, String, Text, func
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import relationship
from app.database import Base
from app.models.enums import AttackSuccessResult, ContainmentResult, TestResult
class TestRoundHistory(Base):
"""Archived snapshot of one red/blue round of a test, taken on reopen."""
__tablename__ = "test_round_history"
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
test_id = Column(UUID(as_uuid=True), ForeignKey("tests.id"), nullable=False)
team = Column(String(10), nullable=False) # "red" or "blue"
round_number = Column(Integer, nullable=False)
started_at = Column(DateTime, nullable=True)
ended_at = Column(DateTime, nullable=True)
paused_seconds = Column(Integer, default=0)
# Red-side round output
procedure_text = Column(Text, nullable=True)
tool_used = Column(String, nullable=True)
# native_enum=False — stored as plain VARCHAR, not the shared Postgres
# enum type used by tests.attack_success. This is an archive table with
# no DB-level constraint needs, and reusing the native type name here
# would make Alembic try to (re)create it.
attack_success = Column(Enum(AttackSuccessResult, name="test_round_history_attack_success", native_enum=False), nullable=True)
red_summary = Column(Text, nullable=True)
execution_start_time = Column(DateTime, nullable=True)
execution_end_time = Column(DateTime, nullable=True)
# Blue-side round output
detection_result = Column(Enum(TestResult, name="test_round_history_detection_result", native_enum=False), nullable=True)
containment_result = Column(Enum(ContainmentResult, name="test_round_history_containment_result", native_enum=False), nullable=True)
detection_time = Column(DateTime, nullable=True)
containment_time = Column(DateTime, nullable=True)
blue_summary = Column(Text, nullable=True)
detect_procedure = Column(Text, nullable=True)
# Why the round was closed
review_notes = Column(Text, nullable=True)
reviewed_by = Column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=True)
archived_at = Column(DateTime(timezone=True), server_default=func.now())
test = relationship("Test", back_populates="round_history")
reviewer = relationship("User", foreign_keys=[reviewed_by])
+2 -6
View File
@@ -41,12 +41,8 @@ class TestTemplate(Base):
source_url = Column(String, nullable=True) source_url = Column(String, nullable=True)
# Assign attack_procedure = Column(Text, nullable=True) # Suggested attack procedure # Assign attack_procedure = Column(Text, nullable=True) # Suggested attack procedure
attack_procedure = Column(Text, nullable=True) # Suggested attack procedure attack_procedure = Column(Text, nullable=True) # Suggested attack procedure
# What blue team should detect — narrative guidance from imports/leads, # Assign expected_detection = Column(Text, nullable=True) # What blue team should detect
# plus concrete commands appended via approved procedure suggestions expected_detection = Column(Text, nullable=True) # What blue team should detect
# (Blue's counterpart to attack_procedure). External syncs never touch
# an existing row (they only insert brand-new ones), so anything added
# here is safe across re-syncs.
expected_detection = Column(Text, nullable=True)
# Assign platform = Column(String, nullable=True) # windows / linux... # Assign platform = Column(String, nullable=True) # windows / linux...
platform = Column(String, nullable=True) # windows / linux / macos platform = Column(String, nullable=True) # windows / linux / macos
# Assign tool_suggested = Column(String, nullable=True) # Assign tool_suggested = Column(String, nullable=True)
+9 -22
View File
@@ -2,8 +2,12 @@
# Import uuid # Import uuid
import uuid import uuid
from sqlalchemy import Column, String, Boolean, DateTime, func
from sqlalchemy.dialects.postgresql import UUID, JSONB # Import Boolean, Column, DateTime, String, func from sqlalchemy
from sqlalchemy import Boolean, Column, DateTime, String, func
# Import UUID from sqlalchemy.dialects.postgresql
from sqlalchemy.dialects.postgresql import UUID
# Import Base from app.database # Import Base from app.database
from app.database import Base from app.database import Base
@@ -26,26 +30,14 @@ class User(Base):
# Assign id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) # Assign id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
# Internal login identifier — always kept equal to `email` (see below). # Assign username = Column(String, unique=True, nullable=False)
# Kept as a separate column (rather than removed) since the JWT `sub`
# claim, audit logs, Jira actor attribution, and SSO provisioning all
# still key off it; changing all of those to read `email` directly
# would be a much larger, riskier refactor for no behavioral gain now
# that the two are always identical.
username = Column(String, unique=True, nullable=False) username = Column(String, unique=True, nullable=False)
# The unique identifier a user logs in with. Every user must have one. # Assign email = Column(String, nullable=True)
email = Column(String, unique=True, nullable=False, index=True) email = Column(String, nullable=True)
# Display name shown everywhere in the UI instead of username (which is
# now just an internal login identifier, auto-set to the user's email).
full_name = Column(String, nullable=True)
# Assign hashed_password = Column(String, nullable=False) # Assign hashed_password = Column(String, nullable=False)
hashed_password = Column(String, nullable=False) hashed_password = Column(String, nullable=False)
# Assign role = Column(String, nullable=False, default="viewer") # Assign role = Column(String, nullable=False, default="viewer")
role = Column(String, nullable=False, default="viewer") role = Column(String, nullable=False, default="viewer")
# Other roles this user can switch into (the currently-active role
# lives in `role` above and is what every permission check reads —
# switching just swaps which one is active, never grants both at once).
extra_roles = Column(JSONB, nullable=False, default=list, server_default="[]")
# Assign is_active = Column(Boolean, default=True) # Assign is_active = Column(Boolean, default=True)
is_active = Column(Boolean, default=True) is_active = Column(Boolean, default=True)
# Assign must_change_password = Column(Boolean, default=True) # Assign must_change_password = Column(Boolean, default=True)
@@ -54,8 +46,3 @@ class User(Base):
created_at = Column(DateTime(timezone=True), server_default=func.now()) created_at = Column(DateTime(timezone=True), server_default=func.now())
# Assign last_login = Column(DateTime, nullable=True) # Assign last_login = Column(DateTime, nullable=True)
last_login = Column(DateTime, nullable=True) last_login = Column(DateTime, nullable=True)
notification_preferences = Column(JSONB, nullable=True, server_default='{"email_on_test_validated": true, "email_on_campaign_completed": true, "email_on_new_mitre_techniques": false, "in_app_all": true}')
jira_account_id = Column(String(100), nullable=True)
jira_api_token = Column(String(500), nullable=True) # personal Atlassian token
jira_email = Column(String(255), nullable=True) # Atlassian email (overrides account email)
tempo_api_token = Column(String(500), nullable=True) # personal Tempo API token
-18
View File
@@ -1,18 +0,0 @@
"""WebhookConfig model — outbound HTTP notification endpoints."""
import uuid
from sqlalchemy import Column, String, Boolean, DateTime, Integer, Text, ForeignKey, func
from sqlalchemy.dialects.postgresql import UUID, JSONB
from app.database import Base
class WebhookConfig(Base):
__tablename__ = "webhook_configs"
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
name = Column(String(200), nullable=False)
url = Column(Text, nullable=False)
secret = Column(String(256), nullable=True) # HMAC signature key
events = Column(JSONB, nullable=False, server_default="[]") # list of event types
is_active = Column(Boolean, default=True, nullable=False)
created_by = Column(UUID(as_uuid=True), ForeignKey("users.id", ondelete="SET NULL"), nullable=True)
last_triggered_at = Column(DateTime, nullable=True)
failure_count = Column(Integer, default=0, nullable=False)
created_at = Column(DateTime(timezone=True), server_default=func.now())
-352
View File
@@ -1,352 +0,0 @@
"""Admin configuration export/import — single-file migration bundle.
GET /admin/export-config — download JSON bundle (admin only)
POST /admin/import-config — upload JSON bundle and restore (admin only)
What is exported (and what is NOT):
✓ system_configs — email / jira settings (passwords REDACTED)
✓ webhook_configs — notification webhooks (secrets REDACTED)
✓ sso_configs — SAML/SSO config (private keys REDACTED)
✓ scoring_config — technique scoring weights
✓ test_templates — CUSTOM templates only (source='custom')
✓ users — username / email / role (no passwords / tokens)
✗ atomic/sigma/elastic templates, techniques, tests, campaigns, reports
"""
from datetime import datetime
from typing import Any
from fastapi import APIRouter, Depends, HTTPException, Request
from fastapi.responses import JSONResponse
from sqlalchemy.orm import Session
from app.auth import hash_password
from app.database import get_db
from app.dependencies.auth import require_role
from app.models.scoring_config import ScoringConfig
from app.models.sso_config import SsoConfig
from app.models.system_config import SystemConfig
from app.models.test_template import TestTemplate
from app.models.user import User
from app.models.webhook_config import WebhookConfig
router = APIRouter(prefix="/admin", tags=["admin"])
# Keys whose values contain secrets and must be redacted in the export.
# Must be kept in sync with the actual SystemConfig keys written by
# routers/system.py's _JIRA_KEYS map and the SMTP settings section —
# a stale/mismatched name here silently exports a live credential in
# plaintext instead of redacting it.
_REDACTED_KEYS = {
"smtp.password",
"jira.admin_api_token",
"tempo.admin_token",
"email_webhook.api_key",
# Older/alternate key names kept defensively in case a prior schema
# version wrote under these instead.
"jira.api_token",
"jira.password",
"tempo.api_token",
}
_EXPORT_VERSION = "1.0"
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _redact(key: str, value: Any) -> Any:
if key in _REDACTED_KEYS:
return "[REDACTED]"
return value
# ---------------------------------------------------------------------------
# GET /admin/export-config
# ---------------------------------------------------------------------------
@router.get("/export-config")
def export_config(
db: Session = Depends(get_db),
current_user: User = Depends(require_role("admin")),
):
"""Export all platform configuration as a downloadable JSON bundle."""
# ── 1. system_configs ────────────────────────────────────────────
system_configs = [
{
"key": r.key,
"value": _redact(r.key, r.value),
"description": r.description,
}
for r in db.query(SystemConfig).order_by(SystemConfig.key).all()
]
# ── 2. webhook_configs ───────────────────────────────────────────
webhooks = [
{
"name": w.name,
"url": w.url,
"secret": "[REDACTED]" if w.secret else None,
"events": w.events or [],
"is_active": w.is_active,
}
for w in db.query(WebhookConfig).order_by(WebhookConfig.name).all()
]
# ── 3. SSO config (single row) ───────────────────────────────────
sso_row = db.query(SsoConfig).first()
sso = None
if sso_row:
sso = {
"is_enabled": sso_row.is_enabled,
"provider_name": sso_row.provider_name,
"sp_entity_id": sso_row.sp_entity_id,
"sp_acs_url": sso_row.sp_acs_url,
"sp_slo_url": sso_row.sp_slo_url,
"sp_certificate": sso_row.sp_certificate,
"sp_private_key": "[REDACTED]", # never export private keys
"idp_entity_id": sso_row.idp_entity_id,
"idp_sso_url": getattr(sso_row, "idp_sso_url", None),
"idp_slo_url": getattr(sso_row, "idp_slo_url", None),
"idp_certificate": getattr(sso_row, "idp_certificate", None),
"attr_email": getattr(sso_row, "attr_email", None),
"attr_username": getattr(sso_row, "attr_username", None),
"attr_role": getattr(sso_row, "attr_role", None),
"default_role": getattr(sso_row, "default_role", None),
"auto_provision": getattr(sso_row, "auto_provision", False),
}
# ── 4. Scoring config (single row) ──────────────────────────────
sc = db.query(ScoringConfig).first()
scoring = None
if sc:
scoring = {
"weight_tests": sc.weight_tests,
"weight_detection_rules": sc.weight_detection_rules,
"weight_d3fend": sc.weight_d3fend,
"weight_recency": sc.weight_recency,
"weight_severity": sc.weight_severity,
}
# ── 5. Custom test templates only ───────────────────────────────
templates = [
{
"mitre_technique_id": t.mitre_technique_id,
"name": t.name,
"description": t.description,
"source": t.source,
"source_url": t.source_url,
"attack_procedure": t.attack_procedure,
"expected_detection": t.expected_detection,
"platform": t.platform,
"tool_suggested": t.tool_suggested,
"severity": t.severity,
"suggested_remediation": t.suggested_remediation,
"is_active": t.is_active,
}
for t in db.query(TestTemplate).filter(TestTemplate.source == "custom").all()
]
# ── 6. Users (sanitized — no passwords/tokens) ───────────────────
users = [
{
"username": u.username,
"email": u.email if hasattr(u, "email") else None,
"role": u.role,
"is_active": u.is_active,
"must_change_password": True, # force password reset on new instance # nosec B105
}
for u in db.query(User).order_by(User.username).all()
]
bundle = {
"_meta": {
"version": _EXPORT_VERSION,
"exported_at": datetime.utcnow().isoformat() + "Z",
"exported_by": current_user.username,
"note": (
"Sensitive values (passwords, API tokens, private keys) are REDACTED. "
"Re-enter them manually after import. "
"User passwords are NOT exported — users must reset passwords on first login."
),
},
"system_configs": system_configs,
"webhooks": webhooks,
"sso": sso,
"scoring": scoring,
"custom_templates": templates,
"users": users,
}
filename = f"aegis-config-{datetime.utcnow().strftime('%Y%m%d-%H%M%S')}.json"
return JSONResponse(
content=bundle,
headers={
"Content-Disposition": f'attachment; filename="{filename}"',
"X-Export-Version": _EXPORT_VERSION,
},
)
# ---------------------------------------------------------------------------
# POST /admin/import-config
# ---------------------------------------------------------------------------
@router.post("/import-config")
async def import_config(
request: Request,
db: Session = Depends(get_db),
current_user: User = Depends(require_role("admin")),
):
"""Restore platform configuration from a previously exported JSON bundle.
Idempotent: safe to run multiple times. Existing records are updated,
missing ones are created. REDACTED values are skipped (left as-is).
User passwords are set to a random temp value with must_change_password=True.
"""
try:
bundle = await request.json()
except Exception:
raise HTTPException(status_code=400, detail="Invalid JSON body")
meta = bundle.get("_meta", {})
version = meta.get("version", "unknown")
summary: dict[str, int] = {
"system_configs": 0,
"webhooks": 0,
"custom_templates": 0,
"users_created": 0,
"users_updated": 0,
"users_skipped_no_email": 0,
}
# ── 1. system_configs ────────────────────────────────────────────
for item in bundle.get("system_configs", []):
key = item.get("key")
value = item.get("value")
if not key or value == "[REDACTED]":
continue
row = db.query(SystemConfig).filter(SystemConfig.key == key).first()
if row:
row.value = value
row.description = item.get("description") or row.description
else:
db.add(SystemConfig(key=key, value=value, description=item.get("description")))
summary["system_configs"] += 1
# ── 2. webhooks ──────────────────────────────────────────────────
for item in bundle.get("webhooks", []):
name = item.get("name")
url = item.get("url")
if not name or not url:
continue
existing = db.query(WebhookConfig).filter(WebhookConfig.name == name).first()
if existing:
existing.url = url
existing.events = item.get("events", [])
existing.is_active = item.get("is_active", True)
existing.failure_count = 0
else:
db.add(WebhookConfig(
name=name,
url=url,
secret=None, # never restore secrets
events=item.get("events", []),
is_active=item.get("is_active", True),
created_by=current_user.id,
failure_count=0,
))
summary["webhooks"] += 1
# ── 3. SSO config ────────────────────────────────────────────────
sso_data = bundle.get("sso")
if sso_data:
sso_row = db.query(SsoConfig).first()
if sso_row:
for field, val in sso_data.items():
if val == "[REDACTED]":
continue
if hasattr(sso_row, field):
setattr(sso_row, field, val)
else:
clean = {k: v for k, v in sso_data.items() if v != "[REDACTED]"}
clean.pop("sp_private_key", None)
db.add(SsoConfig(**clean))
# ── 4. Scoring config ────────────────────────────────────────────
scoring_data = bundle.get("scoring")
if scoring_data:
sc = db.query(ScoringConfig).first()
if sc:
for field, val in scoring_data.items():
if hasattr(sc, field) and val is not None:
setattr(sc, field, val)
else:
db.add(ScoringConfig(**scoring_data))
# ── 5. Custom templates ──────────────────────────────────────────
for item in bundle.get("custom_templates", []):
name = item.get("name")
mitre_id = item.get("mitre_technique_id")
if not name or not mitre_id:
continue
existing = (
db.query(TestTemplate)
.filter(TestTemplate.name == name, TestTemplate.source == "custom")
.first()
)
if existing:
for field, val in item.items():
if hasattr(existing, field):
setattr(existing, field, val)
else:
db.add(TestTemplate(**{k: v for k, v in item.items()
if k not in ("id", "created_at")}))
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", []):
email = item.get("email")
if not email:
summary["users_skipped_no_email"] += 1
continue
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)
summary["users_updated"] += 1
else:
# Create with random temp password — user must reset on login
temp_pw = _secrets.token_urlsafe(16) + "Aa1!"
new_user = User(
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,
)
db.add(new_user)
summary["users_created"] += 1
db.commit()
return {
"status": "ok",
"imported_from_version": version,
"summary": summary,
"warnings": [
"REDACTED values were skipped — re-enter passwords/tokens manually.",
"All imported users have must_change_password=True.",
"SSO private key was not restored — re-upload it manually.",
],
}
-104
View File
@@ -1,104 +0,0 @@
"""Phase 14: API Key management router."""
from typing import List
from uuid import UUID
from fastapi import APIRouter, Depends, Query
from sqlalchemy.orm import Session
from app.database import get_db
from app.dependencies.auth import get_current_user, require_any_role
from app.models.user import User
from app.schemas.api_key_schema import (
ApiKeyCreate, ApiKeyCreated, ApiKeyOut, ApiKeyUpdate,
)
import app.services.api_key_service as svc
router = APIRouter(prefix="/api-keys", tags=["API Keys"])
@router.post("", response_model=ApiKeyCreated, status_code=201)
def create_key(
body: ApiKeyCreate,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""
Create a scoped API key.
The ``raw_key`` field in the response is shown **exactly once** and
cannot be retrieved later. Store it securely.
"""
key, raw_key = svc.create_api_key(
db,
user_id = current_user.id,
name = body.name,
scopes = body.scopes,
description = body.description,
expires_at = body.expires_at,
)
out = ApiKeyOut.model_validate(key)
return ApiKeyCreated(**out.model_dump(), raw_key=raw_key)
@router.get("", response_model=List[ApiKeyOut])
def list_keys(
include_inactive: bool = Query(False),
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""List API keys owned by the current user."""
# Admins can see all keys; others only see their own
user_id = None if current_user.role == "admin" else current_user.id
return svc.list_api_keys(db, user_id=user_id, include_inactive=include_inactive)
@router.get("/{key_id}", response_model=ApiKeyOut)
def get_key(
key_id: UUID,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Get a single API key (owner or admin)."""
user_id = None if current_user.role == "admin" else current_user.id
return svc.get_api_key(db, key_id, user_id=user_id)
@router.patch("/{key_id}", response_model=ApiKeyOut)
def update_key(
key_id: UUID,
body: ApiKeyUpdate,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Update name, description, scopes, expiry, or active status."""
user_id = None if current_user.role == "admin" else current_user.id
return svc.update_api_key(
db, key_id, user_id,
name = body.name,
description = body.description,
scopes = body.scopes,
expires_at = body.expires_at,
is_active = body.is_active,
)
@router.post("/{key_id}/revoke", response_model=ApiKeyOut)
def revoke_key(
key_id: UUID,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Revoke an API key (soft-delete — sets is_active=False)."""
user_id = None if current_user.role == "admin" else current_user.id
return svc.revoke_api_key(db, key_id, user_id=user_id)
@router.delete("/{key_id}", status_code=204)
def delete_key(
key_id: UUID,
db: Session = Depends(get_db),
current_user: User = Depends(require_any_role("admin")),
):
"""Permanently delete an API key (admin only)."""
svc.delete_api_key(db, key_id)
-249
View File
@@ -1,249 +0,0 @@
"""Phase 10: Attack Paths & Advanced Purple Team router."""
from typing import Optional
from uuid import UUID
from fastapi import APIRouter, Depends
from sqlalchemy.orm import Session
from app.database import get_db
from app.dependencies.auth import get_current_user, require_any_role
from app.schemas.attack_path_schema import (
AttackPathCreate, AttackPathUpdate, AttackPathOut,
AttackPathStepCreate, AttackPathStepUpdate, AttackPathStepOut,
ExecutionCreate, ExecutionOut,
StepExecuteRequest, StepResultOut,
TimelineEntryCreate, TimelineEntryOut,
)
from app.services import attack_path_service as svc
router = APIRouter(prefix="/attack-paths", tags=["attack-paths"])
# ── Attack Paths CRUD ─────────────────────────────────────────────────────────
@router.post("", response_model=AttackPathOut, status_code=201)
def create_attack_path(
body: AttackPathCreate,
db: Session = Depends(get_db),
user=Depends(get_current_user),
):
return svc.create_attack_path(db, body.model_dump(), user.id)
@router.get("", response_model=list[AttackPathOut])
def list_attack_paths(
is_template: Optional[bool] = None,
technique_id: Optional[UUID] = None,
is_active: Optional[bool] = True,
db: Session = Depends(get_db),
user=Depends(get_current_user),
):
paths = svc.list_attack_paths(db, is_template=is_template,
technique_id=technique_id, is_active=is_active)
# Inject step_count
result = []
for p in paths:
d = AttackPathOut.model_validate(p)
d.step_count = len(p.steps)
result.append(d)
return result
@router.get("/{path_id}", response_model=AttackPathOut)
def get_attack_path(
path_id: UUID,
db: Session = Depends(get_db),
user=Depends(get_current_user),
):
p = svc.get_attack_path(db, path_id)
d = AttackPathOut.model_validate(p)
d.step_count = len(p.steps)
return d
@router.patch("/{path_id}", response_model=AttackPathOut)
def update_attack_path(
path_id: UUID,
body: AttackPathUpdate,
db: Session = Depends(get_db),
user=Depends(get_current_user),
):
return svc.update_attack_path(db, path_id, body.model_dump(exclude_unset=True), user.id)
@router.delete("/{path_id}", status_code=204)
def delete_attack_path(
path_id: UUID,
db: Session = Depends(get_db),
user=Depends(require_any_role("admin", "red_lead", "blue_lead")),
):
svc.delete_attack_path(db, path_id, user.id)
# ── Steps ─────────────────────────────────────────────────────────────────────
@router.get("/{path_id}/steps", response_model=list[AttackPathStepOut])
def list_steps(
path_id: UUID,
db: Session = Depends(get_db),
user=Depends(get_current_user),
):
path = svc.get_attack_path(db, path_id)
return path.steps
@router.post("/{path_id}/steps", response_model=AttackPathStepOut, status_code=201)
def add_step(
path_id: UUID,
body: AttackPathStepCreate,
db: Session = Depends(get_db),
user=Depends(get_current_user),
):
return svc.add_step(db, path_id, body.model_dump(), user.id)
@router.patch("/{path_id}/steps/{step_id}", response_model=AttackPathStepOut)
def update_step(
path_id: UUID,
step_id: UUID,
body: AttackPathStepUpdate,
db: Session = Depends(get_db),
user=Depends(get_current_user),
):
return svc.update_step(db, step_id, body.model_dump(exclude_unset=True), user.id)
@router.delete("/{path_id}/steps/{step_id}", status_code=204)
def delete_step(
path_id: UUID,
step_id: UUID,
db: Session = Depends(get_db),
user=Depends(get_current_user),
):
svc.delete_step(db, step_id, user.id)
@router.post("/{path_id}/steps/reorder", response_model=list[AttackPathStepOut])
def reorder_steps(
path_id: UUID,
step_ids: list[UUID],
db: Session = Depends(get_db),
user=Depends(get_current_user),
):
"""Pass an ordered list of step UUIDs to reorder the steps."""
return svc.reorder_steps(db, path_id, step_ids, user.id)
# ── Executions ────────────────────────────────────────────────────────────────
@router.post("/{path_id}/executions", response_model=ExecutionOut, status_code=201)
def create_execution(
path_id: UUID,
body: ExecutionCreate,
db: Session = Depends(get_db),
user=Depends(get_current_user),
):
return svc.create_execution(db, path_id, body.model_dump(), user.id)
@router.get("/{path_id}/executions", response_model=list[ExecutionOut])
def list_executions(
path_id: UUID,
db: Session = Depends(get_db),
user=Depends(get_current_user),
):
return svc.list_executions(db, path_id)
@router.get("/executions/{execution_id}", response_model=ExecutionOut)
def get_execution(
execution_id: UUID,
db: Session = Depends(get_db),
user=Depends(get_current_user),
):
return svc.get_execution(db, execution_id)
@router.post("/executions/{execution_id}/start", response_model=ExecutionOut)
def start_execution(
execution_id: UUID,
db: Session = Depends(get_db),
user=Depends(get_current_user),
):
return svc.start_execution(db, execution_id, user.id)
@router.post("/executions/{execution_id}/steps/{step_id}", response_model=StepResultOut)
def execute_step(
execution_id: UUID,
step_id: UUID,
body: StepExecuteRequest,
db: Session = Depends(get_db),
user=Depends(get_current_user),
):
"""Record the result of one step (detected / not_detected / skipped)."""
return svc.execute_step(db, execution_id, step_id, body.model_dump(), user.id)
@router.get("/executions/{execution_id}/steps", response_model=list[StepResultOut])
def list_step_results(
execution_id: UUID,
db: Session = Depends(get_db),
user=Depends(get_current_user),
):
ex = svc.get_execution(db, execution_id)
return ex.step_results
@router.post("/executions/{execution_id}/complete", response_model=ExecutionOut)
def complete_execution(
execution_id: UUID,
db: Session = Depends(get_db),
user=Depends(get_current_user),
):
"""Mark execution as complete and compute kill-chain metrics."""
return svc.complete_execution(db, execution_id, user.id)
@router.post("/executions/{execution_id}/abort", response_model=ExecutionOut)
def abort_execution(
execution_id: UUID,
db: Session = Depends(get_db),
user=Depends(require_any_role("admin", "red_lead", "blue_lead")),
):
return svc.abort_execution(db, execution_id, user.id)
# ── Timeline ──────────────────────────────────────────────────────────────────
@router.post("/executions/{execution_id}/timeline",
response_model=TimelineEntryOut, status_code=201)
def add_timeline_entry(
execution_id: UUID,
body: TimelineEntryCreate,
db: Session = Depends(get_db),
user=Depends(get_current_user),
):
return svc.add_timeline_entry(db, execution_id, body.model_dump(), user.id)
@router.get("/executions/{execution_id}/timeline", response_model=list[TimelineEntryOut])
def get_timeline(
execution_id: UUID,
db: Session = Depends(get_db),
user=Depends(get_current_user),
):
return svc.get_timeline(db, execution_id)
# ── Kill-Chain Metrics ────────────────────────────────────────────────────────
@router.get("/executions/{execution_id}/metrics")
def get_metrics(
execution_id: UUID,
db: Session = Depends(get_db),
user=Depends(get_current_user),
):
"""Return full kill-chain metrics for a completed (or partial) execution."""
return svc.get_kill_chain_metrics(db, execution_id)
+11 -141
View File
@@ -16,18 +16,14 @@ from fastapi import APIRouter, Cookie, Depends, Request, Response
# Import OAuth2PasswordRequestForm from fastapi.security # Import OAuth2PasswordRequestForm from fastapi.security
from fastapi.security import OAuth2PasswordRequestForm from fastapi.security import OAuth2PasswordRequestForm
# Import datetime, timezone from datetime
from datetime import datetime, timezone
# Import jwt (PyJWT) # Import jwt (PyJWT)
import jwt import jwt
from jwt.exceptions import PyJWTError as JWTError
# Import Session from sqlalchemy.orm # Import Session from sqlalchemy.orm
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
# Import blacklist_token, create_access_token, verify_pa... from app.auth # Import blacklist_token, create_access_token, verify_pa... from app.auth
from app.auth import blacklist_token, create_access_token, is_token_blacklisted, verify_password from app.auth import blacklist_token, create_access_token, verify_password
# Import settings from app.config # Import settings from app.config
from app.config import settings from app.config import settings
@@ -57,7 +53,7 @@ from app.models.user import User
from app.schemas.auth import TokenResponse, UserOut from app.schemas.auth import TokenResponse, UserOut
# Import PasswordChange from app.schemas.user # Import PasswordChange from app.schemas.user
from app.schemas.user import PasswordChange, SetPasswordRequest, SetPasswordTokenValidateOut from app.schemas.user import PasswordChange
# Import log_action from app.services.audit_service # Import log_action from app.services.audit_service
from app.services.audit_service import log_action from app.services.audit_service import log_action
@@ -72,25 +68,12 @@ from app.services.auth_service import (
change_password as auth_change_password, change_password as auth_change_password,
) )
from app.domain.errors import EntityNotFoundError
from app.services.password_setup_service import (
consume_token_and_set_password,
validate_token as validate_password_setup_token,
)
# Assign router = APIRouter(prefix="/auth", tags=["auth"]) # Assign router = APIRouter(prefix="/auth", tags=["auth"])
router = APIRouter(prefix="/auth", tags=["auth"]) router = APIRouter(prefix="/auth", tags=["auth"])
# SECURE_COOKIES desacopla la seguridad de la cookie del entorno de ejecucion. # Assign _IS_HTTPS = os.environ.get("AEGIS_ENV", "").lower() == "production"
# Por defecto activo en produccion; ponlo en "false" para servidores HTTP. _IS_HTTPS = os.environ.get("AEGIS_ENV", "").lower() == "production"
_aegis_env = os.environ.get("AEGIS_ENV", "development").lower() # Assign _COOKIE_NAME = "aegis_token"
_secure_cookie_env = os.environ.get("SECURE_COOKIES", "auto").lower()
if _secure_cookie_env == "false":
_IS_HTTPS = False
elif _secure_cookie_env == "true":
_IS_HTTPS = True
else: # "auto" — activo solo si AEGIS_ENV=production
_IS_HTTPS = _aegis_env == "production"
_COOKIE_NAME = "aegis_token" _COOKIE_NAME = "aegis_token"
@@ -114,10 +97,8 @@ def login(
Rate-limited to **5 attempts per minute per IP**. Failed and successful Rate-limited to **5 attempts per minute per IP**. Failed and successful
logins are recorded in the audit log (SEC-009). logins are recorded in the audit log (SEC-009).
""" """
# OAuth2PasswordRequestForm's field is spec-named "username" but the # Assign user = db.query(User).filter(User.username == form_data.username).first()
# value a user actually types in is their email — email is the unique user = db.query(User).filter(User.username == form_data.username).first()
# login identifier (username is an internal id, always kept == email).
user = db.query(User).filter(User.email == form_data.username).first()
# Assign target_hash = user.hashed_password if user else _DUMMY_HASH # Assign target_hash = user.hashed_password if user else _DUMMY_HASH
target_hash = user.hashed_password if user else _DUMMY_HASH target_hash = user.hashed_password if user else _DUMMY_HASH
# Assign password_valid = verify_password(form_data.password, target_hash) # Assign password_valid = verify_password(form_data.password, target_hash)
@@ -142,7 +123,7 @@ def login(
# Keyword argument: details # Keyword argument: details
details={ details={
# Literal argument value # Literal argument value
"email": form_data.username, "username": form_data.username,
# Literal argument value # Literal argument value
"ip": ip, "ip": ip,
# Literal argument value # Literal argument value
@@ -154,7 +135,7 @@ def login(
# Call uow.commit() # Call uow.commit()
uow.commit() uow.commit()
# Raise BusinessRuleViolation # Raise BusinessRuleViolation
raise BusinessRuleViolation("Incorrect email or password") raise BusinessRuleViolation("Incorrect username or password")
# Check: not user.is_active # Check: not user.is_active
if not user.is_active: if not user.is_active:
@@ -176,21 +157,13 @@ def login(
"auth", "auth",
str(user.id), str(user.id),
# Keyword argument: details # Keyword argument: details
details={"email": user.email, "ip": ip}, details={"username": user.username, "ip": ip},
# Keyword argument: ip_address # Keyword argument: ip_address
ip_address=ip, ip_address=ip,
) )
# Call uow.commit() # Call uow.commit()
uow.commit() uow.commit()
# Auto-discover the user's Jira account ID on every login (non-fatal)
try:
from app.services.jira_service import lookup_user_jira_account_id
if lookup_user_jira_account_id(db, user):
db.commit()
except Exception:
pass
# Call response.set_cookie() # Call response.set_cookie()
response.set_cookie( response.set_cookie(
# Keyword argument: key # Keyword argument: key
@@ -283,76 +256,7 @@ def logout(
return {"detail": "Logged out"} return {"detail": "Logged out"}
# A token that has *just* expired can still be refreshed within this window. # Apply the @router.get decorator
# Without this grace period, `/auth/refresh` decodes the same token with the
# same strict expiry check as every other endpoint — so by the time a 401
# triggers a refresh attempt, the refresh call itself has also expired and
# the session is unrecoverable. The grace window only widens the refresh
# check; a brand-new token is always issued with the full expiry.
_REFRESH_GRACE_SECONDS = 15 * 60
@router.post("/refresh", response_model=TokenResponse)
def refresh_token(
response: Response,
aegis_token: str | None = Cookie(None),
db: Session = Depends(get_db),
):
"""Issue a new access token if the current one is valid or recently expired.
Called automatically by the frontend when it detects an expired
session while the user is actively using the app. Expiry is checked
manually (with `_REFRESH_GRACE_SECONDS` of leeway) instead of relying on
PyJWT's built-in check, so a token that expired moments ago can still
be refreshed. Revoked (blacklisted) tokens are never refreshable.
"""
if not aegis_token:
raise PermissionViolation("No active session")
try:
payload = jwt.decode(
aegis_token,
settings.SECRET_KEY,
algorithms=[settings.ALGORITHM],
options={"verify_exp": False},
)
except JWTError:
raise PermissionViolation("Session expired — please log in again")
jti: str | None = payload.get("jti")
if jti and is_token_blacklisted(jti):
raise PermissionViolation("Session expired — please log in again")
exp = payload.get("exp")
now = datetime.now(timezone.utc).timestamp()
if exp is None or now - exp > _REFRESH_GRACE_SECONDS:
raise PermissionViolation("Session expired — please log in again")
username: str | None = payload.get("sub")
if not username:
raise PermissionViolation("Invalid session")
user = db.query(User).filter(User.username == username).first()
if user is None or not user.is_active:
raise PermissionViolation("Account not found or disabled")
if getattr(user, "must_change_password", False):
raise PermissionViolation("Password change required before refreshing session")
# Issue a fresh token with a new expiry
new_token = create_access_token(data={"sub": user.username})
response.set_cookie(
key=_COOKIE_NAME,
value=new_token,
httponly=True,
secure=_IS_HTTPS,
samesite="strict",
max_age=settings.ACCESS_TOKEN_EXPIRE_MINUTES * 60,
path="/",
)
return TokenResponse(access_token=new_token)
@router.get("/me", response_model=UserOut) @router.get("/me", response_model=UserOut)
# Define function read_current_user # Define function read_current_user
def read_current_user(current_user: User = Depends(get_current_user)) -> UserOut: def read_current_user(current_user: User = Depends(get_current_user)) -> UserOut:
@@ -389,37 +293,3 @@ def change_password(
# Return {"detail": "Password changed successfully"} # Return {"detail": "Password changed successfully"}
return {"detail": "Password changed successfully"} return {"detail": "Password changed successfully"}
# ---------------------------------------------------------------------------
# Set password via a one-time emailed link — public (no auth), rate-limited
# ---------------------------------------------------------------------------
@router.get("/set-password/validate", response_model=SetPasswordTokenValidateOut)
@limiter.limit("20/minute")
def validate_set_password_token(request: Request, token: str, db: Session = Depends(get_db)) -> SetPasswordTokenValidateOut:
"""Check a set-password token before rendering the form. Public endpoint."""
try:
user = validate_password_setup_token(db, token)
except (EntityNotFoundError, BusinessRuleViolation):
return SetPasswordTokenValidateOut(valid=False)
return SetPasswordTokenValidateOut(valid=True, full_name=user.full_name)
@router.post("/set-password")
@limiter.limit("10/minute")
def set_password(request: Request, body: SetPasswordRequest, db: Session = Depends(get_db)) -> dict:
"""Complete a password setup/reset using a one-time token. Public endpoint."""
with UnitOfWork(db) as uow:
user = consume_token_and_set_password(db, body.token, body.new_password)
log_action(
db,
user.id,
"SET_PASSWORD_VIA_TOKEN",
"auth",
str(user.id),
details={},
)
uow.commit()
return {"detail": "Password set successfully — you can now log in"}
+48 -528
View File
@@ -9,7 +9,8 @@ import logging
# Import uuid # Import uuid
import uuid import uuid
from datetime import datetime
# Import Optional from typing
from typing import Optional from typing import Optional
# Import APIRouter, Depends, Query from fastapi # Import APIRouter, Depends, Query from fastapi
@@ -25,16 +26,23 @@ from sqlalchemy.orm import Session
from app.database import get_db from app.database import get_db
# Import get_current_user, require_any_role from app.dependencies.auth # Import get_current_user, require_any_role from app.dependencies.auth
from app.dependencies.auth import get_current_user, require_any_role, require_any_role_strict from app.dependencies.auth import get_current_user, require_any_role
# Import UnitOfWork from app.domain.unit_of_work # Import UnitOfWork from app.domain.unit_of_work
from app.domain.unit_of_work import UnitOfWork from app.domain.unit_of_work import UnitOfWork
# Import User from app.models.user # Import User from app.models.user
from app.models.user import User from app.models.user import User
from app.models.campaign import Campaign, CampaignTest
from app.models.test import Test # Import log_action from app.services.audit_service
from app.services.campaign_service import generate_campaign_from_threat_actor from app.services.audit_service import log_action
# Import from app.services.campaign_crud_service
from app.services.campaign_crud_service import (
activate_campaign as crud_activate,
)
# Import from app.services.campaign_crud_service
from app.services.campaign_crud_service import ( from app.services.campaign_crud_service import (
add_test_to_campaign as crud_add_test, add_test_to_campaign as crud_add_test,
) )
@@ -47,7 +55,10 @@ from app.services.campaign_crud_service import (
# Import from app.services.campaign_crud_service # Import from app.services.campaign_crud_service
from app.services.campaign_crud_service import ( from app.services.campaign_crud_service import (
create_campaign as crud_create, create_campaign as crud_create,
delete_campaign as crud_delete, )
# Import from app.services.campaign_crud_service
from app.services.campaign_crud_service import (
get_campaign_detail as crud_get_detail, get_campaign_detail as crud_get_detail,
) )
@@ -56,10 +67,6 @@ from app.services.campaign_crud_service import (
get_campaign_history as crud_get_history, get_campaign_history as crud_get_history,
) )
from app.services.campaign_crud_service import (
get_campaign_timeline as crud_get_timeline,
)
# Import from app.services.campaign_crud_service # Import from app.services.campaign_crud_service
from app.services.campaign_crud_service import ( from app.services.campaign_crud_service import (
get_campaign_progress_data as crud_get_progress, get_campaign_progress_data as crud_get_progress,
@@ -90,42 +97,11 @@ from app.services.campaign_crud_service import (
update_campaign as crud_update, update_campaign as crud_update,
) )
# Import activate_campaign from app.services.campaign_crud_service # Import generate_campaign_from_threat_actor from app.services.campaign_service
from app.services.campaign_crud_service import ( from app.services.campaign_service import generate_campaign_from_threat_actor
activate_campaign as crud_activate,
)
from app.services.campaign_crud_service import (
submit_campaign_for_approval as crud_submit,
)
from app.services.campaign_crud_service import (
approve_campaign as crud_approve,
)
from app.services.campaign_crud_service import (
reject_campaign as crud_reject,
)
from app.services.campaign_crud_service import (
create_modification_request as crud_create_mod_request,
)
from app.services.campaign_crud_service import (
approve_modification_request as crud_approve_mod_request,
)
from app.services.campaign_crud_service import (
reject_modification_request as crud_reject_mod_request,
)
from app.services.campaign_crud_service import (
list_modification_requests as crud_list_mod_requests,
)
from app.services.campaign_crud_service import (
serialize_modification_request as crud_serialize_mod_request,
)
# Import log_action from app.services.audit_service
from app.services.audit_service import log_action
# Import notify_role from app.services.notification_service # Import notify_role from app.services.notification_service
from app.services.notification_service import notify_role, notify_roles_by_email from app.services.notification_service import notify_role
from app.services.webhook_service import dispatch_webhook
# Assign logger = logging.getLogger(__name__) # Assign logger = logging.getLogger(__name__)
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -134,24 +110,6 @@ logger = logging.getLogger(__name__)
router = APIRouter(prefix="/campaigns", tags=["campaigns"]) router = APIRouter(prefix="/campaigns", tags=["campaigns"])
def _create_jira_tickets_for_campaign(db: Session, campaign: Campaign, campaign_id: str, user: User) -> None:
"""Create Jira tickets for *campaign* now, if its start_date has arrived.
Shared by both paths that bring a campaign to ``active``: the admin-only
emergency `/activate` override and the normal manager `/approve` flow.
If ``start_date`` is still in the future, ticket creation is skipped
here and left to the periodic ``sync_due_campaign_jira_tickets`` job,
which creates them once that date actually arrives — this is what makes
the campaign's real scheduled date/time authoritative for when tickets
(and their Jira start-date field) appear, instead of always at approval
time.
"""
if campaign.start_date and campaign.start_date > datetime.utcnow():
return
from app.services.jira_service import ensure_campaign_jira_tickets
ensure_campaign_jira_tickets(db, campaign, user)
# ── Pydantic schemas ───────────────────────────────────────────────── # ── Pydantic schemas ─────────────────────────────────────────────────
class CampaignCreate(BaseModel): class CampaignCreate(BaseModel):
@@ -171,11 +129,6 @@ class CampaignCreate(BaseModel):
tags: Optional[list[str]] = Field(default_factory=list) tags: Optional[list[str]] = Field(default_factory=list)
# Assign scheduled_at = None # Assign scheduled_at = None
scheduled_at: Optional[str] = None scheduled_at: Optional[str] = None
# Only honored when the creator is a manager — see create_campaign():
# a manager's own campaign is auto-approved on creation instead of
# going through the draft -> submit -> approve queue, so they need to
# supply the start_date up front instead of via a later /approve call.
start_date: Optional[str] = None
# Define class CampaignUpdate # Define class CampaignUpdate
@@ -222,34 +175,6 @@ class SchedulePayload(BaseModel):
next_run_at: Optional[str] = None next_run_at: Optional[str] = None
class ApprovePayload(BaseModel):
"""Payload for a manager approving a pending campaign."""
start_date: str # ISO date/datetime — required, campaign won't activate without it
class RejectPayload(BaseModel):
"""Payload for a manager rejecting a pending campaign."""
reason: str
class ModificationRequestPayload(BaseModel):
"""Payload for a lead requesting to add/remove a test on an active campaign."""
action: str # add_test | remove_test
test_id: str
justification: str
order_index: Optional[int] = None
phase: Optional[str] = None
class RejectModificationPayload(BaseModel):
"""Payload for a manager rejecting a modification request."""
review_notes: str
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# GET /campaigns — List campaigns with filters # GET /campaigns — List campaigns with filters
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -273,7 +198,7 @@ def list_campaigns(
db: Session = Depends(get_db), db: Session = Depends(get_db),
# Entry: current_user # Entry: current_user
current_user: User = Depends(get_current_user), current_user: User = Depends(get_current_user),
) -> dict: ) -> list:
"""List campaigns with optional filters and pagination. """List campaigns with optional filters and pagination.
Args: Args:
@@ -319,28 +244,18 @@ def create_campaign(
# Entry: db # Entry: db
db: Session = Depends(get_db), db: Session = Depends(get_db),
# Entry: current_user # Entry: current_user
# Strict variant — admin must NOT get a free pass here. Admin current_user: User = Depends(require_any_role("red_lead", "blue_lead")),
# administers the site, not campaign content; the only admin path to
# an active campaign is the emergency /activate override below.
current_user: User = Depends(require_any_role_strict("red_lead", "blue_lead", "manager")),
) -> dict: ) -> dict:
"""Create a new campaign. """Create a new campaign.
A manager's campaign is auto-approved on creation — a manager is the
same role that would otherwise approve it, so routing it through the
draft -> submit -> pending_approval queue would just mean approving
their own submission. red_lead/blue_lead campaigns still go through
that queue as before.
Args: Args:
payload (CampaignCreate): Fields for the new campaign (name, type, threat actor, etc.). payload (CampaignCreate): Fields for the new campaign (name, type, threat actor, etc.).
db (Session): SQLAlchemy database session. db (Session): SQLAlchemy database session.
current_user (User): Authenticated red_lead, blue_lead, or manager creating the campaign. current_user (User): Authenticated red_lead or blue_lead creating the campaign.
Returns: Returns:
dict: Serialised representation of the newly created campaign. dict: Serialised representation of the newly created campaign.
""" """
is_manager_create = current_user.role == "manager"
# Open context manager # Open context manager
with UnitOfWork(db) as uow: with UnitOfWork(db) as uow:
# Assign result = crud_create( # Assign result = crud_create(
@@ -362,32 +277,24 @@ def create_campaign(
tags=payload.tags, tags=payload.tags,
# Keyword argument: scheduled_at # Keyword argument: scheduled_at
scheduled_at=payload.scheduled_at, scheduled_at=payload.scheduled_at,
auto_approve=is_manager_create,
start_date=payload.start_date if is_manager_create else None,
approver_id=current_user.id if is_manager_create else None,
) )
campaign_id = result["id"] # Call log_action()
log_action( log_action(
db, db,
# Keyword argument: user_id # Keyword argument: user_id
user_id=current_user.id, user_id=current_user.id,
# Keyword argument: action # Keyword argument: action
action="create_campaign_auto_approved" if is_manager_create else "create_campaign", action="create_campaign",
# Keyword argument: entity_type # Keyword argument: entity_type
entity_type="campaign", entity_type="campaign",
entity_id=campaign_id, # Keyword argument: entity_id
entity_id=result["id"],
# Keyword argument: details
details={"name": payload.name, "type": payload.type}, details={"name": payload.name, "type": payload.type},
) )
# Call uow.commit() # Call uow.commit()
uow.commit() uow.commit()
if is_manager_create:
campaign = db.query(Campaign).filter(Campaign.id == uuid.UUID(campaign_id)).first()
db.refresh(campaign)
# Create Jira tickets now if the manager's chosen start_date is
# already due — mirrors the normal manager /approve path.
_create_jira_tickets_for_campaign(db, campaign, campaign_id, current_user)
# Return result # Return result
return result return result
@@ -434,19 +341,15 @@ def update_campaign(
# Entry: db # Entry: db
db: Session = Depends(get_db), db: Session = Depends(get_db),
# Entry: current_user # Entry: current_user
current_user: User = Depends(require_any_role("red_lead", "blue_lead", "manager")), current_user: User = Depends(require_any_role("red_lead", "blue_lead")),
) -> dict: ) -> dict:
"""Update a campaign. Only allowed in draft or active state. """Update a campaign. Only allowed in draft or active state.
A manager may only edit a campaign that's sitting in draft with a
rejection_reason set (i.e. one they previously rejected) — see
``update_campaign()``'s ownership check.
Args: Args:
campaign_id (str): UUID string of the campaign to update. campaign_id (str): UUID string of the campaign to update.
payload (CampaignUpdate): Partial update payload; only set fields are applied. payload (CampaignUpdate): Partial update payload; only set fields are applied.
db (Session): SQLAlchemy database session. db (Session): SQLAlchemy database session.
current_user (User): Authenticated red_lead, blue_lead, or manager performing the update. current_user (User): Authenticated red_lead or blue_lead performing the update.
Returns: Returns:
dict: Serialised representation of the updated campaign. dict: Serialised representation of the updated campaign.
@@ -486,269 +389,6 @@ def update_campaign(
return result return result
# ---------------------------------------------------------------------------
# POST /campaigns/{id}/submit — Submit draft for manager approval
# ---------------------------------------------------------------------------
@router.post("/{campaign_id}/submit")
def submit_campaign(
campaign_id: str,
db: Session = Depends(get_db),
current_user: User = Depends(require_any_role("red_lead", "blue_lead")),
) -> dict:
"""Submit a draft campaign into the manager's approval queue."""
with UnitOfWork(db) as uow:
campaign = crud_submit(
db, campaign_id,
submitter_id=current_user.id,
submitter_role=current_user.role,
)
log_action(
db,
user_id=current_user.id,
action="submit_campaign_for_approval",
entity_type="campaign",
entity_id=campaign.id,
details={"name": campaign.name},
)
uow.commit()
db.refresh(campaign)
return serialize_campaign(db, campaign)
# ---------------------------------------------------------------------------
# POST /campaigns/{id}/approve — Manager approves a pending campaign
# ---------------------------------------------------------------------------
@router.post("/{campaign_id}/approve")
def approve_campaign_endpoint(
campaign_id: str,
payload: ApprovePayload,
db: Session = Depends(get_db),
# admin passes automatically via require_any_role's built-in bypass — do not add "admin" here
current_user: User = Depends(require_any_role("manager")),
) -> dict:
"""Manager approves a pending campaign, fixing its start date and activating it."""
with UnitOfWork(db) as uow:
campaign = crud_approve(
db, campaign_id,
approver_id=current_user.id,
start_date=payload.start_date,
)
log_action(
db,
user_id=current_user.id,
action="approve_campaign",
entity_type="campaign",
entity_id=campaign.id,
details={"start_date": payload.start_date},
)
notify_roles_by_email(
db, roles=["red_tech"],
preference_key="email_on_assigned_to_campaign",
subject=f"Campaign Activated: {campaign.name}",
message=f'Campaign "{campaign.name}" has been approved and activated. You may have tests assigned.',
)
uow.commit()
db.refresh(campaign)
# Create Jira tickets for campaign and its already-linked tests (non-fatal).
# Mirrors the admin-only /activate override — this is the normal path.
_create_jira_tickets_for_campaign(db, campaign, campaign_id, current_user)
return serialize_campaign(db, campaign)
# ---------------------------------------------------------------------------
# POST /campaigns/{id}/reject — Manager rejects a pending campaign
# ---------------------------------------------------------------------------
@router.post("/{campaign_id}/reject")
def reject_campaign_endpoint(
campaign_id: str,
payload: RejectPayload,
db: Session = Depends(get_db),
# admin passes automatically via require_any_role's built-in bypass — do not add "admin" here
current_user: User = Depends(require_any_role("manager")),
) -> dict:
"""Manager rejects a pending campaign, returning it to draft with a reason."""
with UnitOfWork(db) as uow:
campaign = crud_reject(
db, campaign_id,
rejecter_id=current_user.id,
reason=payload.reason,
)
log_action(
db,
user_id=current_user.id,
action="reject_campaign",
entity_type="campaign",
entity_id=campaign.id,
details={"reason": payload.reason},
)
uow.commit()
db.refresh(campaign)
return serialize_campaign(db, campaign)
# ---------------------------------------------------------------------------
# POST /campaigns/{id}/modification-requests — Request a test add/remove
# ---------------------------------------------------------------------------
@router.post("/{campaign_id}/modification-requests", status_code=201)
def create_modification_request_endpoint(
campaign_id: str,
payload: ModificationRequestPayload,
db: Session = Depends(get_db),
current_user: User = Depends(require_any_role("red_lead", "blue_lead")),
) -> dict:
"""File a request to add/remove a test on an active campaign — needs manager approval."""
with UnitOfWork(db) as uow:
request = crud_create_mod_request(
db, campaign_id,
requester_id=current_user.id,
action=payload.action,
test_id=payload.test_id,
justification=payload.justification,
order_index=payload.order_index,
phase=payload.phase,
)
log_action(
db,
user_id=current_user.id,
action="request_campaign_modification",
entity_type="campaign",
entity_id=campaign_id,
details={
"action": payload.action,
"test_id": payload.test_id,
"justification": payload.justification,
},
)
uow.commit()
return crud_serialize_mod_request(db, request)
# ---------------------------------------------------------------------------
# GET /campaigns/{id}/modification-requests — List requests for one campaign
# ---------------------------------------------------------------------------
@router.get("/{campaign_id}/modification-requests")
def list_campaign_modification_requests_endpoint(
campaign_id: str,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
) -> list:
"""List modification requests filed against a specific campaign."""
return crud_list_mod_requests(db, campaign_id=campaign_id)
# ---------------------------------------------------------------------------
# GET /campaigns/modification-requests/pending — Manager's global queue
# ---------------------------------------------------------------------------
@router.get("/modification-requests/pending")
def list_pending_modification_requests_endpoint(
db: Session = Depends(get_db),
# admin passes automatically via require_any_role's built-in bypass — do not add "admin" here
current_user: User = Depends(require_any_role("manager")),
) -> list:
"""List all modification requests awaiting manager review, across all campaigns."""
return crud_list_mod_requests(db, status="pending")
# ---------------------------------------------------------------------------
# POST /campaigns/modification-requests/{id}/approve — Apply the requested change
# ---------------------------------------------------------------------------
@router.post("/modification-requests/{request_id}/approve")
def approve_modification_request_endpoint(
request_id: str,
db: Session = Depends(get_db),
# admin passes automatically via require_any_role's built-in bypass — do not add "admin" here
current_user: User = Depends(require_any_role("manager")),
) -> dict:
"""Manager approves a modification request — the test change is applied now."""
with UnitOfWork(db) as uow:
request = crud_approve_mod_request(db, request_id, reviewer_id=current_user.id)
log_action(
db,
user_id=current_user.id,
action="approve_campaign_modification",
entity_type="campaign",
entity_id=str(request.campaign_id),
details={
"request_id": request_id,
"action": request.action,
"test_id": str(request.test_id) if request.test_id else None,
},
)
uow.commit()
return crud_serialize_mod_request(db, request)
# ---------------------------------------------------------------------------
# POST /campaigns/modification-requests/{id}/reject — Deny the requested change
# ---------------------------------------------------------------------------
@router.post("/modification-requests/{request_id}/reject")
def reject_modification_request_endpoint(
request_id: str,
payload: RejectModificationPayload,
db: Session = Depends(get_db),
# admin passes automatically via require_any_role's built-in bypass — do not add "admin" here
current_user: User = Depends(require_any_role("manager")),
) -> dict:
"""Manager rejects a modification request. No change is applied."""
with UnitOfWork(db) as uow:
request = crud_reject_mod_request(
db, request_id,
reviewer_id=current_user.id,
review_notes=payload.review_notes,
)
log_action(
db,
user_id=current_user.id,
action="reject_campaign_modification",
entity_type="campaign",
entity_id=str(request.campaign_id),
details={"request_id": request_id, "review_notes": payload.review_notes},
)
uow.commit()
return crud_serialize_mod_request(db, request)
# ---------------------------------------------------------------------------
# DELETE /campaigns/{id} — Delete campaign
# ---------------------------------------------------------------------------
@router.delete("/{campaign_id}", status_code=204)
def delete_campaign(
campaign_id: str,
delete_tests: bool = Query(False, description="Also delete associated tests"),
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Delete a campaign. Only draft campaigns can be deleted (admins can delete any)."""
with UnitOfWork(db) as uow:
crud_delete(
db,
campaign_id,
deleter_id=current_user.id,
deleter_role=current_user.role,
delete_tests=delete_tests,
)
log_action(
db,
user_id=current_user.id,
action="delete_campaign",
entity_type="campaign",
entity_id=campaign_id,
details={"delete_tests": delete_tests},
)
uow.commit()
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# POST /campaigns/{id}/tests — Add test to campaign # POST /campaigns/{id}/tests — Add test to campaign
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -793,7 +433,7 @@ def add_test_to_campaign(
) )
# Call uow.commit() # Call uow.commit()
uow.commit() uow.commit()
# Return result
return result return result
@@ -843,18 +483,22 @@ def remove_test_from_campaign(
def activate_campaign( def activate_campaign(
# Entry: campaign_id # Entry: campaign_id
campaign_id: str, campaign_id: str,
# Entry: db
db: Session = Depends(get_db), db: Session = Depends(get_db),
# admin passes automatically via require_any_role's built-in bypass — do not add "admin" here # Entry: current_user
current_user: User = Depends(require_any_role("admin")), current_user: User = Depends(require_any_role("red_lead", "blue_lead")),
): ) -> dict:
"""Admin-only emergency override: activate a draft campaign directly, bypassing the approval queue. """Activate a campaign, moving it from draft to active.
Every other path to 'active' goes through /submit -> /approve, where the Args:
manager sets start_date. A draft campaign never has a start_date set campaign_id (str): UUID string of the campaign to activate.
(only /approve sets it, in the same call that moves the campaign to db (Session): SQLAlchemy database session.
'active'), so there is no "scheduled for the future" case to guard current_user (User): Authenticated red_lead or blue_lead activating the campaign.
against here anymore.
Returns:
dict: Serialised representation of the activated campaign.
""" """
# Open context manager
with UnitOfWork(db) as uow: with UnitOfWork(db) as uow:
# Assign campaign = crud_activate(db, campaign_id) # Assign campaign = crud_activate(db, campaign_id)
campaign = crud_activate(db, campaign_id) campaign = crud_activate(db, campaign_id)
@@ -874,12 +518,6 @@ def activate_campaign(
# Keyword argument: entity_id # Keyword argument: entity_id
entity_id=campaign.id, entity_id=campaign.id,
) )
notify_roles_by_email(
db, roles=["red_tech"],
preference_key="email_on_assigned_to_campaign",
subject=f"Campaign Activated: {campaign.name}",
message=f'Campaign "{campaign.name}" has been activated. You may have tests assigned.',
)
# Call log_action() # Call log_action()
log_action( log_action(
db, db,
@@ -899,10 +537,7 @@ def activate_campaign(
# Reload ORM object attributes from the database # Reload ORM object attributes from the database
db.refresh(campaign) db.refresh(campaign)
# Create Jira tickets for campaign and tests at activation time (non-fatal). # Return serialize_campaign(db, campaign)
# Campaign ticket is created here if it doesn't already exist (deferred from creation).
_create_jira_tickets_for_campaign(db, campaign, campaign_id, current_user)
return serialize_campaign(db, campaign) return serialize_campaign(db, campaign)
@@ -952,7 +587,6 @@ def complete_campaign(
uow.commit() uow.commit()
# Reload ORM object attributes from the database # Reload ORM object attributes from the database
db.refresh(campaign) db.refresh(campaign)
dispatch_webhook("campaign.completed", {"campaign_id": str(campaign.id), "name": campaign.name})
# Return serialize_campaign(db, campaign) # Return serialize_campaign(db, campaign)
return serialize_campaign(db, campaign) return serialize_campaign(db, campaign)
@@ -990,21 +624,15 @@ def get_campaign_progress_endpoint(
# POST /campaigns/from-threat-actor/{actor_id} — Auto-generate campaign # POST /campaigns/from-threat-actor/{actor_id} — Auto-generate campaign
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
class GenerateFromActorPayload(BaseModel):
start_date: Optional[datetime] = None
@router.post("/from-threat-actor/{actor_id}", status_code=201) @router.post("/from-threat-actor/{actor_id}", status_code=201)
# Define function generate_campaign_from_actor # Define function generate_campaign_from_actor
def generate_campaign_from_actor( def generate_campaign_from_actor(
# Entry: actor_id # Entry: actor_id
actor_id: str, actor_id: str,
payload: GenerateFromActorPayload = GenerateFromActorPayload(), # Entry: db
db: Session = Depends(get_db), db: Session = Depends(get_db),
# Entry: current_user # Entry: current_user
# Strict variant — admin must NOT get a free pass here, same as the current_user: User = Depends(require_any_role("red_lead", "blue_lead")),
# plain create_campaign endpoint above.
current_user: User = Depends(require_any_role_strict("red_lead", "blue_lead")),
) -> dict: ) -> dict:
"""Auto-generate a campaign from a threat actor's uncovered techniques. """Auto-generate a campaign from a threat actor's uncovered techniques.
@@ -1019,11 +647,11 @@ def generate_campaign_from_actor(
Returns: Returns:
dict: Serialised representation of the newly generated campaign. dict: Serialised representation of the newly generated campaign.
""" """
# Assign campaign = generate_campaign_from_threat_actor(
campaign = generate_campaign_from_threat_actor( campaign = generate_campaign_from_threat_actor(
db, db,
uuid.UUID(actor_id), uuid.UUID(actor_id),
current_user, current_user,
start_date=payload.start_date,
) )
# Open context manager # Open context manager
@@ -1151,111 +779,3 @@ def get_campaign_history(
""" """
# Return crud_get_history(db, campaign_id) # Return crud_get_history(db, campaign_id)
return crud_get_history(db, campaign_id) return crud_get_history(db, campaign_id)
# ---------------------------------------------------------------------------
# GET /campaigns/{id}/timeline — Audit-log history for this campaign
# ---------------------------------------------------------------------------
@router.get("/{campaign_id}/timeline")
def get_campaign_timeline_endpoint(
campaign_id: str,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
) -> list:
"""Return the chronological audit-log history for a campaign."""
return crud_get_timeline(db, campaign_id)
# ---------------------------------------------------------------------------
# GET /campaigns/{id}/timing-summary — Aggregated timing across campaign tests
# ---------------------------------------------------------------------------
def _seconds_between(start: datetime | None, end: datetime | None) -> int:
"""Return elapsed seconds between two datetimes; 0 if either is None."""
if not start or not end:
return 0
diff = (end - start).total_seconds()
return max(0, int(diff))
@router.get("/{campaign_id}/timing-summary")
def get_campaign_timing_summary(
campaign_id: str,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Return aggregated Red/Blue timing metrics for all tests in a campaign.
For each test we calculate:
- red_execution_secs : red_started_at → blue_started_at (minus red_paused_seconds)
- blue_queue_secs : blue_started_at → blue_work_started_at (waiting for Blue pick-up)
- blue_evaluation_secs: blue_work_started_at → first validation timestamp (minus blue_paused_seconds)
- total_secs : sum of the three phases
Returns totals + per-test breakdown.
"""
# Load campaign
campaign = db.query(Campaign).filter(Campaign.id == campaign_id).first()
if not campaign:
from fastapi import HTTPException
raise HTTPException(status_code=404, detail="Campaign not found")
# Load all tests for this campaign
test_ids = [
ct.test_id
for ct in db.query(CampaignTest).filter(CampaignTest.campaign_id == campaign.id).all()
]
tests = db.query(Test).filter(Test.id.in_(test_ids)).all() if test_ids else []
breakdown = []
total_red = 0
total_queue = 0
total_blue = 0
for t in tests:
# Red execution: from start-execution to submit-to-blue, minus paused time
red_secs = max(
0,
_seconds_between(t.red_started_at, t.blue_started_at) - (t.red_paused_seconds or 0),
)
# Blue queue: from receiving the test to actually starting evaluation
queue_secs = _seconds_between(t.blue_started_at, t.blue_work_started_at)
# Blue evaluation: from starting evaluation to first validation, minus paused time
eval_end = t.red_validated_at or t.blue_validated_at
blue_secs = max(
0,
_seconds_between(t.blue_work_started_at, eval_end) - (t.blue_paused_seconds or 0),
)
total_red += red_secs
total_queue += queue_secs
total_blue += blue_secs
breakdown.append({
"test_id": str(t.id),
"test_name": t.name,
"state": t.state.value if t.state else None,
"red_execution_secs": red_secs,
"blue_queue_secs": queue_secs,
"blue_evaluation_secs": blue_secs,
"total_secs": red_secs + queue_secs + blue_secs,
"has_timing": bool(t.red_started_at),
})
total_secs = total_red + total_queue + total_blue
return {
"campaign_id": campaign_id,
"campaign_name": campaign.name,
"tests_total": len(tests),
"tests_with_timing": sum(1 for b in breakdown if b["has_timing"]),
"red_execution_secs": total_red,
"blue_queue_secs": total_queue,
"blue_evaluation_secs": total_blue,
"total_secs": total_secs,
"breakdown": sorted(breakdown, key=lambda x: -(x["total_secs"])),
}
-33
View File
@@ -26,9 +26,6 @@ from app.models.user import User
# Import from app.services.compliance_import_service # Import from app.services.compliance_import_service
from app.services.compliance_import_service import ( from app.services.compliance_import_service import (
import_cis_controls_v8_mappings, import_cis_controls_v8_mappings,
import_dora_mappings,
import_iso_27001_mappings,
import_iso_42001_mappings,
import_nist_800_53_mappings, import_nist_800_53_mappings,
) )
@@ -235,33 +232,3 @@ def import_cis(
result = import_cis_controls_v8_mappings(db) result = import_cis_controls_v8_mappings(db)
# Return result # Return result
return result return result
@router.post("/import/dora")
def import_dora(
db: Session = Depends(get_db),
current_user: User = Depends(require_role("admin")),
):
"""Import DORA (EU 2022/2554) compliance mappings (admin only)."""
result = import_dora_mappings(db)
return result
@router.post("/import/iso-27001")
def import_iso27001(
db: Session = Depends(get_db),
current_user: User = Depends(require_role("admin")),
):
"""Import ISO/IEC 27001:2022 Annex A compliance mappings (admin only)."""
result = import_iso_27001_mappings(db)
return result
@router.post("/import/iso-42001")
def import_iso42001(
db: Session = Depends(get_db),
current_user: User = Depends(require_role("admin")),
):
"""Import ISO/IEC 42001:2023 AI Management System compliance mappings (admin only)."""
result = import_iso_42001_mappings(db)
return result
+2 -2
View File
@@ -64,7 +64,7 @@ def list_defensive_techniques(
db: Session = Depends(get_db), db: Session = Depends(get_db),
# Entry: current_user # Entry: current_user
current_user: User = Depends(get_current_user), current_user: User = Depends(get_current_user),
) -> dict: ) -> list:
"""List all D3FEND defensive techniques with optional filters.""" """List all D3FEND defensive techniques with optional filters."""
# Return list_defensive_techniques_svc( # Return list_defensive_techniques_svc(
return list_defensive_techniques_svc( return list_defensive_techniques_svc(
@@ -102,7 +102,7 @@ def get_defenses_for_attack_technique_endpoint(
db: Session = Depends(get_db), db: Session = Depends(get_db),
# Entry: current_user # Entry: current_user
current_user: User = Depends(get_current_user), current_user: User = Depends(get_current_user),
) -> dict: ) -> list:
"""Get all D3FEND defensive techniques mapped to a given ATT&CK technique.""" """Get all D3FEND defensive techniques mapped to a given ATT&CK technique."""
# Return get_defenses_for_attack_technique(db, mitre_id) # Return get_defenses_for_attack_technique(db, mitre_id)
return get_defenses_for_attack_technique(db, mitre_id) return get_defenses_for_attack_technique(db, mitre_id)
-319
View File
@@ -1,319 +0,0 @@
"""Detection Lifecycle Management router."""
import hashlib
from datetime import datetime, timedelta
from typing import Optional
from uuid import UUID
from fastapi import APIRouter, Depends, Query
from sqlalchemy import func
from sqlalchemy.orm import Session
from app.database import get_db
from app.dependencies.auth import get_current_user, require_any_role
from app.domain.exceptions import EntityNotFoundError
from app.models.detection_lifecycle import (
DetectionAsset, DetectionTechniqueMapping, DetectionValidation,
TechniqueConfidenceScore, InfrastructureChangeLog,
)
from app.schemas.detection_lifecycle_schema import (
DetectionAssetCreate, DetectionAssetUpdate, DetectionAssetOut,
DetectionValidationCreate, DetectionValidationOut,
TechniqueConfidenceOut,
InfrastructureChangeCreate, InfrastructureChangeOut,
)
from app.services import detection_asset_service, decay_engine_service, audit_service
router = APIRouter(prefix="/detection-lifecycle", tags=["detection-lifecycle"])
def _now() -> datetime:
return datetime.utcnow()
# ── Detection Assets ─────────────────────────────────────────────────────────
@router.post("/assets", response_model=DetectionAssetOut, status_code=201)
def create_asset(body: DetectionAssetCreate, db: Session = Depends(get_db), user=Depends(get_current_user)):
asset = detection_asset_service.create_detection_asset(db, body.model_dump(), user.id)
return asset
@router.get("/assets", response_model=list[DetectionAssetOut])
def list_assets(
platform: Optional[str] = None,
asset_type: Optional[str] = None,
health_status: Optional[str] = None,
technique_id: Optional[UUID] = None,
is_active: Optional[bool] = True,
db: Session = Depends(get_db),
user=Depends(get_current_user),
):
return detection_asset_service.list_assets(db, platform=platform, asset_type=asset_type, health_status=health_status, technique_id=technique_id, is_active=is_active)
@router.get("/assets/{asset_id}", response_model=DetectionAssetOut)
def get_asset(asset_id: UUID, db: Session = Depends(get_db), user=Depends(get_current_user)):
return detection_asset_service.get_asset_with_details(db, asset_id)
@router.patch("/assets/{asset_id}", response_model=DetectionAssetOut)
def update_asset(asset_id: UUID, body: DetectionAssetUpdate, db: Session = Depends(get_db), user=Depends(get_current_user)):
return detection_asset_service.update_detection_asset(db, asset_id, body.model_dump(exclude_unset=True), user.id)
@router.delete("/assets/{asset_id}", status_code=204)
def delete_asset(asset_id: UUID, db: Session = Depends(get_db), user=Depends(require_any_role("red_lead", "blue_lead"))):
asset = db.query(DetectionAsset).filter(DetectionAsset.id == asset_id).first()
if not asset:
raise EntityNotFoundError("DetectionAsset", str(asset_id))
asset.is_active = False
db.commit()
# ── Technique Mappings ───────────────────────────────────────────────────────
@router.post("/assets/{asset_id}/techniques/{technique_id}")
def map_technique(
asset_id: UUID, technique_id: UUID,
coverage_type: str = Query("detect"),
confidence_level: str = Query("medium"),
db: Session = Depends(get_db),
user=Depends(get_current_user),
):
# Validate asset exists
asset = db.query(DetectionAsset).filter(DetectionAsset.id == asset_id).first()
if not asset:
raise EntityNotFoundError("DetectionAsset", str(asset_id))
# Prevent duplicate mappings
existing = db.query(DetectionTechniqueMapping).filter(
DetectionTechniqueMapping.detection_asset_id == asset_id,
DetectionTechniqueMapping.technique_id == technique_id,
).first()
if existing:
# Update coverage/confidence on existing mapping instead of duplicating
existing.coverage_type = coverage_type
existing.confidence_level = confidence_level
db.commit()
return {"message": "Technique mapping updated", "mapping_id": str(existing.id)}
mapping = DetectionTechniqueMapping(
detection_asset_id=asset_id, technique_id=technique_id,
coverage_type=coverage_type, confidence_level=confidence_level,
)
db.add(mapping)
db.commit()
return {"message": "Technique mapped", "mapping_id": str(mapping.id)}
@router.get("/techniques/{technique_id}/detections")
def get_technique_detections(technique_id: UUID, db: Session = Depends(get_db), user=Depends(get_current_user)):
return detection_asset_service.get_technique_detection_summary(db, technique_id)
# ── Validations ──────────────────────────────────────────────────────────────
@router.post("/validations", response_model=DetectionValidationOut, status_code=201)
def create_validation(body: DetectionValidationCreate, db: Session = Depends(get_db), user=Depends(get_current_user)):
asset = db.query(DetectionAsset).filter(DetectionAsset.id == body.detection_asset_id).first()
if not asset:
raise EntityNotFoundError("DetectionAsset", str(body.detection_asset_id))
now = _now()
validation = DetectionValidation(
detection_asset_id=body.detection_asset_id,
technique_id=body.technique_id,
test_id=body.test_id,
validation_result=body.validation_result,
validation_method=body.validation_method,
notes=body.notes,
evidence_ids=[str(e) for e in (body.evidence_ids or [])],
validated_by=user.id,
validated_at=now,
expires_at=now + timedelta(days=body.validity_days),
rule_hash_at_validation=asset.rule_hash,
log_source_version_at_validation=asset.log_source_version,
infrastructure_hash_at_validation=asset.infrastructure_hash,
)
data = f"{validation.detection_asset_id}:{validation.validated_by}:{validation.validation_result}:{validation.validated_at}"
validation.integrity_hash = hashlib.sha256(data.encode()).hexdigest()
db.add(validation)
db.commit()
db.refresh(validation)
if body.technique_id:
decay_engine_service.calculate_confidence_for_technique(db, body.technique_id)
audit_service.log_action(db, user.id, "DETECTION_VALIDATED", "detection_validation", str(validation.id),
details={"asset_id": str(body.detection_asset_id), "result": body.validation_result, "validity_days": body.validity_days})
return validation
@router.get("/validations", response_model=list[DetectionValidationOut])
def list_validations(
asset_id: Optional[UUID] = None,
technique_id: Optional[UUID] = None,
is_valid: Optional[bool] = None,
db: Session = Depends(get_db),
user=Depends(get_current_user),
):
query = db.query(DetectionValidation)
if asset_id:
query = query.filter(DetectionValidation.detection_asset_id == asset_id)
if technique_id:
query = query.filter(DetectionValidation.technique_id == technique_id)
if is_valid is not None:
query = query.filter(DetectionValidation.is_valid == is_valid)
return query.order_by(DetectionValidation.validated_at.desc()).all()
@router.post("/validations/{validation_id}/invalidate")
def invalidate_validation(
validation_id: UUID,
reason: str = Query(...),
details: Optional[str] = None,
db: Session = Depends(get_db),
user=Depends(require_any_role("admin", "blue_lead")),
):
validation = db.query(DetectionValidation).filter(DetectionValidation.id == validation_id).first()
if not validation:
raise EntityNotFoundError("DetectionValidation", str(validation_id))
from app.models.detection_lifecycle import InvalidationReason
try:
reason_enum = InvalidationReason(reason)
except ValueError:
reason_enum = InvalidationReason.manual
validation.is_valid = False
validation.invalidated_at = _now()
validation.invalidation_reason = reason_enum
validation.invalidation_details = details
validation.invalidated_by = user.id
db.commit()
return {"message": "Validation invalidated"}
# ── Confidence Scores ────────────────────────────────────────────────────────
@router.get("/confidence", response_model=list[TechniqueConfidenceOut])
def list_confidence_scores(
confidence_level: Optional[str] = None,
min_score: Optional[float] = None,
max_score: Optional[float] = None,
db: Session = Depends(get_db),
user=Depends(get_current_user),
):
query = db.query(TechniqueConfidenceScore)
if confidence_level:
query = query.filter(TechniqueConfidenceScore.confidence_level == confidence_level)
if min_score is not None:
query = query.filter(TechniqueConfidenceScore.confidence_score >= min_score)
if max_score is not None:
query = query.filter(TechniqueConfidenceScore.confidence_score <= max_score)
return query.order_by(TechniqueConfidenceScore.confidence_score.asc()).all()
@router.get("/confidence/{technique_id}", response_model=TechniqueConfidenceOut)
def get_technique_confidence(
technique_id: UUID,
recalculate: bool = Query(False),
db: Session = Depends(get_db),
user=Depends(get_current_user),
):
if recalculate:
return decay_engine_service.calculate_confidence_for_technique(db, technique_id)
score = db.query(TechniqueConfidenceScore).filter(TechniqueConfidenceScore.technique_id == technique_id).first()
if not score:
return decay_engine_service.calculate_confidence_for_technique(db, technique_id)
return score
# ── Infrastructure Changes ───────────────────────────────────────────────────
@router.post("/infrastructure-changes", response_model=InfrastructureChangeOut, status_code=201)
def report_infrastructure_change(
body: InfrastructureChangeCreate,
db: Session = Depends(get_db),
user=Depends(require_any_role("admin", "blue_lead")),
):
change = InfrastructureChangeLog(
change_type=body.change_type,
description=body.description,
affected_platforms=body.affected_platforms,
affected_log_sources=body.affected_log_sources,
change_date=body.change_date or _now(),
auto_invalidate=body.auto_invalidate,
reported_by=user.id,
)
db.add(change)
db.commit()
db.refresh(change)
if change.auto_invalidate:
decay_engine_service.process_infrastructure_change(db, change.id)
db.refresh(change)
audit_service.log_action(db, user.id, "INFRASTRUCTURE_CHANGE_REPORTED", "infrastructure_change", str(change.id),
details={"type": body.change_type, "invalidated_count": change.invalidated_count})
return change
@router.get("/infrastructure-changes", response_model=list[InfrastructureChangeOut])
def list_infrastructure_changes(
days: int = Query(90, ge=1, le=730),
db: Session = Depends(get_db),
user=Depends(get_current_user),
):
cutoff = _now() - timedelta(days=days)
return db.query(InfrastructureChangeLog).filter(InfrastructureChangeLog.change_date >= cutoff).order_by(InfrastructureChangeLog.change_date.desc()).all()
# ── Decay Engine Control ─────────────────────────────────────────────────────
@router.post("/decay-engine/run")
def trigger_decay_engine(db: Session = Depends(get_db), user=Depends(require_any_role("admin"))):
results = decay_engine_service.run_decay_engine(db)
return {"message": "Decay engine completed", "results": results}
# ── Dashboard ────────────────────────────────────────────────────────────────
@router.get("/dashboard")
def lifecycle_dashboard(db: Session = Depends(get_db), user=Depends(get_current_user)):
now = _now()
health_dist = dict(
db.query(DetectionAsset.health_status, func.count(DetectionAsset.id))
.filter(DetectionAsset.is_active == True)
.group_by(DetectionAsset.health_status)
.all()
)
confidence_dist = dict(
db.query(TechniqueConfidenceScore.confidence_level, func.count(TechniqueConfidenceScore.id))
.group_by(TechniqueConfidenceScore.confidence_level)
.all()
)
expiring_soon = db.query(func.count(DetectionValidation.id)).filter(
DetectionValidation.is_valid == True,
DetectionValidation.expires_at <= (now + timedelta(days=7)),
).scalar() or 0
total_assets = db.query(func.count(DetectionAsset.id)).filter(DetectionAsset.is_active == True).scalar() or 0
total_valid = db.query(func.count(DetectionValidation.id)).filter(DetectionValidation.is_valid == True).scalar() or 0
recent_changes = db.query(func.count(InfrastructureChangeLog.id)).filter(
InfrastructureChangeLog.change_date >= (now - timedelta(days=30))
).scalar() or 0
return {
"total_detection_assets": total_assets,
"total_valid_validations": total_valid,
"health_distribution": {k.value if hasattr(k, "value") else str(k): v for k, v in health_dist.items()},
"confidence_distribution": {k.value if hasattr(k, "value") else str(k): v for k, v in confidence_dist.items()},
"validations_expiring_7d": expiring_soon,
"infrastructure_changes_30d": recent_changes,
}
+3 -3
View File
@@ -80,7 +80,7 @@ def list_detection_rules(
db: Session = Depends(get_db), db: Session = Depends(get_db),
# Entry: current_user # Entry: current_user
current_user: User = Depends(get_current_user), current_user: User = Depends(get_current_user),
) -> dict: ) -> list:
"""List detection rules with optional filters and pagination.""" """List detection rules with optional filters and pagination."""
# Return list_rules( # Return list_rules(
return list_rules( return list_rules(
@@ -112,7 +112,7 @@ def get_detection_rules_for_template(
db: Session = Depends(get_db), db: Session = Depends(get_db),
# Entry: current_user # Entry: current_user
current_user: User = Depends(get_current_user), current_user: User = Depends(get_current_user),
) -> dict: ) -> list:
"""Get detection rules associated with a test template.""" """Get detection rules associated with a test template."""
# Return get_rules_for_template(db, template_id) # Return get_rules_for_template(db, template_id)
return get_rules_for_template(db, template_id) return get_rules_for_template(db, template_id)
@@ -151,7 +151,7 @@ def get_detection_rules_for_test(
db: Session = Depends(get_db), db: Session = Depends(get_db),
# Entry: current_user # Entry: current_user
current_user: User = Depends(get_current_user), current_user: User = Depends(get_current_user),
) -> dict: ) -> list:
"""Get detection rules relevant to a test, along with their evaluation results. """Get detection rules relevant to a test, along with their evaluation results.
Finds rules by matching the test's technique_id to detection rules, Finds rules by matching the test's technique_id to detection rules,
+19 -88
View File
@@ -4,8 +4,7 @@ Endpoints
--------- ---------
POST /tests/{test_id}/evidence — upload evidence (with team=red/blue) POST /tests/{test_id}/evidence — upload evidence (with team=red/blue)
GET /tests/{test_id}/evidence — list evidences (filterable by team) GET /tests/{test_id}/evidence — list evidences (filterable by team)
GET /evidence/{id}metadata + download_url GET /evidence/{id}presigned download URL
GET /evidence/{id}/file — proxy download (streams file through backend)
DELETE /evidence/{id} — delete evidence (only in editable states) DELETE /evidence/{id} — delete evidence (only in editable states)
Access Control Access Control
@@ -22,17 +21,20 @@ Access Control
# Import hashlib # Import hashlib
import hashlib import hashlib
import logging
# Import os
import os import os
# Import uuid # Import uuid
import uuid as _uuid import uuid as _uuid
from datetime import datetime
# Import Optional from typing
from typing import Optional from typing import Optional
# Import APIRouter, Depends, File, Form, Query, Request,... from fastapi # Import APIRouter, Depends, File, Form, Query, Request,... from fastapi
from fastapi import APIRouter, Depends, File, Form, Query, Request, UploadFile, status from fastapi import APIRouter, Depends, File, Form, Query, Request, UploadFile, status
from fastapi.responses import StreamingResponse
# Import Session from sqlalchemy.orm
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
# Import get_db from app.database # Import get_db from app.database
@@ -72,9 +74,9 @@ from app.services.evidence_service import (
validate_file, validate_file,
validate_upload_permission, validate_upload_permission,
) )
from app.storage import download_file, upload_file
logger = logging.getLogger(__name__) # Import get_presigned_url, upload_file from app.storage
from app.storage import get_presigned_url, upload_file
# Assign router = APIRouter(tags=["evidence"]) # Assign router = APIRouter(tags=["evidence"])
router = APIRouter(tags=["evidence"]) router = APIRouter(tags=["evidence"])
@@ -85,11 +87,8 @@ router = APIRouter(tags=["evidence"])
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
def _evidence_to_out(evidence: Evidence) -> EvidenceOut: def _evidence_to_out(evidence: Evidence) -> EvidenceOut:
"""Convert an ORM ``Evidence`` to the API schema. """Convert an ORM ``Evidence`` to the API schema, injecting a presigned URL."""
# Return EvidenceOut(
``download_url`` points to the backend proxy endpoint so the browser
never needs direct access to MinIO.
"""
return EvidenceOut( return EvidenceOut(
# Keyword argument: id # Keyword argument: id
id=evidence.id, id=evidence.id,
@@ -107,7 +106,8 @@ def _evidence_to_out(evidence: Evidence) -> EvidenceOut:
team=evidence.team, team=evidence.team,
# Keyword argument: notes # Keyword argument: notes
notes=evidence.notes, notes=evidence.notes,
download_url=f"/api/v1/evidence/{evidence.id}/file", # Keyword argument: download_url
download_url=get_presigned_url(evidence.file_path),
) )
@@ -185,7 +185,7 @@ async def upload_evidence(
sha256_hash=sha256, sha256_hash=sha256,
# Keyword argument: uploaded_by # Keyword argument: uploaded_by
uploaded_by=current_user.id, uploaded_by=current_user.id,
uploaded_at=datetime.utcnow(), # set explicitly — DB column has no server default # Keyword argument: team
team=team, team=team,
# Keyword argument: notes # Keyword argument: notes
notes=notes, notes=notes,
@@ -222,43 +222,10 @@ async def upload_evidence(
# Reload ORM object attributes from the database # Reload ORM object attributes from the database
db.refresh(evidence) db.refresh(evidence)
# 7. Attach to Jira ticket if one exists (non-fatal) # Return _evidence_to_out(evidence)
_attach_evidence_to_jira(db, test_id, content, safe_name, current_user)
return _evidence_to_out(evidence) return _evidence_to_out(evidence)
def _attach_evidence_to_jira(
db,
test_id: _uuid.UUID,
content: bytes,
file_name: str,
actor,
) -> None:
"""Attach uploaded evidence to the linked Jira ticket (non-fatal)."""
try:
from app.services.jira_service import get_test_jira_key, get_admin_jira_client, has_admin_jira_configured
if not has_admin_jira_configured(db):
return
issue_key = get_test_jira_key(db, test_id)
if not issue_key:
return
import io
jira = get_admin_jira_client(db)
buf = io.BytesIO(content)
buf.name = file_name # requests uses .name as the multipart filename
jira.add_attachment_object(issue_key, buf)
import logging
logging.getLogger(__name__).info(
"Attached evidence '%s' to Jira ticket %s", file_name, issue_key
)
except Exception as exc:
import logging
logging.getLogger(__name__).warning(
"Failed to attach evidence '%s' to Jira: %s", file_name, exc, exc_info=True
)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# GET /tests/{test_id}/evidence — list (with optional team filter) # GET /tests/{test_id}/evidence — list (with optional team filter)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -286,7 +253,7 @@ def list_evidence(
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# GET /evidence/{id} — metadata + proxy download URL # GET /evidence/{id} — presigned download URL
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -299,50 +266,14 @@ def get_evidence(
db: Session = Depends(get_db), db: Session = Depends(get_db),
# Entry: current_user # Entry: current_user
current_user: User = Depends(get_current_user), current_user: User = Depends(get_current_user),
): ) -> EvidenceOut:
"""Return evidence metadata. ``download_url`` is a backend proxy URL.""" """Return evidence metadata together with a presigned download URL."""
# Assign evidence = get_evidence_or_raise(db, evidence_id)
evidence = get_evidence_or_raise(db, evidence_id) evidence = get_evidence_or_raise(db, evidence_id)
# Return _evidence_to_out(evidence) # Return _evidence_to_out(evidence)
return _evidence_to_out(evidence) return _evidence_to_out(evidence)
# ---------------------------------------------------------------------------
# GET /evidence/{id}/file — proxy download (streams file via backend)
# ---------------------------------------------------------------------------
@router.get("/evidence/{evidence_id}/file")
def download_evidence_file(
evidence_id: _uuid.UUID,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Stream the evidence file through the backend.
The browser calls this endpoint (authenticated via JWT cookie/header).
The backend fetches the file from MinIO internally and streams it back,
so MinIO never needs to be publicly accessible.
"""
import mimetypes
evidence = get_evidence_or_raise(db, evidence_id)
content = download_file(evidence.file_path)
mime_type, _ = mimetypes.guess_type(evidence.file_name)
if not mime_type:
mime_type = "application/octet-stream"
safe_name = evidence.file_name.replace('"', '\\"')
return StreamingResponse(
iter([content]),
media_type=mime_type,
headers={
"Content-Disposition": f'inline; filename="{safe_name}"',
"Content-Length": str(len(content)),
},
)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# DELETE /evidence/{id} — delete evidence (editable states only) # DELETE /evidence/{id} — delete evidence (editable states only)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
-123
View File
@@ -1,123 +0,0 @@
"""Phase 13: Executive Dashboard router."""
from typing import List
from fastapi import APIRouter, Depends, Query
from sqlalchemy.orm import Session
from app.database import get_db
from app.dependencies.auth import get_current_user, require_any_role
from app.schemas.executive_dashboard_schema import (
PostureSnapshotOut,
ExecutiveSummary,
KpiBlock,
CoverageByTactic,
PostureHistoryEntry,
ActivityEntry,
)
import app.services.executive_dashboard_service as svc
router = APIRouter(prefix="/dashboard", tags=["Executive Dashboard"])
@router.get("/executive", response_model=ExecutiveSummary)
def executive_view(
db: Session = Depends(get_db),
user=Depends(get_current_user),
):
"""
Full executive view — snapshot, 30-day trends, top risks,
coverage by tactic, and recent activity feed.
"""
data = svc.get_executive_summary(db)
snap = data["snapshot"]
return ExecutiveSummary(
snapshot=PostureSnapshotOut.model_validate(snap),
coverage_trend=data["coverage_trend"],
risk_trend=data["risk_trend"],
top_risks=data["top_risks"],
coverage_by_tactic=data["coverage_by_tactic"],
recent_activity=data["recent_activity"],
)
@router.get("/kpis", response_model=KpiBlock)
def kpis(
db: Session = Depends(get_db),
user=Depends(get_current_user),
):
"""Compact KPI block — live aggregation without persisting a snapshot."""
live = svc.get_live_kpis(db)
# Try to find today's snapshot id; fall back to None
from datetime import date
from app.models.executive_dashboard import PostureSnapshot
today_snap = db.query(PostureSnapshot).filter(
PostureSnapshot.snapshot_date == date.today()
).first()
return KpiBlock(
coverage_pct=live["coverage_pct"],
avg_risk_score=live["avg_risk_score"],
critical_count=live["critical_count"],
open_queue_items=live["open_queue_items"],
orphan_techniques=live["orphan_techniques"],
mttd_avg_seconds=live.get("mttd_avg_seconds"),
detection_rate_30d=live.get("detection_rate_30d"),
playbook_count=live["playbook_count"],
lesson_count=live["lesson_count"],
snapshot_date=live["snapshot_date"],
snapshot_id=today_snap.id if today_snap else None,
)
@router.get("/coverage-by-tactic", response_model=List[CoverageByTactic])
def coverage_by_tactic(
db: Session = Depends(get_db),
user=Depends(get_current_user),
):
"""Per-tactic validated / partial / not_covered breakdown."""
return svc.get_coverage_by_tactic(db)
@router.get("/posture-history", response_model=List[PostureHistoryEntry])
def posture_history(
days: int = Query(30, ge=1, le=365),
db: Session = Depends(get_db),
user=Depends(get_current_user),
):
"""Historical posture snapshots for trend charts (default last 30 days)."""
snaps = svc.get_posture_history(db, days=days)
return [
PostureHistoryEntry(
snapshot_date=s.snapshot_date,
coverage_pct=s.coverage_pct,
avg_risk_score=s.avg_risk_score,
critical_count=s.critical_count,
open_queue_items=s.open_queue_items,
)
for s in snaps
]
@router.post("/posture-snapshot", response_model=PostureSnapshotOut, status_code=201)
def create_posture_snapshot(
db: Session = Depends(get_db),
user=Depends(require_any_role("admin", "red_lead", "blue_lead")),
):
"""
Take (or refresh) today's posture snapshot — admin / leads only.
Aggregates live data from all phases into a single PostureSnapshot row.
"""
snap = svc.take_posture_snapshot(db, created_by=user.id)
return PostureSnapshotOut.model_validate(snap)
@router.get("/activity", response_model=List[ActivityEntry])
def recent_activity(
limit: int = Query(20, ge=1, le=100),
db: Session = Depends(get_db),
user=Depends(get_current_user),
):
"""Recent activity feed — tests, attack-path executions, OSINT signals."""
return svc.get_recent_activity(db, limit=limit)
-54
View File
@@ -1,54 +0,0 @@
"""Intel items endpoints — list and manage threat intelligence items."""
import uuid
from typing import Optional
from fastapi import APIRouter, Depends, Query
from pydantic import BaseModel
from sqlalchemy.orm import Session
from app.database import get_db
from app.dependencies.auth import get_current_user
from app.models.intel import IntelItem
from app.models.user import User
router = APIRouter(prefix="/intel", tags=["intel"])
class IntelItemOut(BaseModel):
id: uuid.UUID
technique_id: Optional[uuid.UUID] = None
url: str
title: Optional[str] = None
source: Optional[str] = None
detected_at: Optional[str] = None
reviewed: bool
class Config:
from_attributes = True
@router.get("/items", response_model=list[IntelItemOut])
def list_intel_items(
technique_id: Optional[uuid.UUID] = Query(None, description="Filter by technique"),
limit: int = Query(50, ge=1, le=200),
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""List threat intelligence items, optionally filtered by technique."""
query = db.query(IntelItem).order_by(IntelItem.detected_at.desc())
if technique_id:
query = query.filter(IntelItem.technique_id == technique_id)
items = query.limit(limit).all()
return [
IntelItemOut(
id=item.id,
technique_id=item.technique_id,
url=item.url,
title=item.title,
source=item.source,
detected_at=item.detected_at.isoformat() if item.detected_at else None,
reviewed=item.reviewed,
)
for item in items
]
+4 -4
View File
@@ -129,19 +129,19 @@ def list_links(
entity_type: Optional[JiraLinkEntityType] = None, entity_type: Optional[JiraLinkEntityType] = None,
# Entry: entity_id # Entry: entity_id
entity_id: Optional[UUID] = None, entity_id: Optional[UUID] = None,
entity_ids: Optional[list[UUID]] = Query(default=None, description="Filter by multiple entity IDs"), # Entry: db
db: Session = Depends(get_db), db: Session = Depends(get_db),
# Entry: user # Entry: user
user: User = Depends(get_current_user), user: User = Depends(get_current_user),
): ) -> list[JiraLinkOut]:
"""List Jira links, optionally filtered by entity or a list of entity IDs.""" """List Jira links, optionally filtered by entity."""
# Return jira_service.list_links(
return jira_service.list_links( return jira_service.list_links(
db, db,
# Keyword argument: entity_type # Keyword argument: entity_type
entity_type=entity_type, entity_type=entity_type,
# Keyword argument: entity_id # Keyword argument: entity_id
entity_id=entity_id, entity_id=entity_id,
entity_ids=entity_ids,
) )
-206
View File
@@ -1,206 +0,0 @@
"""Phase 11: Knowledge Management router — Playbooks + Lessons Learned."""
from typing import List, Optional
from uuid import UUID
from fastapi import APIRouter, Depends
from sqlalchemy.orm import Session
from app.database import get_db
from app.dependencies.auth import get_current_user, require_any_role
from app.schemas.knowledge_schema import (
PlaybookCreate, PlaybookUpdate, PlaybookOut, PlaybookVersionOut,
LessonLearnedCreate, LessonLearnedUpdate, LessonLearnedOut,
)
from app.services import playbook_service as pb_svc
from app.services import lesson_learned_service as ll_svc
router = APIRouter(prefix="/knowledge", tags=["knowledge"])
# ══════════════════════════════════════════════════════════════════════════════
# Playbooks
# ══════════════════════════════════════════════════════════════════════════════
@router.get("/playbooks", response_model=List[PlaybookOut])
def list_playbooks(
technique_id: Optional[UUID] = None,
playbook_type: Optional[str] = None,
include_inactive: bool = False,
db: Session = Depends(get_db),
user=Depends(get_current_user),
):
return pb_svc.list_playbooks(
db,
technique_id=technique_id,
playbook_type=playbook_type,
include_inactive=include_inactive,
)
@router.post("/playbooks", response_model=PlaybookOut, status_code=201)
def create_playbook(
body: PlaybookCreate,
db: Session = Depends(get_db),
user=Depends(require_any_role("admin", "red_lead", "blue_lead")),
):
return pb_svc.create_playbook(db, body.model_dump(), user.id)
@router.get("/playbooks/{playbook_id}", response_model=PlaybookOut)
def get_playbook(
playbook_id: UUID,
db: Session = Depends(get_db),
user=Depends(get_current_user),
):
return pb_svc.get_playbook(db, playbook_id)
@router.patch("/playbooks/{playbook_id}", response_model=PlaybookOut)
def update_playbook(
playbook_id: UUID,
body: PlaybookUpdate,
db: Session = Depends(get_db),
user=Depends(require_any_role("admin", "red_lead", "blue_lead")),
):
return pb_svc.update_playbook(db, playbook_id, body.model_dump(exclude_unset=True), user.id)
@router.delete("/playbooks/{playbook_id}", status_code=204)
def delete_playbook(
playbook_id: UUID,
db: Session = Depends(get_db),
user=Depends(require_any_role("admin", "red_lead", "blue_lead")),
):
pb_svc.delete_playbook(db, playbook_id, user.id)
# ── Versions ──────────────────────────────────────────────────────────────────
@router.get("/playbooks/{playbook_id}/versions", response_model=List[PlaybookVersionOut])
def list_versions(
playbook_id: UUID,
db: Session = Depends(get_db),
user=Depends(get_current_user),
):
return pb_svc.get_playbook_versions(db, playbook_id)
@router.post("/playbooks/{playbook_id}/restore/{version}", response_model=PlaybookOut)
def restore_version(
playbook_id: UUID,
version: int,
db: Session = Depends(get_db),
user=Depends(require_any_role("admin", "red_lead", "blue_lead")),
):
"""Roll the playbook back to a specific historical version."""
return pb_svc.restore_version(db, playbook_id, version, user.id)
# ── By technique (convenience) ────────────────────────────────────────────────
@router.get(
"/techniques/{technique_id}/playbooks",
response_model=List[PlaybookOut],
)
def playbooks_for_technique(
technique_id: UUID,
db: Session = Depends(get_db),
user=Depends(get_current_user),
):
"""List all active playbooks for a specific technique."""
return pb_svc.list_playbooks(db, technique_id=technique_id)
@router.get(
"/techniques/{technique_id}/playbooks/{playbook_type}",
response_model=PlaybookOut,
)
def get_playbook_by_technique_type(
technique_id: UUID,
playbook_type: str,
db: Session = Depends(get_db),
user=Depends(get_current_user),
):
pb = pb_svc.get_playbook_by_technique_type(db, technique_id, playbook_type)
if not pb:
from app.domain.errors import EntityNotFoundError
raise EntityNotFoundError("Playbook", f"{technique_id}/{playbook_type}")
return pb
# ══════════════════════════════════════════════════════════════════════════════
# Lessons Learned
# ══════════════════════════════════════════════════════════════════════════════
@router.get("/lessons", response_model=List[LessonLearnedOut])
def list_lessons(
entity_type: Optional[str] = None,
entity_id: Optional[UUID] = None,
severity: Optional[str] = None,
tag: Optional[str] = None,
technique_id: Optional[str] = None,
include_inactive: bool = False,
db: Session = Depends(get_db),
user=Depends(get_current_user),
):
return ll_svc.list_lessons_learned(
db,
entity_type=entity_type,
entity_id=entity_id,
severity=severity,
tag=tag,
technique_id=technique_id,
include_inactive=include_inactive,
)
@router.post("/lessons", response_model=LessonLearnedOut, status_code=201)
def create_lesson(
body: LessonLearnedCreate,
db: Session = Depends(get_db),
user=Depends(require_any_role("admin", "red_lead", "blue_lead")),
):
return ll_svc.create_lesson_learned(db, body.model_dump(), user.id)
@router.get("/lessons/{lesson_id}", response_model=LessonLearnedOut)
def get_lesson(
lesson_id: UUID,
db: Session = Depends(get_db),
user=Depends(get_current_user),
):
return ll_svc.get_lesson_learned(db, lesson_id)
@router.patch("/lessons/{lesson_id}", response_model=LessonLearnedOut)
def update_lesson(
lesson_id: UUID,
body: LessonLearnedUpdate,
db: Session = Depends(get_db),
user=Depends(require_any_role("admin", "red_lead", "blue_lead")),
):
return ll_svc.update_lesson_learned(
db, lesson_id, body.model_dump(exclude_unset=True), user.id
)
@router.delete("/lessons/{lesson_id}", status_code=204)
def delete_lesson(
lesson_id: UUID,
db: Session = Depends(get_db),
user=Depends(require_any_role("admin", "red_lead", "blue_lead")),
):
"""Soft-delete a lesson (admin / lead only)."""
ll_svc.delete_lesson_learned(db, lesson_id, user.id)
# ── Stats ─────────────────────────────────────────────────────────────────────
@router.get("/stats")
def knowledge_stats(
db: Session = Depends(get_db),
user=Depends(get_current_user),
):
"""Summary counts: total playbooks, lessons by severity, playbooks by type."""
return ll_svc.get_knowledge_stats(db)
-191
View File
@@ -1,191 +0,0 @@
"""Phase 13: Operational Alerts router."""
from typing import List, Optional
from uuid import UUID
from fastapi import APIRouter, Depends, Query
from sqlalchemy.orm import Session
from app.database import get_db
from app.dependencies.auth import get_current_user, require_any_role
from app.models.user import User
from app.schemas.operational_alert_schema import (
AlertRuleCreate, AlertRuleOut, AlertRuleUpdate,
AlertInstanceOut, EvaluationResult, AlertSummary,
)
import app.services.operational_alert_service as svc
router = APIRouter(prefix="/alerts", tags=["Operational Alerts"])
# ── Evaluation ────────────────────────────────────────────────────────────────
@router.post("/evaluate", response_model=EvaluationResult, status_code=202)
def evaluate_rules(
db: Session = Depends(get_db),
user=Depends(require_any_role("admin", "red_lead", "blue_lead")),
):
"""
Run the alert evaluation engine against all enabled rules.
Fires AlertInstances for rules whose conditions are met and are not in cooldown.
Admin / leads only.
"""
result = svc.evaluate_all_rules(db)
return EvaluationResult(
rules_evaluated = result["rules_evaluated"],
alerts_fired = result["alerts_fired"],
alerts = [AlertInstanceOut.model_validate(a) for a in result["alerts"]],
duration_seconds = result["duration_seconds"],
)
# ── Alert instances ───────────────────────────────────────────────────────────
@router.get("", response_model=List[AlertInstanceOut])
def list_alerts(
status: Optional[str] = Query(None),
severity: Optional[str] = Query(None),
rule_type: Optional[str] = Query(None),
limit: int = Query(50, ge=1, le=200),
offset: int = Query(0, ge=0),
db: Session = Depends(get_db),
user=Depends(get_current_user),
):
"""List alert instances with optional filters."""
return svc.list_instances(db, status=status, severity=severity,
rule_type=rule_type, limit=limit, offset=offset)
@router.get("/summary", response_model=AlertSummary)
def alert_summary(
db: Session = Depends(get_db),
user=Depends(get_current_user),
):
"""Aggregate counts by status, severity, and rule type."""
data = svc.get_summary(db)
return AlertSummary(
total_open = data["total_open"],
total_acknowledged = data["total_acknowledged"],
total_resolved = data["total_resolved"],
by_severity = data["by_severity"],
by_rule_type = data["by_rule_type"],
recent_alerts = [AlertInstanceOut.model_validate(a) for a in data["recent_alerts"]],
)
@router.get("/{alert_id}", response_model=AlertInstanceOut)
def get_alert(
alert_id: UUID,
db: Session = Depends(get_db),
user=Depends(get_current_user),
):
"""Get a single alert instance."""
return svc.get_instance(db, alert_id)
@router.post("/{alert_id}/acknowledge", response_model=AlertInstanceOut)
def acknowledge_alert(
alert_id: UUID,
db: Session = Depends(get_db),
current_user: User = Depends(require_any_role("admin", "red_lead", "blue_lead")),
):
"""Acknowledge an open alert (admin / lead roles only)."""
return svc.acknowledge(db, alert_id, current_user.id)
@router.post("/{alert_id}/resolve", response_model=AlertInstanceOut)
def resolve_alert(
alert_id: UUID,
db: Session = Depends(get_db),
current_user: User = Depends(require_any_role("admin", "red_lead", "blue_lead")),
):
"""Mark an alert as resolved (admin / lead roles only)."""
return svc.resolve(db, alert_id, current_user.id)
@router.post("/{alert_id}/dismiss", response_model=AlertInstanceOut)
def dismiss_alert(
alert_id: UUID,
db: Session = Depends(get_db),
current_user: User = Depends(require_any_role("admin", "red_lead", "blue_lead")),
):
"""Dismiss an alert (admin / lead roles only — won't re-fire until cooldown resets)."""
return svc.dismiss(db, alert_id, current_user.id)
# ── Alert rules ───────────────────────────────────────────────────────────────
@router.get("/rules/list", response_model=List[AlertRuleOut])
def list_rules(
rule_type: Optional[str] = Query(None),
include_disabled: bool = Query(False),
db: Session = Depends(get_db),
user=Depends(get_current_user),
):
"""List alert rules (all users can read; admins/leads manage them)."""
return svc.list_rules(db, rule_type=rule_type, include_disabled=include_disabled)
@router.post("/rules", response_model=AlertRuleOut, status_code=201)
def create_rule(
body: AlertRuleCreate,
db: Session = Depends(get_db),
current_user: User = Depends(require_any_role("admin", "red_lead", "blue_lead")),
):
"""Create a custom alert rule."""
return svc.create_rule(
db,
created_by = current_user.id,
name = body.name,
description = body.description,
rule_type = body.rule_type,
severity = body.severity,
config = body.config,
notify_in_app = body.notify_in_app,
notify_webhook = body.notify_webhook,
webhook_id = body.webhook_id,
cooldown_hours = body.cooldown_hours,
)
@router.get("/rules/{rule_id}", response_model=AlertRuleOut)
def get_rule(
rule_id: UUID,
db: Session = Depends(get_db),
user=Depends(get_current_user),
):
"""Get a single alert rule."""
return svc.get_rule(db, rule_id)
@router.patch("/rules/{rule_id}", response_model=AlertRuleOut)
def update_rule(
rule_id: UUID,
body: AlertRuleUpdate,
db: Session = Depends(get_db),
user=Depends(require_any_role("admin", "red_lead", "blue_lead")),
):
"""Update an alert rule (enable/disable, thresholds, cooldown)."""
return svc.update_rule(
db, rule_id,
name = body.name,
description = body.description,
severity = body.severity,
is_enabled = body.is_enabled,
config = body.config,
notify_in_app = body.notify_in_app,
notify_webhook = body.notify_webhook,
webhook_id = body.webhook_id,
cooldown_hours = body.cooldown_hours,
)
@router.delete("/rules/{rule_id}", status_code=204)
def delete_rule(
rule_id: UUID,
db: Session = Depends(get_db),
user=Depends(require_any_role("admin")),
):
"""Delete a custom alert rule (system rules cannot be deleted)."""
svc.delete_rule(db, rule_id)
+12 -20
View File
@@ -19,11 +19,8 @@ from app.dependencies.auth import get_current_user
# Import User from app.models.user # Import User from app.models.user
from app.models.user import User from app.models.user import User
from datetime import datetime, date
# Import from app.services.operational_metrics_service # Import from app.services.operational_metrics_service
from app.services.operational_metrics_service import ( from app.services.operational_metrics_service import (
get_all_operational_metrics,
get_metrics_by_team, get_metrics_by_team,
get_operational_trend, get_operational_trend,
) )
@@ -36,20 +33,18 @@ router = APIRouter(prefix="/metrics/operational", tags=["operational-metrics"])
@router.get("") @router.get("")
# Define function operational_metrics
def operational_metrics( def operational_metrics(
# Entry: db
db: Session = Depends(get_db), db: Session = Depends(get_db),
# Entry: current_user
current_user: User = Depends(get_current_user), current_user: User = Depends(get_current_user),
since: str | None = Query(None, description="ISO date YYYY-MM-DD — filter metrics to tests on or after this date"),
) -> dict: ) -> dict:
"""Get all operational metrics (MTTD, MTTR, etc.). Uses cache when no time filter is set.""" """Get all operational metrics (MTTD, MTTR, etc.) — cached for 5 min."""
if since: # Import get_operational_metrics_cached from app.services.score_cache
try:
since_dt = datetime.combine(date.fromisoformat(since), datetime.min.time())
except ValueError:
since_dt = None
return get_all_operational_metrics(db, since_dt)
from app.services.score_cache import get_operational_metrics_cached from app.services.score_cache import get_operational_metrics_cached
# Return get_operational_metrics_cached(db)
return get_operational_metrics_cached(db) return get_operational_metrics_cached(db)
@@ -75,16 +70,13 @@ def operational_trend(
@router.get("/by-team") @router.get("/by-team")
# Define function metrics_by_team
def metrics_by_team( def metrics_by_team(
# Entry: db
db: Session = Depends(get_db), db: Session = Depends(get_db),
# Entry: current_user
current_user: User = Depends(get_current_user), current_user: User = Depends(get_current_user),
since: str | None = Query(None, description="ISO date YYYY-MM-DD — filter to tests on or after this date"),
) -> dict: ) -> dict:
"""Get metrics broken down by Red Team vs Blue Team.""" """Get metrics broken down by Red Team vs Blue Team."""
since_dt = None # Return get_metrics_by_team(db)
if since: return get_metrics_by_team(db)
try:
since_dt = datetime.combine(date.fromisoformat(since), datetime.min.time())
except ValueError:
pass
return get_metrics_by_team(db, since_dt)
+1 -1
View File
@@ -97,7 +97,7 @@ def list_osint_items(
db: Session = Depends(get_db), db: Session = Depends(get_db),
# Entry: user # Entry: user
user: User = Depends(get_current_user), user: User = Depends(get_current_user),
) -> dict: ) -> list:
"""List OSINT items with optional filters. """List OSINT items with optional filters.
Args: Args:
-215
View File
@@ -1,215 +0,0 @@
"""Phase 9: Ownership & Daily Operations router."""
from typing import Optional
from uuid import UUID
from fastapi import APIRouter, Depends, Query
from sqlalchemy.orm import Session
from app.database import get_db
from app.dependencies.auth import get_current_user, require_any_role
from app.domain.exceptions import EntityNotFoundError
from app.schemas.ownership_queue_schema import (
TechniqueOwnershipSet, TechniqueOwnershipOut,
DetectionAssetOwnershipPatch,
BulkAssignRequest, BulkAssignResult,
QueueItemCreate, QueueItemPatch, QueueItemOut,
)
from app.services import ownership_service, revalidation_queue_service
from app.models.ownership_queue import RevalidationQueueItem
router = APIRouter(prefix="/ownership", tags=["ownership"])
# ── Technique Ownership ───────────────────────────────────────────────────────
@router.get("/techniques/{technique_id}", response_model=TechniqueOwnershipOut)
def get_technique_ownership(
technique_id: UUID,
db: Session = Depends(get_db),
user=Depends(get_current_user),
):
ownership = ownership_service.get_technique_ownership(db, technique_id)
if not ownership:
raise EntityNotFoundError("TechniqueOwnership", str(technique_id))
return ownership
@router.put("/techniques/{technique_id}", response_model=TechniqueOwnershipOut)
def set_technique_ownership(
technique_id: UUID,
body: TechniqueOwnershipSet,
db: Session = Depends(get_db),
user=Depends(require_any_role("admin", "blue_lead", "red_lead")),
):
return ownership_service.set_technique_ownership(
db, technique_id,
owner_id=body.owner_id,
backup_owner_id=body.backup_owner_id,
team=body.team,
notes=body.notes,
assigned_by=user.id,
)
# ── Detection Asset Ownership ─────────────────────────────────────────────────
@router.patch("/assets/{asset_id}", response_model=dict)
def set_asset_ownership(
asset_id: UUID,
body: DetectionAssetOwnershipPatch,
db: Session = Depends(get_db),
user=Depends(require_any_role("admin", "blue_lead")),
):
ownership_service.set_asset_ownership(
db, asset_id,
owner_id=body.owner_id,
backup_owner_id=body.backup_owner_id,
team=body.team,
user_id=user.id,
)
return {"message": "Asset ownership updated"}
# ── Orphan Reports ────────────────────────────────────────────────────────────
@router.get("/orphans/techniques", response_model=list[dict])
def orphan_techniques(
db: Session = Depends(get_db),
user=Depends(get_current_user),
):
"""Return techniques with no assigned owner."""
return ownership_service.get_orphan_techniques(db)
@router.get("/orphans/assets", response_model=list[dict])
def orphan_assets(
db: Session = Depends(get_db),
user=Depends(get_current_user),
):
"""Return active detection assets with no assigned owner."""
return ownership_service.get_orphan_assets(db)
# ── Bulk Assignment ───────────────────────────────────────────────────────────
@router.post("/bulk-assign", response_model=BulkAssignResult)
def bulk_assign(
body: BulkAssignRequest,
db: Session = Depends(get_db),
user=Depends(require_any_role("admin", "blue_lead", "red_lead")),
):
"""
Bulk-assign ownership.
- If `tactic` is set → assigns technique ownership for all techniques of that tactic.
- If `platform` is set → assigns asset ownership for all assets on that platform.
At least one of tactic/platform must be provided.
"""
if not body.tactic and not body.platform:
from fastapi import HTTPException
raise HTTPException(status_code=422, detail="Provide at least one of: tactic, platform")
if body.tactic:
result = ownership_service.bulk_assign_techniques_by_tactic(
db, body.tactic,
owner_id=body.owner_id,
backup_owner_id=body.backup_owner_id,
team=body.team,
overwrite=body.overwrite,
user_id=user.id,
)
else:
result = ownership_service.bulk_assign_assets_by_platform(
db, body.platform,
owner_id=body.owner_id,
backup_owner_id=body.backup_owner_id,
team=body.team,
overwrite=body.overwrite,
user_id=user.id,
)
return BulkAssignResult(**result)
# ── Revalidation Queue ────────────────────────────────────────────────────────
@router.get("/queue", response_model=list[QueueItemOut])
def list_queue(
status: Optional[str] = Query(None),
priority: Optional[str] = Query(None),
reason: Optional[str] = Query(None),
assigned_to: Optional[UUID] = Query(None),
technique_id: Optional[UUID] = Query(None),
detection_asset_id: Optional[UUID] = Query(None),
limit: int = Query(100, ge=1, le=500),
offset: int = Query(0, ge=0),
db: Session = Depends(get_db),
user=Depends(get_current_user),
):
return revalidation_queue_service.list_queue(
db, status=status, priority=priority, reason=reason,
assigned_to=assigned_to, technique_id=technique_id,
detection_asset_id=detection_asset_id, limit=limit, offset=offset,
)
@router.post("/queue", response_model=QueueItemOut, status_code=201)
def create_queue_item(
body: QueueItemCreate,
db: Session = Depends(get_db),
user=Depends(get_current_user),
):
return revalidation_queue_service.create_queue_item(db, body.model_dump(), user.id)
@router.patch("/queue/{item_id}", response_model=QueueItemOut)
def update_queue_item(
item_id: UUID,
body: QueueItemPatch,
db: Session = Depends(get_db),
user=Depends(get_current_user),
):
return revalidation_queue_service.update_queue_item(db, item_id, body.model_dump(exclude_unset=True), user.id)
@router.post("/queue/generate", response_model=dict)
def generate_queue(
db: Session = Depends(get_db),
user=Depends(require_any_role("admin", "blue_lead")),
):
"""Scan the system and create new revalidation queue items."""
return revalidation_queue_service.generate_queue_items(db)
# ── Analyst Dashboard ─────────────────────────────────────────────────────────
@router.get("/analyst-dashboard")
def analyst_dashboard(
db: Session = Depends(get_db),
user=Depends(get_current_user),
):
"""Personalised daily workday view: my queue, expiring validations, infra changes, low-confidence techniques."""
dashboard = revalidation_queue_service.get_analyst_dashboard(db, user.id)
# Serialize queue items to dicts (ORM objects → plain dicts)
def _item_to_dict(item: RevalidationQueueItem) -> dict:
return {
"id": str(item.id),
"technique_id": str(item.technique_id) if item.technique_id else None,
"detection_asset_id": str(item.detection_asset_id) if item.detection_asset_id else None,
"priority": item.priority.value if hasattr(item.priority, "value") else item.priority,
"reason": item.reason.value if hasattr(item.reason, "value") else item.reason,
"reason_detail": item.reason_detail,
"status": item.status.value if hasattr(item.status, "value") else item.status,
"assigned_to": str(item.assigned_to) if item.assigned_to else None,
"due_date": item.due_date.isoformat() if item.due_date else None,
"created_at": item.created_at.isoformat() if item.created_at else None,
}
return {
"my_pending_items": [_item_to_dict(i) for i in dashboard["my_pending_items"]],
"expiring_validations_7d": dashboard["expiring_validations_7d"],
"recent_infra_changes": dashboard["recent_infra_changes"],
"my_low_confidence_techniques": dashboard["my_low_confidence_techniques"],
"summary": dashboard["summary"],
}
@@ -1,158 +0,0 @@
"""Router for procedure-improvement suggestion review.
Endpoints
---------
GET /procedure-suggestions — list pending suggestions (lead/admin)
POST /procedure-suggestions/{id}/approve — write suggestion into template (lead/admin)
POST /procedure-suggestions/{id}/reject — dismiss suggestion (lead/admin)
A red_lead only ever sees/acts on "red" team suggestions, a blue_lead only
"blue" — admins can see and act on both.
"""
import uuid
from fastapi import APIRouter, Depends, HTTPException
from app.database import get_db
from app.dependencies.auth import require_any_role_strict
from app.domain.errors import EntityNotFoundError, InvalidOperationError
from app.domain.unit_of_work import UnitOfWork
from app.models.procedure_suggestion import ProcedureSuggestion
from app.models.test_template import TestTemplate
from app.models.user import User
from app.schemas.procedure_suggestion import ProcedureSuggestionOut
from app.services.audit_service import log_action
from app.services.procedure_suggestion_service import (
approve_suggestion as approve_suggestion_svc,
)
from app.services.procedure_suggestion_service import (
list_pending_suggestions,
)
from app.services.procedure_suggestion_service import (
reject_suggestion as reject_suggestion_svc,
)
from sqlalchemy.orm import Session
router = APIRouter(prefix="/procedure-suggestions", tags=["procedure-suggestions"])
def _team_for_role(user: User) -> str | None:
"""Return the single team a non-admin lead is scoped to, or None for admin (both)."""
if user.role == "red_lead":
return "red"
if user.role == "blue_lead":
return "blue"
return None
def _to_out(suggestion, template_by_id: dict) -> ProcedureSuggestionOut:
out = ProcedureSuggestionOut.model_validate(suggestion)
template = template_by_id.get(suggestion.template_id)
if template is not None:
out.template_name = template.name
out.template_current_text = (
template.attack_procedure if suggestion.team == "red" else template.expected_detection
)
return out
@router.get("", response_model=list[ProcedureSuggestionOut])
def list_suggestions(
db: Session = Depends(get_db),
current_user: User = Depends(require_any_role_strict("red_lead", "blue_lead", "admin")),
) -> list[ProcedureSuggestionOut]:
"""List pending procedure suggestions, scoped to the caller's team (admins see both)."""
team = _team_for_role(current_user)
suggestions = list_pending_suggestions(db, team=team)
template_ids = {s.template_id for s in suggestions}
templates = (
db.query(TestTemplate).filter(TestTemplate.id.in_(template_ids)).all() if template_ids else []
)
template_by_id = {t.id: t for t in templates}
return [_to_out(s, template_by_id) for s in suggestions]
@router.get("/for-test/{test_id}", response_model=list[ProcedureSuggestionOut])
def list_suggestions_for_test(
test_id: uuid.UUID,
db: Session = Depends(get_db),
current_user: User = Depends(require_any_role_strict("red_lead", "blue_lead", "admin")),
) -> list[ProcedureSuggestionOut]:
"""List pending suggestions tied to a specific test, scoped to the
caller's team (admins see both) — powers the blocking review popup a
lead sees when opening a test that has one awaiting them."""
team = _team_for_role(current_user)
suggestions = list_pending_suggestions(db, team=team, source_test_id=test_id)
template_ids = {s.template_id for s in suggestions}
templates = (
db.query(TestTemplate).filter(TestTemplate.id.in_(template_ids)).all() if template_ids else []
)
template_by_id = {t.id: t for t in templates}
return [_to_out(s, template_by_id) for s in suggestions]
@router.post("/{suggestion_id}/approve", response_model=ProcedureSuggestionOut)
def approve_suggestion(
suggestion_id: uuid.UUID,
db: Session = Depends(get_db),
current_user: User = Depends(require_any_role_strict("red_lead", "blue_lead", "admin")),
) -> ProcedureSuggestionOut:
"""Approve a suggestion, writing it into its template's suggested-procedure field."""
_check_team_permission(current_user, _team_or_404(db, suggestion_id))
try:
with UnitOfWork(db) as uow:
suggestion = approve_suggestion_svc(db, suggestion_id, current_user)
log_action(
db, user_id=current_user.id, action="approve_procedure_suggestion",
entity_type="procedure_suggestion", entity_id=suggestion.id,
details={"template_id": str(suggestion.template_id), "team": suggestion.team},
)
uow.commit()
except EntityNotFoundError as e:
raise HTTPException(status_code=404, detail=str(e)) from e
except InvalidOperationError as e:
raise HTTPException(status_code=400, detail=str(e)) from e
db.refresh(suggestion)
template = db.query(TestTemplate).filter(TestTemplate.id == suggestion.template_id).first()
return _to_out(suggestion, {template.id: template} if template else {})
@router.post("/{suggestion_id}/reject", response_model=ProcedureSuggestionOut)
def reject_suggestion(
suggestion_id: uuid.UUID,
db: Session = Depends(get_db),
current_user: User = Depends(require_any_role_strict("red_lead", "blue_lead", "admin")),
) -> ProcedureSuggestionOut:
"""Reject a suggestion without touching the template."""
_check_team_permission(current_user, _team_or_404(db, suggestion_id))
try:
with UnitOfWork(db) as uow:
suggestion = reject_suggestion_svc(db, suggestion_id, current_user)
log_action(
db, user_id=current_user.id, action="reject_procedure_suggestion",
entity_type="procedure_suggestion", entity_id=suggestion.id,
details={"template_id": str(suggestion.template_id), "team": suggestion.team},
)
uow.commit()
except EntityNotFoundError as e:
raise HTTPException(status_code=404, detail=str(e)) from e
except InvalidOperationError as e:
raise HTTPException(status_code=400, detail=str(e)) from e
db.refresh(suggestion)
return _to_out(suggestion, {})
def _team_or_404(db: Session, suggestion_id: uuid.UUID) -> str:
"""Look up just the team of a suggestion, for a permission check before mutating it."""
suggestion = db.query(ProcedureSuggestion).filter(ProcedureSuggestion.id == suggestion_id).first()
if suggestion is None:
raise HTTPException(status_code=404, detail="Procedure suggestion not found")
return suggestion.team
def _check_team_permission(user: User, team: str) -> None:
"""Raise 403 if a non-admin lead tries to act on the other team's suggestion."""
scoped_team = _team_for_role(user)
if scoped_team is not None and scoped_team != team:
raise HTTPException(status_code=403, detail=f"Not authorized to review {team} team suggestions")
+7 -37
View File
@@ -2,10 +2,9 @@
# Import UUID from uuid # Import UUID from uuid
from uuid import UUID from uuid import UUID
from pathlib import Path
# Import APIRouter, Depends, HTTPException, Query, Request from fastapi # Import APIRouter, Depends, Query, Request from fastapi
from fastapi import APIRouter, Depends, HTTPException, Query, Request from fastapi import APIRouter, Depends, Query, Request
# Import FileResponse from fastapi.responses # Import FileResponse from fastapi.responses
from fastapi.responses import FileResponse from fastapi.responses import FileResponse
@@ -22,36 +21,12 @@ from app.dependencies.auth import get_current_user, require_any_role
# Import limiter from app.limiter # Import limiter from app.limiter
from app.limiter import limiter from app.limiter import limiter
# Import settings from app.config
from app.config import settings
# Import User from app.models.user # Import User from app.models.user
from app.models.user import User from app.models.user import User
# Import report_generation_service from app.services # Import report_generation_service from app.services
from app.services import report_generation_service from app.services import report_generation_service
def _assert_safe_report_path(filepath: str) -> str:
"""Raise 500 if the generated filepath escapes the configured report directory."""
output_dir = Path(settings.REPORT_OUTPUT_DIR).resolve()
resolved = Path(filepath).resolve()
if not resolved.is_relative_to(output_dir):
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"]) # Assign router = APIRouter(prefix="/reports/generate", tags=["professional-reports"])
router = APIRouter(prefix="/reports/generate", tags=["professional-reports"]) router = APIRouter(prefix="/reports/generate", tags=["professional-reports"])
@@ -84,14 +59,13 @@ def generate_purple_report(
user: User = Depends(require_any_role("red_lead", "blue_lead", "viewer")), user: User = Depends(require_any_role("red_lead", "blue_lead", "viewer")),
) -> FileResponse: ) -> FileResponse:
"""Generate a Purple Team campaign assessment report.""" """Generate a Purple Team campaign assessment report."""
_assert_reports_enabled()
# Assign filepath = report_generation_service.generate_purple_campaign_report( # Assign filepath = report_generation_service.generate_purple_campaign_report(
filepath = report_generation_service.generate_purple_campaign_report( filepath = report_generation_service.generate_purple_campaign_report(
db, str(campaign_id), output_format=format, db, str(campaign_id), output_format=format,
) )
# Return FileResponse( # Return FileResponse(
return FileResponse( return FileResponse(
_assert_safe_report_path(filepath), filepath,
# Keyword argument: media_type # Keyword argument: media_type
media_type=_MEDIA_TYPES[format], media_type=_MEDIA_TYPES[format],
# Keyword argument: filename # Keyword argument: filename
@@ -115,14 +89,13 @@ def generate_coverage_report(
user: User = Depends(require_any_role("red_lead", "blue_lead", "viewer")), user: User = Depends(require_any_role("red_lead", "blue_lead", "viewer")),
) -> FileResponse: ) -> FileResponse:
"""Generate an organization-wide MITRE ATT&CK coverage report.""" """Generate an organization-wide MITRE ATT&CK coverage report."""
_assert_reports_enabled()
# Assign filepath = report_generation_service.generate_coverage_report( # Assign filepath = report_generation_service.generate_coverage_report(
filepath = report_generation_service.generate_coverage_report( filepath = report_generation_service.generate_coverage_report(
db, output_format=format, db, output_format=format,
) )
# Return FileResponse( # Return FileResponse(
return FileResponse( return FileResponse(
_assert_safe_report_path(filepath), filepath,
# Keyword argument: media_type # Keyword argument: media_type
media_type=_MEDIA_TYPES[format], media_type=_MEDIA_TYPES[format],
# Keyword argument: filename # Keyword argument: filename
@@ -146,14 +119,13 @@ def generate_executive_report(
user: User = Depends(require_any_role("red_lead", "blue_lead", "viewer")), user: User = Depends(require_any_role("red_lead", "blue_lead", "viewer")),
) -> FileResponse: ) -> FileResponse:
"""Generate an executive security summary report.""" """Generate an executive security summary report."""
_assert_reports_enabled()
# Assign filepath = report_generation_service.generate_executive_summary( # Assign filepath = report_generation_service.generate_executive_summary(
filepath = report_generation_service.generate_executive_summary( filepath = report_generation_service.generate_executive_summary(
db, output_format=format, db, output_format=format,
) )
# Return FileResponse( # Return FileResponse(
return FileResponse( return FileResponse(
_assert_safe_report_path(filepath), filepath,
# Keyword argument: media_type # Keyword argument: media_type
media_type=_MEDIA_TYPES[format], media_type=_MEDIA_TYPES[format],
# Keyword argument: filename # Keyword argument: filename
@@ -177,14 +149,13 @@ def generate_quarterly_report(
user: User = Depends(require_any_role("red_lead", "blue_lead", "viewer")), user: User = Depends(require_any_role("red_lead", "blue_lead", "viewer")),
) -> FileResponse: ) -> FileResponse:
"""Generate a quarterly security summary report.""" """Generate a quarterly security summary report."""
_assert_reports_enabled()
# Assign filepath = report_generation_service.generate_quarterly_summary( # Assign filepath = report_generation_service.generate_quarterly_summary(
filepath = report_generation_service.generate_quarterly_summary( filepath = report_generation_service.generate_quarterly_summary(
db, output_format=format, db, output_format=format,
) )
# Return FileResponse( # Return FileResponse(
return FileResponse( return FileResponse(
_assert_safe_report_path(filepath), filepath,
# Keyword argument: media_type # Keyword argument: media_type
media_type=_MEDIA_TYPES[format], media_type=_MEDIA_TYPES[format],
# Keyword argument: filename # Keyword argument: filename
@@ -210,14 +181,13 @@ def generate_technique_report(
user: User = Depends(get_current_user), user: User = Depends(get_current_user),
) -> FileResponse: ) -> FileResponse:
"""Generate a detailed report for one MITRE technique.""" """Generate a detailed report for one MITRE technique."""
_assert_reports_enabled()
# Assign filepath = report_generation_service.generate_technique_detail_report( # Assign filepath = report_generation_service.generate_technique_detail_report(
filepath = report_generation_service.generate_technique_detail_report( filepath = report_generation_service.generate_technique_detail_report(
db, str(technique_id), output_format=format, db, str(technique_id), output_format=format,
) )
# Return FileResponse( # Return FileResponse(
return FileResponse( return FileResponse(
_assert_safe_report_path(filepath), filepath,
# Keyword argument: media_type # Keyword argument: media_type
media_type=_MEDIA_TYPES[format], media_type=_MEDIA_TYPES[format],
# Keyword argument: filename # Keyword argument: filename
-113
View File
@@ -1,113 +0,0 @@
"""Phase 12: Risk Intelligence router."""
from typing import List, Optional
from uuid import UUID
from fastapi import APIRouter, Depends, Query
from sqlalchemy.orm import Session
from app.database import get_db
from app.dependencies.auth import get_current_user, require_any_role
from app.schemas.risk_schema import (
TechniqueRiskProfileOut,
ComputeResult,
)
from app.services import risk_intelligence_service as svc
router = APIRouter(prefix="/risk", tags=["risk-intelligence"])
# ── Compute ──────────────────────────────────────────────────────────────────
@router.post("/compute", response_model=ComputeResult, status_code=202)
def compute_all(
db: Session = Depends(get_db),
user=Depends(require_any_role("admin", "red_lead", "blue_lead")),
):
"""Recompute risk scores for ALL techniques (admin / leads only)."""
result = svc.compute_all_risk_scores(db)
return result
@router.post("/profiles/{technique_id}/compute", response_model=TechniqueRiskProfileOut)
def compute_one(
technique_id: UUID,
db: Session = Depends(get_db),
user=Depends(get_current_user),
):
"""Compute (or refresh) the risk profile for a single technique."""
return svc.compute_technique_risk(db, technique_id)
# ── Read ─────────────────────────────────────────────────────────────────────
@router.get("/profiles", response_model=List[TechniqueRiskProfileOut])
def list_profiles(
risk_level: Optional[str] = None,
min_score: Optional[float] = None,
max_score: Optional[float] = None,
stale_only: bool = False,
limit: int = Query(100, ge=1, le=500),
offset: int = Query(0, ge=0),
db: Session = Depends(get_db),
user=Depends(get_current_user),
):
"""List risk profiles with optional filters."""
return svc.list_risk_profiles(
db,
risk_level=risk_level,
min_score=min_score,
max_score=max_score,
stale_only=stale_only,
limit=limit,
offset=offset,
)
@router.get("/profiles/{technique_id}", response_model=TechniqueRiskProfileOut)
def get_profile(
technique_id: UUID,
db: Session = Depends(get_db),
user=Depends(get_current_user),
):
"""Get the current risk profile for a technique."""
return svc.get_risk_profile(db, technique_id)
@router.get("/matrix")
def risk_matrix(
db: Session = Depends(get_db),
user=Depends(get_current_user),
):
"""All profiled techniques with likelihood/impact coordinates for matrix view."""
return svc.get_risk_matrix(db)
@router.get("/summary")
def risk_summary(
db: Session = Depends(get_db),
user=Depends(get_current_user),
):
"""Aggregate risk statistics: counts by level, average score, top risks."""
return svc.get_risk_summary(db)
@router.get("/recommendations")
def recommendations(
limit: int = Query(20, ge=1, le=100),
db: Session = Depends(get_db),
user=Depends(get_current_user),
):
"""Prioritised list of techniques with actionable recommendations."""
return svc.get_recommendations(db, limit=limit)
@router.get("/top")
def top_risks(
limit: int = Query(10, ge=1, le=50),
db: Session = Depends(get_db),
user=Depends(get_current_user),
):
"""Top N highest-risk techniques (sorted by risk score desc)."""
profiles = svc.list_risk_profiles(db, limit=limit)
return profiles
+2 -2
View File
@@ -165,7 +165,7 @@ def score_history(
db: Session = Depends(get_db), db: Session = Depends(get_db),
# Entry: current_user # Entry: current_user
current_user: User = Depends(get_current_user), current_user: User = Depends(get_current_user),
) -> list: ) -> dict:
"""Get historical score data points (weekly). """Get historical score data points (weekly).
Args: Args:
@@ -174,7 +174,7 @@ def score_history(
current_user (User): Authenticated user making the request. current_user (User): Authenticated user making the request.
Returns: Returns:
list: Weekly score data points for the requested period. dict: Weekly score data points for the requested period.
""" """
# Return get_score_history(db, period) # Return get_score_history(db, period)
return get_score_history(db, period) return get_score_history(db, period)

Some files were not shown because too many files have changed in this diff Show More