Compare commits
323 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 8fcd733d4a | |||
| 4692a8b93e | |||
| 7ec3c5bc8b | |||
| 8bdbe48fbe | |||
| fd94e55799 | |||
| f87959369b | |||
| bdc6579c10 | |||
| 92d65374ef | |||
| 9d66c30127 | |||
| c6bdded3f6 | |||
| 814cfa9671 | |||
| 6d6f87b968 | |||
| 4221d2858b | |||
| b48de743b6 | |||
| 8fe38dc661 | |||
| 0bb34f6834 | |||
| a3f86c7b31 | |||
| 337faf824d | |||
| 0fc2427f71 | |||
| be2a3fdced | |||
| 997b38d333 | |||
| 1535ddaa5d | |||
| a46aa157f3 | |||
| 5d22514f7f | |||
| 58c0143304 | |||
| f32f636f05 | |||
| 2afb886cd9 | |||
| 119db6f91d | |||
| d726e3adfe | |||
| 512b682cc5 | |||
| 8031682e9c | |||
| 0de9f4a6c0 | |||
| 4e31359fca | |||
| 226869d308 | |||
| adae7e617e | |||
| 4ddd7f9ec3 | |||
| ccc99439d5 | |||
| df49d336f7 | |||
| 0b6a664651 | |||
| 8cf8001de5 | |||
| 1c8fa436ab | |||
| 21c6febd22 | |||
| c234fd64c2 | |||
| b6da0e22c2 | |||
| 1ddbd6989f | |||
| bd26b09827 | |||
| fefe70baed | |||
| 1519ccc95c | |||
| a66fefa30b | |||
| b6b674d620 | |||
| 42b05379f7 | |||
| d6fbc2ffe8 | |||
| 949e5d42c4 | |||
| c0a88fc489 | |||
| 022c1c756a | |||
| 19c4866103 | |||
| f53e124c50 | |||
| 388c9773ab | |||
| d5e3df00f9 | |||
| 82033b5037 | |||
| 8a028bf0ed | |||
| b527eeac7d | |||
| e3cd75bb56 | |||
| 22be620665 | |||
| c41876b62f | |||
| 2d98ed5ec6 | |||
| 58d1da93ce | |||
| af65681179 | |||
| 164ef25393 | |||
| 83b4d2578a | |||
| aeaf1cf243 | |||
| 952b6f27d7 | |||
| b106780102 | |||
| 6b6375ddad | |||
| f19d10e19c | |||
| 858dd3e9e7 | |||
| 15ef555131 | |||
| 108743daa5 | |||
| 5bc71f677f | |||
| 959def2f49 | |||
| 6186c246a4 | |||
| 95b5ac50c6 | |||
| 4f5ffcf3f9 | |||
| 6749daa7cb | |||
| fd47db7bea | |||
| 12a5484003 | |||
| dc5f206bf5 | |||
| 6eadc7405b | |||
| 385d402a57 | |||
| 79b573fb60 | |||
| 1c91bd543d | |||
| f6eac1641c | |||
| a6b46f5f76 | |||
| 8e7f98301e | |||
| a113f3687c | |||
| 62e3bfebe8 | |||
| 74cb946317 | |||
| f1e0e0acf0 | |||
| 7613f5ec8b | |||
| 070b402358 | |||
| 6e29c7081f | |||
| 006bdc60e1 | |||
| d7ad981b89 | |||
| 7f990987de | |||
| cef082d0c4 | |||
| 2e45cf1be0 | |||
| 18695197e8 | |||
| bb8b9a6a72 | |||
| 4e71217dd7 | |||
| 4e1f35c250 | |||
| 6147f15238 | |||
| 30ca709c11 | |||
| a58f9fd357 | |||
| 263823f290 | |||
| 986682aad1 | |||
| f8824291a2 | |||
| 443a04befb | |||
| 88c2af472e | |||
| 8ba9790625 | |||
| af5b6e1cff | |||
| dcd4bebc92 | |||
| f54dc0d342 | |||
| acc9092baa | |||
| 6d3617938e | |||
| 709a810775 | |||
| cf33c69f95 | |||
| 392ce162dc | |||
| 5e8b5ee33c | |||
| ebf47c6142 | |||
| 0e2e9d0bb0 | |||
| 9472fe91fa | |||
| 675870b469 | |||
| 92f4bdcdce | |||
| 3ec51524d6 | |||
| 7ded48bdb7 | |||
| 6ca37f743f | |||
| cea518b33c | |||
| 22293804ab | |||
| 64cc438bcc | |||
| 8fea0c1ada | |||
| 98fddccd32 | |||
| f6d33638fd | |||
| 0001b33594 | |||
| a7725ba519 | |||
| 0c9f3051b4 | |||
| e2861a08bc | |||
| 467afc334d | |||
| b630cd3210 | |||
| c0cecab797 | |||
| 51d86e5436 | |||
| 8515b8de17 | |||
| b037500b7c | |||
| 6f835c8501 | |||
| 46ade20d14 | |||
| 5f54396cb6 | |||
| f4289249b8 | |||
| 6ab61c8ace | |||
| 725cf3406e | |||
| 564eb406aa | |||
| bf3add9b09 | |||
| 840e1ac0bb | |||
| b5f924abe0 | |||
| 6b1f5d690a | |||
| 27c67a5f76 | |||
| f605b52d89 | |||
| af864ed735 | |||
| 92e8ff7aff | |||
| 9fb84fa65c | |||
| cafd7db94b | |||
| 80991b2f59 | |||
| 200ef88d67 | |||
| fd39658f5d | |||
| 3b552dbe4e | |||
| 2ecb950770 | |||
| c141e5bb67 | |||
| 6c343bd7a1 | |||
| 643e65fbe5 | |||
| de0db3cec8 | |||
| cd2fe5aad6 | |||
| a1415a379f | |||
| 611689f3ce | |||
| 7d7d351ca8 | |||
| 33e6a1a3f4 | |||
| 399628e20a | |||
| 0531e7e73e | |||
| a1c67419e7 | |||
| e5e1779208 | |||
| f3c07fdaf1 | |||
| 6ceb4125a0 | |||
| 96b6d683a4 | |||
| 14aea87675 | |||
| 2624585e05 | |||
| aae032445c | |||
| 0eeca61de2 | |||
| 31c644d23f | |||
| 70d5274448 | |||
| 17f9d1078f | |||
| a33a13eca8 | |||
| b438dd0af0 | |||
| 5c5398683a | |||
| 62f5542ef2 | |||
| e82af44a6c | |||
| 546b5692f0 | |||
| ebe8eecb94 | |||
| aa3e08f9b6 | |||
| 6e1f51e0ff | |||
| abcc948513 | |||
| 1fd5e37bd0 | |||
| 865a7b6e0f | |||
| f0fe8be005 | |||
| 0e51af9cf7 | |||
| 6021f0801c | |||
| 98e0f27172 | |||
| 6af37030f4 | |||
| 6f1f09d74d | |||
| 857c793f31 | |||
| b60e5562c0 | |||
| a238b05ca8 | |||
| 5e748dbf80 | |||
| 104ea5c65b | |||
| 5a5f8a01e7 | |||
| 791407d02f | |||
| 940e575a65 | |||
| d761b46590 | |||
| 662d38423e | |||
| fa994801a5 | |||
| 6a4a153d59 | |||
| 069728a010 | |||
| 14e9b8b43a | |||
| d125b0c8e4 | |||
| 9a30c11413 | |||
| ab68542120 | |||
| 4b1ea7b9d2 | |||
| 4c3773de34 | |||
| ea453feea0 | |||
| 64cda5e608 | |||
| 8d71ee1da2 | |||
| 1c27e31101 | |||
| 366fc2170c | |||
| 7594a09b20 | |||
| 60e2a31046 | |||
| f0bd4b7e7d | |||
| 8d64905739 | |||
| 965ff96433 | |||
| 785b5b44a3 | |||
| 424eef70c5 | |||
| 322b6fcb62 | |||
| cf19a18810 | |||
| a48bd3c475 | |||
| 117600acea | |||
| 8b48716766 | |||
| 1a974265de | |||
| cd718512ad | |||
| 18df271d07 | |||
| c0a0e1aa00 | |||
| e6c188c782 | |||
| eac6d10639 | |||
| 7e9a5a35f6 | |||
| 76a76607b0 | |||
| dd9d817d5d | |||
| cd9bdc7399 | |||
| b5a81b69ed | |||
| aaff54f432 | |||
| e2b8e7e207 | |||
| 3b20911c93 | |||
| 851100d8ec | |||
| 06e45b098c | |||
| 8968382731 | |||
| e9a3985a1f | |||
| 3d8f445d1b | |||
| 8577588a21 | |||
| 001cefb882 | |||
| 1ce427db88 | |||
| 95b4c4ea65 | |||
| 174b7e8d24 | |||
| d307039a41 | |||
| aaa344ab79 | |||
| 1f08eb014b | |||
| e08d8a9beb | |||
| e4342e1c3f | |||
| d3831b8ed9 | |||
| 87af1735ce | |||
| f3109644cb | |||
| ee1c773073 | |||
| a294300052 | |||
| f72287984c | |||
| 8a51f98631 | |||
| 8e7ee1494e | |||
| 6f24d340d2 | |||
| da89a9ae51 | |||
| 6665efd276 | |||
| 37f2d6daa6 | |||
| f2787bf860 | |||
| 21ed939569 | |||
| 3c077f971e | |||
| d9292fb3ff | |||
| 6fad769c13 | |||
| d1443d1ffa | |||
| 9d0cb6d67d | |||
| 3f174e7d89 | |||
| 6c3f00f6e6 | |||
| ed579fb8f7 | |||
| 612dec7a93 | |||
| a138c7a8ed | |||
| 6c4517c7f3 | |||
| dd1f0e472f | |||
| 8c73377571 | |||
| ab50bcd90e | |||
| b78593ca10 | |||
| 0b81580b44 | |||
| 95b46a95a8 | |||
| b493f92f75 | |||
| 61c26ddd0f | |||
| 634abc289b | |||
| 519ddfb7a0 | |||
| 7009fcabbf | |||
| b714b466c8 | |||
| ca17675253 | |||
| 9552ba2f14 | |||
| c172a8af00 | |||
| 83b74c5262 | |||
| d6fce0bc4e | |||
| cdb5055193 |
@@ -1,189 +0,0 @@
|
||||
---
|
||||
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)
|
||||
@@ -0,0 +1,71 @@
|
||||
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
|
||||
@@ -60,3 +60,12 @@ Thumbs.db
|
||||
|
||||
# Local development
|
||||
*.local
|
||||
|
||||
# Documentation drafts — never commit, delivered directly in chat
|
||||
docs/confluence/
|
||||
docs/drafts/
|
||||
|
||||
# Editor / AI assistant working files — never commit
|
||||
.claude/
|
||||
.cursor/
|
||||
CLAUDE.md
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
# 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/**
|
||||
@@ -0,0 +1,2 @@
|
||||
skips:
|
||||
- B311
|
||||
+5
-1
@@ -3,10 +3,14 @@ FROM python:3.11-slim
|
||||
WORKDIR /app
|
||||
|
||||
# Install system dependencies
|
||||
RUN apt-get update && apt-get install -y \
|
||||
RUN apt-get update && apt-get upgrade -y && apt-get install -y \
|
||||
gcc \
|
||||
libpq-dev \
|
||||
curl \
|
||||
pkg-config \
|
||||
libxml2-dev \
|
||||
libxmlsec1-dev \
|
||||
libxmlsec1-openssl \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Copy requirements first for better caching
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
"""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")
|
||||
@@ -0,0 +1,41 @@
|
||||
"""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")
|
||||
@@ -0,0 +1,43 @@
|
||||
"""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")
|
||||
@@ -0,0 +1,174 @@
|
||||
"""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)
|
||||
@@ -0,0 +1,118 @@
|
||||
"""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"))
|
||||
@@ -0,0 +1,184 @@
|
||||
"""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"))
|
||||
@@ -0,0 +1,106 @@
|
||||
"""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"))
|
||||
@@ -0,0 +1,62 @@
|
||||
"""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"))
|
||||
@@ -0,0 +1,77 @@
|
||||
"""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"))
|
||||
@@ -0,0 +1,75 @@
|
||||
"""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"))
|
||||
@@ -0,0 +1,82 @@
|
||||
"""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"))
|
||||
@@ -0,0 +1,25 @@
|
||||
"""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")
|
||||
@@ -0,0 +1,28 @@
|
||||
"""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")
|
||||
@@ -0,0 +1,25 @@
|
||||
"""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")
|
||||
@@ -0,0 +1,16 @@
|
||||
"""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")
|
||||
@@ -0,0 +1,22 @@
|
||||
"""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
|
||||
@@ -0,0 +1,27 @@
|
||||
"""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")
|
||||
@@ -0,0 +1,39 @@
|
||||
"""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")
|
||||
@@ -0,0 +1,26 @@
|
||||
"""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")
|
||||
@@ -0,0 +1,24 @@
|
||||
"""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")
|
||||
@@ -0,0 +1,25 @@
|
||||
"""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")
|
||||
@@ -0,0 +1,31 @@
|
||||
"""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)
|
||||
@@ -0,0 +1,27 @@
|
||||
"""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")
|
||||
@@ -0,0 +1,36 @@
|
||||
"""Add approved_by, approved_at, rejection_reason to campaigns.
|
||||
|
||||
Revision ID: b054
|
||||
Revises: b053
|
||||
Create Date: 2026-07-02
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.dialects import postgresql
|
||||
|
||||
revision = "b054"
|
||||
down_revision = "b053"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column(
|
||||
"campaigns",
|
||||
sa.Column("approved_by", postgresql.UUID(as_uuid=True), nullable=True),
|
||||
)
|
||||
op.add_column("campaigns", sa.Column("approved_at", sa.DateTime(), nullable=True))
|
||||
op.add_column("campaigns", sa.Column("rejection_reason", sa.Text(), nullable=True))
|
||||
op.create_foreign_key(
|
||||
"fk_campaigns_approved_by_users",
|
||||
"campaigns", "users",
|
||||
["approved_by"], ["id"],
|
||||
ondelete="SET NULL",
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_constraint("fk_campaigns_approved_by_users", "campaigns", type_="foreignkey")
|
||||
op.drop_column("campaigns", "rejection_reason")
|
||||
op.drop_column("campaigns", "approved_at")
|
||||
op.drop_column("campaigns", "approved_by")
|
||||
@@ -0,0 +1,59 @@
|
||||
"""Add campaign_modification_requests table.
|
||||
|
||||
Revision ID: b055
|
||||
Revises: b054
|
||||
Create Date: 2026-07-02
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.dialects import postgresql
|
||||
|
||||
revision = "b055"
|
||||
down_revision = "b054"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"campaign_modification_requests",
|
||||
sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True),
|
||||
sa.Column(
|
||||
"campaign_id", postgresql.UUID(as_uuid=True),
|
||||
sa.ForeignKey("campaigns.id", ondelete="CASCADE"), nullable=False,
|
||||
),
|
||||
sa.Column(
|
||||
"requested_by", postgresql.UUID(as_uuid=True),
|
||||
sa.ForeignKey("users.id", ondelete="SET NULL"), nullable=True,
|
||||
),
|
||||
sa.Column("action", sa.String(), nullable=False),
|
||||
sa.Column(
|
||||
"test_id", postgresql.UUID(as_uuid=True),
|
||||
sa.ForeignKey("tests.id", ondelete="CASCADE"), nullable=False,
|
||||
),
|
||||
sa.Column("order_index", sa.Integer(), nullable=True),
|
||||
sa.Column("phase", sa.String(), nullable=True),
|
||||
sa.Column("justification", sa.Text(), nullable=False),
|
||||
sa.Column("status", sa.String(), nullable=False, server_default="pending"),
|
||||
sa.Column(
|
||||
"reviewed_by", postgresql.UUID(as_uuid=True),
|
||||
sa.ForeignKey("users.id", ondelete="SET NULL"), nullable=True,
|
||||
),
|
||||
sa.Column("reviewed_at", sa.DateTime(), nullable=True),
|
||||
sa.Column("review_notes", sa.Text(), nullable=True),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
|
||||
)
|
||||
op.create_index(
|
||||
"ix_campaign_mod_requests_campaign",
|
||||
"campaign_modification_requests", ["campaign_id"],
|
||||
)
|
||||
op.create_index(
|
||||
"ix_campaign_mod_requests_status",
|
||||
"campaign_modification_requests", ["status"],
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index("ix_campaign_mod_requests_status", table_name="campaign_modification_requests")
|
||||
op.drop_index("ix_campaign_mod_requests_campaign", table_name="campaign_modification_requests")
|
||||
op.drop_table("campaign_modification_requests")
|
||||
@@ -0,0 +1,50 @@
|
||||
"""Change campaign_modification_requests.test_id to nullable with ON DELETE SET NULL.
|
||||
|
||||
Approving a "remove_test" modification request deletes the underlying Test
|
||||
row. With the original ON DELETE CASCADE, that delete cascaded and wiped
|
||||
out the modification-request row itself, destroying the audit record
|
||||
(justification, reviewer, decision) the request exists to preserve.
|
||||
|
||||
Revision ID: b056
|
||||
Revises: b055
|
||||
Create Date: 2026-07-03
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
revision = "b056"
|
||||
down_revision = "b055"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
_FK_NAME = "campaign_modification_requests_test_id_fkey"
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.alter_column(
|
||||
"campaign_modification_requests", "test_id",
|
||||
existing_type=sa.dialects.postgresql.UUID(as_uuid=True),
|
||||
nullable=True,
|
||||
)
|
||||
op.drop_constraint(_FK_NAME, "campaign_modification_requests", type_="foreignkey")
|
||||
op.create_foreign_key(
|
||||
_FK_NAME,
|
||||
"campaign_modification_requests", "tests",
|
||||
["test_id"], ["id"],
|
||||
ondelete="SET NULL",
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_constraint(_FK_NAME, "campaign_modification_requests", type_="foreignkey")
|
||||
op.create_foreign_key(
|
||||
_FK_NAME,
|
||||
"campaign_modification_requests", "tests",
|
||||
["test_id"], ["id"],
|
||||
ondelete="CASCADE",
|
||||
)
|
||||
op.alter_column(
|
||||
"campaign_modification_requests", "test_id",
|
||||
existing_type=sa.dialects.postgresql.UUID(as_uuid=True),
|
||||
nullable=False,
|
||||
)
|
||||
@@ -0,0 +1,44 @@
|
||||
"""Add red_review, blue_review states and review-assignment columns to tests.
|
||||
|
||||
Revision ID: b057
|
||||
Revises: b056
|
||||
Create Date: 2026-07-06
|
||||
"""
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.dialects import postgresql
|
||||
|
||||
revision = "b057"
|
||||
down_revision = "b056"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.execute("ALTER TYPE teststate ADD VALUE IF NOT EXISTS 'red_review'")
|
||||
op.execute("ALTER TYPE teststate ADD VALUE IF NOT EXISTS 'blue_review'")
|
||||
|
||||
op.add_column("tests", sa.Column("red_reviewer_assignee", postgresql.UUID(as_uuid=True), sa.ForeignKey("users.id"), nullable=True))
|
||||
op.add_column("tests", sa.Column("red_review_by", postgresql.UUID(as_uuid=True), sa.ForeignKey("users.id"), nullable=True))
|
||||
op.add_column("tests", sa.Column("red_review_at", sa.DateTime(), nullable=True))
|
||||
op.add_column("tests", sa.Column("red_review_notes", sa.Text(), nullable=True))
|
||||
op.add_column("tests", sa.Column("blue_reviewer_assignee", postgresql.UUID(as_uuid=True), sa.ForeignKey("users.id"), nullable=True))
|
||||
op.add_column("tests", sa.Column("blue_review_by", postgresql.UUID(as_uuid=True), sa.ForeignKey("users.id"), nullable=True))
|
||||
op.add_column("tests", sa.Column("blue_review_at", sa.DateTime(), nullable=True))
|
||||
op.add_column("tests", sa.Column("blue_review_notes", sa.Text(), nullable=True))
|
||||
op.add_column("tests", sa.Column("system_gaps", sa.Text(), nullable=True))
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column("tests", "system_gaps")
|
||||
op.drop_column("tests", "blue_review_notes")
|
||||
op.drop_column("tests", "blue_review_at")
|
||||
op.drop_column("tests", "blue_review_by")
|
||||
op.drop_column("tests", "blue_reviewer_assignee")
|
||||
op.drop_column("tests", "red_review_notes")
|
||||
op.drop_column("tests", "red_review_at")
|
||||
op.drop_column("tests", "red_review_by")
|
||||
op.drop_column("tests", "red_reviewer_assignee")
|
||||
# PostgreSQL does not support removing enum values; downgrade is a no-op
|
||||
# for the enum part, matching b046_add_disputed_test_state.py.
|
||||
@@ -0,0 +1,48 @@
|
||||
"""Convert tests.attack_success from Boolean to a 3-value enum.
|
||||
|
||||
Revision ID: b058
|
||||
Revises: b057
|
||||
Create Date: 2026-07-06
|
||||
"""
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
revision = "b058"
|
||||
down_revision = "b057"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
_ENUM_NAME = "attacksuccessresult"
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
attack_success_enum = sa.Enum(
|
||||
"successful", "not_successful", "partially_successful", name=_ENUM_NAME
|
||||
)
|
||||
attack_success_enum.create(op.get_bind(), checkfirst=True)
|
||||
|
||||
op.add_column("tests", sa.Column("attack_success_new", attack_success_enum, nullable=True))
|
||||
|
||||
op.execute(
|
||||
"UPDATE tests SET attack_success_new = (CASE "
|
||||
"WHEN attack_success = true THEN 'successful' "
|
||||
"WHEN attack_success = false THEN 'not_successful' "
|
||||
f"ELSE NULL END)::{_ENUM_NAME}"
|
||||
)
|
||||
|
||||
op.drop_column("tests", "attack_success")
|
||||
op.alter_column("tests", "attack_success_new", new_column_name="attack_success")
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.add_column("tests", sa.Column("attack_success_bool", sa.Boolean(), nullable=True))
|
||||
op.execute(
|
||||
"UPDATE tests SET attack_success_bool = CASE "
|
||||
"WHEN attack_success = 'successful' THEN true "
|
||||
"WHEN attack_success = 'not_successful' THEN false "
|
||||
"ELSE NULL END"
|
||||
)
|
||||
op.drop_column("tests", "attack_success")
|
||||
op.alter_column("tests", "attack_success_bool", new_column_name="attack_success")
|
||||
sa.Enum(name=_ENUM_NAME).drop(op.get_bind(), checkfirst=True)
|
||||
@@ -0,0 +1,41 @@
|
||||
"""Rename data_classification values to match the org's real scheme.
|
||||
|
||||
public -> public_release, internal -> general_use, sensitive -> confidential.
|
||||
restricted is unchanged. Applies to tests, campaigns, and evidences tables
|
||||
(all plain String(20) columns — no native enum type to alter).
|
||||
|
||||
Revision ID: b059
|
||||
Revises: b058
|
||||
Create Date: 2026-07-07
|
||||
"""
|
||||
|
||||
from alembic import op
|
||||
|
||||
revision = "b059"
|
||||
down_revision = "b058"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
_TABLES = ("tests", "campaigns", "evidences")
|
||||
|
||||
_RENAMES_UP = {
|
||||
"public": "public_release",
|
||||
"internal": "general_use",
|
||||
"sensitive": "confidential",
|
||||
}
|
||||
|
||||
_RENAMES_DOWN = {v: k for k, v in _RENAMES_UP.items()}
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
for table in _TABLES:
|
||||
for old, new in _RENAMES_UP.items():
|
||||
op.execute(f"UPDATE {table} SET data_classification = '{new}' WHERE data_classification = '{old}'")
|
||||
op.alter_column(table, "data_classification", server_default="confidential")
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
for table in _TABLES:
|
||||
for new, old in _RENAMES_DOWN.items():
|
||||
op.execute(f"UPDATE {table} SET data_classification = '{old}' WHERE data_classification = '{new}'")
|
||||
op.alter_column(table, "data_classification", server_default="internal")
|
||||
@@ -0,0 +1,47 @@
|
||||
"""Rename data_classification values to match Jira's real Data Sensitivity field.
|
||||
|
||||
Jira's actual "Data Sensitivity" custom field (customfield_11814) has 6
|
||||
options — Public, General Use, Internal Use Only, Trusted People, Customer
|
||||
Content, PII — not the 4-tier scheme Aegis invented independently. This
|
||||
migration replicates Jira's scheme so ticket sync stops guessing at a
|
||||
mapping. Applies to tests, campaigns, and evidences tables (all plain
|
||||
String(20) columns — no native enum type to alter).
|
||||
|
||||
public_release -> public, confidential -> internal_use_only,
|
||||
restricted -> pii. general_use is unchanged.
|
||||
|
||||
Revision ID: b060
|
||||
Revises: b059
|
||||
Create Date: 2026-07-09
|
||||
"""
|
||||
|
||||
from alembic import op
|
||||
|
||||
revision = "b060"
|
||||
down_revision = "b059"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
_TABLES = ("tests", "campaigns", "evidences")
|
||||
|
||||
_RENAMES_UP = {
|
||||
"public_release": "public",
|
||||
"confidential": "internal_use_only",
|
||||
"restricted": "pii",
|
||||
}
|
||||
|
||||
_RENAMES_DOWN = {v: k for k, v in _RENAMES_UP.items()}
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
for table in _TABLES:
|
||||
for old, new in _RENAMES_UP.items():
|
||||
op.execute(f"UPDATE {table} SET data_classification = '{new}' WHERE data_classification = '{old}'")
|
||||
op.alter_column(table, "data_classification", server_default="internal_use_only")
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
for table in _TABLES:
|
||||
for new, old in _RENAMES_DOWN.items():
|
||||
op.execute(f"UPDATE {table} SET data_classification = '{old}' WHERE data_classification = '{new}'")
|
||||
op.alter_column(table, "data_classification", server_default="confidential")
|
||||
@@ -0,0 +1,64 @@
|
||||
"""Archive round data on reopen instead of overwriting it.
|
||||
|
||||
Reopening a test for rework used to reset the live procedure/result fields
|
||||
in place, losing the prior round's data (and clobbering the Phase Timeline,
|
||||
which read those same mutable columns). This adds an append-only
|
||||
``test_round_history`` table that snapshots a round's fields right before
|
||||
they get reset, plus per-team round counters on ``tests`` so each round is
|
||||
numbered.
|
||||
|
||||
Revision ID: b061
|
||||
Revises: b060
|
||||
Create Date: 2026-07-13
|
||||
"""
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
from sqlalchemy.dialects import postgresql
|
||||
|
||||
revision = "b061"
|
||||
down_revision = "b060"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column("tests", sa.Column("red_round_number", sa.Integer(), nullable=False, server_default="1"))
|
||||
op.add_column("tests", sa.Column("blue_round_number", sa.Integer(), nullable=False, server_default="1"))
|
||||
|
||||
op.create_table(
|
||||
"test_round_history",
|
||||
sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True),
|
||||
sa.Column("test_id", postgresql.UUID(as_uuid=True), sa.ForeignKey("tests.id"), nullable=False),
|
||||
sa.Column("team", sa.String(10), nullable=False),
|
||||
sa.Column("round_number", sa.Integer(), nullable=False),
|
||||
sa.Column("started_at", sa.DateTime(), nullable=True),
|
||||
sa.Column("ended_at", sa.DateTime(), nullable=True),
|
||||
sa.Column("paused_seconds", sa.Integer(), nullable=True),
|
||||
sa.Column("procedure_text", sa.Text(), nullable=True),
|
||||
sa.Column("tool_used", sa.String(), nullable=True),
|
||||
# Plain strings, not the shared Postgres enum types tests.* uses —
|
||||
# this is an archive table with no DB-level constraint needs, and
|
||||
# reusing those native type names here would make Alembic try to
|
||||
# (re)create them.
|
||||
sa.Column("attack_success", sa.String(), nullable=True),
|
||||
sa.Column("red_summary", sa.Text(), nullable=True),
|
||||
sa.Column("execution_start_time", sa.DateTime(), nullable=True),
|
||||
sa.Column("execution_end_time", sa.DateTime(), nullable=True),
|
||||
sa.Column("detection_result", sa.String(), nullable=True),
|
||||
sa.Column("containment_result", sa.String(), nullable=True),
|
||||
sa.Column("detection_time", sa.DateTime(), nullable=True),
|
||||
sa.Column("containment_time", sa.DateTime(), nullable=True),
|
||||
sa.Column("blue_summary", sa.Text(), nullable=True),
|
||||
sa.Column("review_notes", sa.Text(), nullable=True),
|
||||
sa.Column("reviewed_by", postgresql.UUID(as_uuid=True), sa.ForeignKey("users.id"), nullable=True),
|
||||
sa.Column("archived_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
|
||||
)
|
||||
op.create_index("ix_test_round_history_test_id", "test_round_history", ["test_id"])
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index("ix_test_round_history_test_id", table_name="test_round_history")
|
||||
op.drop_table("test_round_history")
|
||||
op.drop_column("tests", "blue_round_number")
|
||||
op.drop_column("tests", "red_round_number")
|
||||
@@ -0,0 +1,58 @@
|
||||
"""Add detect_procedure fields and procedure_suggestions review table.
|
||||
|
||||
Blue Team gets a "Detect Procedure" field (what they actually did to
|
||||
detect the attack) mirroring Red's procedure_text, plus a matching
|
||||
"suggested" field on test_templates. Commands extracted from either
|
||||
side's procedure text are proposed as template improvements via a new
|
||||
procedure_suggestions table, reviewed and approved/rejected by a lead
|
||||
rather than written automatically.
|
||||
|
||||
Revision ID: b062
|
||||
Revises: b061
|
||||
Create Date: 2026-07-14
|
||||
"""
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
from sqlalchemy.dialects import postgresql
|
||||
|
||||
revision = "b062"
|
||||
down_revision = "b061"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column("tests", sa.Column("detect_procedure", sa.Text(), nullable=True))
|
||||
op.add_column(
|
||||
"tests",
|
||||
sa.Column("source_template_id", postgresql.UUID(as_uuid=True), sa.ForeignKey("test_templates.id"), nullable=True),
|
||||
)
|
||||
op.add_column("test_templates", sa.Column("detect_suggested_procedure", sa.Text(), nullable=True))
|
||||
op.add_column("test_round_history", sa.Column("detect_procedure", sa.Text(), nullable=True))
|
||||
|
||||
op.create_table(
|
||||
"procedure_suggestions",
|
||||
sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True),
|
||||
sa.Column("template_id", postgresql.UUID(as_uuid=True), sa.ForeignKey("test_templates.id"), nullable=False),
|
||||
sa.Column("team", sa.String(10), nullable=False),
|
||||
sa.Column("suggested_text", sa.Text(), nullable=False),
|
||||
sa.Column("source_test_id", postgresql.UUID(as_uuid=True), sa.ForeignKey("tests.id"), nullable=True),
|
||||
sa.Column("submitted_by", postgresql.UUID(as_uuid=True), sa.ForeignKey("users.id"), nullable=True),
|
||||
sa.Column("status", sa.String(10), nullable=False, server_default="pending"),
|
||||
sa.Column("reviewed_by", postgresql.UUID(as_uuid=True), sa.ForeignKey("users.id"), nullable=True),
|
||||
sa.Column("reviewed_at", sa.DateTime(), nullable=True),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
|
||||
)
|
||||
op.create_index("ix_procedure_suggestions_template_id", "procedure_suggestions", ["template_id"])
|
||||
op.create_index("ix_procedure_suggestions_status", "procedure_suggestions", ["status"])
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index("ix_procedure_suggestions_status", table_name="procedure_suggestions")
|
||||
op.drop_index("ix_procedure_suggestions_template_id", table_name="procedure_suggestions")
|
||||
op.drop_table("procedure_suggestions")
|
||||
op.drop_column("test_round_history", "detect_procedure")
|
||||
op.drop_column("test_templates", "detect_suggested_procedure")
|
||||
op.drop_column("tests", "source_template_id")
|
||||
op.drop_column("tests", "detect_procedure")
|
||||
+24
-8
@@ -39,8 +39,7 @@ class Settings(BaseSettings):
|
||||
SECRET_KEY: str = ""
|
||||
# Assign ALGORITHM = "HS256"
|
||||
ALGORITHM: str = "HS256"
|
||||
# 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
|
||||
ACCESS_TOKEN_EXPIRE_MINUTES: int = 480 # 8 hours — /auth/refresh extends active sessions
|
||||
|
||||
# ── Redis ─────────────────────────────────────────────────────────
|
||||
REDIS_URL: str = "redis://redis:6379/0"
|
||||
@@ -57,7 +56,10 @@ class Settings(BaseSettings):
|
||||
|
||||
# ── MinIO / S3 ───────────────────────────────────────────────────
|
||||
MINIO_ENDPOINT: str = "minio:9000"
|
||||
# Assign MINIO_ACCESS_KEY = "minioadmin"
|
||||
# Public hostname used in presigned URLs returned to browsers.
|
||||
# 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"
|
||||
# Assign MINIO_SECRET_KEY = "minioadmin"
|
||||
MINIO_SECRET_KEY: str = "minioadmin"
|
||||
@@ -81,10 +83,11 @@ class Settings(BaseSettings):
|
||||
JIRA_IS_CLOUD: bool = True
|
||||
# Assign JIRA_DEFAULT_PROJECT = ""
|
||||
JIRA_DEFAULT_PROJECT: str = ""
|
||||
# Assign JIRA_ISSUE_TYPE_TEST = "Task"
|
||||
JIRA_ISSUE_TYPE_TEST: str = "Task"
|
||||
# Assign JIRA_ISSUE_TYPE_CAMPAIGN = "Epic"
|
||||
JIRA_ISSUE_TYPE_CAMPAIGN: str = "Epic"
|
||||
JIRA_ISSUE_TYPE_TEST: str = "Task" # tests (campaign or standalone)
|
||||
JIRA_ISSUE_TYPE_CAMPAIGN: str = "Epic" # campaigns (under Initiative)
|
||||
# Jira custom field ID for "Start date" — Jira Cloud team-managed: customfield_10015
|
||||
# Override with the correct field ID for your Jira instance if different.
|
||||
JIRA_START_DATE_FIELD: str = "customfield_10015"
|
||||
|
||||
# ── Tempo Integration ─────────────────────────────────────────────
|
||||
TEMPO_ENABLED: bool = False
|
||||
@@ -94,6 +97,9 @@ class Settings(BaseSettings):
|
||||
TEMPO_API_VERSION: int = 4
|
||||
# Assign TEMPO_DEFAULT_WORK_TYPE = "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 ────────────────────────────────────────
|
||||
NVD_API_KEY: str = "" # optional; increases NVD rate limit from 5/30s to 50/30s
|
||||
@@ -103,12 +109,22 @@ class Settings(BaseSettings):
|
||||
# ── Reporting ─────────────────────────────────────────────────────
|
||||
REPORT_TEMPLATES_DIR: str = "app/templates/reports"
|
||||
# Assign REPORT_OUTPUT_DIR = "/tmp/aegis_reports"
|
||||
REPORT_OUTPUT_DIR: str = "/tmp/aegis_reports"
|
||||
REPORT_OUTPUT_DIR: str = "/app/reports"
|
||||
# Assign COMPANY_NAME = "Organization"
|
||||
COMPANY_NAME: str = "Organization"
|
||||
# Assign COMPANY_LOGO_PATH = "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_WEIGHT_TESTS: int = 40
|
||||
# Assign SCORING_WEIGHT_DETECTION_RULES = 25
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
Provides:
|
||||
- ``get_current_user``: decodes JWT from HttpOnly cookie (preferred) or
|
||||
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
|
||||
(admins always pass).
|
||||
"""
|
||||
@@ -36,6 +37,7 @@ from app.database import get_db
|
||||
|
||||
# Import User from app.models.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)
|
||||
@@ -98,7 +100,15 @@ async def get_current_user(
|
||||
# Raise credentials_exception
|
||||
raise credentials_exception
|
||||
|
||||
# Attempt the following; catch errors below
|
||||
# ── API Key path (Bearer token starts with "aegis_") ──────────────────
|
||||
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:
|
||||
# Assign payload = jwt.decode(
|
||||
payload = jwt.decode(
|
||||
@@ -162,12 +172,27 @@ async def require_password_changed(
|
||||
return current_user
|
||||
|
||||
|
||||
# Define function require_role
|
||||
def require_role(required_role: str) -> Callable[..., object]:
|
||||
def _check_api_key_scope(user: User, required_scope: str) -> None:
|
||||
"""Raise 403 if the request was authenticated via an API key that lacks *required_scope*.
|
||||
|
||||
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*.
|
||||
|
||||
The dependency allows the request to proceed when
|
||||
``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**.
|
||||
"""
|
||||
|
||||
@@ -185,7 +210,8 @@ def require_role(required_role: str) -> Callable[..., object]:
|
||||
# Keyword argument: detail
|
||||
detail="Not enough permissions",
|
||||
)
|
||||
# Return current_user
|
||||
scope = "admin" if required_role == "admin" else "write"
|
||||
_check_api_key_scope(current_user, scope)
|
||||
return current_user
|
||||
|
||||
# Return role_checker
|
||||
@@ -196,7 +222,11 @@ def require_role(required_role: str) -> Callable[..., object]:
|
||||
def require_any_role(*roles: str) -> Callable[..., object]:
|
||||
"""Return a FastAPI dependency that enforces **any** of the given *roles*.
|
||||
|
||||
Admins always pass. Usage example::
|
||||
Admins always pass. Also enforces API key scopes: if the only accepted
|
||||
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"))])
|
||||
"""
|
||||
@@ -215,8 +245,52 @@ def require_any_role(*roles: str) -> Callable[..., object]:
|
||||
# Keyword argument: detail
|
||||
detail="Not enough permissions",
|
||||
)
|
||||
# Return current_user
|
||||
scope = "admin" if set(roles) == {"admin"} else "write"
|
||||
_check_api_key_scope(current_user, scope)
|
||||
return current_user
|
||||
|
||||
# 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
|
||||
|
||||
@@ -33,6 +33,8 @@ class CampaignStatus(str, enum.Enum):
|
||||
|
||||
# Assign draft = "draft"
|
||||
draft = "draft"
|
||||
# Assign pending_approval = "pending_approval"
|
||||
pending_approval = "pending_approval"
|
||||
# Assign active = "active"
|
||||
active = "active"
|
||||
# Assign completed = "completed"
|
||||
@@ -57,7 +59,8 @@ class CampaignType(str, enum.Enum):
|
||||
|
||||
# Assign VALID_TRANSITIONS = {
|
||||
VALID_TRANSITIONS: dict[CampaignStatus, list[CampaignStatus]] = {
|
||||
CampaignStatus.draft: [CampaignStatus.active],
|
||||
CampaignStatus.draft: [CampaignStatus.pending_approval, CampaignStatus.active],
|
||||
CampaignStatus.pending_approval: [CampaignStatus.active, CampaignStatus.draft],
|
||||
CampaignStatus.active: [CampaignStatus.completed],
|
||||
CampaignStatus.completed: [CampaignStatus.archived],
|
||||
CampaignStatus.archived: [],
|
||||
@@ -132,6 +135,49 @@ class CampaignEntity:
|
||||
# Assign 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
|
||||
def complete(self) -> None:
|
||||
"""Transition the campaign from ``active`` to ``completed``.
|
||||
|
||||
@@ -221,14 +221,17 @@ class TechniqueEntity:
|
||||
) -> TechniqueStatus:
|
||||
"""Recompute ``status_global`` from a list of (state, detection_result) pairs.
|
||||
|
||||
Rules (v2):
|
||||
Rules (v3):
|
||||
1. No tests -> not_evaluated
|
||||
2. All validated -> inspect detection results:
|
||||
- All detected -> validated
|
||||
- Any partially_detected -> partial
|
||||
- Otherwise -> not_covered
|
||||
3. Some validated, others in progress -> partial
|
||||
4. All in intermediate states -> in_progress
|
||||
2. All tests validated -> inspect detection results:
|
||||
a. All detected AND ≥ 1 validated test -> validated
|
||||
b. Any partially_detected -> partial
|
||||
d. Otherwise (no detected results) -> not_covered
|
||||
3. Some validated, others in intermediate states -> partial
|
||||
4. All tests in intermediate states (draft/executing/evaluating/review/rejected)
|
||||
-> in_progress
|
||||
|
||||
Minimum validated count for "validated": 1 test.
|
||||
|
||||
Args:
|
||||
test_snapshots (list[tuple[str, str | None]]): Each element is a
|
||||
@@ -240,7 +243,8 @@ class TechniqueEntity:
|
||||
TechniqueStatus: The newly computed status, which is also stored on
|
||||
the entity's ``status_global`` field.
|
||||
"""
|
||||
# Assign tests = [
|
||||
min_validated_for_full = 1 # require ≥ N validated tests for "validated"
|
||||
|
||||
tests = [
|
||||
_TestSnapshot(
|
||||
# Keyword argument: state
|
||||
@@ -257,21 +261,23 @@ class TechniqueEntity:
|
||||
self.status_global = TechniqueStatus.not_evaluated
|
||||
# Alternative: all(t.state == TestState.validated for t in tests)
|
||||
elif all(t.state == TestState.validated for t in tests):
|
||||
# Assign results = [t.detection_result for t in tests if t.detection_result]
|
||||
validated_count = len(tests)
|
||||
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...
|
||||
if results and all(r == TestResult.detected or r == "detected" for r in results):
|
||||
# Assign self.status_global = TechniqueStatus.validated
|
||||
self.status_global = TechniqueStatus.validated
|
||||
# elif any(
|
||||
# Need at least min_validated_for_full tests for "validated"
|
||||
if validated_count >= min_validated_for_full:
|
||||
self.status_global = TechniqueStatus.validated
|
||||
else:
|
||||
self.status_global = TechniqueStatus.partial
|
||||
elif any(
|
||||
# Keyword argument: r
|
||||
r == TestResult.partially_detected or r == "partially_detected"
|
||||
r in (TestResult.detected, "detected",
|
||||
TestResult.partially_detected, "partially_detected")
|
||||
for r in results
|
||||
):
|
||||
# Assign self.status_global = TechniqueStatus.partial
|
||||
# Mix of detected + not_detected, or any partially_detected → partial
|
||||
self.status_global = TechniqueStatus.partial
|
||||
# Fallback: handle remaining cases
|
||||
# Fallback: handle remaining cases (all not_detected)
|
||||
else:
|
||||
# Assign self.status_global = TechniqueStatus.not_covered
|
||||
self.status_global = TechniqueStatus.not_covered
|
||||
|
||||
@@ -35,14 +35,19 @@ class TestState(str, enum.Enum):
|
||||
draft = "draft"
|
||||
# Assign 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"
|
||||
blue_evaluating = "blue_evaluating"
|
||||
# Blue Lead reviews the operator's work before cross-validation
|
||||
blue_review = "blue_review"
|
||||
# Assign in_review = "in_review"
|
||||
in_review = "in_review"
|
||||
# Assign validated = "validated"
|
||||
validated = "validated"
|
||||
# Assign rejected = "rejected"
|
||||
rejected = "rejected"
|
||||
disputed = "disputed" # one lead approved, the other rejected
|
||||
|
||||
|
||||
# Define class TeamSide
|
||||
@@ -67,15 +72,40 @@ class TestResult(str, enum.Enum):
|
||||
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
|
||||
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"
|
||||
# Assign internal = "internal"
|
||||
internal = "internal"
|
||||
# Assign sensitive = "sensitive"
|
||||
sensitive = "sensitive"
|
||||
# Assign restricted = "restricted"
|
||||
restricted = "restricted"
|
||||
general_use = "general_use"
|
||||
internal_use_only = "internal_use_only"
|
||||
trusted_people = "trusted_people"
|
||||
customer_content = "customer_content"
|
||||
pii = "pii"
|
||||
|
||||
@@ -60,22 +60,33 @@ class TestState(str, enum.Enum):
|
||||
draft = "draft"
|
||||
# Assign 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"
|
||||
blue_evaluating = "blue_evaluating"
|
||||
# Blue Lead reviews the operator's work before cross-validation
|
||||
blue_review = "blue_review"
|
||||
# Assign in_review = "in_review"
|
||||
in_review = "in_review"
|
||||
# Assign validated = "validated"
|
||||
validated = "validated"
|
||||
# Assign rejected = "rejected"
|
||||
rejected = "rejected"
|
||||
disputed = "disputed" # one lead approved, the other rejected
|
||||
|
||||
|
||||
# Assign VALID_TRANSITIONS = {
|
||||
VALID_TRANSITIONS: dict[TestState, list[TestState]] = {
|
||||
TestState.draft: [TestState.red_executing],
|
||||
TestState.red_executing: [TestState.blue_evaluating],
|
||||
TestState.blue_evaluating: [TestState.in_review],
|
||||
TestState.in_review: [TestState.validated, TestState.rejected],
|
||||
TestState.red_executing: [TestState.red_review],
|
||||
TestState.red_review: [TestState.blue_evaluating, TestState.red_executing],
|
||||
TestState.blue_evaluating: [TestState.blue_review],
|
||||
TestState.blue_review: [TestState.in_review, TestState.blue_evaluating],
|
||||
TestState.in_review: [TestState.validated, TestState.rejected, TestState.disputed],
|
||||
TestState.disputed: [
|
||||
TestState.validated, TestState.rejected,
|
||||
TestState.red_executing, TestState.blue_evaluating,
|
||||
],
|
||||
TestState.rejected: [TestState.draft],
|
||||
TestState.validated: [],
|
||||
}
|
||||
@@ -361,10 +372,12 @@ class TestEntity:
|
||||
|
||||
# Define function submit_red_evidence
|
||||
def submit_red_evidence(self) -> int:
|
||||
"""Transition the test from ``red_executing`` to ``blue_evaluating``.
|
||||
"""Transition the test from ``red_executing`` to ``red_review``.
|
||||
|
||||
Auto-resumes if paused. Returns paused seconds accumulated
|
||||
during this phase (for worklog calculation).
|
||||
during this phase (for worklog calculation). The Blue Team queue
|
||||
timer does not start yet — that happens in ``approve_red_review``,
|
||||
once the Red Lead actually releases the test to Blue Team.
|
||||
|
||||
Returns:
|
||||
int: Total seconds the red phase was paused.
|
||||
@@ -372,13 +385,9 @@ class TestEntity:
|
||||
# Assign paused_extra = self._auto_resume()
|
||||
paused_extra = self._auto_resume()
|
||||
# Call self._transition()
|
||||
self._transition(TestState.blue_evaluating)
|
||||
self._transition(TestState.red_review)
|
||||
# Assign 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()
|
||||
self._events.append(DomainEvent(
|
||||
# Literal argument value
|
||||
@@ -388,9 +397,36 @@ class TestEntity:
|
||||
# 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
|
||||
def submit_blue_evidence(self) -> int:
|
||||
"""Transition the test from ``blue_evaluating`` to ``in_review``.
|
||||
"""Transition the test from ``blue_evaluating`` to ``blue_review``.
|
||||
|
||||
Auto-resumes if paused. Returns paused seconds accumulated
|
||||
during this phase (for worklog calculation).
|
||||
@@ -401,7 +437,7 @@ class TestEntity:
|
||||
# Assign paused_extra = self._auto_resume()
|
||||
paused_extra = self._auto_resume()
|
||||
# Call self._transition()
|
||||
self._transition(TestState.in_review)
|
||||
self._transition(TestState.blue_review)
|
||||
# Assign total_paused = self.blue_paused_seconds + paused_extra
|
||||
total_paused = self.blue_paused_seconds + paused_extra
|
||||
# Call self._events.append()
|
||||
@@ -413,6 +449,39 @@ class TestEntity:
|
||||
# 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
|
||||
def pause_timer(self) -> None:
|
||||
"""Pause the active phase timer.
|
||||
@@ -565,6 +634,44 @@ class TestEntity:
|
||||
# Call self._events.append()
|
||||
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}))
|
||||
|
||||
# -- Private -------------------------------------------------------
|
||||
|
||||
def _auto_resume(self) -> int:
|
||||
@@ -591,37 +698,23 @@ class TestEntity:
|
||||
def check_dual_validation(self) -> None:
|
||||
"""Evaluate both leads' votes and advance state if appropriate.
|
||||
|
||||
- Both **approved** -> ``validated``
|
||||
- Either **rejected** -> ``rejected``
|
||||
- Otherwise no change (waiting for the other lead).
|
||||
Rules (v2 — consensus required):
|
||||
- Both **approved** -> ``validated``
|
||||
- Both **rejected** -> ``rejected``
|
||||
- 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`.
|
||||
Also available as a standalone entry point for backward compatibility
|
||||
when validation fields are set externally.
|
||||
|
||||
Returns:
|
||||
None
|
||||
"""
|
||||
# Call self._check_dual_validation()
|
||||
self._check_dual_validation()
|
||||
|
||||
# Define function _assert_in_review
|
||||
def _assert_in_review(self, side: str) -> None:
|
||||
"""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
|
||||
if self.state not in (TestState.in_review, TestState.disputed):
|
||||
raise InvalidOperationError(
|
||||
f"Cannot validate {side} side while test is in "
|
||||
f"'{self.state.value}' state (must be in_review)"
|
||||
f"'{self.state.value}' state (must be in_review or disputed)"
|
||||
)
|
||||
|
||||
# Apply the @staticmethod decorator
|
||||
@@ -646,22 +739,31 @@ class TestEntity:
|
||||
|
||||
# Define function _check_dual_validation
|
||||
def _check_dual_validation(self) -> None:
|
||||
"""Advance to ``validated`` or ``rejected`` once both leads have voted.
|
||||
"""Advance the test state once enough leads have voted.
|
||||
|
||||
Returns:
|
||||
None
|
||||
A genuine conflict (one lead's *recorded* decision disagreeing with
|
||||
the other's) routes to ``disputed`` — there are two opinions to
|
||||
reconcile. A lone rejection while the other side hasn't voted yet
|
||||
isn't a conflict (nothing to disagree with), so it still vetoes
|
||||
straight to ``rejected`` without waiting for the second vote.
|
||||
"""
|
||||
# r, b = self.red_validation_status, self.blue_validation_status
|
||||
r, b = self.red_validation_status, self.blue_validation_status
|
||||
# Check: r == "rejected" or b == "rejected"
|
||||
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
|
||||
|
||||
if r == "approved" and b == "approved":
|
||||
self.state = TestState.validated
|
||||
# Call self._events.append()
|
||||
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"))
|
||||
|
||||
@@ -12,6 +12,7 @@ sessions.
|
||||
|
||||
# Import logging
|
||||
import logging
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
# Import BackgroundScheduler from apscheduler.schedulers.background
|
||||
from apscheduler.schedulers.background import BackgroundScheduler
|
||||
@@ -63,7 +64,7 @@ scheduler = BackgroundScheduler()
|
||||
|
||||
def _run_mitre_sync() -> None:
|
||||
"""Execute a MITRE sync inside its own DB session."""
|
||||
# Log info: "Scheduled MITRE sync job starting..."
|
||||
from app.services.webhook_service import dispatch_webhook
|
||||
logger.info("Scheduled MITRE sync job starting...")
|
||||
# Assign db = SessionLocal()
|
||||
db = SessionLocal()
|
||||
@@ -73,7 +74,7 @@ def _run_mitre_sync() -> None:
|
||||
summary = sync_mitre(db)
|
||||
# Log info: "Scheduled MITRE sync job finished — %s", summary
|
||||
logger.info("Scheduled MITRE sync job finished — %s", summary)
|
||||
# Handle Exception
|
||||
dispatch_webhook("mitre.synced", {"created": summary.get("created", 0), "updated": summary.get("updated", 0)})
|
||||
except Exception:
|
||||
# Log exception: "Scheduled MITRE sync job failed"
|
||||
logger.exception("Scheduled MITRE sync job failed")
|
||||
@@ -163,7 +164,6 @@ def _run_recurring_campaigns() -> None:
|
||||
db.close()
|
||||
|
||||
|
||||
# Define function _run_intel_scan
|
||||
def _run_intel_scan() -> None:
|
||||
"""Execute an intel scan inside its own DB session."""
|
||||
# Log info: "Scheduled intel scan job starting..."
|
||||
@@ -186,7 +186,83 @@ def _run_intel_scan() -> None:
|
||||
db.close()
|
||||
|
||||
|
||||
# Define function _run_osint_enrichment
|
||||
def _run_evaluation_round_check() -> None:
|
||||
"""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:
|
||||
"""Execute weekly OSINT enrichment inside its own DB session."""
|
||||
# Log info: "Scheduled OSINT enrichment job starting..."
|
||||
@@ -209,7 +285,61 @@ def _run_osint_enrichment() -> None:
|
||||
db.close()
|
||||
|
||||
|
||||
# Define function _run_stale_detection
|
||||
_FREQUENCY_INTERVALS: dict[str, timedelta] = {
|
||||
"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:
|
||||
"""Execute daily stale coverage detection inside its own DB session."""
|
||||
# Log info: "Scheduled stale coverage detection starting..."
|
||||
@@ -232,6 +362,53 @@ def _run_stale_detection() -> None:
|
||||
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
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -307,7 +484,6 @@ def start_scheduler() -> None:
|
||||
# Keyword argument: replace_existing
|
||||
replace_existing=True,
|
||||
)
|
||||
# Call scheduler.add_job()
|
||||
scheduler.add_job(
|
||||
_run_recurring_campaigns,
|
||||
# Keyword argument: trigger
|
||||
@@ -377,7 +553,50 @@ def start_scheduler() -> None:
|
||||
# Keyword argument: replace_existing
|
||||
replace_existing=True,
|
||||
)
|
||||
# Call scheduler.start()
|
||||
scheduler.add_job(
|
||||
_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()
|
||||
# Log info:
|
||||
logger.info(
|
||||
@@ -389,6 +608,6 @@ def start_scheduler() -> None:
|
||||
"recurring_campaigns (daily), jira_sync (1h), "
|
||||
# Literal argument value
|
||||
"osint_enrichment (weekly), stale_detection (daily), "
|
||||
# Literal argument value
|
||||
"retention_policies (daily)"
|
||||
"retention_policies (daily), data_sources_sync (6h), "
|
||||
"alert_evaluation (1h), attck_evaluation_check (Mondays 06:00)"
|
||||
)
|
||||
|
||||
+91
-89
@@ -38,10 +38,46 @@ from slowapi.errors import RateLimitExceeded
|
||||
# Import SQLAlchemyError from sqlalchemy.exc
|
||||
from sqlalchemy.exc import SQLAlchemyError
|
||||
|
||||
# Import settings as _settings from app.config
|
||||
from app.config import settings as _settings
|
||||
|
||||
# Import DomainError from app.domain.errors
|
||||
from app.routers import auth as auth_router
|
||||
from app.routers import techniques as techniques_router
|
||||
from app.routers import tests as tests_router
|
||||
from app.routers import evidence as evidence_router
|
||||
from app.routers import test_templates as test_templates_router
|
||||
from app.routers import procedure_suggestions as procedure_suggestions_router
|
||||
from app.routers import 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
|
||||
|
||||
# Import scheduler, start_scheduler from app.jobs.mitre_sync_job
|
||||
@@ -58,94 +94,15 @@ from app.middleware.error_handler import domain_exception_handler
|
||||
|
||||
# Import RequestContextMiddleware from app.middleware.request_context
|
||||
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.config import settings as _settings
|
||||
from starlette.middleware.base import BaseHTTPMiddleware
|
||||
|
||||
# Configure structured logging before any module initialises its own logger
|
||||
setup_logging()
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# ── Environment detection ─────────────────────────────────────────────────
|
||||
_IS_PRODUCTION = os.environ.get("AEGIS_ENV", "").lower() == "production"
|
||||
|
||||
@@ -165,7 +122,25 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
|
||||
ensure_bucket_exists()
|
||||
# Call start_scheduler()
|
||||
start_scheduler()
|
||||
# Yield value
|
||||
# Seed decay policies
|
||||
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
|
||||
# Graceful shutdown of the background scheduler
|
||||
scheduler.shutdown(wait=False)
|
||||
@@ -193,6 +168,21 @@ app.add_exception_handler(RateLimitExceeded, _rate_limit_exceeded_handler)
|
||||
# Call app.add_middleware()
|
||||
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 ──────────────────────────────────────
|
||||
app.add_exception_handler(DomainError, domain_exception_handler)
|
||||
|
||||
@@ -224,6 +214,7 @@ app.include_router(tests_router.router, prefix="/api/v1")
|
||||
app.include_router(evidence_router.router, prefix="/api/v1")
|
||||
# Call app.include_router()
|
||||
app.include_router(test_templates_router.router, prefix="/api/v1")
|
||||
app.include_router(procedure_suggestions_router.router, prefix="/api/v1")
|
||||
# Call app.include_router()
|
||||
app.include_router(system_router.router, prefix="/api/v1")
|
||||
# Call app.include_router()
|
||||
@@ -254,7 +245,8 @@ app.include_router(scores_router.router, prefix="/api/v1")
|
||||
app.include_router(operational_metrics_router.router, prefix="/api/v1")
|
||||
# Call app.include_router()
|
||||
app.include_router(compliance_router.router, prefix="/api/v1")
|
||||
# Call app.include_router()
|
||||
app.include_router(intel_router.router, prefix="/api/v1")
|
||||
app.include_router(admin_config_router.router, prefix="/api/v1")
|
||||
app.include_router(snapshots_router.router, prefix="/api/v1")
|
||||
# Call app.include_router()
|
||||
app.include_router(jira_router.router, prefix="/api/v1")
|
||||
@@ -268,6 +260,16 @@ app.include_router(analytics_router.router, prefix="/api/v1")
|
||||
app.include_router(advanced_metrics_router.router, prefix="/api/v1")
|
||||
# Call app.include_router()
|
||||
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
|
||||
|
||||
@@ -1,73 +1,52 @@
|
||||
"""SQLAlchemy ORM model definitions for all database tables."""
|
||||
# Import all models here so Alembic can detect them
|
||||
from app.models.audit import AuditLog
|
||||
|
||||
# Import Campaign, CampaignTest from app.models.campaign
|
||||
from app.models.campaign import Campaign, CampaignTest
|
||||
|
||||
# Import from app.models.compliance
|
||||
from app.models.compliance import (
|
||||
ComplianceControl,
|
||||
ComplianceControlMapping,
|
||||
ComplianceFramework,
|
||||
)
|
||||
|
||||
# Import CoverageSnapshot, SnapshotTechniqueState from app.models.coverage_snapshot
|
||||
from app.models.coverage_snapshot import CoverageSnapshot, SnapshotTechniqueState
|
||||
|
||||
# Import DataSource from app.models.data_source
|
||||
from app.models.data_source import DataSource
|
||||
|
||||
# Import DefensiveTechnique, DefensiveTechniqueMapping from app.models.defensive_technique
|
||||
from app.models.defensive_technique import DefensiveTechnique, DefensiveTechniqueMapping
|
||||
|
||||
# Import DetectionRule from app.models.detection_rule
|
||||
from app.models.detection_rule import DetectionRule
|
||||
|
||||
# Import TeamSide, TechniqueStatus, TestResult, TestState from app.models.enums
|
||||
from app.models.enums import TeamSide, TechniqueStatus, TestResult, TestState
|
||||
|
||||
# Import Evidence from app.models.evidence
|
||||
from app.models.evidence import Evidence
|
||||
|
||||
# Import IntelItem from app.models.intel
|
||||
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
|
||||
|
||||
# Import Test from app.models.test
|
||||
from app.models.test import Test
|
||||
|
||||
# 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
|
||||
|
||||
# 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.data_source import DataSource
|
||||
from app.models.detection_rule import DetectionRule
|
||||
from app.models.threat_actor import ThreatActor, ThreatActorTechnique
|
||||
|
||||
# Import User from app.models.user
|
||||
from app.models.user import User
|
||||
|
||||
# Import Worklog from app.models.worklog
|
||||
from app.models.defensive_technique import DefensiveTechnique, DefensiveTechniqueMapping
|
||||
from app.models.test_template_detection_rule import TestTemplateDetectionRule
|
||||
from app.models.test_detection_result import TestDetectionResult
|
||||
from app.models.campaign import Campaign, CampaignTest, CampaignModificationRequest
|
||||
from app.models.compliance import ComplianceFramework, ComplianceControl, ComplianceControlMapping
|
||||
from app.models.coverage_snapshot import CoverageSnapshot, SnapshotTechniqueState
|
||||
from app.models.jira_link import JiraLink, JiraLinkEntityType, JiraSyncDirection
|
||||
from app.models.worklog import Worklog
|
||||
from app.models.osint_item import OsintItem
|
||||
from app.models.scoring_config import ScoringConfig
|
||||
from app.models.enums import TechniqueStatus, TestState, TestResult, TeamSide
|
||||
from app.models.webhook_config import WebhookConfig
|
||||
from app.models.system_config import SystemConfig
|
||||
from app.models.detection_lifecycle import (
|
||||
DetectionAsset, DetectionTechniqueMapping, DetectionValidation,
|
||||
TechniqueConfidenceScore, InfrastructureChangeLog,
|
||||
DetectionConfidence, DetectionHealthStatus, InvalidationReason,
|
||||
)
|
||||
from app.models.decay_policy import DecayPolicy
|
||||
from app.models.ownership_queue import (
|
||||
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.intel import IntelItem
|
||||
from app.models.technique import Technique
|
||||
from app.models.test import Test
|
||||
from app.models.test_round_history import TestRoundHistory
|
||||
from app.models.test_template import TestTemplate
|
||||
from app.models.procedure_suggestion import ProcedureSuggestion
|
||||
from app.models.user import User
|
||||
|
||||
# Assign __all__ = [
|
||||
__all__ = [
|
||||
@@ -82,7 +61,7 @@ __all__ = [
|
||||
# Literal argument value
|
||||
"TestTemplateDetectionRule", "TestDetectionResult",
|
||||
# Literal argument value
|
||||
"Campaign", "CampaignTest",
|
||||
"Campaign", "CampaignTest", "CampaignModificationRequest",
|
||||
# Literal argument value
|
||||
"ComplianceFramework", "ComplianceControl", "ComplianceControlMapping",
|
||||
# Literal argument value
|
||||
@@ -93,4 +72,22 @@ __all__ = [
|
||||
"Worklog", "OsintItem", "ScoringConfig",
|
||||
# Literal argument value
|
||||
"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",
|
||||
]
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
"""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"),
|
||||
)
|
||||
@@ -0,0 +1,253 @@
|
||||
"""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.0–1.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"),
|
||||
)
|
||||
@@ -73,8 +73,15 @@ class Campaign(Base):
|
||||
# Keyword argument: nullable
|
||||
nullable=True,
|
||||
)
|
||||
# Assign scheduled_at = Column(DateTime, nullable=True)
|
||||
start_date = Column(DateTime, nullable=True) # campaign won't activate before this date
|
||||
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)
|
||||
completed_at = Column(DateTime, nullable=True)
|
||||
# Assign target_platform = Column(String, nullable=True)
|
||||
@@ -83,8 +90,8 @@ class Campaign(Base):
|
||||
tags = Column(JSONB, nullable=True, default=[])
|
||||
# Assign created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||
# Assign data_classification = Column(String(20), nullable=False, server_default="internal")
|
||||
data_classification = Column(String(20), nullable=False, server_default="internal")
|
||||
# Assign data_classification = Column(String(20), nullable=False, server_default="internal_use_only")
|
||||
data_classification = Column(String(20), nullable=False, server_default="internal_use_only")
|
||||
|
||||
# Recurring scheduling fields
|
||||
is_recurring = Column(Boolean, default=False)
|
||||
@@ -229,3 +236,57 @@ class CampaignTest(Base):
|
||||
Index('ix_campaign_tests_campaign', 'campaign_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"),
|
||||
)
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
"""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)
|
||||
@@ -0,0 +1,168 @@
|
||||
"""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)
|
||||
@@ -7,6 +7,8 @@ working with ``from app.models.enums import ...``.
|
||||
|
||||
# Import # noqa: F401 from app.domain.enums
|
||||
from app.domain.enums import ( # noqa: F401
|
||||
AttackSuccessResult,
|
||||
ContainmentResult,
|
||||
DataClassification,
|
||||
TeamSide,
|
||||
TechniqueStatus,
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
"""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"),
|
||||
)
|
||||
@@ -50,8 +50,8 @@ class Evidence(Base):
|
||||
team = Column(Enum(TeamSide, name="teamside"), nullable=False, default=TeamSide.red)
|
||||
# Assign notes = Column(Text, nullable=True)
|
||||
notes = Column(Text, nullable=True)
|
||||
# Assign data_classification = Column(String(20), nullable=False, server_default="internal")
|
||||
data_classification = Column(String(20), nullable=False, server_default="internal")
|
||||
# Assign data_classification = Column(String(20), nullable=False, server_default="internal_use_only")
|
||||
data_classification = Column(String(20), nullable=False, server_default="internal_use_only")
|
||||
|
||||
# Relationships
|
||||
test = relationship("Test", back_populates="evidences")
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
"""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) # 0–100
|
||||
|
||||
# ── 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"),
|
||||
)
|
||||
@@ -0,0 +1,129 @@
|
||||
"""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"),
|
||||
)
|
||||
@@ -0,0 +1,144 @@
|
||||
"""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"),
|
||||
)
|
||||
@@ -0,0 +1,136 @@
|
||||
"""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)
|
||||
@@ -0,0 +1,41 @@
|
||||
"""SQLAlchemy model for procedure-improvement suggestions.
|
||||
|
||||
When an operator submits a round with a filled-in procedure field
|
||||
(``procedure_text`` for Red, ``detect_procedure`` for Blue), the command(s)
|
||||
in it are extracted heuristically and proposed as an update to the
|
||||
originating template's suggested-procedure field. Nothing is written to
|
||||
the template automatically — a lead reviews and approves or rejects each
|
||||
suggestion, so a junior who later picks up the same template only ever
|
||||
sees vetted guidance.
|
||||
"""
|
||||
|
||||
import uuid
|
||||
|
||||
from sqlalchemy import Column, DateTime, ForeignKey, String, Text, func
|
||||
from sqlalchemy.dialects.postgresql import UUID
|
||||
from sqlalchemy.orm import relationship
|
||||
|
||||
from app.database import Base
|
||||
|
||||
|
||||
class ProcedureSuggestion(Base):
|
||||
"""A proposed update to a TestTemplate's suggested procedure, pending lead review."""
|
||||
|
||||
__tablename__ = "procedure_suggestions"
|
||||
|
||||
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
template_id = Column(UUID(as_uuid=True), ForeignKey("test_templates.id"), nullable=False)
|
||||
team = Column(String(10), nullable=False) # "red" or "blue"
|
||||
suggested_text = Column(Text, nullable=False)
|
||||
source_test_id = Column(UUID(as_uuid=True), ForeignKey("tests.id"), nullable=True)
|
||||
submitted_by = Column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=True)
|
||||
|
||||
status = Column(String(10), nullable=False, default="pending", server_default="pending") # pending/approved/rejected
|
||||
reviewed_by = Column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=True)
|
||||
reviewed_at = Column(DateTime, nullable=True)
|
||||
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||
|
||||
template = relationship("TestTemplate", foreign_keys=[template_id])
|
||||
source_test = relationship("Test", foreign_keys=[source_test_id])
|
||||
submitter = relationship("User", foreign_keys=[submitted_by])
|
||||
reviewer = relationship("User", foreign_keys=[reviewed_by])
|
||||
@@ -0,0 +1,69 @@
|
||||
"""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 → 0–100
|
||||
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) # 0–100
|
||||
likelihood = Column(Float, nullable=False, default=0.0) # 0–100
|
||||
impact = Column(Float, nullable=False, default=0.0) # 0–100
|
||||
risk_level = Column(String(16), nullable=False, default="info")
|
||||
|
||||
# ── Raw factor values ─────────────────────────────────────────────────────
|
||||
detection_gap = Column(Float, nullable=False, default=1.0) # 0–1
|
||||
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) # 0–1
|
||||
confidence_level = Column(Float, nullable=False, default=0.0) # DLC 0–1
|
||||
|
||||
# ── 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"),
|
||||
)
|
||||
@@ -0,0 +1,49 @@
|
||||
"""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)
|
||||
@@ -0,0 +1,26 @@
|
||||
"""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())
|
||||
@@ -59,6 +59,8 @@ class Technique(Base):
|
||||
review_required = Column(Boolean, default=False)
|
||||
# Assign 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
|
||||
tests = relationship("Test", back_populates="technique")
|
||||
|
||||
@@ -26,8 +26,8 @@ from sqlalchemy.orm import relationship
|
||||
# Import Base from app.database
|
||||
from app.database import Base
|
||||
|
||||
# Import TestResult, TestState from app.models.enums
|
||||
from app.models.enums import TestResult, TestState
|
||||
# Import ContainmentResult, TestResult, TestState from app.models.enums
|
||||
from app.models.enums import AttackSuccessResult, ContainmentResult, TestResult, TestState
|
||||
|
||||
|
||||
# Define class Test
|
||||
@@ -59,6 +59,12 @@ class Test(Base):
|
||||
execution_date = Column(DateTime, nullable=True)
|
||||
# Assign created_by = Column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=True)
|
||||
created_by = Column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=True)
|
||||
# The template this test was instantiated from, if any (null for
|
||||
# standalone/manually-created/RT-imported tests). Lets a procedure
|
||||
# suggestion know exactly which template to propose improving —
|
||||
# matching purely by technique would be ambiguous when a technique has
|
||||
# multiple templates.
|
||||
source_template_id = Column(UUID(as_uuid=True), ForeignKey("test_templates.id"), nullable=True)
|
||||
# Assign result = Column(Enum(TestResult, name="testresult"), nullable=True)
|
||||
result = Column(Enum(TestResult, name="testresult"), nullable=True)
|
||||
# Assign state = Column(Enum(TestState, name="teststate"), default=TestState.draft)
|
||||
@@ -69,7 +75,9 @@ class Test(Base):
|
||||
# ── Red Team fields ─────────────────────────────────────────────
|
||||
red_summary = Column(Text, nullable=True)
|
||||
# Assign attack_success = Column(Boolean, nullable=True)
|
||||
attack_success = Column(Boolean, nullable=True)
|
||||
attack_success = Column(Enum(AttackSuccessResult, name="attacksuccessresult"), nullable=True)
|
||||
execution_start_time = Column(DateTime, nullable=True)
|
||||
execution_end_time = Column(DateTime, 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)
|
||||
# Assign red_validated_at = Column(DateTime, nullable=True)
|
||||
@@ -81,8 +89,14 @@ class Test(Base):
|
||||
|
||||
# ── Blue Team fields ────────────────────────────────────────────
|
||||
blue_summary = Column(Text, nullable=True)
|
||||
# What Blue actually did to detect the attack — Blue's counterpart to
|
||||
# procedure_text. Free text; may be parsed for a procedure suggestion.
|
||||
detect_procedure = Column(Text, nullable=True)
|
||||
# Assign detection_result = Column(Enum(TestResult, name="testresult"), nullable=True)
|
||||
detection_result = Column(Enum(TestResult, name="testresult"), nullable=True)
|
||||
containment_result = Column(Enum(ContainmentResult, name="containmentresult"), nullable=True)
|
||||
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)
|
||||
blue_validated_by = Column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=True)
|
||||
# Assign blue_validated_at = Column(DateTime, nullable=True)
|
||||
@@ -96,13 +110,17 @@ class Test(Base):
|
||||
red_started_at = Column(DateTime, nullable=True)
|
||||
# Assign blue_started_at = Column(DateTime, nullable=True)
|
||||
blue_started_at = Column(DateTime, nullable=True)
|
||||
# Assign paused_at = Column(DateTime, nullable=True)
|
||||
blue_work_started_at = Column(DateTime, nullable=True) # when blue tech picks up (Tempo start)
|
||||
paused_at = Column(DateTime, nullable=True)
|
||||
# Assign red_paused_seconds = Column(Integer, default=0)
|
||||
red_paused_seconds = Column(Integer, default=0)
|
||||
# Assign blue_paused_seconds = Column(Integer, default=0)
|
||||
blue_paused_seconds = Column(Integer, default=0)
|
||||
|
||||
# ── Round tracking (bumped on each reopen-for-rework) ────────────
|
||||
red_round_number = Column(Integer, nullable=False, default=1, server_default="1")
|
||||
blue_round_number = Column(Integer, nullable=False, default=1, server_default="1")
|
||||
|
||||
# ── Remediation fields ───────────────────────────────────────────
|
||||
remediation_steps = Column(Text, nullable=True)
|
||||
# Assign remediation_status = Column(String, nullable=True) # pending / in_progress / completed ...
|
||||
@@ -114,13 +132,17 @@ class Test(Base):
|
||||
retest_of = Column(UUID(as_uuid=True), ForeignKey("tests.id"), nullable=True)
|
||||
# Assign retest_count = Column(Integer, default=0)
|
||||
retest_count = Column(Integer, default=0)
|
||||
# Assign data_classification = Column(String(20), nullable=False, server_default="internal")
|
||||
data_classification = Column(String(20), nullable=False, server_default="internal")
|
||||
# Assign data_classification = Column(String(20), nullable=False, server_default="internal_use_only")
|
||||
data_classification = Column(String(20), nullable=False, server_default="internal_use_only")
|
||||
|
||||
# ── Relationships ───────────────────────────────────────────────
|
||||
technique = relationship("Technique", back_populates="tests")
|
||||
# Assign evidences = relationship("Evidence", back_populates="test")
|
||||
evidences = relationship("Evidence", back_populates="test")
|
||||
round_history = relationship(
|
||||
"TestRoundHistory", back_populates="test",
|
||||
order_by="TestRoundHistory.round_number", cascade="all, delete-orphan",
|
||||
)
|
||||
# Assign creator = relationship("User", foreign_keys=[created_by])
|
||||
creator = relationship("User", foreign_keys=[created_by])
|
||||
# Assign red_validator = relationship("User", foreign_keys=[red_validated_by])
|
||||
@@ -129,6 +151,36 @@ class Test(Base):
|
||||
blue_validator = relationship("User", foreign_keys=[blue_validated_by])
|
||||
# Assign 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])
|
||||
original_test = relationship("Test", remote_side="Test.id", foreign_keys=[retest_of])
|
||||
# Assign retests = relationship("Test", foreign_keys=[retest_of], back_populates="orig...
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
"""SQLAlchemy model for archived test rounds.
|
||||
|
||||
Each row is a snapshot of one team's round of work (procedure/results for
|
||||
Red, detection/containment for Blue) taken right before a lead reopens the
|
||||
test for rework. Reopening resets the live fields on ``Test`` for a fresh
|
||||
attempt, but the prior attempt's data must not be lost — it's archived here
|
||||
so the full history stays visible (e.g. to Blue Team, who need to see what
|
||||
Red actually did on earlier rounds, not just the latest one).
|
||||
"""
|
||||
|
||||
import uuid
|
||||
|
||||
from sqlalchemy import Column, DateTime, Enum, ForeignKey, Integer, String, Text, func
|
||||
from sqlalchemy.dialects.postgresql import UUID
|
||||
from sqlalchemy.orm import relationship
|
||||
|
||||
from app.database import Base
|
||||
from app.models.enums import AttackSuccessResult, ContainmentResult, TestResult
|
||||
|
||||
|
||||
class TestRoundHistory(Base):
|
||||
"""Archived snapshot of one red/blue round of a test, taken on reopen."""
|
||||
|
||||
__tablename__ = "test_round_history"
|
||||
|
||||
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
test_id = Column(UUID(as_uuid=True), ForeignKey("tests.id"), nullable=False)
|
||||
team = Column(String(10), nullable=False) # "red" or "blue"
|
||||
round_number = Column(Integer, nullable=False)
|
||||
|
||||
started_at = Column(DateTime, nullable=True)
|
||||
ended_at = Column(DateTime, nullable=True)
|
||||
paused_seconds = Column(Integer, default=0)
|
||||
|
||||
# Red-side round output
|
||||
procedure_text = Column(Text, nullable=True)
|
||||
tool_used = Column(String, nullable=True)
|
||||
# native_enum=False — stored as plain VARCHAR, not the shared Postgres
|
||||
# enum type used by tests.attack_success. This is an archive table with
|
||||
# no DB-level constraint needs, and reusing the native type name here
|
||||
# would make Alembic try to (re)create it.
|
||||
attack_success = Column(Enum(AttackSuccessResult, name="test_round_history_attack_success", native_enum=False), nullable=True)
|
||||
red_summary = Column(Text, nullable=True)
|
||||
execution_start_time = Column(DateTime, nullable=True)
|
||||
execution_end_time = Column(DateTime, nullable=True)
|
||||
|
||||
# Blue-side round output
|
||||
detection_result = Column(Enum(TestResult, name="test_round_history_detection_result", native_enum=False), nullable=True)
|
||||
containment_result = Column(Enum(ContainmentResult, name="test_round_history_containment_result", native_enum=False), nullable=True)
|
||||
detection_time = Column(DateTime, nullable=True)
|
||||
containment_time = Column(DateTime, nullable=True)
|
||||
blue_summary = Column(Text, nullable=True)
|
||||
detect_procedure = Column(Text, nullable=True)
|
||||
|
||||
# Why the round was closed
|
||||
review_notes = Column(Text, nullable=True)
|
||||
reviewed_by = Column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=True)
|
||||
archived_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||
|
||||
test = relationship("Test", back_populates="round_history")
|
||||
reviewer = relationship("User", foreign_keys=[reviewed_by])
|
||||
@@ -43,6 +43,11 @@ class TestTemplate(Base):
|
||||
attack_procedure = Column(Text, nullable=True) # Suggested attack procedure
|
||||
# Assign expected_detection = Column(Text, nullable=True) # What blue team should detect
|
||||
expected_detection = Column(Text, nullable=True) # What blue team should detect
|
||||
# Suggested detection procedure — Blue's counterpart to attack_procedure.
|
||||
# Only ever filled in via an approved procedure suggestion or a lead
|
||||
# editing the template directly; external syncs never touch it (those
|
||||
# only insert brand-new rows, never update existing ones).
|
||||
detect_suggested_procedure = Column(Text, nullable=True)
|
||||
# Assign platform = Column(String, nullable=True) # windows / linux...
|
||||
platform = Column(String, nullable=True) # windows / linux / macos
|
||||
# Assign tool_suggested = Column(String, nullable=True)
|
||||
|
||||
@@ -2,12 +2,8 @@
|
||||
|
||||
# Import uuid
|
||||
import uuid
|
||||
|
||||
# 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
|
||||
from sqlalchemy import Column, String, Boolean, DateTime, func
|
||||
from sqlalchemy.dialects.postgresql import UUID, JSONB
|
||||
|
||||
# Import Base from app.database
|
||||
from app.database import Base
|
||||
@@ -46,3 +42,8 @@ class User(Base):
|
||||
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||
# Assign 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
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
"""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())
|
||||
@@ -0,0 +1,347 @@
|
||||
"""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",
|
||||
# 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,
|
||||
}
|
||||
|
||||
# ── 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 ─────────────────────────────────────────────────────
|
||||
import secrets as _secrets
|
||||
for item in bundle.get("users", []):
|
||||
username = item.get("username")
|
||||
if not username:
|
||||
continue
|
||||
existing = db.query(User).filter(User.username == username).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=username,
|
||||
hashed_password=hash_password(temp_pw),
|
||||
role=item.get("role", "viewer"),
|
||||
is_active=item.get("is_active", True),
|
||||
must_change_password=True,
|
||||
)
|
||||
if item.get("email") and hasattr(User, "email"):
|
||||
new_user.email = item["email"]
|
||||
db.add(new_user)
|
||||
summary["users_created"] += 1
|
||||
|
||||
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.",
|
||||
],
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
"""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)
|
||||
@@ -0,0 +1,249 @@
|
||||
"""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)
|
||||
@@ -16,14 +16,18 @@ from fastapi import APIRouter, Cookie, Depends, Request, Response
|
||||
# Import OAuth2PasswordRequestForm from fastapi.security
|
||||
from fastapi.security import OAuth2PasswordRequestForm
|
||||
|
||||
# Import datetime, timezone from datetime
|
||||
from datetime import datetime, timezone
|
||||
|
||||
# Import jwt (PyJWT)
|
||||
import jwt
|
||||
from jwt.exceptions import PyJWTError as JWTError
|
||||
|
||||
# Import Session from sqlalchemy.orm
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
# Import blacklist_token, create_access_token, verify_pa... from app.auth
|
||||
from app.auth import blacklist_token, create_access_token, verify_password
|
||||
from app.auth import blacklist_token, create_access_token, is_token_blacklisted, verify_password
|
||||
|
||||
# Import settings from app.config
|
||||
from app.config import settings
|
||||
@@ -71,9 +75,16 @@ from app.services.auth_service import (
|
||||
# Assign router = APIRouter(prefix="/auth", tags=["auth"])
|
||||
router = APIRouter(prefix="/auth", tags=["auth"])
|
||||
|
||||
# Assign _IS_HTTPS = os.environ.get("AEGIS_ENV", "").lower() == "production"
|
||||
_IS_HTTPS = os.environ.get("AEGIS_ENV", "").lower() == "production"
|
||||
# Assign _COOKIE_NAME = "aegis_token"
|
||||
# SECURE_COOKIES desacopla la seguridad de la cookie del entorno de ejecucion.
|
||||
# Por defecto activo en produccion; ponlo en "false" para servidores HTTP.
|
||||
_aegis_env = os.environ.get("AEGIS_ENV", "development").lower()
|
||||
_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"
|
||||
|
||||
|
||||
@@ -164,6 +175,14 @@ def login(
|
||||
# Call 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()
|
||||
response.set_cookie(
|
||||
# Keyword argument: key
|
||||
@@ -256,7 +275,76 @@ def logout(
|
||||
return {"detail": "Logged out"}
|
||||
|
||||
|
||||
# Apply the @router.get decorator
|
||||
# A token that has *just* expired can still be refreshed within this window.
|
||||
# Without this grace period, `/auth/refresh` decodes the same token with the
|
||||
# same strict expiry check as every other endpoint — so by the time a 401
|
||||
# triggers a refresh attempt, the refresh call itself has also expired and
|
||||
# the session is unrecoverable. The grace window only widens the refresh
|
||||
# check; a brand-new token is always issued with the full expiry.
|
||||
_REFRESH_GRACE_SECONDS = 15 * 60
|
||||
|
||||
|
||||
@router.post("/refresh", response_model=TokenResponse)
|
||||
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)
|
||||
# Define function read_current_user
|
||||
def read_current_user(current_user: User = Depends(get_current_user)) -> UserOut:
|
||||
|
||||
@@ -9,8 +9,7 @@ import logging
|
||||
|
||||
# Import uuid
|
||||
import uuid
|
||||
|
||||
# Import Optional from typing
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
# Import APIRouter, Depends, Query from fastapi
|
||||
@@ -33,16 +32,9 @@ from app.domain.unit_of_work import UnitOfWork
|
||||
|
||||
# Import User from app.models.user
|
||||
from app.models.user import User
|
||||
|
||||
# Import log_action from app.services.audit_service
|
||||
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.models.campaign import Campaign, CampaignTest
|
||||
from app.models.test import Test
|
||||
from app.services.campaign_service import generate_campaign_from_threat_actor
|
||||
from app.services.campaign_crud_service import (
|
||||
add_test_to_campaign as crud_add_test,
|
||||
)
|
||||
@@ -55,10 +47,7 @@ from app.services.campaign_crud_service import (
|
||||
# Import from app.services.campaign_crud_service
|
||||
from app.services.campaign_crud_service import (
|
||||
create_campaign as crud_create,
|
||||
)
|
||||
|
||||
# Import from app.services.campaign_crud_service
|
||||
from app.services.campaign_crud_service import (
|
||||
delete_campaign as crud_delete,
|
||||
get_campaign_detail as crud_get_detail,
|
||||
)
|
||||
|
||||
@@ -67,6 +56,10 @@ from app.services.campaign_crud_service import (
|
||||
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
|
||||
from app.services.campaign_crud_service import (
|
||||
get_campaign_progress_data as crud_get_progress,
|
||||
@@ -97,11 +90,42 @@ from app.services.campaign_crud_service import (
|
||||
update_campaign as crud_update,
|
||||
)
|
||||
|
||||
# Import generate_campaign_from_threat_actor from app.services.campaign_service
|
||||
from app.services.campaign_service import generate_campaign_from_threat_actor
|
||||
# Import activate_campaign from app.services.campaign_crud_service
|
||||
from app.services.campaign_crud_service import (
|
||||
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
|
||||
from app.services.notification_service import notify_role
|
||||
from app.services.webhook_service import dispatch_webhook
|
||||
|
||||
# Assign logger = logging.getLogger(__name__)
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -110,6 +134,42 @@ logger = logging.getLogger(__name__)
|
||||
router = APIRouter(prefix="/campaigns", tags=["campaigns"])
|
||||
|
||||
|
||||
def _create_jira_tickets_for_campaign(db: Session, campaign: Campaign, campaign_id: str, user: User) -> None:
|
||||
"""Create Jira tickets for a campaign and its already-linked tests, if missing.
|
||||
|
||||
Shared by both paths that bring a campaign to ``active``: the admin-only
|
||||
emergency `/activate` override and the normal manager `/approve` flow.
|
||||
Tests may already be linked to the campaign from when it was still a
|
||||
draft, i.e. before any Jira ticket existed for the campaign — so they
|
||||
need their own tickets created here too. Best-effort: failures are
|
||||
logged, not raised, since a Jira outage must not block activation.
|
||||
"""
|
||||
try:
|
||||
from app.services.jira_service import (
|
||||
auto_create_campaign_issue,
|
||||
auto_create_test_issue,
|
||||
get_campaign_jira_key,
|
||||
get_test_jira_key,
|
||||
)
|
||||
campaign_jira_key = get_campaign_jira_key(db, campaign_id)
|
||||
if not campaign_jira_key:
|
||||
campaign_jira_key = auto_create_campaign_issue(db, campaign, user)
|
||||
if campaign_jira_key:
|
||||
for ct in campaign.campaign_tests:
|
||||
if ct.test and not get_test_jira_key(db, ct.test.id):
|
||||
auto_create_test_issue(
|
||||
db, ct.test, user,
|
||||
parent_ticket_override=campaign_jira_key,
|
||||
campaign_start_date=campaign.start_date,
|
||||
)
|
||||
db.commit()
|
||||
except Exception:
|
||||
logger.exception(
|
||||
"Jira ticket creation failed while activating campaign %s",
|
||||
campaign_id,
|
||||
)
|
||||
|
||||
|
||||
# ── Pydantic schemas ─────────────────────────────────────────────────
|
||||
|
||||
class CampaignCreate(BaseModel):
|
||||
@@ -175,6 +235,34 @@ class SchedulePayload(BaseModel):
|
||||
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
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -198,7 +286,7 @@ def list_campaigns(
|
||||
db: Session = Depends(get_db),
|
||||
# Entry: current_user
|
||||
current_user: User = Depends(get_current_user),
|
||||
) -> list:
|
||||
) -> dict:
|
||||
"""List campaigns with optional filters and pagination.
|
||||
|
||||
Args:
|
||||
@@ -278,7 +366,7 @@ def create_campaign(
|
||||
# Keyword argument: scheduled_at
|
||||
scheduled_at=payload.scheduled_at,
|
||||
)
|
||||
# Call log_action()
|
||||
campaign_id = result["id"]
|
||||
log_action(
|
||||
db,
|
||||
# Keyword argument: user_id
|
||||
@@ -287,9 +375,7 @@ def create_campaign(
|
||||
action="create_campaign",
|
||||
# Keyword argument: entity_type
|
||||
entity_type="campaign",
|
||||
# Keyword argument: entity_id
|
||||
entity_id=result["id"],
|
||||
# Keyword argument: details
|
||||
entity_id=campaign_id,
|
||||
details={"name": payload.name, "type": payload.type},
|
||||
)
|
||||
# Call uow.commit()
|
||||
@@ -389,6 +475,263 @@ def update_campaign(
|
||||
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},
|
||||
)
|
||||
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
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -433,7 +776,7 @@ def add_test_to_campaign(
|
||||
)
|
||||
# Call uow.commit()
|
||||
uow.commit()
|
||||
# Return result
|
||||
|
||||
return result
|
||||
|
||||
|
||||
@@ -483,22 +826,18 @@ def remove_test_from_campaign(
|
||||
def activate_campaign(
|
||||
# Entry: campaign_id
|
||||
campaign_id: str,
|
||||
# Entry: db
|
||||
db: Session = Depends(get_db),
|
||||
# Entry: current_user
|
||||
current_user: User = Depends(require_any_role("red_lead", "blue_lead")),
|
||||
) -> dict:
|
||||
"""Activate a campaign, moving it from draft to active.
|
||||
# admin passes automatically via require_any_role's built-in bypass — do not add "admin" here
|
||||
current_user: User = Depends(require_any_role("admin")),
|
||||
):
|
||||
"""Admin-only emergency override: activate a draft campaign directly, bypassing the approval queue.
|
||||
|
||||
Args:
|
||||
campaign_id (str): UUID string of the campaign to activate.
|
||||
db (Session): SQLAlchemy database session.
|
||||
current_user (User): Authenticated red_lead or blue_lead activating the campaign.
|
||||
|
||||
Returns:
|
||||
dict: Serialised representation of the activated campaign.
|
||||
Every other path to 'active' goes through /submit -> /approve, where the
|
||||
manager sets start_date. A draft campaign never has a start_date set
|
||||
(only /approve sets it, in the same call that moves the campaign to
|
||||
'active'), so there is no "scheduled for the future" case to guard
|
||||
against here anymore.
|
||||
"""
|
||||
# Open context manager
|
||||
with UnitOfWork(db) as uow:
|
||||
# Assign campaign = crud_activate(db, campaign_id)
|
||||
campaign = crud_activate(db, campaign_id)
|
||||
@@ -537,7 +876,10 @@ def activate_campaign(
|
||||
# Reload ORM object attributes from the database
|
||||
db.refresh(campaign)
|
||||
|
||||
# Return serialize_campaign(db, campaign)
|
||||
# Create Jira tickets for campaign and tests at activation time (non-fatal).
|
||||
# Campaign ticket is created here if it doesn't already exist (deferred from creation).
|
||||
_create_jira_tickets_for_campaign(db, campaign, campaign_id, current_user)
|
||||
|
||||
return serialize_campaign(db, campaign)
|
||||
|
||||
|
||||
@@ -587,6 +929,7 @@ def complete_campaign(
|
||||
uow.commit()
|
||||
# Reload ORM object attributes from the database
|
||||
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)
|
||||
@@ -624,12 +967,16 @@ def get_campaign_progress_endpoint(
|
||||
# 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)
|
||||
# Define function generate_campaign_from_actor
|
||||
def generate_campaign_from_actor(
|
||||
# Entry: actor_id
|
||||
actor_id: str,
|
||||
# Entry: db
|
||||
payload: GenerateFromActorPayload = GenerateFromActorPayload(),
|
||||
db: Session = Depends(get_db),
|
||||
# Entry: current_user
|
||||
current_user: User = Depends(require_any_role("red_lead", "blue_lead")),
|
||||
@@ -647,11 +994,11 @@ def generate_campaign_from_actor(
|
||||
Returns:
|
||||
dict: Serialised representation of the newly generated campaign.
|
||||
"""
|
||||
# Assign campaign = generate_campaign_from_threat_actor(
|
||||
campaign = generate_campaign_from_threat_actor(
|
||||
db,
|
||||
uuid.UUID(actor_id),
|
||||
current_user,
|
||||
start_date=payload.start_date,
|
||||
)
|
||||
|
||||
# Open context manager
|
||||
@@ -779,3 +1126,111 @@ def get_campaign_history(
|
||||
"""
|
||||
# Return crud_get_history(db, campaign_id)
|
||||
return crud_get_history(db, campaign_id)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# GET /campaigns/{id}/timeline — Audit-log history for this campaign
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@router.get("/{campaign_id}/timeline")
|
||||
def get_campaign_timeline_endpoint(
|
||||
campaign_id: str,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
) -> list:
|
||||
"""Return the chronological audit-log history for a campaign."""
|
||||
return crud_get_timeline(db, campaign_id)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# GET /campaigns/{id}/timing-summary — Aggregated timing across campaign tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
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"])),
|
||||
}
|
||||
|
||||
@@ -26,6 +26,9 @@ from app.models.user import User
|
||||
# Import from app.services.compliance_import_service
|
||||
from app.services.compliance_import_service import (
|
||||
import_cis_controls_v8_mappings,
|
||||
import_dora_mappings,
|
||||
import_iso_27001_mappings,
|
||||
import_iso_42001_mappings,
|
||||
import_nist_800_53_mappings,
|
||||
)
|
||||
|
||||
@@ -232,3 +235,33 @@ def import_cis(
|
||||
result = import_cis_controls_v8_mappings(db)
|
||||
# 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
|
||||
|
||||
@@ -64,7 +64,7 @@ def list_defensive_techniques(
|
||||
db: Session = Depends(get_db),
|
||||
# Entry: current_user
|
||||
current_user: User = Depends(get_current_user),
|
||||
) -> list:
|
||||
) -> dict:
|
||||
"""List all D3FEND defensive techniques with optional filters."""
|
||||
# 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),
|
||||
# Entry: current_user
|
||||
current_user: User = Depends(get_current_user),
|
||||
) -> list:
|
||||
) -> dict:
|
||||
"""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)
|
||||
|
||||
@@ -0,0 +1,319 @@
|
||||
"""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,
|
||||
}
|
||||
@@ -80,7 +80,7 @@ def list_detection_rules(
|
||||
db: Session = Depends(get_db),
|
||||
# Entry: current_user
|
||||
current_user: User = Depends(get_current_user),
|
||||
) -> list:
|
||||
) -> dict:
|
||||
"""List detection rules with optional filters and pagination."""
|
||||
# Return list_rules(
|
||||
return list_rules(
|
||||
@@ -112,7 +112,7 @@ def get_detection_rules_for_template(
|
||||
db: Session = Depends(get_db),
|
||||
# Entry: current_user
|
||||
current_user: User = Depends(get_current_user),
|
||||
) -> list:
|
||||
) -> dict:
|
||||
"""Get detection rules associated with a test template."""
|
||||
# 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),
|
||||
# Entry: current_user
|
||||
current_user: User = Depends(get_current_user),
|
||||
) -> list:
|
||||
) -> dict:
|
||||
"""Get detection rules relevant to a test, along with their evaluation results.
|
||||
|
||||
Finds rules by matching the test's technique_id to detection rules,
|
||||
|
||||
@@ -4,7 +4,8 @@ Endpoints
|
||||
---------
|
||||
POST /tests/{test_id}/evidence — upload evidence (with team=red/blue)
|
||||
GET /tests/{test_id}/evidence — list evidences (filterable by team)
|
||||
GET /evidence/{id} — presigned download URL
|
||||
GET /evidence/{id} — metadata + download_url
|
||||
GET /evidence/{id}/file — proxy download (streams file through backend)
|
||||
DELETE /evidence/{id} — delete evidence (only in editable states)
|
||||
|
||||
Access Control
|
||||
@@ -21,20 +22,17 @@ Access Control
|
||||
|
||||
# Import hashlib
|
||||
import hashlib
|
||||
|
||||
# Import os
|
||||
import logging
|
||||
import os
|
||||
|
||||
# Import uuid
|
||||
import uuid as _uuid
|
||||
|
||||
# Import Optional from typing
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
# Import APIRouter, Depends, File, Form, Query, Request,... from fastapi
|
||||
from fastapi import APIRouter, Depends, File, Form, Query, Request, UploadFile, status
|
||||
|
||||
# Import Session from sqlalchemy.orm
|
||||
from fastapi.responses import StreamingResponse
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
# Import get_db from app.database
|
||||
@@ -74,9 +72,9 @@ from app.services.evidence_service import (
|
||||
validate_file,
|
||||
validate_upload_permission,
|
||||
)
|
||||
from app.storage import download_file, upload_file
|
||||
|
||||
# Import get_presigned_url, upload_file from app.storage
|
||||
from app.storage import get_presigned_url, upload_file
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Assign router = APIRouter(tags=["evidence"])
|
||||
router = APIRouter(tags=["evidence"])
|
||||
@@ -87,8 +85,11 @@ router = APIRouter(tags=["evidence"])
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _evidence_to_out(evidence: Evidence) -> EvidenceOut:
|
||||
"""Convert an ORM ``Evidence`` to the API schema, injecting a presigned URL."""
|
||||
# Return EvidenceOut(
|
||||
"""Convert an ORM ``Evidence`` to the API schema.
|
||||
|
||||
``download_url`` points to the backend proxy endpoint so the browser
|
||||
never needs direct access to MinIO.
|
||||
"""
|
||||
return EvidenceOut(
|
||||
# Keyword argument: id
|
||||
id=evidence.id,
|
||||
@@ -106,8 +107,7 @@ def _evidence_to_out(evidence: Evidence) -> EvidenceOut:
|
||||
team=evidence.team,
|
||||
# Keyword argument: notes
|
||||
notes=evidence.notes,
|
||||
# Keyword argument: download_url
|
||||
download_url=get_presigned_url(evidence.file_path),
|
||||
download_url=f"/api/v1/evidence/{evidence.id}/file",
|
||||
)
|
||||
|
||||
|
||||
@@ -185,7 +185,7 @@ async def upload_evidence(
|
||||
sha256_hash=sha256,
|
||||
# Keyword argument: uploaded_by
|
||||
uploaded_by=current_user.id,
|
||||
# Keyword argument: team
|
||||
uploaded_at=datetime.utcnow(), # set explicitly — DB column has no server default
|
||||
team=team,
|
||||
# Keyword argument: notes
|
||||
notes=notes,
|
||||
@@ -222,10 +222,43 @@ async def upload_evidence(
|
||||
# Reload ORM object attributes from the database
|
||||
db.refresh(evidence)
|
||||
|
||||
# Return _evidence_to_out(evidence)
|
||||
# 7. Attach to Jira ticket if one exists (non-fatal)
|
||||
_attach_evidence_to_jira(db, test_id, content, safe_name, current_user)
|
||||
|
||||
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)
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -253,7 +286,7 @@ def list_evidence(
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# GET /evidence/{id} — presigned download URL
|
||||
# GET /evidence/{id} — metadata + proxy download URL
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@@ -266,14 +299,50 @@ def get_evidence(
|
||||
db: Session = Depends(get_db),
|
||||
# Entry: current_user
|
||||
current_user: User = Depends(get_current_user),
|
||||
) -> EvidenceOut:
|
||||
"""Return evidence metadata together with a presigned download URL."""
|
||||
# Assign evidence = get_evidence_or_raise(db, evidence_id)
|
||||
):
|
||||
"""Return evidence metadata. ``download_url`` is a backend proxy URL."""
|
||||
evidence = get_evidence_or_raise(db, evidence_id)
|
||||
# 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)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
"""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)
|
||||
@@ -0,0 +1,54 @@
|
||||
"""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
|
||||
]
|
||||
@@ -129,19 +129,19 @@ def list_links(
|
||||
entity_type: Optional[JiraLinkEntityType] = None,
|
||||
# Entry: entity_id
|
||||
entity_id: Optional[UUID] = None,
|
||||
# Entry: db
|
||||
entity_ids: Optional[list[UUID]] = Query(default=None, description="Filter by multiple entity IDs"),
|
||||
db: Session = Depends(get_db),
|
||||
# Entry: user
|
||||
user: User = Depends(get_current_user),
|
||||
) -> list[JiraLinkOut]:
|
||||
"""List Jira links, optionally filtered by entity."""
|
||||
# Return jira_service.list_links(
|
||||
):
|
||||
"""List Jira links, optionally filtered by entity or a list of entity IDs."""
|
||||
return jira_service.list_links(
|
||||
db,
|
||||
# Keyword argument: entity_type
|
||||
entity_type=entity_type,
|
||||
# Keyword argument: entity_id
|
||||
entity_id=entity_id,
|
||||
entity_ids=entity_ids,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,206 @@
|
||||
"""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)
|
||||
@@ -0,0 +1,191 @@
|
||||
"""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)
|
||||
@@ -19,8 +19,11 @@ from app.dependencies.auth import get_current_user
|
||||
# Import User from app.models.user
|
||||
from app.models.user import User
|
||||
|
||||
from datetime import datetime, date
|
||||
|
||||
# Import from app.services.operational_metrics_service
|
||||
from app.services.operational_metrics_service import (
|
||||
get_all_operational_metrics,
|
||||
get_metrics_by_team,
|
||||
get_operational_trend,
|
||||
)
|
||||
@@ -33,18 +36,20 @@ router = APIRouter(prefix="/metrics/operational", tags=["operational-metrics"])
|
||||
|
||||
|
||||
@router.get("")
|
||||
# Define function operational_metrics
|
||||
def operational_metrics(
|
||||
# Entry: db
|
||||
db: Session = Depends(get_db),
|
||||
# Entry: 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:
|
||||
"""Get all operational metrics (MTTD, MTTR, etc.) — cached for 5 min."""
|
||||
# Import get_operational_metrics_cached from app.services.score_cache
|
||||
from app.services.score_cache import get_operational_metrics_cached
|
||||
"""Get all operational metrics (MTTD, MTTR, etc.). Uses cache when no time filter is set."""
|
||||
if since:
|
||||
try:
|
||||
since_dt = datetime.combine(date.fromisoformat(since), datetime.min.time())
|
||||
except ValueError:
|
||||
since_dt = None
|
||||
return get_all_operational_metrics(db, since_dt)
|
||||
|
||||
# Return get_operational_metrics_cached(db)
|
||||
from app.services.score_cache import get_operational_metrics_cached
|
||||
return get_operational_metrics_cached(db)
|
||||
|
||||
|
||||
@@ -70,13 +75,16 @@ def operational_trend(
|
||||
|
||||
|
||||
@router.get("/by-team")
|
||||
# Define function metrics_by_team
|
||||
def metrics_by_team(
|
||||
# Entry: db
|
||||
db: Session = Depends(get_db),
|
||||
# Entry: 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:
|
||||
"""Get metrics broken down by Red Team vs Blue Team."""
|
||||
# Return get_metrics_by_team(db)
|
||||
return get_metrics_by_team(db)
|
||||
since_dt = None
|
||||
if since:
|
||||
try:
|
||||
since_dt = datetime.combine(date.fromisoformat(since), datetime.min.time())
|
||||
except ValueError:
|
||||
pass
|
||||
return get_metrics_by_team(db, since_dt)
|
||||
|
||||
@@ -97,7 +97,7 @@ def list_osint_items(
|
||||
db: Session = Depends(get_db),
|
||||
# Entry: user
|
||||
user: User = Depends(get_current_user),
|
||||
) -> list:
|
||||
) -> dict:
|
||||
"""List OSINT items with optional filters.
|
||||
|
||||
Args:
|
||||
|
||||
@@ -0,0 +1,215 @@
|
||||
"""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"],
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
"""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.detect_suggested_procedure
|
||||
)
|
||||
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.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")
|
||||
@@ -2,9 +2,10 @@
|
||||
|
||||
# Import UUID from uuid
|
||||
from uuid import UUID
|
||||
from pathlib import Path
|
||||
|
||||
# Import APIRouter, Depends, Query, Request from fastapi
|
||||
from fastapi import APIRouter, Depends, Query, Request
|
||||
# Import APIRouter, Depends, HTTPException, Query, Request from fastapi
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Request
|
||||
|
||||
# Import FileResponse from fastapi.responses
|
||||
from fastapi.responses import FileResponse
|
||||
@@ -21,12 +22,24 @@ from app.dependencies.auth import get_current_user, require_any_role
|
||||
# Import limiter from app.limiter
|
||||
from app.limiter import limiter
|
||||
|
||||
# Import settings from app.config
|
||||
from app.config import settings
|
||||
|
||||
# Import User from app.models.user
|
||||
from app.models.user import User
|
||||
|
||||
# Import report_generation_service from app.services
|
||||
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
|
||||
|
||||
# Assign router = APIRouter(prefix="/reports/generate", tags=["professional-reports"])
|
||||
router = APIRouter(prefix="/reports/generate", tags=["professional-reports"])
|
||||
|
||||
@@ -65,7 +78,7 @@ def generate_purple_report(
|
||||
)
|
||||
# Return FileResponse(
|
||||
return FileResponse(
|
||||
filepath,
|
||||
_assert_safe_report_path(filepath),
|
||||
# Keyword argument: media_type
|
||||
media_type=_MEDIA_TYPES[format],
|
||||
# Keyword argument: filename
|
||||
@@ -95,7 +108,7 @@ def generate_coverage_report(
|
||||
)
|
||||
# Return FileResponse(
|
||||
return FileResponse(
|
||||
filepath,
|
||||
_assert_safe_report_path(filepath),
|
||||
# Keyword argument: media_type
|
||||
media_type=_MEDIA_TYPES[format],
|
||||
# Keyword argument: filename
|
||||
@@ -125,7 +138,7 @@ def generate_executive_report(
|
||||
)
|
||||
# Return FileResponse(
|
||||
return FileResponse(
|
||||
filepath,
|
||||
_assert_safe_report_path(filepath),
|
||||
# Keyword argument: media_type
|
||||
media_type=_MEDIA_TYPES[format],
|
||||
# Keyword argument: filename
|
||||
@@ -155,7 +168,7 @@ def generate_quarterly_report(
|
||||
)
|
||||
# Return FileResponse(
|
||||
return FileResponse(
|
||||
filepath,
|
||||
_assert_safe_report_path(filepath),
|
||||
# Keyword argument: media_type
|
||||
media_type=_MEDIA_TYPES[format],
|
||||
# Keyword argument: filename
|
||||
@@ -187,7 +200,7 @@ def generate_technique_report(
|
||||
)
|
||||
# Return FileResponse(
|
||||
return FileResponse(
|
||||
filepath,
|
||||
_assert_safe_report_path(filepath),
|
||||
# Keyword argument: media_type
|
||||
media_type=_MEDIA_TYPES[format],
|
||||
# Keyword argument: filename
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
"""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
|
||||
@@ -87,7 +87,7 @@ def list_snapshots(
|
||||
db: Session = Depends(get_db),
|
||||
# Entry: current_user
|
||||
current_user: User = Depends(get_current_user),
|
||||
) -> list:
|
||||
) -> dict:
|
||||
"""List coverage snapshots ordered by creation date (newest first)."""
|
||||
# Return list_snapshots_svc(db, offset=offset, limit=limit)
|
||||
return list_snapshots_svc(db, offset=offset, limit=limit)
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
"""Phase 14: SSO / SAML 2.0 router."""
|
||||
|
||||
import os
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request, Response
|
||||
from fastapi.responses import RedirectResponse
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.database import get_db
|
||||
from app.dependencies.auth import require_any_role
|
||||
from app import auth as auth_lib
|
||||
from app.schemas.sso_schema import (
|
||||
SsoConfigCreate, SsoConfigOut, SsoStatusResponse,
|
||||
)
|
||||
import app.services.sso_service as svc
|
||||
|
||||
router = APIRouter(prefix="/sso", tags=["SSO"])
|
||||
|
||||
_COOKIE_NAME = "aegis_token"
|
||||
|
||||
# Mirror the same SECURE_COOKIES logic used in the auth router so that
|
||||
# SAML-authenticated sessions respect the deployment's HTTPS configuration.
|
||||
_aegis_env = os.environ.get("AEGIS_ENV", "development").lower()
|
||||
_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" — active only when AEGIS_ENV=production
|
||||
_IS_HTTPS = _aegis_env == "production"
|
||||
|
||||
_COOKIE_OPTS = {"httponly": True, "samesite": "lax", "secure": _IS_HTTPS}
|
||||
|
||||
|
||||
# ── Public ────────────────────────────────────────────────────────────────────
|
||||
|
||||
@router.get("/status", response_model=SsoStatusResponse)
|
||||
def sso_status(db: Session = Depends(get_db)):
|
||||
"""Return whether SSO is enabled and configured (public — for login page)."""
|
||||
return svc.get_status(db)
|
||||
|
||||
|
||||
@router.get("/metadata", response_model=None)
|
||||
def sp_metadata(db: Session = Depends(get_db)):
|
||||
"""
|
||||
Return the Service Provider SAML metadata XML.
|
||||
|
||||
Upload this XML to your IdP (Okta, Azure AD, etc.) to register Aegis.
|
||||
"""
|
||||
try:
|
||||
xml = svc.get_sp_metadata(db)
|
||||
except Exception as exc:
|
||||
raise HTTPException(status_code=503, detail=str(exc))
|
||||
return Response(content=xml, media_type="application/xml")
|
||||
|
||||
|
||||
@router.get("/login")
|
||||
def sso_login(request: Request, db: Session = Depends(get_db)):
|
||||
"""
|
||||
Initiate SAML login — redirects the browser to the IdP.
|
||||
|
||||
The IdP will POST the SAML Response to ``/sso/callback`` after authentication.
|
||||
"""
|
||||
request_data = {
|
||||
"https": request.url.scheme == "https",
|
||||
"http_host": request.url.hostname,
|
||||
"path": request.url.path,
|
||||
"port": str(request.url.port or (443 if request.url.scheme == "https" else 80)),
|
||||
"get_data": dict(request.query_params),
|
||||
"post_data": {},
|
||||
"query_string": str(request.url.query),
|
||||
}
|
||||
try:
|
||||
result = svc.initiate_login(db, request_data)
|
||||
except RuntimeError as exc:
|
||||
raise HTTPException(status_code=503, detail=str(exc))
|
||||
redirect_url = result["redirect_url"]
|
||||
_parsed = urlparse(redirect_url.strip())
|
||||
if _parsed.scheme not in ("http", "https") or not _parsed.netloc:
|
||||
raise HTTPException(status_code=400, detail="Invalid IdP redirect URL")
|
||||
return RedirectResponse(url=redirect_url)
|
||||
|
||||
|
||||
@router.post("/callback")
|
||||
async def sso_callback(request: Request, db: Session = Depends(get_db)):
|
||||
"""
|
||||
SAML Assertion Consumer Service (ACS) endpoint.
|
||||
|
||||
The IdP POSTs the SAML Response here. On success, sets the aegis_token
|
||||
cookie and redirects to the frontend.
|
||||
"""
|
||||
form = await request.form()
|
||||
request_data = {
|
||||
"https": request.url.scheme == "https",
|
||||
"http_host": request.url.hostname,
|
||||
"path": request.url.path,
|
||||
"port": str(request.url.port or (443 if request.url.scheme == "https" else 80)),
|
||||
"get_data": dict(request.query_params),
|
||||
"post_data": dict(form),
|
||||
"query_string": str(request.url.query),
|
||||
}
|
||||
try:
|
||||
user = svc.process_callback(db, request_data)
|
||||
except (ValueError, RuntimeError) as exc:
|
||||
raise HTTPException(status_code=401, detail=str(exc))
|
||||
|
||||
access_token = auth_lib.create_access_token({"sub": user.username})
|
||||
response = RedirectResponse(url="/", status_code=302)
|
||||
response.set_cookie(_COOKIE_NAME, access_token, **_COOKIE_OPTS)
|
||||
return response
|
||||
|
||||
|
||||
# ── Admin configuration ────────────────────────────────────────────────────────
|
||||
|
||||
@router.get("/config", response_model=SsoConfigOut)
|
||||
def get_sso_config(
|
||||
db: Session = Depends(get_db),
|
||||
_user=Depends(require_any_role("admin")),
|
||||
):
|
||||
"""Return the current SSO configuration (admin only)."""
|
||||
cfg = svc.get_config(db)
|
||||
if not cfg:
|
||||
raise HTTPException(status_code=404, detail="SSO not configured yet")
|
||||
return SsoConfigOut.model_validate(cfg)
|
||||
|
||||
|
||||
@router.put("/config", response_model=SsoConfigOut)
|
||||
def upsert_sso_config(
|
||||
body: SsoConfigCreate,
|
||||
db: Session = Depends(get_db),
|
||||
_user=Depends(require_any_role("admin")),
|
||||
):
|
||||
"""Create or replace the SSO configuration (admin only)."""
|
||||
cfg = svc.upsert_config(db, **body.model_dump(exclude_unset=False))
|
||||
return SsoConfigOut.model_validate(cfg)
|
||||
+726
-40
@@ -3,41 +3,30 @@
|
||||
Provides manual triggers for background operations such as the MITRE
|
||||
ATT&CK synchronisation, intel scanning, Atomic Red Team import, and
|
||||
scheduler health introspection.
|
||||
|
||||
Also exposes email configuration CRUD (admin only) that writes to the
|
||||
system_configs table so settings survive container restarts.
|
||||
"""
|
||||
|
||||
# Import logging
|
||||
import logging
|
||||
from typing import Optional
|
||||
|
||||
# Import APIRouter, Depends, Request from fastapi
|
||||
from fastapi import APIRouter, Depends, Request
|
||||
|
||||
# Import Session from sqlalchemy.orm
|
||||
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, Request, status
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
# Import get_db from app.database
|
||||
from app.database import get_db
|
||||
|
||||
# Import require_role from app.dependencies.auth
|
||||
from app.dependencies.auth import require_role
|
||||
|
||||
# Import scheduler from app.jobs.mitre_sync_job
|
||||
from app.database import SessionLocal, get_db
|
||||
from app.dependencies.auth import require_role, get_current_user
|
||||
from app.models.user import User
|
||||
from app.services.mitre_sync_service import sync_mitre
|
||||
from app.services.intel_service import scan_intel
|
||||
from app.services.atomic_import_service import import_atomic_red_team
|
||||
from app.jobs.mitre_sync_job import scheduler
|
||||
|
||||
# Import limiter from app.limiter
|
||||
from app.limiter import limiter
|
||||
|
||||
# Import User from app.models.user
|
||||
from app.models.user import User
|
||||
|
||||
# Import import_atomic_red_team from app.services.atomic_import_service
|
||||
from app.services.atomic_import_service import import_atomic_red_team
|
||||
|
||||
# Import scan_intel from app.services.intel_service
|
||||
from app.services.intel_service import scan_intel
|
||||
|
||||
# Import sync_mitre from app.services.mitre_sync_service
|
||||
from app.services.mitre_sync_service import sync_mitre
|
||||
|
||||
# Assign logger = logging.getLogger(__name__)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -45,7 +34,81 @@ logger = logging.getLogger(__name__)
|
||||
router = APIRouter(prefix="/system", tags=["system"])
|
||||
|
||||
|
||||
# Apply the @router.post decorator
|
||||
# ---------------------------------------------------------------------------
|
||||
# Pydantic schemas for email config
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class EmailConfigOut(BaseModel):
|
||||
enabled: bool
|
||||
host: str
|
||||
port: int
|
||||
username: str
|
||||
from_email: str
|
||||
use_tls: bool
|
||||
# password is never returned
|
||||
|
||||
|
||||
class EmailConfigUpdate(BaseModel):
|
||||
enabled: Optional[bool] = None
|
||||
host: Optional[str] = None
|
||||
port: Optional[int] = None
|
||||
username: Optional[str] = None
|
||||
password: Optional[str] = None
|
||||
from_email: Optional[str] = None
|
||||
use_tls: Optional[bool] = None
|
||||
|
||||
|
||||
class EmailTestRequest(BaseModel):
|
||||
to: str
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers for system_configs CRUD
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_SMTP_KEYS = {
|
||||
"enabled": "smtp.enabled",
|
||||
"host": "smtp.host",
|
||||
"port": "smtp.port",
|
||||
"username": "smtp.username",
|
||||
"password": "smtp.password", # nosec B105
|
||||
"from_email": "smtp.from_email",
|
||||
"use_tls": "smtp.use_tls",
|
||||
}
|
||||
|
||||
|
||||
def _upsert_config(db: Session, key: str, value: str) -> None:
|
||||
from app.models.system_config import SystemConfig # lazy import avoids circular
|
||||
|
||||
row = db.query(SystemConfig).filter(SystemConfig.key == key).first()
|
||||
if row:
|
||||
row.value = value
|
||||
else:
|
||||
row = SystemConfig(key=key, value=value)
|
||||
db.add(row)
|
||||
|
||||
|
||||
def _read_email_config_from_db(db: Session) -> dict:
|
||||
"""Return a dict with resolved email settings (DB overrides env)."""
|
||||
from app.services.email_service import _get_smtp_config
|
||||
|
||||
return _get_smtp_config(db)
|
||||
|
||||
|
||||
def _bg_mitre_sync() -> None:
|
||||
"""Run MITRE sync in a background task with its own DB session."""
|
||||
logger.info("Background MITRE sync task starting...")
|
||||
db = SessionLocal()
|
||||
try:
|
||||
summary = sync_mitre(db)
|
||||
logger.info("Background MITRE sync task finished — %s", summary)
|
||||
except Exception:
|
||||
logger.exception("Background MITRE sync task failed")
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
@router.post("/sync-mitre")
|
||||
# Apply the @limiter.limit decorator
|
||||
@limiter.limit("2/hour")
|
||||
@@ -53,28 +116,22 @@ router = APIRouter(prefix="/system", tags=["system"])
|
||||
def trigger_mitre_sync(
|
||||
# Entry: request
|
||||
request: Request,
|
||||
# Entry: db
|
||||
db: Session = Depends(get_db),
|
||||
# Entry: current_user
|
||||
background_tasks: BackgroundTasks,
|
||||
current_user: User = Depends(require_role("admin")),
|
||||
) -> dict:
|
||||
"""Manually trigger a MITRE ATT&CK synchronisation.
|
||||
):
|
||||
"""Manually trigger a MITRE ATT&CK synchronisation in the background.
|
||||
|
||||
**Requires** the ``admin`` role.
|
||||
|
||||
Returns a JSON object with the sync summary including the count of
|
||||
new and updated techniques.
|
||||
Returns immediately — the sync runs asynchronously. Poll
|
||||
``/system/scheduler-status`` for progress, or check server logs.
|
||||
"""
|
||||
# Assign summary = sync_mitre(db)
|
||||
summary = sync_mitre(db)
|
||||
# Return {
|
||||
background_tasks.add_task(_bg_mitre_sync)
|
||||
return {
|
||||
# Literal argument value
|
||||
"message": "MITRE sync completed",
|
||||
# Literal argument value
|
||||
"new": summary["created"],
|
||||
# Literal argument value
|
||||
"updated": summary["updated"],
|
||||
"message": "MITRE sync started in background",
|
||||
"status": "started",
|
||||
"new": 0,
|
||||
"updated": 0,
|
||||
}
|
||||
|
||||
|
||||
@@ -185,3 +242,632 @@ def scheduler_status(
|
||||
for job in jobs
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Jira config endpoints (admin only)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class JiraConfigOut(BaseModel):
|
||||
enabled: bool
|
||||
url: str
|
||||
project_key: str
|
||||
parent_ticket: str
|
||||
parent_ticket_standalone: str
|
||||
admin_email_set: bool # whether admin email is configured (value never returned)
|
||||
tempo_admin_token_set: bool # whether admin Tempo token is configured
|
||||
|
||||
|
||||
class JiraConfigUpdate(BaseModel):
|
||||
enabled: Optional[bool] = None
|
||||
url: Optional[str] = None
|
||||
project_key: Optional[str] = None
|
||||
parent_ticket: Optional[str] = None
|
||||
parent_ticket_standalone: Optional[str] = None
|
||||
admin_email: Optional[str] = None
|
||||
admin_api_token: Optional[str] = None
|
||||
tempo_admin_token: Optional[str] = None
|
||||
|
||||
|
||||
_JIRA_KEYS = {
|
||||
"enabled": "jira.enabled",
|
||||
"url": "jira.url",
|
||||
"project_key": "jira.project_key",
|
||||
"parent_ticket": "jira.parent_ticket",
|
||||
"parent_ticket_standalone": "jira.parent_ticket_standalone",
|
||||
"admin_email": "jira.admin_email",
|
||||
"admin_api_token": "jira.admin_api_token",
|
||||
"tempo_admin_token": "tempo.admin_token",
|
||||
}
|
||||
|
||||
|
||||
@router.get("/jira-config", response_model=JiraConfigOut)
|
||||
def get_jira_config(
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""Return current Jira configuration (merged DB + env).
|
||||
|
||||
Open to any authenticated user — the frontend needs ``url`` to build
|
||||
correct ``/browse/{key}`` links for non-admin users too. Credential
|
||||
values (tokens, admin email) are never returned, only booleans.
|
||||
"""
|
||||
from app.services.jira_service import (
|
||||
get_jira_url, get_jira_project_key, is_jira_enabled,
|
||||
get_jira_parent_ticket, get_jira_parent_ticket_standalone,
|
||||
get_admin_jira_email, _read_system_config,
|
||||
)
|
||||
|
||||
return JiraConfigOut(
|
||||
enabled=is_jira_enabled(db),
|
||||
url=get_jira_url(db) or "",
|
||||
project_key=get_jira_project_key(db) or "",
|
||||
parent_ticket=get_jira_parent_ticket(db) or "",
|
||||
parent_ticket_standalone=get_jira_parent_ticket_standalone(db) or "",
|
||||
admin_email_set=bool(get_admin_jira_email(db)),
|
||||
tempo_admin_token_set=bool(_read_system_config(db, "tempo.admin_token")),
|
||||
)
|
||||
|
||||
|
||||
@router.patch("/jira-config", response_model=JiraConfigOut)
|
||||
def update_jira_config(
|
||||
payload: JiraConfigUpdate,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(require_role("admin")),
|
||||
):
|
||||
"""Update Jira configuration and persist to DB.
|
||||
|
||||
**Requires** the ``admin`` role. Only provided fields are updated.
|
||||
"""
|
||||
from app.services.jira_service import (
|
||||
upsert_jira_config, get_jira_url, get_jira_project_key, is_jira_enabled,
|
||||
get_jira_parent_ticket, get_jira_parent_ticket_standalone,
|
||||
get_admin_jira_email, _read_system_config,
|
||||
)
|
||||
|
||||
update_data = payload.model_dump(exclude_unset=True)
|
||||
for field, val in update_data.items():
|
||||
db_key = _JIRA_KEYS.get(field)
|
||||
if db_key:
|
||||
upsert_jira_config(db, db_key, str(val))
|
||||
db.commit()
|
||||
|
||||
return JiraConfigOut(
|
||||
enabled=is_jira_enabled(db),
|
||||
url=get_jira_url(db) or "",
|
||||
project_key=get_jira_project_key(db) or "",
|
||||
parent_ticket=get_jira_parent_ticket(db) or "",
|
||||
parent_ticket_standalone=get_jira_parent_ticket_standalone(db) or "",
|
||||
admin_email_set=bool(get_admin_jira_email(db)),
|
||||
tempo_admin_token_set=bool(_read_system_config(db, "tempo.admin_token")),
|
||||
)
|
||||
|
||||
|
||||
@router.post("/jira-test")
|
||||
def test_jira_connection(
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(require_role("admin")),
|
||||
):
|
||||
"""Test the Jira connection using the admin account credentials.
|
||||
|
||||
Always returns HTTP 200 with a ``status`` field so Cloudflare never
|
||||
replaces the response with its own error page.
|
||||
"""
|
||||
from app.services.jira_service import (
|
||||
get_admin_jira_client, get_jira_url, has_admin_jira_configured,
|
||||
get_admin_jira_email, get_admin_jira_api_token, is_jira_enabled,
|
||||
)
|
||||
|
||||
jira_url = get_jira_url(db)
|
||||
if not jira_url:
|
||||
return {"status": "error", "message": "Jira URL is not configured.", "jira_url": ""}
|
||||
|
||||
if not has_admin_jira_configured(db):
|
||||
# has_admin_jira_configured() bundles 4 independent checks into one
|
||||
# bool — report exactly which one(s) are actually missing instead of
|
||||
# always blaming "Admin Email and Admin API Token" regardless of
|
||||
# which check failed (e.g. URL/email/token can all be set while the
|
||||
# "Enable Jira Integration" toggle itself is still off).
|
||||
missing = []
|
||||
if not is_jira_enabled(db):
|
||||
missing.append("Jira integration is not enabled (toggle 'Enable Jira Integration' above)")
|
||||
if not get_admin_jira_email(db):
|
||||
missing.append("Admin Email is not set")
|
||||
if not get_admin_jira_api_token(db):
|
||||
missing.append("Admin API Token is not set")
|
||||
return {
|
||||
"status": "error",
|
||||
"message": "Cannot connect: " + "; ".join(missing) + ".",
|
||||
"jira_url": jira_url,
|
||||
}
|
||||
|
||||
admin_email = get_admin_jira_email(db)
|
||||
|
||||
try:
|
||||
jira = get_admin_jira_client(db)
|
||||
try:
|
||||
jira._session.timeout = 10 # type: ignore[attr-defined]
|
||||
except Exception: # nosec B110
|
||||
pass
|
||||
myself = jira.myself()
|
||||
connected_as = (
|
||||
(myself.get("displayName") if isinstance(myself, dict) else None)
|
||||
or (myself.get("emailAddress") if isinstance(myself, dict) else None)
|
||||
or admin_email
|
||||
or "authenticated"
|
||||
)
|
||||
return {"status": "ok", "connected_as": connected_as, "jira_url": jira_url}
|
||||
except Exception as exc:
|
||||
err = str(exc)
|
||||
if "Expecting value" in err or "line 1 column 1" in err:
|
||||
msg = (
|
||||
"Jira returned a non-JSON response. "
|
||||
"Verify the URL (e.g. https://company.atlassian.net), "
|
||||
"admin email and API token."
|
||||
)
|
||||
elif "401" in err or "Unauthorized" in err:
|
||||
msg = (
|
||||
"Authentication failed (401). "
|
||||
f"Check that the admin email ({admin_email or 'not set'}) "
|
||||
"and API token are correct."
|
||||
)
|
||||
elif "403" in err or "Forbidden" in err:
|
||||
msg = "Access denied (403). The admin token may not have permission for this Jira project."
|
||||
elif "timed out" in err.lower() or "timeout" in err.lower():
|
||||
msg = "Connection timed out. Check that the Jira URL is reachable from the server."
|
||||
else:
|
||||
msg = f"Jira connection failed: {err}"
|
||||
logger.warning("Admin Jira test connection failed: %s", err)
|
||||
return {"status": "error", "message": msg, "jira_url": jira_url}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# POST /system/tempo-test
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@router.post("/tempo-test")
|
||||
def test_tempo_connection(
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(require_role("admin")),
|
||||
):
|
||||
"""Test the admin Tempo token connectivity.
|
||||
|
||||
Always returns HTTP 200 with a ``status`` field so Cloudflare never
|
||||
intercepts the response.
|
||||
"""
|
||||
from app.services.tempo_service import get_admin_tempo_token, get_admin_tempo_client
|
||||
|
||||
admin_token = get_admin_tempo_token(db)
|
||||
if not admin_token:
|
||||
return {
|
||||
"status": "error",
|
||||
"message": (
|
||||
"Admin Tempo token not configured. "
|
||||
"Set it in System Settings → Jira Configuration → Admin Tempo Token."
|
||||
),
|
||||
}
|
||||
|
||||
try:
|
||||
tempo = get_admin_tempo_client(db)
|
||||
worklogs = tempo.search_worklogs(dateFrom="2024-01-01", dateTo="2024-01-02")
|
||||
count = len(worklogs) if isinstance(worklogs, list) else "n/a"
|
||||
return {
|
||||
"status": "ok",
|
||||
"message": "Admin Tempo token connected successfully.",
|
||||
"worklogs_found": count,
|
||||
}
|
||||
except Exception as exc:
|
||||
err = str(exc)
|
||||
if "401" in err or "Unauthorized" in err:
|
||||
msg = (
|
||||
"Authentication failed (401). "
|
||||
"Check the admin Tempo API token — obtain it at "
|
||||
"Jira → Apps → Tempo → Settings → API Integration."
|
||||
)
|
||||
elif "403" in err or "Forbidden" in err:
|
||||
msg = "Access denied (403). The admin Tempo token lacks the required permissions."
|
||||
else:
|
||||
msg = f"Tempo connection failed: {err}"
|
||||
logger.warning("Admin Tempo test connection failed: %s", err)
|
||||
return {"status": "error", "message": msg}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# GET /system/email-config
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@router.get("/email-config", response_model=EmailConfigOut)
|
||||
def get_email_config(
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(require_role("admin")),
|
||||
):
|
||||
"""Return current SMTP email configuration (merged DB + env).
|
||||
|
||||
**Requires** the ``admin`` role. Password is never returned.
|
||||
"""
|
||||
cfg = _read_email_config_from_db(db)
|
||||
return EmailConfigOut(
|
||||
enabled=cfg["enabled"],
|
||||
host=cfg["host"],
|
||||
port=cfg["port"],
|
||||
username=cfg["username"],
|
||||
from_email=cfg["from_email"],
|
||||
use_tls=cfg["use_tls"],
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# PATCH /system/email-config
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@router.patch("/email-config", response_model=EmailConfigOut)
|
||||
def update_email_config(
|
||||
payload: EmailConfigUpdate,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(require_role("admin")),
|
||||
):
|
||||
"""Update SMTP email configuration and persist to DB.
|
||||
|
||||
**Requires** the ``admin`` role.
|
||||
Only provided fields are updated (partial update).
|
||||
"""
|
||||
update_data = payload.model_dump(exclude_unset=True)
|
||||
for field, val in update_data.items():
|
||||
db_key = _SMTP_KEYS.get(field)
|
||||
if db_key:
|
||||
_upsert_config(db, db_key, str(val))
|
||||
db.commit()
|
||||
|
||||
cfg = _read_email_config_from_db(db)
|
||||
return EmailConfigOut(
|
||||
enabled=cfg["enabled"],
|
||||
host=cfg["host"],
|
||||
port=cfg["port"],
|
||||
username=cfg["username"],
|
||||
from_email=cfg["from_email"],
|
||||
use_tls=cfg["use_tls"],
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# POST /system/email-test
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# ATT&CK Evaluations endpoints (admin only)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@router.get("/attck-evaluations/rounds")
|
||||
def list_evaluation_rounds(
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(require_role("admin")),
|
||||
):
|
||||
"""Return all public CrowdStrike ENTERPRISE evaluation rounds with import status.
|
||||
|
||||
Each entry includes whether it has already been imported into this platform.
|
||||
"""
|
||||
from app.services.attck_evaluations_service import fetch_rounds_with_status
|
||||
from app.models.evaluation_import import EvaluationImport
|
||||
|
||||
status_info = fetch_rounds_with_status()
|
||||
rounds = status_info["rounds"]
|
||||
|
||||
imported = {
|
||||
row.adversary_name.lower(): row
|
||||
for row in db.query(EvaluationImport).filter(EvaluationImport.status == "completed").all()
|
||||
}
|
||||
|
||||
round_list = [
|
||||
{
|
||||
"name": r["name"],
|
||||
"display_name": r.get("display_name", r["name"]),
|
||||
"eval_round": r["eval_round"],
|
||||
"imported": r["name"].lower() in imported,
|
||||
"imported_at": imported[r["name"].lower()].imported_at.isoformat()
|
||||
if r["name"].lower() in imported else None,
|
||||
"tests_created": imported[r["name"].lower()].tests_created
|
||||
if r["name"].lower() in imported else None,
|
||||
"techniques_covered": imported[r["name"].lower()].techniques_covered
|
||||
if r["name"].lower() in imported else None,
|
||||
}
|
||||
for r in rounds
|
||||
]
|
||||
|
||||
return {
|
||||
"rounds": round_list,
|
||||
"api_reachable": status_info["api_reachable"],
|
||||
"api_error": status_info.get("api_error"),
|
||||
}
|
||||
|
||||
|
||||
@router.post("/attck-evaluations/import")
|
||||
def import_evaluation_round(
|
||||
payload: dict,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(require_role("admin")),
|
||||
):
|
||||
"""Import a specific ATT&CK Evaluation round for CrowdStrike.
|
||||
|
||||
Body: { "adversary_name": "apt29", "adversary_display": "APT29", "eval_round": 2 }
|
||||
|
||||
Creates tests in ``in_review`` state — Blue Leads must validate each
|
||||
result before it counts as real coverage.
|
||||
"""
|
||||
from app.services.attck_evaluations_service import import_evaluation_round as _import
|
||||
|
||||
adversary_name = payload.get("adversary_name", "")
|
||||
adversary_display = payload.get("adversary_display", adversary_name)
|
||||
eval_round = payload.get("eval_round", 0)
|
||||
|
||||
if not adversary_name or not eval_round:
|
||||
raise HTTPException(status_code=400, detail="adversary_name and eval_round are required")
|
||||
|
||||
try:
|
||||
summary = _import(db, adversary_name, adversary_display, eval_round, current_user)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=409, detail=str(exc))
|
||||
except Exception as exc:
|
||||
logger.error("ATT&CK Evaluation import failed: %s", exc, exc_info=True)
|
||||
raise HTTPException(status_code=502, detail=f"Import failed: {exc}")
|
||||
|
||||
return {
|
||||
"message": f"Import complete — {summary['created']} tests created",
|
||||
**summary,
|
||||
}
|
||||
|
||||
|
||||
@router.post("/attck-evaluations/import-latest")
|
||||
def import_latest_evaluation(
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(require_role("admin")),
|
||||
):
|
||||
"""Import the latest available CrowdStrike evaluation round automatically.
|
||||
|
||||
Returns 409 if the latest round was already imported.
|
||||
"""
|
||||
from app.services.attck_evaluations_service import get_latest_round, import_evaluation_round as _import
|
||||
|
||||
try:
|
||||
latest = get_latest_round()
|
||||
except Exception as exc:
|
||||
raise HTTPException(status_code=502, detail=f"Could not reach MITRE Evaluations API: {exc}")
|
||||
|
||||
try:
|
||||
summary = _import(
|
||||
db,
|
||||
latest["name"],
|
||||
latest.get("display_name", latest["name"]),
|
||||
latest["eval_round"],
|
||||
current_user,
|
||||
)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=409, detail=str(exc))
|
||||
except Exception as exc:
|
||||
logger.error("ATT&CK Evaluation import failed: %s", exc, exc_info=True)
|
||||
raise HTTPException(status_code=502, detail=f"Import failed: {exc}")
|
||||
|
||||
return {
|
||||
"message": f"Import complete — {summary['created']} tests created",
|
||||
**summary,
|
||||
}
|
||||
|
||||
|
||||
@router.get("/attck-evaluations/check-new")
|
||||
def check_new_evaluation_round(
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(require_role("admin")),
|
||||
):
|
||||
"""Check if a new ATT&CK Evaluation round is available that hasn't been imported yet."""
|
||||
from app.services.attck_evaluations_service import check_for_new_round
|
||||
|
||||
return check_for_new_round(db)
|
||||
|
||||
|
||||
@router.post("/attck-evaluations/bulk-approve")
|
||||
def bulk_approve_evaluation_tests(
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(require_role("admin")),
|
||||
):
|
||||
"""Bulk-approve all Blue Team validation for ATT&CK Evaluation imported tests.
|
||||
|
||||
Finds every test in ``in_review`` state whose name starts with ``[EVAL R``
|
||||
and approves the Blue Team side. Because all evaluation imports pre-approve
|
||||
the Red Team side, this moves every matched test to ``validated`` state.
|
||||
|
||||
**Important caveats** (enforced by UI warnings before this is called):
|
||||
- Results come from a controlled MITRE lab, NOT the organisation's env.
|
||||
- Validated tests will immediately affect coverage metrics and dashboards.
|
||||
- Blue Leads should still spot-check high-priority techniques individually.
|
||||
"""
|
||||
from datetime import datetime
|
||||
from app.models.test import Test
|
||||
from app.models.enums import TestState
|
||||
from app.models.technique import Technique
|
||||
from app.services.status_service import recalculate_technique_status
|
||||
from app.services.audit_service import log_action
|
||||
|
||||
# Find all pending evaluation tests
|
||||
pending = (
|
||||
db.query(Test)
|
||||
.filter(
|
||||
Test.state == TestState.in_review,
|
||||
Test.name.like("[EVAL R%"),
|
||||
)
|
||||
.all()
|
||||
)
|
||||
|
||||
if not pending:
|
||||
return {
|
||||
"approved": 0,
|
||||
"techniques_recalculated": 0,
|
||||
"message": "No pending evaluation tests found — nothing to approve.",
|
||||
}
|
||||
|
||||
now = datetime.utcnow()
|
||||
affected_technique_ids: set = set()
|
||||
|
||||
for test in pending:
|
||||
# Approve blue side
|
||||
test.blue_validation_status = "approved"
|
||||
test.blue_validated_by = current_user.id
|
||||
test.blue_validated_at = now
|
||||
test.blue_validation_notes = (
|
||||
"Bulk-approved via ATT&CK Evaluations admin panel. "
|
||||
"Source: MITRE lab environment — not organisational detection."
|
||||
)
|
||||
|
||||
# Red side was pre-approved during import → move to validated
|
||||
if test.red_validation_status == "approved":
|
||||
test.state = TestState.validated
|
||||
# else stays in_review (shouldn't happen for eval imports, but be safe)
|
||||
|
||||
if test.technique_id:
|
||||
affected_technique_ids.add(test.technique_id)
|
||||
|
||||
log_action(
|
||||
db,
|
||||
user_id=current_user.id,
|
||||
action="bulk_eval_approve",
|
||||
entity_type="test",
|
||||
entity_id=test.id,
|
||||
details={"source": "attck_evaluation_bulk_approve"},
|
||||
)
|
||||
|
||||
db.flush()
|
||||
|
||||
# Recalculate coverage for every touched technique
|
||||
for tech_id in affected_technique_ids:
|
||||
tech = db.query(Technique).filter(Technique.id == tech_id).first()
|
||||
if tech:
|
||||
recalculate_technique_status(db, tech)
|
||||
|
||||
db.commit()
|
||||
|
||||
logger.info(
|
||||
"Bulk eval approval: %d tests validated, %d techniques recalculated (by %s)",
|
||||
len(pending), len(affected_technique_ids), current_user.email,
|
||||
)
|
||||
|
||||
return {
|
||||
"approved": len(pending),
|
||||
"techniques_recalculated": len(affected_technique_ids),
|
||||
"message": (
|
||||
f"{len(pending)} evaluation tests approved and moved to Validated. "
|
||||
f"{len(affected_technique_ids)} technique statuses recalculated."
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
@router.get("/attck-evaluations/pending-count")
|
||||
def get_pending_evaluation_count(
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(require_role("admin")),
|
||||
):
|
||||
"""Return the number of imported evaluation tests still awaiting Blue approval."""
|
||||
from app.models.test import Test
|
||||
from app.models.enums import TestState
|
||||
|
||||
count = (
|
||||
db.query(Test)
|
||||
.filter(
|
||||
Test.state == TestState.in_review,
|
||||
Test.name.like("[EVAL R%"),
|
||||
)
|
||||
.count()
|
||||
)
|
||||
return {"pending": count}
|
||||
|
||||
|
||||
@router.post("/attck-evaluations/re-enrich")
|
||||
def re_enrich_evaluation_round(
|
||||
payload: dict,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(require_role("admin")),
|
||||
):
|
||||
"""Re-enrich already-imported evaluation tests with rich data from the MITRE API.
|
||||
|
||||
Updates procedure_text (attack path + criteria), description (data sources +
|
||||
substep references) and red_summary — without changing detection results,
|
||||
state or validation status.
|
||||
|
||||
Body: { "adversary_name": "turla", "adversary_display": "Turla", "eval_round": 5 }
|
||||
|
||||
Useful to upgrade tests that were imported before the enrichment feature
|
||||
was added.
|
||||
"""
|
||||
from app.services.attck_evaluations_service import re_enrich_evaluation_round as _re_enrich
|
||||
|
||||
adversary_name = payload.get("adversary_name", "")
|
||||
adversary_display = payload.get("adversary_display", adversary_name)
|
||||
eval_round = payload.get("eval_round", 0)
|
||||
|
||||
if not adversary_name or not eval_round:
|
||||
raise HTTPException(status_code=400, detail="adversary_name and eval_round are required")
|
||||
|
||||
try:
|
||||
summary = _re_enrich(db, adversary_name, adversary_display, eval_round, current_user)
|
||||
except Exception as exc:
|
||||
logger.error("ATT&CK Evaluation re-enrich failed: %s", exc, exc_info=True)
|
||||
raise HTTPException(status_code=502, detail=f"Re-enrich failed: {exc}")
|
||||
|
||||
return summary
|
||||
|
||||
|
||||
@router.post("/recalculate-coverage")
|
||||
def recalculate_all_coverage(
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(require_role("admin")),
|
||||
):
|
||||
"""Recompute status_global for every technique based on its current tests.
|
||||
|
||||
**Requires** the ``admin`` role.
|
||||
Run this after logic changes to the coverage scoring rules.
|
||||
"""
|
||||
from app.models.technique import Technique
|
||||
from app.services.status_service import recalculate_technique_status
|
||||
|
||||
techniques = db.query(Technique).all()
|
||||
updated = 0
|
||||
for tech in techniques:
|
||||
old_status = tech.status_global
|
||||
recalculate_technique_status(db, tech)
|
||||
if tech.status_global != old_status:
|
||||
updated += 1
|
||||
db.commit()
|
||||
logger.info(
|
||||
"Bulk coverage recalculation: %d/%d techniques updated (by %s)",
|
||||
updated, len(techniques), current_user.email,
|
||||
)
|
||||
return {
|
||||
"total": len(techniques),
|
||||
"updated": updated,
|
||||
"message": f"Coverage recalculated for {len(techniques)} techniques. {updated} statuses changed.",
|
||||
}
|
||||
|
||||
|
||||
@router.post("/email-test")
|
||||
def send_test_email(
|
||||
payload: EmailTestRequest,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(require_role("admin")),
|
||||
):
|
||||
"""Send a test email to verify SMTP configuration.
|
||||
|
||||
**Requires** the ``admin`` role.
|
||||
Returns 200 on success, 502 if sending fails.
|
||||
"""
|
||||
from app.services.email_service import send_test_email as _send_test
|
||||
|
||||
ok = _send_test(payload.to, db=db)
|
||||
if not ok:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_502_BAD_GATEWAY,
|
||||
detail="Failed to send test email. Check SMTP configuration and server logs.",
|
||||
)
|
||||
return {"detail": f"Test email sent to {payload.to}"}
|
||||
|
||||
@@ -37,13 +37,12 @@ from sqlalchemy.orm import Session
|
||||
# Import get_db from app.database
|
||||
from app.database import get_db
|
||||
|
||||
# Import get_current_user, require_any_role from app.dependencies.auth
|
||||
from app.dependencies.auth import get_current_user, require_any_role
|
||||
# Import get_current_user, require_any_role_strict from app.dependencies.auth
|
||||
from app.dependencies.auth import get_current_user, require_any_role_strict
|
||||
|
||||
# Import UnitOfWork from app.domain.unit_of_work
|
||||
from app.domain.unit_of_work import UnitOfWork
|
||||
|
||||
# Import User from app.models.user
|
||||
from app.models.technique import Technique
|
||||
from app.models.user import User
|
||||
|
||||
# Import from app.schemas.test_template
|
||||
@@ -168,7 +167,7 @@ def template_stats(
|
||||
# Entry: db
|
||||
db: Session = Depends(get_db),
|
||||
# Entry: current_user
|
||||
current_user: User = Depends(require_any_role("red_lead", "blue_lead")),
|
||||
current_user: User = Depends(require_any_role_strict("red_lead", "blue_lead")),
|
||||
) -> dict:
|
||||
"""Return catalog statistics: active, by_source, by_platform.
|
||||
|
||||
@@ -196,7 +195,7 @@ def bulk_activate_templates(
|
||||
# Entry: db
|
||||
db: Session = Depends(get_db),
|
||||
# Entry: current_user
|
||||
current_user: User = Depends(require_any_role("red_lead", "blue_lead")),
|
||||
current_user: User = Depends(require_any_role_strict("red_lead", "blue_lead")),
|
||||
) -> dict:
|
||||
"""Set all templates to active or inactive.
|
||||
|
||||
@@ -318,7 +317,7 @@ def create_template(
|
||||
# Entry: db
|
||||
db: Session = Depends(get_db),
|
||||
# Entry: current_user
|
||||
current_user: User = Depends(require_any_role("red_lead", "blue_lead")),
|
||||
current_user: User = Depends(require_any_role_strict("red_lead", "blue_lead")),
|
||||
) -> TestTemplateOut:
|
||||
"""Create a custom test template.
|
||||
|
||||
@@ -334,7 +333,15 @@ def create_template(
|
||||
template = create_template_svc(db, **payload.model_dump())
|
||||
# Open context manager
|
||||
with UnitOfWork(db) as uow:
|
||||
# Call log_action()
|
||||
# Flag the associated technique for review — new template available
|
||||
if template.mitre_technique_id:
|
||||
technique = (
|
||||
db.query(Technique)
|
||||
.filter(Technique.mitre_id == template.mitre_technique_id)
|
||||
.first()
|
||||
)
|
||||
if technique:
|
||||
technique.review_required = True
|
||||
log_action(
|
||||
db,
|
||||
# Keyword argument: user_id
|
||||
@@ -379,7 +386,7 @@ def update_template(
|
||||
# Entry: db
|
||||
db: Session = Depends(get_db),
|
||||
# Entry: current_user
|
||||
current_user: User = Depends(require_any_role("red_lead", "blue_lead")),
|
||||
current_user: User = Depends(require_any_role_strict("red_lead", "blue_lead")),
|
||||
) -> TestTemplateOut:
|
||||
"""Update fields of an existing test template.
|
||||
|
||||
@@ -432,7 +439,7 @@ def toggle_template_active(
|
||||
# Entry: db
|
||||
db: Session = Depends(get_db),
|
||||
# Entry: current_user
|
||||
current_user: User = Depends(require_any_role("red_lead", "blue_lead")),
|
||||
current_user: User = Depends(require_any_role_strict("red_lead", "blue_lead")),
|
||||
) -> TestTemplateOut:
|
||||
"""Toggle a template between active and inactive (is_active = not is_active).
|
||||
|
||||
@@ -484,7 +491,7 @@ def delete_template(
|
||||
# Entry: db
|
||||
db: Session = Depends(get_db),
|
||||
# Entry: current_user
|
||||
current_user: User = Depends(require_any_role("red_lead", "blue_lead")),
|
||||
current_user: User = Depends(require_any_role_strict("red_lead", "blue_lead")),
|
||||
) -> dict:
|
||||
"""Soft-delete a test template by setting ``is_active=False``.
|
||||
|
||||
|
||||
+861
-106
File diff suppressed because it is too large
Load Diff
@@ -62,7 +62,7 @@ def list_threat_actors(
|
||||
db: Session = Depends(get_db),
|
||||
# Entry: current_user
|
||||
current_user: User = Depends(get_current_user),
|
||||
) -> list:
|
||||
) -> dict:
|
||||
"""List threat actors with optional filters and pagination.
|
||||
|
||||
**Requires** authentication (any role).
|
||||
@@ -138,7 +138,7 @@ def get_threat_actor_gaps(
|
||||
db: Session = Depends(get_db),
|
||||
# Entry: current_user
|
||||
current_user: User = Depends(get_current_user),
|
||||
) -> list:
|
||||
) -> dict:
|
||||
"""Identify techniques of this actor that are NOT fully validated.
|
||||
|
||||
**Requires** authentication (any role).
|
||||
|
||||
@@ -13,18 +13,15 @@ from sqlalchemy.orm import Session
|
||||
from app.database import get_db
|
||||
|
||||
# Import require_role from app.dependencies.auth
|
||||
from app.dependencies.auth import require_role
|
||||
from app.dependencies.auth import require_role, require_any_role_strict
|
||||
|
||||
# Import UnitOfWork from app.domain.unit_of_work
|
||||
from app.domain.unit_of_work import UnitOfWork
|
||||
|
||||
# Import User from app.models.user
|
||||
from app.models.user import User
|
||||
|
||||
# Import UserCreate, UserOut, UserUpdate from app.schemas.user
|
||||
from app.schemas.user import UserCreate, UserOut, UserUpdate
|
||||
|
||||
# Import log_action from app.services.audit_service
|
||||
from app.dependencies.auth import get_current_user
|
||||
from app.schemas.user import UserCreate, UserUpdate, UserOut, UserPreferencesUpdate, UserOperatorOut
|
||||
from app.services.audit_service import log_action
|
||||
|
||||
# Import from app.services.user_service
|
||||
@@ -39,6 +36,47 @@ from app.services.user_service import (
|
||||
router = APIRouter(prefix="/users", tags=["users"])
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# PATCH /users/me/preferences — update current user preferences
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@router.patch("/me/preferences", response_model=UserOut)
|
||||
def update_my_preferences(
|
||||
payload: UserPreferencesUpdate,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""Update the current user's notification preferences, Jira account ID and Jira API token.
|
||||
|
||||
Send ``jira_api_token: ""`` to clear a previously stored token.
|
||||
The token is never returned in any response.
|
||||
"""
|
||||
update_data = payload.model_dump(exclude_unset=True)
|
||||
for field, value in update_data.items():
|
||||
if field in ("jira_api_token", "jira_email", "tempo_api_token"):
|
||||
# Empty string means "clear the value"
|
||||
setattr(current_user, field, value if value else None)
|
||||
else:
|
||||
setattr(current_user, field, value)
|
||||
db.commit()
|
||||
db.refresh(current_user)
|
||||
return current_user
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# GET /users/me — get current user's own profile
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@router.get("/me", response_model=UserOut)
|
||||
def get_me(
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""Return the currently authenticated user's profile."""
|
||||
return current_user
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# GET /users — list all users
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -110,6 +148,34 @@ def create_user_route(
|
||||
return user
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# GET /users/operators — minimal operator/lead list for assignment pickers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@router.get("/operators", response_model=list[UserOperatorOut])
|
||||
def list_operators_route(
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(require_any_role_strict("manager", "red_lead", "blue_lead")),
|
||||
) -> list[UserOperatorOut]:
|
||||
"""Return active red/blue operators and leads, for lead/manager assignment pickers.
|
||||
|
||||
Not reachable by admin — admin administers the site, leads/managers
|
||||
coordinate operators, and admin cannot itself be assigned as an
|
||||
operator either. Returns only id/username/role — no emails or
|
||||
tokens — since this is reachable by non-admin leads.
|
||||
"""
|
||||
return (
|
||||
db.query(User)
|
||||
.filter(
|
||||
User.role.in_(["red_tech", "red_lead", "blue_tech", "blue_lead"]),
|
||||
User.is_active.is_(True),
|
||||
)
|
||||
.order_by(User.username)
|
||||
.all()
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# GET /users/{id} — get a single user
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
"""Webhook configuration CRUD router — admin only.
|
||||
|
||||
Endpoints
|
||||
---------
|
||||
GET /webhooks — list all webhook configs
|
||||
POST /webhooks — create a new webhook config
|
||||
GET /webhooks/{id} — get a single webhook config
|
||||
PATCH /webhooks/{id} — update a webhook config
|
||||
DELETE /webhooks/{id} — hard-delete a webhook config
|
||||
POST /webhooks/{id}/test — send a test ping
|
||||
"""
|
||||
|
||||
import uuid
|
||||
|
||||
from fastapi import APIRouter, Depends, status
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.database import get_db
|
||||
from app.dependencies.auth import require_any_role
|
||||
from app.domain.unit_of_work import UnitOfWork
|
||||
from app.models.user import User
|
||||
from app.schemas.webhook import WebhookConfigCreate, WebhookConfigOut, WebhookConfigUpdate
|
||||
from app.services.webhook_service import (
|
||||
create_webhook,
|
||||
delete_webhook,
|
||||
dispatch_webhook,
|
||||
get_webhook_or_raise,
|
||||
list_webhooks,
|
||||
update_webhook,
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/webhooks", tags=["webhooks"])
|
||||
|
||||
|
||||
def _mask_secret(wh) -> WebhookConfigOut:
|
||||
"""Return a WebhookConfigOut with the secret masked."""
|
||||
out = WebhookConfigOut.model_validate(wh)
|
||||
if wh.secret:
|
||||
out.secret = "***" # nosec B105
|
||||
else:
|
||||
out.secret = None
|
||||
return out
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# GET /webhooks
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@router.get("", response_model=list[WebhookConfigOut])
|
||||
def list_webhooks_route(
|
||||
offset: int = 0,
|
||||
limit: int = 50,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(require_any_role("admin")),
|
||||
):
|
||||
"""Return all webhook configurations. **Requires admin role.**"""
|
||||
webhooks = list_webhooks(db, offset=offset, limit=limit)
|
||||
return [_mask_secret(wh) for wh in webhooks]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# POST /webhooks
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@router.post("", response_model=WebhookConfigOut, status_code=status.HTTP_201_CREATED)
|
||||
def create_webhook_route(
|
||||
payload: WebhookConfigCreate,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(require_any_role("admin")),
|
||||
):
|
||||
"""Create a new webhook configuration. **Requires admin role.**"""
|
||||
with UnitOfWork(db) as uow:
|
||||
wh = create_webhook(db, created_by=current_user.id, payload=payload)
|
||||
uow.commit()
|
||||
db.refresh(wh)
|
||||
return _mask_secret(wh)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# GET /webhooks/{id}
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@router.get("/{webhook_id}", response_model=WebhookConfigOut)
|
||||
def get_webhook_route(
|
||||
webhook_id: uuid.UUID,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(require_any_role("admin")),
|
||||
):
|
||||
"""Return a single webhook configuration. **Requires admin role.**"""
|
||||
wh = get_webhook_or_raise(db, webhook_id)
|
||||
return _mask_secret(wh)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# PATCH /webhooks/{id}
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@router.patch("/{webhook_id}", response_model=WebhookConfigOut)
|
||||
def update_webhook_route(
|
||||
webhook_id: uuid.UUID,
|
||||
payload: WebhookConfigUpdate,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(require_any_role("admin")),
|
||||
):
|
||||
"""Update one or more fields of a webhook configuration. **Requires admin role.**"""
|
||||
with UnitOfWork(db) as uow:
|
||||
wh = update_webhook(db, webhook_id, payload)
|
||||
uow.commit()
|
||||
db.refresh(wh)
|
||||
return _mask_secret(wh)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# DELETE /webhooks/{id}
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@router.delete("/{webhook_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
def delete_webhook_route(
|
||||
webhook_id: uuid.UUID,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(require_any_role("admin")),
|
||||
):
|
||||
"""Hard-delete a webhook configuration. **Requires admin role.**"""
|
||||
with UnitOfWork(db) as uow:
|
||||
delete_webhook(db, webhook_id)
|
||||
uow.commit()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# POST /webhooks/{id}/test
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@router.post("/{webhook_id}/test", status_code=status.HTTP_202_ACCEPTED)
|
||||
def test_webhook_route(
|
||||
webhook_id: uuid.UUID,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(require_any_role("admin")),
|
||||
):
|
||||
"""Send a test ping to the webhook endpoint. **Requires admin role.**"""
|
||||
# Verify the webhook exists before dispatching
|
||||
get_webhook_or_raise(db, webhook_id)
|
||||
dispatch_webhook("webhook.test", {"webhook_id": str(webhook_id), "message": "Test ping from Aegis"})
|
||||
return {"detail": "Test ping dispatched"}
|
||||
@@ -0,0 +1,68 @@
|
||||
"""Phase 14: API Key Pydantic schemas."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from typing import List, Optional
|
||||
from uuid import UUID
|
||||
|
||||
from pydantic import BaseModel, Field, field_validator
|
||||
|
||||
from app.models.api_key import VALID_SCOPES
|
||||
|
||||
|
||||
class ApiKeyCreate(BaseModel):
|
||||
name: str = Field(..., min_length=1, max_length=200)
|
||||
description: Optional[str] = None
|
||||
scopes: List[str] = Field(default=["read"])
|
||||
expires_at: Optional[datetime] = None
|
||||
|
||||
@field_validator("scopes")
|
||||
@classmethod
|
||||
def validate_scopes(cls, v: list) -> list:
|
||||
invalid = set(v) - VALID_SCOPES
|
||||
if invalid:
|
||||
raise ValueError(f"Invalid scopes: {invalid}. Valid: {VALID_SCOPES}")
|
||||
if not v:
|
||||
raise ValueError("At least one scope is required")
|
||||
return v
|
||||
|
||||
|
||||
class ApiKeyOut(BaseModel):
|
||||
"""Safe representation — never exposes key_hash."""
|
||||
id: UUID
|
||||
name: str
|
||||
description: Optional[str] = None
|
||||
key_prefix: str
|
||||
user_id: UUID
|
||||
scopes: List[str]
|
||||
last_used_at: Optional[datetime] = None
|
||||
expires_at: Optional[datetime] = None
|
||||
is_active: bool
|
||||
created_at: Optional[datetime] = None
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class ApiKeyCreated(ApiKeyOut):
|
||||
"""Returned only once at creation — includes the raw key."""
|
||||
raw_key: str = Field(..., description="The full API key — shown only this once.")
|
||||
|
||||
|
||||
class ApiKeyUpdate(BaseModel):
|
||||
name: Optional[str] = Field(None, min_length=1, max_length=200)
|
||||
description: Optional[str] = None
|
||||
scopes: Optional[List[str]] = None
|
||||
expires_at: Optional[datetime] = None
|
||||
is_active: Optional[bool] = None
|
||||
|
||||
@field_validator("scopes")
|
||||
@classmethod
|
||||
def validate_scopes(cls, v: Optional[list]) -> Optional[list]:
|
||||
if v is None:
|
||||
return v
|
||||
invalid = set(v) - VALID_SCOPES
|
||||
if invalid:
|
||||
raise ValueError(f"Invalid scopes: {invalid}")
|
||||
return v
|
||||
@@ -0,0 +1,230 @@
|
||||
"""Pydantic schemas for Phase 10: Attack Paths & Advanced Purple Team."""
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
from uuid import UUID
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, field_validator
|
||||
|
||||
VALID_KILL_CHAIN_PHASES = [
|
||||
"reconnaissance", "resource_development", "initial_access", "execution",
|
||||
"persistence", "privilege_escalation", "defense_evasion", "credential_access",
|
||||
"discovery", "lateral_movement", "collection", "command_and_control",
|
||||
"exfiltration", "impact",
|
||||
]
|
||||
|
||||
|
||||
# ── Attack Path ───────────────────────────────────────────────────────────────
|
||||
|
||||
class AttackPathCreate(BaseModel):
|
||||
name: str
|
||||
description: Optional[str] = None
|
||||
objective: Optional[str] = None
|
||||
is_template: bool = False
|
||||
threat_actor_id: Optional[UUID] = None
|
||||
tags: Optional[list[str]] = None
|
||||
|
||||
|
||||
class AttackPathUpdate(BaseModel):
|
||||
name: Optional[str] = None
|
||||
description: Optional[str] = None
|
||||
objective: Optional[str] = None
|
||||
is_template: Optional[bool] = None
|
||||
threat_actor_id: Optional[UUID] = None
|
||||
tags: Optional[list[str]] = None
|
||||
is_active: Optional[bool] = None
|
||||
|
||||
|
||||
class AttackPathOut(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: UUID
|
||||
name: str
|
||||
description: Optional[str] = None
|
||||
objective: Optional[str] = None
|
||||
is_template: bool
|
||||
threat_actor_id: Optional[UUID] = None
|
||||
created_by: Optional[UUID] = None
|
||||
tags: Optional[list] = None
|
||||
is_active: bool
|
||||
created_at: Optional[datetime] = None
|
||||
updated_at: Optional[datetime] = None
|
||||
step_count: Optional[int] = None # injected by service
|
||||
|
||||
|
||||
# ── Attack Path Step ──────────────────────────────────────────────────────────
|
||||
|
||||
class AttackPathStepCreate(BaseModel):
|
||||
order_index: int = 0
|
||||
kill_chain_phase: Optional[str] = None
|
||||
technique_id: Optional[UUID] = None
|
||||
test_id: Optional[UUID] = None
|
||||
name: Optional[str] = None
|
||||
description: Optional[str] = None
|
||||
expected_detection: bool = True
|
||||
notes: Optional[str] = None
|
||||
|
||||
@field_validator("kill_chain_phase")
|
||||
@classmethod
|
||||
def validate_phase(cls, v):
|
||||
if v is not None and v not in VALID_KILL_CHAIN_PHASES:
|
||||
raise ValueError(f"Invalid kill_chain_phase '{v}'. Valid: {VALID_KILL_CHAIN_PHASES}")
|
||||
return v
|
||||
|
||||
|
||||
class AttackPathStepUpdate(BaseModel):
|
||||
order_index: Optional[int] = None
|
||||
kill_chain_phase: Optional[str] = None
|
||||
technique_id: Optional[UUID] = None
|
||||
test_id: Optional[UUID] = None
|
||||
name: Optional[str] = None
|
||||
description: Optional[str] = None
|
||||
expected_detection: Optional[bool] = None
|
||||
notes: Optional[str] = None
|
||||
|
||||
@field_validator("kill_chain_phase")
|
||||
@classmethod
|
||||
def validate_phase(cls, v):
|
||||
if v is not None and v not in VALID_KILL_CHAIN_PHASES:
|
||||
raise ValueError(f"Invalid kill_chain_phase '{v}'.")
|
||||
return v
|
||||
|
||||
|
||||
class AttackPathStepOut(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: UUID
|
||||
attack_path_id: UUID
|
||||
order_index: int
|
||||
kill_chain_phase: Optional[str] = None
|
||||
technique_id: Optional[UUID] = None
|
||||
test_id: Optional[UUID] = None
|
||||
name: Optional[str] = None
|
||||
description: Optional[str] = None
|
||||
expected_detection: bool
|
||||
notes: Optional[str] = None
|
||||
|
||||
|
||||
# ── Execution ─────────────────────────────────────────────────────────────────
|
||||
|
||||
class ExecutionCreate(BaseModel):
|
||||
environment: Optional[str] = None
|
||||
red_team_lead: Optional[UUID] = None
|
||||
blue_team_lead: Optional[UUID] = None
|
||||
notes: Optional[str] = None
|
||||
|
||||
|
||||
class ExecutionOut(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: UUID
|
||||
attack_path_id: UUID
|
||||
status: str
|
||||
environment: Optional[str] = None
|
||||
red_team_lead: Optional[UUID] = None
|
||||
blue_team_lead: Optional[UUID] = None
|
||||
started_by: Optional[UUID] = None
|
||||
started_at: Optional[datetime] = None
|
||||
completed_at: Optional[datetime] = None
|
||||
notes: Optional[str] = None
|
||||
created_at: Optional[datetime] = None
|
||||
# metrics
|
||||
total_steps: Optional[int] = None
|
||||
detected_steps: Optional[int] = None
|
||||
not_detected_steps: Optional[int] = None
|
||||
skipped_steps: Optional[int] = None
|
||||
detection_rate: Optional[float] = None
|
||||
mttd_seconds: Optional[float] = None
|
||||
furthest_undetected_step: Optional[int] = None
|
||||
|
||||
|
||||
# ── Step Result ───────────────────────────────────────────────────────────────
|
||||
|
||||
class StepExecuteRequest(BaseModel):
|
||||
status: str # detected / not_detected / skipped
|
||||
executed_at: Optional[datetime] = None
|
||||
detected_at: Optional[datetime] = None
|
||||
detection_asset_id: Optional[UUID] = None
|
||||
notes: Optional[str] = None
|
||||
evidence_ids: Optional[list[UUID]] = None
|
||||
|
||||
@field_validator("status")
|
||||
@classmethod
|
||||
def validate_status(cls, v):
|
||||
valid = ("detected", "not_detected", "skipped", "executing")
|
||||
if v not in valid:
|
||||
raise ValueError(f"status must be one of {valid}")
|
||||
return v
|
||||
|
||||
|
||||
class StepResultOut(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: UUID
|
||||
execution_id: UUID
|
||||
step_id: UUID
|
||||
step_order: int
|
||||
status: str
|
||||
executed_by: Optional[UUID] = None
|
||||
executed_at: Optional[datetime] = None
|
||||
detected_at: Optional[datetime] = None
|
||||
time_to_detect_seconds: Optional[float] = None
|
||||
detection_asset_id: Optional[UUID] = None
|
||||
notes: Optional[str] = None
|
||||
evidence_ids: Optional[list] = None
|
||||
|
||||
|
||||
# ── Timeline ──────────────────────────────────────────────────────────────────
|
||||
|
||||
class TimelineEntryCreate(BaseModel):
|
||||
actor_side: str
|
||||
entry_type: str
|
||||
content: str
|
||||
step_id: Optional[UUID] = None
|
||||
timestamp: Optional[datetime] = None
|
||||
extra: Optional[dict] = None
|
||||
|
||||
@field_validator("actor_side")
|
||||
@classmethod
|
||||
def validate_side(cls, v):
|
||||
if v not in ("red", "blue", "system"):
|
||||
raise ValueError("actor_side must be red, blue or system")
|
||||
return v
|
||||
|
||||
@field_validator("entry_type")
|
||||
@classmethod
|
||||
def validate_type(cls, v):
|
||||
valid = ("action", "detection", "note", "phase_transition", "flag")
|
||||
if v not in valid:
|
||||
raise ValueError(f"entry_type must be one of {valid}")
|
||||
return v
|
||||
|
||||
|
||||
class TimelineEntryOut(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: UUID
|
||||
execution_id: UUID
|
||||
step_id: Optional[UUID] = None
|
||||
timestamp: datetime
|
||||
actor_side: str
|
||||
actor_id: Optional[UUID] = None
|
||||
entry_type: str
|
||||
content: str
|
||||
extra: Optional[dict] = None
|
||||
|
||||
|
||||
# ── Metrics ───────────────────────────────────────────────────────────────────
|
||||
|
||||
class KillChainMetrics(BaseModel):
|
||||
execution_id: UUID
|
||||
total_steps: int
|
||||
detected_steps: int
|
||||
not_detected_steps: int
|
||||
skipped_steps: int
|
||||
detection_rate: float # 0.0–1.0
|
||||
mttd_seconds: Optional[float] # mean time to detect
|
||||
furthest_undetected_step: Optional[int]
|
||||
furthest_undetected_phase: Optional[str]
|
||||
step_breakdown: list[dict] # per-step detail
|
||||
phase_summary: dict # detection rate per kill-chain phase
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user