Compare commits
4 Commits
main
..
4e378540af
| Author | SHA1 | Date | |
|---|---|---|---|
| 4e378540af | |||
| c99cc4946a | |||
| 394d5d9056 | |||
| ec26183e2e |
@@ -0,0 +1,189 @@
|
|||||||
|
---
|
||||||
|
description: Aegis backend Clean Architecture rules. Apply when working on any backend Python file under backend/app/ or backend/tests/.
|
||||||
|
globs: backend/**/*.py
|
||||||
|
---
|
||||||
|
|
||||||
|
# Aegis — Clean Modular Monolith Architecture
|
||||||
|
|
||||||
|
## Architecture Overview
|
||||||
|
|
||||||
|
Aegis follows a **Clean Architecture** pattern inside a modular monolith. The backend has four layers with strict dependency rules:
|
||||||
|
|
||||||
|
```
|
||||||
|
Presentation → Application → Domain ← Infrastructure
|
||||||
|
```
|
||||||
|
|
||||||
|
**The golden rule:** dependencies only point towards the Domain layer. Infrastructure implements the ports (interfaces) defined in Domain.
|
||||||
|
|
||||||
|
## Layer Structure and Rules
|
||||||
|
|
||||||
|
### Domain Layer (`backend/app/domain/`)
|
||||||
|
|
||||||
|
The innermost layer. **ZERO** imports from FastAPI, SQLAlchemy, Pydantic, or any framework.
|
||||||
|
|
||||||
|
| Directory | Purpose |
|
||||||
|
|-----------|---------|
|
||||||
|
| `domain/enums.py` | Canonical domain enums (TechniqueStatus, TestState, TeamSide, TestResult) |
|
||||||
|
| `domain/errors.py` | Exception hierarchy (DomainError → EntityNotFoundError, InvalidStateTransition, etc.) |
|
||||||
|
| `domain/exceptions.py` | Backward-compatible re-exports from errors.py |
|
||||||
|
| `domain/test_entity.py` | TestEntity — pure state machine with domain events |
|
||||||
|
| `domain/entities/` | Rich domain entities (TechniqueEntity, etc.) with business behavior |
|
||||||
|
| `domain/value_objects/` | Immutable value types (MitreId, ScoringWeights) |
|
||||||
|
| `domain/ports/repositories/` | Protocol interfaces defining data access contracts |
|
||||||
|
| `domain/ports/services/` | Protocol interfaces for external capabilities (storage, events) |
|
||||||
|
| `domain/unit_of_work.py` | UnitOfWork wrapping SQLAlchemy session |
|
||||||
|
|
||||||
|
**NEVER** import from `app.models`, `app.routers`, `app.infrastructure`, `fastapi`, or `sqlalchemy` inside `domain/`.
|
||||||
|
|
||||||
|
### Application Layer (`backend/app/application/` — future)
|
||||||
|
|
||||||
|
Use case orchestrators. Depends only on Domain.
|
||||||
|
|
||||||
|
| Directory | Purpose |
|
||||||
|
|-----------|---------|
|
||||||
|
| `application/use_cases/` | One class per business operation |
|
||||||
|
| `application/dto/` | Plain data containers for use case input/output |
|
||||||
|
| `application/interfaces/` | Application-level contracts (UnitOfWork protocol) |
|
||||||
|
|
||||||
|
### Infrastructure Layer (`backend/app/infrastructure/`)
|
||||||
|
|
||||||
|
Implements ports defined in Domain. Depends on Domain and Application.
|
||||||
|
|
||||||
|
| Directory | Purpose |
|
||||||
|
|-----------|---------|
|
||||||
|
| `infrastructure/redis_client.py` | Redis connection singleton |
|
||||||
|
| `infrastructure/persistence/repositories/` | SQLAlchemy implementations of repository ports |
|
||||||
|
| `infrastructure/persistence/mappers/` | ORM model ↔ domain entity converters |
|
||||||
|
|
||||||
|
### Presentation Layer (routers, schemas, dependencies)
|
||||||
|
|
||||||
|
HTTP boundary. Depends on Application and Domain (for exceptions).
|
||||||
|
|
||||||
|
| Directory | Purpose |
|
||||||
|
|-----------|---------|
|
||||||
|
| `routers/` | FastAPI routers — HTTP mapping only |
|
||||||
|
| `schemas/` | Pydantic request/response models |
|
||||||
|
| `dependencies/` | FastAPI `Depends()` wiring (auth, repositories) |
|
||||||
|
| `middleware/` | Error handler mapping domain exceptions → HTTP responses |
|
||||||
|
|
||||||
|
## Import Rules (Strict)
|
||||||
|
|
||||||
|
| From \ To | domain/ | application/ | infrastructure/ | presentation/ |
|
||||||
|
|-----------|---------|-------------|----------------|--------------|
|
||||||
|
| **domain/** | Self only | FORBIDDEN | FORBIDDEN | FORBIDDEN |
|
||||||
|
| **application/** | ALLOWED | Self only | FORBIDDEN | FORBIDDEN |
|
||||||
|
| **infrastructure/** | ALLOWED (ports) | ALLOWED (UoW) | Self only | FORBIDDEN |
|
||||||
|
| **presentation/** | ALLOWED (exceptions) | ALLOWED (use cases) | ALLOWED (wiring in dependencies/) | Self only |
|
||||||
|
|
||||||
|
## How to Add a New Feature
|
||||||
|
|
||||||
|
### 1. Start from the Domain
|
||||||
|
|
||||||
|
- Define or reuse domain entities in `domain/entities/`
|
||||||
|
- Add value objects if needed in `domain/value_objects/`
|
||||||
|
- Define repository port if a new aggregate root in `domain/ports/repositories/`
|
||||||
|
- Domain exceptions go in `domain/errors.py`
|
||||||
|
- Business rules live IN the entity, not in services or routers
|
||||||
|
|
||||||
|
### 2. Implement Infrastructure
|
||||||
|
|
||||||
|
- Create SQLAlchemy repository implementation in `infrastructure/persistence/repositories/`
|
||||||
|
- Create mapper if converting between ORM model and domain entity
|
||||||
|
- Repository does NOT call `commit()` — only `flush()`
|
||||||
|
- Transaction control belongs to the Unit of Work
|
||||||
|
|
||||||
|
### 3. Wire in Presentation
|
||||||
|
|
||||||
|
- Add FastAPI `Depends()` provider in `dependencies/repositories.py`
|
||||||
|
- Keep routers thin: parse request → call service/use case → return response
|
||||||
|
- Map domain exceptions to HTTP via the error handler middleware (automatic)
|
||||||
|
|
||||||
|
### 4. Tests (Mandatory)
|
||||||
|
|
||||||
|
Every change MUST include tests:
|
||||||
|
- **Domain entities/value objects**: pure unit tests, no DB, no mocking frameworks
|
||||||
|
- **Repositories**: integration tests using the `db` fixture from conftest
|
||||||
|
- **Routers**: API tests using the `client` fixture
|
||||||
|
- At least one success test + one failure/edge-case test per behavior
|
||||||
|
|
||||||
|
Before committing, run: `scripts/agent_validate_backend.sh`
|
||||||
|
|
||||||
|
## Existing Patterns to Follow
|
||||||
|
|
||||||
|
### Domain Entity Pattern (see `domain/test_entity.py`)
|
||||||
|
|
||||||
|
```python
|
||||||
|
@dataclass
|
||||||
|
class SomeEntity:
|
||||||
|
id: uuid.UUID
|
||||||
|
# fields...
|
||||||
|
_events: list[DomainEvent] = field(default_factory=list, repr=False)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_orm(cls, model: Any) -> "SomeEntity":
|
||||||
|
"""Build from SQLAlchemy model."""
|
||||||
|
...
|
||||||
|
|
||||||
|
def apply_to(self, model: Any) -> None:
|
||||||
|
"""Copy mutable fields back onto the ORM model."""
|
||||||
|
...
|
||||||
|
|
||||||
|
def some_business_method(self) -> None:
|
||||||
|
"""Business logic lives HERE, not in services."""
|
||||||
|
...
|
||||||
|
self._events.append(DomainEvent("something_happened"))
|
||||||
|
```
|
||||||
|
|
||||||
|
### Repository Port Pattern (Protocol)
|
||||||
|
|
||||||
|
```python
|
||||||
|
from typing import Protocol, runtime_checkable
|
||||||
|
|
||||||
|
@runtime_checkable
|
||||||
|
class SomeRepository(Protocol):
|
||||||
|
def find_by_id(self, id: uuid.UUID) -> SomeEntity | None: ...
|
||||||
|
def save(self, entity: SomeEntity) -> SomeEntity: ...
|
||||||
|
```
|
||||||
|
|
||||||
|
### Repository Implementation Pattern
|
||||||
|
|
||||||
|
```python
|
||||||
|
class SASomeRepository:
|
||||||
|
def __init__(self, session: Session) -> None:
|
||||||
|
self._session = session
|
||||||
|
|
||||||
|
def find_by_id(self, id: uuid.UUID) -> SomeEntity | None:
|
||||||
|
model = self._session.query(SomeModel).filter(SomeModel.id == id).first()
|
||||||
|
return SomeMapper.to_entity(model) if model else None
|
||||||
|
|
||||||
|
def save(self, entity: SomeEntity) -> SomeEntity:
|
||||||
|
model = SomeMapper.to_model(entity)
|
||||||
|
merged = self._session.merge(model)
|
||||||
|
self._session.flush() # NO commit — UoW does that
|
||||||
|
return SomeMapper.to_entity(merged)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Error Handling (automatic via middleware)
|
||||||
|
|
||||||
|
Services raise domain exceptions → middleware maps to HTTP:
|
||||||
|
- `EntityNotFoundError` → 404
|
||||||
|
- `DuplicateEntityError` → 409
|
||||||
|
- `InvalidStateTransition` → 400
|
||||||
|
- `BusinessRuleViolation` → 400
|
||||||
|
- `PermissionViolation` → 403
|
||||||
|
|
||||||
|
### Coexistence Strategy
|
||||||
|
|
||||||
|
Old code (direct `db.query()` in routers) and new code (repositories) coexist. Migration is incremental:
|
||||||
|
1. New endpoints use repositories
|
||||||
|
2. Existing endpoints are migrated one at a time
|
||||||
|
3. Both access the same DB, same session, same tables
|
||||||
|
|
||||||
|
## Key Conventions
|
||||||
|
|
||||||
|
- **Enums**: canonical source is `domain/enums.py`, `models/enums.py` re-exports
|
||||||
|
- **Exceptions**: raise from `domain/errors.py`, never raise `HTTPException` from services
|
||||||
|
- **Commits**: only via `UnitOfWork.commit()` or at the router level, never inside services/repos
|
||||||
|
- **IDs**: UUID everywhere (primary keys, foreign keys)
|
||||||
|
- **Tests**: SQLite in-memory for unit/integration, PostgreSQL in CI
|
||||||
|
- **Validation**: Pydantic in schemas (presentation), domain rules in entities (domain)
|
||||||
@@ -23,10 +23,7 @@ TOKEN_EXPIRE_MINUTES=60
|
|||||||
# ── Initial Admin Account ────────────────────────────────────────────────────
|
# ── Initial Admin Account ────────────────────────────────────────────────────
|
||||||
# If ADMIN_PASSWORD is empty, a random password is auto-generated and
|
# If ADMIN_PASSWORD is empty, a random password is auto-generated and
|
||||||
# printed to the backend container logs on first startup.
|
# printed to the backend container logs on first startup.
|
||||||
# ADMIN_EMAIL is what you actually log in with (email is the unique
|
|
||||||
# identifier) — set it to a real address you control.
|
|
||||||
ADMIN_USERNAME=admin
|
ADMIN_USERNAME=admin
|
||||||
ADMIN_EMAIL=
|
|
||||||
ADMIN_PASSWORD=
|
ADMIN_PASSWORD=
|
||||||
|
|
||||||
# ── MinIO Object Storage ─────────────────────────────────────────────────────
|
# ── MinIO Object Storage ─────────────────────────────────────────────────────
|
||||||
@@ -42,13 +39,6 @@ CORS_ORIGINS=https://your-domain.com
|
|||||||
# ── Frontend ─────────────────────────────────────────────────────────────────
|
# ── Frontend ─────────────────────────────────────────────────────────────────
|
||||||
FRONTEND_PORT=80
|
FRONTEND_PORT=80
|
||||||
|
|
||||||
# ── Emails ────────────────────────────────────────────────────────────────────
|
|
||||||
# Base URL used to build links in outbound emails (set-password, etc).
|
|
||||||
# REQUIRED in production — must be THIS deployment's real public frontend
|
|
||||||
# URL. There is no safe default (it's unique per deployment); the backend
|
|
||||||
# refuses to start without it when AEGIS_ENV=production.
|
|
||||||
PLATFORM_URL=https://your-domain.com
|
|
||||||
|
|
||||||
# ── Environment flag ─────────────────────────────────────────────────────────
|
# ── Environment flag ─────────────────────────────────────────────────────────
|
||||||
# Set to "production" for production deployments (enforces SECRET_KEY, etc.)
|
# Set to "production" for production deployments (enforces SECRET_KEY, etc.)
|
||||||
AEGIS_ENV=production
|
AEGIS_ENV=production
|
||||||
|
|||||||
@@ -1,71 +0,0 @@
|
|||||||
name: Snyk Security Scan
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
branches: [main, develop]
|
|
||||||
pull_request:
|
|
||||||
branches: [main]
|
|
||||||
schedule:
|
|
||||||
- cron: '0 6 * * 1' # Weekly on Monday 06:00 UTC
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
snyk-backend:
|
|
||||||
name: Python vulnerabilities (backend)
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- uses: actions/setup-python@v5
|
|
||||||
with:
|
|
||||||
python-version: "3.11"
|
|
||||||
|
|
||||||
- name: Install backend dependencies
|
|
||||||
run: pip install -r backend/requirements-lock.txt
|
|
||||||
|
|
||||||
- name: Snyk — scan Python packages
|
|
||||||
uses: snyk/actions/python@master
|
|
||||||
env:
|
|
||||||
SNYK_TOKEN: ${{ secrets.SNYK_TOKEN }}
|
|
||||||
with:
|
|
||||||
args: --file=backend/requirements-lock.txt --severity-threshold=high
|
|
||||||
continue-on-error: true # report without blocking CI during initial cleanup
|
|
||||||
|
|
||||||
snyk-frontend:
|
|
||||||
name: npm vulnerabilities (frontend)
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- uses: actions/setup-node@v4
|
|
||||||
with:
|
|
||||||
node-version: '20'
|
|
||||||
|
|
||||||
- name: Install frontend dependencies
|
|
||||||
run: npm ci
|
|
||||||
working-directory: frontend
|
|
||||||
|
|
||||||
- name: Snyk — scan npm packages
|
|
||||||
uses: snyk/actions/node@master
|
|
||||||
env:
|
|
||||||
SNYK_TOKEN: ${{ secrets.SNYK_TOKEN }}
|
|
||||||
with:
|
|
||||||
args: --file=frontend/package.json --severity-threshold=high
|
|
||||||
continue-on-error: true
|
|
||||||
|
|
||||||
snyk-docker-backend:
|
|
||||||
name: Docker image vulnerabilities (backend)
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- name: Build backend image for scanning
|
|
||||||
run: docker build -t aegis-backend:scan backend/
|
|
||||||
|
|
||||||
- name: Snyk — scan Docker image
|
|
||||||
uses: snyk/actions/docker@master
|
|
||||||
env:
|
|
||||||
SNYK_TOKEN: ${{ secrets.SNYK_TOKEN }}
|
|
||||||
with:
|
|
||||||
image: aegis-backend:scan
|
|
||||||
args: --severity-threshold=high
|
|
||||||
continue-on-error: true
|
|
||||||
@@ -60,12 +60,3 @@ Thumbs.db
|
|||||||
|
|
||||||
# Local development
|
# Local development
|
||||||
*.local
|
*.local
|
||||||
|
|
||||||
# Documentation drafts — never commit, delivered directly in chat
|
|
||||||
docs/confluence/
|
|
||||||
docs/drafts/
|
|
||||||
|
|
||||||
# Editor / AI assistant working files — never commit
|
|
||||||
.claude/
|
|
||||||
.cursor/
|
|
||||||
CLAUDE.md
|
|
||||||
|
|||||||
@@ -1,11 +0,0 @@
|
|||||||
# Snyk (https://snyk.io) policy file
|
|
||||||
version: v1.25.0
|
|
||||||
|
|
||||||
# Snyk Code — exclude paths from SAST scanning
|
|
||||||
exclude:
|
|
||||||
code:
|
|
||||||
# Test fixtures use hardcoded credentials against an in-memory SQLite DB.
|
|
||||||
# These are test-only and carry no production risk.
|
|
||||||
- backend/tests/**
|
|
||||||
# Utility scripts use env-var credentials with safe defaults for local dev only.
|
|
||||||
- scripts/**
|
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
skips:
|
|
||||||
- B311
|
|
||||||
+1
-5
@@ -3,14 +3,10 @@ FROM python:3.11-slim
|
|||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
# Install system dependencies
|
# Install system dependencies
|
||||||
RUN apt-get update && apt-get upgrade -y && apt-get install -y \
|
RUN apt-get update && apt-get install -y \
|
||||||
gcc \
|
gcc \
|
||||||
libpq-dev \
|
libpq-dev \
|
||||||
curl \
|
curl \
|
||||||
pkg-config \
|
|
||||||
libxml2-dev \
|
|
||||||
libxmlsec1-dev \
|
|
||||||
libxmlsec1-openssl \
|
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
# Copy requirements first for better caching
|
# Copy requirements first for better caching
|
||||||
|
|||||||
@@ -1,32 +0,0 @@
|
|||||||
"""Phase 6.1: webhook_configs table.
|
|
||||||
|
|
||||||
Revision ID: b031phase6
|
|
||||||
Revises: b030phase5
|
|
||||||
"""
|
|
||||||
from typing import Sequence, Union
|
|
||||||
import sqlalchemy as sa
|
|
||||||
from sqlalchemy.dialects import postgresql
|
|
||||||
from alembic import op
|
|
||||||
|
|
||||||
revision: str = "b031phase6"
|
|
||||||
down_revision: Union[str, None] = "b030phase5"
|
|
||||||
branch_labels: Union[str, Sequence[str], None] = None
|
|
||||||
depends_on: Union[str, Sequence[str], None] = None
|
|
||||||
|
|
||||||
def upgrade() -> None:
|
|
||||||
op.create_table(
|
|
||||||
"webhook_configs",
|
|
||||||
sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True),
|
|
||||||
sa.Column("name", sa.String(200), nullable=False),
|
|
||||||
sa.Column("url", sa.Text, nullable=False),
|
|
||||||
sa.Column("secret", sa.String(256), nullable=True),
|
|
||||||
sa.Column("events", postgresql.JSONB, nullable=False, server_default="[]"),
|
|
||||||
sa.Column("is_active", sa.Boolean, nullable=False, server_default="true"),
|
|
||||||
sa.Column("created_by", postgresql.UUID(as_uuid=True), sa.ForeignKey("users.id", ondelete="SET NULL"), nullable=True),
|
|
||||||
sa.Column("last_triggered_at", sa.DateTime, nullable=True),
|
|
||||||
sa.Column("failure_count", sa.Integer, nullable=False, server_default="0"),
|
|
||||||
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
|
|
||||||
)
|
|
||||||
|
|
||||||
def downgrade() -> None:
|
|
||||||
op.drop_table("webhook_configs")
|
|
||||||
@@ -1,41 +0,0 @@
|
|||||||
"""Phase 7.2: user notification_preferences and jira_account_id columns.
|
|
||||||
|
|
||||||
Revision ID: b032phase7
|
|
||||||
Revises: b031phase6
|
|
||||||
"""
|
|
||||||
from typing import Sequence, Union
|
|
||||||
import sqlalchemy as sa
|
|
||||||
from sqlalchemy.dialects import postgresql
|
|
||||||
from alembic import op
|
|
||||||
|
|
||||||
revision: str = "b032phase7"
|
|
||||||
down_revision: Union[str, None] = "b031phase6"
|
|
||||||
branch_labels: Union[str, Sequence[str], None] = None
|
|
||||||
depends_on: Union[str, Sequence[str], None] = None
|
|
||||||
|
|
||||||
_DEFAULT_PREFS = '{"email_on_test_validated": true, "email_on_campaign_completed": true, "email_on_new_mitre_techniques": false, "in_app_all": true}'
|
|
||||||
|
|
||||||
def _column_names(table: str) -> set[str]:
|
|
||||||
bind = op.get_bind()
|
|
||||||
insp = sa.inspect(bind)
|
|
||||||
return {c["name"] for c in insp.get_columns(table)}
|
|
||||||
|
|
||||||
def upgrade() -> None:
|
|
||||||
user_cols = _column_names("users")
|
|
||||||
if "notification_preferences" not in user_cols:
|
|
||||||
op.add_column(
|
|
||||||
"users",
|
|
||||||
sa.Column("notification_preferences", postgresql.JSONB, nullable=True, server_default=_DEFAULT_PREFS),
|
|
||||||
)
|
|
||||||
if "jira_account_id" not in user_cols:
|
|
||||||
op.add_column(
|
|
||||||
"users",
|
|
||||||
sa.Column("jira_account_id", sa.String(100), nullable=True),
|
|
||||||
)
|
|
||||||
|
|
||||||
def downgrade() -> None:
|
|
||||||
user_cols = _column_names("users")
|
|
||||||
if "jira_account_id" in user_cols:
|
|
||||||
op.drop_column("users", "jira_account_id")
|
|
||||||
if "notification_preferences" in user_cols:
|
|
||||||
op.drop_column("users", "notification_preferences")
|
|
||||||
@@ -1,43 +0,0 @@
|
|||||||
"""Phase 8: system_configs table for runtime configuration.
|
|
||||||
|
|
||||||
Revision ID: b033syscfg
|
|
||||||
Revises: b032phase7
|
|
||||||
"""
|
|
||||||
from typing import Sequence, Union
|
|
||||||
import sqlalchemy as sa
|
|
||||||
from sqlalchemy.dialects import postgresql
|
|
||||||
from alembic import op
|
|
||||||
|
|
||||||
revision: str = "b033syscfg"
|
|
||||||
down_revision: Union[str, None] = "b032phase7"
|
|
||||||
branch_labels: Union[str, Sequence[str], None] = None
|
|
||||||
depends_on: Union[str, Sequence[str], None] = None
|
|
||||||
|
|
||||||
|
|
||||||
def _table_exists(name: str) -> bool:
|
|
||||||
bind = op.get_bind()
|
|
||||||
insp = sa.inspect(bind)
|
|
||||||
return name in insp.get_table_names()
|
|
||||||
|
|
||||||
|
|
||||||
def upgrade() -> None:
|
|
||||||
if not _table_exists("system_configs"):
|
|
||||||
op.create_table(
|
|
||||||
"system_configs",
|
|
||||||
sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True),
|
|
||||||
sa.Column("key", sa.String(200), unique=True, nullable=False),
|
|
||||||
sa.Column("value", sa.Text, nullable=True),
|
|
||||||
sa.Column("description", sa.String(500), nullable=True),
|
|
||||||
sa.Column(
|
|
||||||
"updated_at",
|
|
||||||
sa.DateTime(timezone=True),
|
|
||||||
server_default=sa.text("now()"),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
op.create_index("ix_system_configs_key", "system_configs", ["key"])
|
|
||||||
|
|
||||||
|
|
||||||
def downgrade() -> None:
|
|
||||||
if _table_exists("system_configs"):
|
|
||||||
op.drop_index("ix_system_configs_key", table_name="system_configs")
|
|
||||||
op.drop_table("system_configs")
|
|
||||||
@@ -1,174 +0,0 @@
|
|||||||
"""Phase 8: Detection Lifecycle Management tables.
|
|
||||||
|
|
||||||
Revision ID: b034dlm
|
|
||||||
Revises: b033syscfg
|
|
||||||
"""
|
|
||||||
from typing import Sequence, Union
|
|
||||||
import sqlalchemy as sa
|
|
||||||
from sqlalchemy.dialects import postgresql
|
|
||||||
from alembic import op
|
|
||||||
|
|
||||||
revision: str = "b034dlm"
|
|
||||||
down_revision: Union[str, None] = "b033syscfg"
|
|
||||||
branch_labels: Union[str, Sequence[str], None] = None
|
|
||||||
depends_on: Union[str, Sequence[str], None] = None
|
|
||||||
|
|
||||||
|
|
||||||
def _table_exists(name: str) -> bool:
|
|
||||||
bind = op.get_bind()
|
|
||||||
insp = sa.inspect(bind)
|
|
||||||
return name in insp.get_table_names()
|
|
||||||
|
|
||||||
|
|
||||||
def upgrade() -> None:
|
|
||||||
if not _table_exists("detection_assets"):
|
|
||||||
op.create_table(
|
|
||||||
"detection_assets",
|
|
||||||
sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True),
|
|
||||||
sa.Column("name", sa.String(500), nullable=False),
|
|
||||||
sa.Column("description", sa.Text),
|
|
||||||
sa.Column("asset_type", sa.String(50), nullable=False),
|
|
||||||
sa.Column("platform", sa.String(100)),
|
|
||||||
sa.Column("rule_content", sa.Text),
|
|
||||||
sa.Column("rule_language", sa.String(50)),
|
|
||||||
sa.Column("rule_repository_url", sa.Text),
|
|
||||||
sa.Column("rule_file_path", sa.String(500)),
|
|
||||||
sa.Column("rule_version", sa.String(50)),
|
|
||||||
sa.Column("rule_hash", sa.String(64)),
|
|
||||||
sa.Column("last_rule_change_at", sa.DateTime),
|
|
||||||
sa.Column("log_source_name", sa.String(200)),
|
|
||||||
sa.Column("log_source_version", sa.String(50)),
|
|
||||||
sa.Column("log_source_config", postgresql.JSONB, server_default="{}"),
|
|
||||||
sa.Column("infrastructure_hash", sa.String(64)),
|
|
||||||
sa.Column("infrastructure_details", postgresql.JSONB, server_default="{}"),
|
|
||||||
sa.Column("health_status", sa.String(20), server_default="untested", nullable=False),
|
|
||||||
sa.Column("last_alert_at", sa.DateTime),
|
|
||||||
sa.Column("alert_count_30d", sa.Integer, server_default="0"),
|
|
||||||
sa.Column("false_positive_rate", sa.Float),
|
|
||||||
sa.Column("expected_alert_frequency", sa.String(50)),
|
|
||||||
sa.Column("owner_id", postgresql.UUID(as_uuid=True), sa.ForeignKey("users.id", ondelete="SET NULL")),
|
|
||||||
sa.Column("backup_owner_id", postgresql.UUID(as_uuid=True), sa.ForeignKey("users.id", ondelete="SET NULL")),
|
|
||||||
sa.Column("team", sa.String(100)),
|
|
||||||
sa.Column("is_active", sa.Boolean, server_default="true", nullable=False),
|
|
||||||
sa.Column("tags", postgresql.JSONB, server_default="[]"),
|
|
||||||
sa.Column("asset_metadata", postgresql.JSONB, server_default="{}"),
|
|
||||||
sa.Column("created_by", postgresql.UUID(as_uuid=True), sa.ForeignKey("users.id", ondelete="SET NULL")),
|
|
||||||
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("now()")),
|
|
||||||
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.text("now()")),
|
|
||||||
)
|
|
||||||
op.create_index("ix_detection_assets_platform", "detection_assets", ["platform"])
|
|
||||||
op.create_index("ix_detection_assets_health_status", "detection_assets", ["health_status"])
|
|
||||||
op.create_index("ix_detection_assets_owner_id", "detection_assets", ["owner_id"])
|
|
||||||
|
|
||||||
if not _table_exists("detection_technique_mappings"):
|
|
||||||
op.create_table(
|
|
||||||
"detection_technique_mappings",
|
|
||||||
sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True),
|
|
||||||
sa.Column("detection_asset_id", postgresql.UUID(as_uuid=True), sa.ForeignKey("detection_assets.id", ondelete="CASCADE"), nullable=False),
|
|
||||||
sa.Column("technique_id", postgresql.UUID(as_uuid=True), sa.ForeignKey("techniques.id", ondelete="CASCADE"), nullable=False),
|
|
||||||
sa.Column("coverage_type", sa.String(50), server_default="detect"),
|
|
||||||
sa.Column("confidence_level", sa.String(20), server_default="medium"),
|
|
||||||
sa.Column("notes", sa.Text),
|
|
||||||
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("now()")),
|
|
||||||
)
|
|
||||||
op.create_index("ix_detection_technique_mappings_technique_id", "detection_technique_mappings", ["technique_id"])
|
|
||||||
op.create_index("ix_detection_technique_mappings_asset_id", "detection_technique_mappings", ["detection_asset_id"])
|
|
||||||
|
|
||||||
if not _table_exists("detection_validations"):
|
|
||||||
op.create_table(
|
|
||||||
"detection_validations",
|
|
||||||
sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True),
|
|
||||||
sa.Column("detection_asset_id", postgresql.UUID(as_uuid=True), sa.ForeignKey("detection_assets.id", ondelete="CASCADE"), nullable=False),
|
|
||||||
sa.Column("technique_id", postgresql.UUID(as_uuid=True), sa.ForeignKey("techniques.id", ondelete="SET NULL")),
|
|
||||||
sa.Column("test_id", postgresql.UUID(as_uuid=True), sa.ForeignKey("tests.id", ondelete="SET NULL")),
|
|
||||||
sa.Column("validated_at", sa.DateTime),
|
|
||||||
sa.Column("expires_at", sa.DateTime, nullable=False),
|
|
||||||
sa.Column("is_valid", sa.Boolean, server_default="true", nullable=False),
|
|
||||||
sa.Column("validation_result", sa.String(50)),
|
|
||||||
sa.Column("validation_method", sa.String(100)),
|
|
||||||
sa.Column("rule_hash_at_validation", sa.String(64)),
|
|
||||||
sa.Column("log_source_version_at_validation", sa.String(50)),
|
|
||||||
sa.Column("infrastructure_hash_at_validation", sa.String(64)),
|
|
||||||
sa.Column("environment_snapshot", postgresql.JSONB, server_default="{}"),
|
|
||||||
sa.Column("invalidated_at", sa.DateTime),
|
|
||||||
sa.Column("invalidation_reason", sa.String(50)),
|
|
||||||
sa.Column("invalidation_details", sa.Text),
|
|
||||||
sa.Column("invalidated_by", postgresql.UUID(as_uuid=True), sa.ForeignKey("users.id", ondelete="SET NULL")),
|
|
||||||
sa.Column("validated_by", postgresql.UUID(as_uuid=True), sa.ForeignKey("users.id", ondelete="SET NULL"), nullable=False),
|
|
||||||
sa.Column("integrity_hash", sa.String(64)),
|
|
||||||
sa.Column("notes", sa.Text),
|
|
||||||
sa.Column("evidence_ids", postgresql.JSONB, server_default="[]"),
|
|
||||||
)
|
|
||||||
op.create_index("ix_detection_validations_asset_id_valid", "detection_validations", ["detection_asset_id", "is_valid"])
|
|
||||||
op.create_index("ix_detection_validations_expires_at", "detection_validations", ["expires_at"])
|
|
||||||
|
|
||||||
if not _table_exists("technique_confidence_scores"):
|
|
||||||
op.create_table(
|
|
||||||
"technique_confidence_scores",
|
|
||||||
sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True),
|
|
||||||
sa.Column("technique_id", postgresql.UUID(as_uuid=True), sa.ForeignKey("techniques.id", ondelete="CASCADE"), nullable=False, unique=True),
|
|
||||||
sa.Column("confidence_level", sa.String(20), server_default="unknown"),
|
|
||||||
sa.Column("confidence_score", sa.Float, server_default="0.0"),
|
|
||||||
sa.Column("detection_count", sa.Integer, server_default="0"),
|
|
||||||
sa.Column("valid_detection_count", sa.Integer, server_default="0"),
|
|
||||||
sa.Column("last_validated_at", sa.DateTime),
|
|
||||||
sa.Column("next_validation_due", sa.DateTime),
|
|
||||||
sa.Column("last_recalculated_at", sa.DateTime),
|
|
||||||
sa.Column("recency_factor", sa.Float, server_default="0.0"),
|
|
||||||
sa.Column("coverage_factor", sa.Float, server_default="0.0"),
|
|
||||||
sa.Column("health_factor", sa.Float, server_default="0.0"),
|
|
||||||
sa.Column("diversity_factor", sa.Float, server_default="0.0"),
|
|
||||||
sa.Column("score_breakdown", postgresql.JSONB, server_default="{}"),
|
|
||||||
sa.Column("risk_factors", postgresql.JSONB, server_default="[]"),
|
|
||||||
sa.Column("updated_at", sa.DateTime),
|
|
||||||
)
|
|
||||||
op.create_index("ix_technique_confidence_scores_technique_id", "technique_confidence_scores", ["technique_id"])
|
|
||||||
op.create_index("ix_technique_confidence_scores_confidence_level", "technique_confidence_scores", ["confidence_level"])
|
|
||||||
|
|
||||||
if not _table_exists("infrastructure_change_logs"):
|
|
||||||
op.create_table(
|
|
||||||
"infrastructure_change_logs",
|
|
||||||
sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True),
|
|
||||||
sa.Column("change_type", sa.String(100), nullable=False),
|
|
||||||
sa.Column("description", sa.Text, nullable=False),
|
|
||||||
sa.Column("affected_platforms", postgresql.JSONB, server_default="[]"),
|
|
||||||
sa.Column("affected_log_sources", postgresql.JSONB, server_default="[]"),
|
|
||||||
sa.Column("change_date", sa.DateTime),
|
|
||||||
sa.Column("reported_by", postgresql.UUID(as_uuid=True), sa.ForeignKey("users.id", ondelete="SET NULL")),
|
|
||||||
sa.Column("auto_invalidate", sa.Boolean, server_default="true"),
|
|
||||||
sa.Column("invalidated_count", sa.Integer, server_default="0"),
|
|
||||||
sa.Column("change_metadata", postgresql.JSONB, server_default="{}"),
|
|
||||||
sa.Column("created_at", sa.DateTime),
|
|
||||||
)
|
|
||||||
op.create_index("ix_infrastructure_change_logs_change_date", "infrastructure_change_logs", ["change_date"])
|
|
||||||
|
|
||||||
if not _table_exists("decay_policies"):
|
|
||||||
op.create_table(
|
|
||||||
"decay_policies",
|
|
||||||
sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True),
|
|
||||||
sa.Column("name", sa.String(200), nullable=False),
|
|
||||||
sa.Column("description", sa.Text),
|
|
||||||
sa.Column("applies_to_platform", sa.String(100)),
|
|
||||||
sa.Column("applies_to_asset_type", sa.String(50)),
|
|
||||||
sa.Column("applies_to_tactic", sa.String(100)),
|
|
||||||
sa.Column("fresh_days", sa.Integer, server_default="90"),
|
|
||||||
sa.Column("aging_days", sa.Integer, server_default="180"),
|
|
||||||
sa.Column("stale_days", sa.Integer, server_default="365"),
|
|
||||||
sa.Column("default_validity_days", sa.Integer, server_default="180"),
|
|
||||||
sa.Column("silent_threshold_days", sa.Integer, server_default="30"),
|
|
||||||
sa.Column("noisy_threshold_daily", sa.Integer, server_default="100"),
|
|
||||||
sa.Column("recency_weight", sa.Float, server_default="0.3"),
|
|
||||||
sa.Column("coverage_weight", sa.Float, server_default="0.3"),
|
|
||||||
sa.Column("health_weight", sa.Float, server_default="0.25"),
|
|
||||||
sa.Column("diversity_weight", sa.Float, server_default="0.15"),
|
|
||||||
sa.Column("is_default", sa.Boolean, server_default="false"),
|
|
||||||
sa.Column("is_active", sa.Boolean, server_default="true"),
|
|
||||||
sa.Column("created_at", sa.DateTime),
|
|
||||||
sa.Column("updated_at", sa.DateTime),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def downgrade() -> None:
|
|
||||||
for table in ["decay_policies", "infrastructure_change_logs", "technique_confidence_scores", "detection_validations", "detection_technique_mappings", "detection_assets"]:
|
|
||||||
if _table_exists(table):
|
|
||||||
op.drop_table(table)
|
|
||||||
@@ -1,118 +0,0 @@
|
|||||||
"""Phase 9: Ownership & Revalidation Queue
|
|
||||||
|
|
||||||
Revision ID: b035ownerq
|
|
||||||
Revises: b034dlm
|
|
||||||
Create Date: 2026-05-19
|
|
||||||
|
|
||||||
Uses raw SQL for all DDL to avoid SQLAlchemy before_create hook issues
|
|
||||||
with existing enum types.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from typing import Union
|
|
||||||
from alembic import op
|
|
||||||
import sqlalchemy as sa
|
|
||||||
|
|
||||||
revision: str = "b035ownerq"
|
|
||||||
down_revision: Union[str, None] = "b034dlm"
|
|
||||||
branch_labels = None
|
|
||||||
depends_on = None
|
|
||||||
|
|
||||||
|
|
||||||
def upgrade() -> None:
|
|
||||||
conn = op.get_bind()
|
|
||||||
|
|
||||||
# ── Enums (idempotent) ────────────────────────────────────────────────────
|
|
||||||
conn.execute(sa.text("""
|
|
||||||
DO $$ BEGIN
|
|
||||||
CREATE TYPE queue_priority AS ENUM ('critical', 'high', 'medium', 'low');
|
|
||||||
EXCEPTION WHEN duplicate_object THEN NULL;
|
|
||||||
END $$
|
|
||||||
"""))
|
|
||||||
conn.execute(sa.text("""
|
|
||||||
DO $$ BEGIN
|
|
||||||
CREATE TYPE queue_status AS ENUM ('pending', 'in_progress', 'completed', 'dismissed');
|
|
||||||
EXCEPTION WHEN duplicate_object THEN NULL;
|
|
||||||
END $$
|
|
||||||
"""))
|
|
||||||
conn.execute(sa.text("""
|
|
||||||
DO $$ BEGIN
|
|
||||||
CREATE TYPE queue_reason AS ENUM (
|
|
||||||
'validation_expired', 'infra_change', 'osint_alert',
|
|
||||||
'mitre_update', 'rule_modified', 'low_confidence', 'manual');
|
|
||||||
EXCEPTION WHEN duplicate_object THEN NULL;
|
|
||||||
END $$
|
|
||||||
"""))
|
|
||||||
|
|
||||||
# ── technique_ownerships ──────────────────────────────────────────────────
|
|
||||||
conn.execute(sa.text("""
|
|
||||||
CREATE TABLE IF NOT EXISTS technique_ownerships (
|
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
||||||
technique_id UUID NOT NULL UNIQUE
|
|
||||||
REFERENCES techniques(id) ON DELETE CASCADE,
|
|
||||||
owner_id UUID
|
|
||||||
REFERENCES users(id) ON DELETE SET NULL,
|
|
||||||
backup_owner_id UUID
|
|
||||||
REFERENCES users(id) ON DELETE SET NULL,
|
|
||||||
team VARCHAR(200),
|
|
||||||
notes TEXT,
|
|
||||||
assigned_at TIMESTAMP,
|
|
||||||
assigned_by UUID
|
|
||||||
REFERENCES users(id) ON DELETE SET NULL,
|
|
||||||
created_at TIMESTAMP DEFAULT now(),
|
|
||||||
updated_at TIMESTAMP DEFAULT now()
|
|
||||||
)
|
|
||||||
"""))
|
|
||||||
conn.execute(sa.text(
|
|
||||||
"CREATE INDEX IF NOT EXISTS ix_techown_owner_id ON technique_ownerships (owner_id)"
|
|
||||||
))
|
|
||||||
conn.execute(sa.text(
|
|
||||||
"CREATE INDEX IF NOT EXISTS ix_techown_technique_id ON technique_ownerships (technique_id)"
|
|
||||||
))
|
|
||||||
|
|
||||||
# ── revalidation_queue_items ──────────────────────────────────────────────
|
|
||||||
conn.execute(sa.text("""
|
|
||||||
CREATE TABLE IF NOT EXISTS revalidation_queue_items (
|
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
||||||
technique_id UUID
|
|
||||||
REFERENCES techniques(id) ON DELETE CASCADE,
|
|
||||||
detection_asset_id UUID
|
|
||||||
REFERENCES detection_assets(id) ON DELETE CASCADE,
|
|
||||||
priority queue_priority NOT NULL DEFAULT 'medium',
|
|
||||||
reason queue_reason NOT NULL,
|
|
||||||
reason_detail TEXT,
|
|
||||||
status queue_status NOT NULL DEFAULT 'pending',
|
|
||||||
assigned_to UUID
|
|
||||||
REFERENCES users(id) ON DELETE SET NULL,
|
|
||||||
due_date TIMESTAMP,
|
|
||||||
created_at TIMESTAMP DEFAULT now(),
|
|
||||||
completed_at TIMESTAMP,
|
|
||||||
dismissed_at TIMESTAMP,
|
|
||||||
completed_by UUID
|
|
||||||
REFERENCES users(id) ON DELETE SET NULL,
|
|
||||||
extra JSONB
|
|
||||||
)
|
|
||||||
"""))
|
|
||||||
conn.execute(sa.text(
|
|
||||||
"CREATE INDEX IF NOT EXISTS ix_rqueue_status ON revalidation_queue_items (status)"
|
|
||||||
))
|
|
||||||
conn.execute(sa.text(
|
|
||||||
"CREATE INDEX IF NOT EXISTS ix_rqueue_priority ON revalidation_queue_items (priority)"
|
|
||||||
))
|
|
||||||
conn.execute(sa.text(
|
|
||||||
"CREATE INDEX IF NOT EXISTS ix_rqueue_assigned_to ON revalidation_queue_items (assigned_to)"
|
|
||||||
))
|
|
||||||
conn.execute(sa.text(
|
|
||||||
"CREATE INDEX IF NOT EXISTS ix_rqueue_technique_id ON revalidation_queue_items (technique_id)"
|
|
||||||
))
|
|
||||||
conn.execute(sa.text(
|
|
||||||
"CREATE INDEX IF NOT EXISTS ix_rqueue_asset_id ON revalidation_queue_items (detection_asset_id)"
|
|
||||||
))
|
|
||||||
|
|
||||||
|
|
||||||
def downgrade() -> None:
|
|
||||||
conn = op.get_bind()
|
|
||||||
conn.execute(sa.text("DROP TABLE IF EXISTS revalidation_queue_items"))
|
|
||||||
conn.execute(sa.text("DROP TABLE IF EXISTS technique_ownerships"))
|
|
||||||
conn.execute(sa.text("DROP TYPE IF EXISTS queue_reason"))
|
|
||||||
conn.execute(sa.text("DROP TYPE IF EXISTS queue_status"))
|
|
||||||
conn.execute(sa.text("DROP TYPE IF EXISTS queue_priority"))
|
|
||||||
@@ -1,184 +0,0 @@
|
|||||||
"""Phase 10: Attack Paths & Advanced Purple Team
|
|
||||||
|
|
||||||
Revision ID: b036atk
|
|
||||||
Revises: b035ownerq
|
|
||||||
Create Date: 2026-05-19
|
|
||||||
|
|
||||||
Uses raw SQL to avoid SQLAlchemy DDL hook issues with enum types.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from typing import Union
|
|
||||||
from alembic import op
|
|
||||||
import sqlalchemy as sa
|
|
||||||
|
|
||||||
revision: str = "b036atk"
|
|
||||||
down_revision: Union[str, None] = "b035ownerq"
|
|
||||||
branch_labels = None
|
|
||||||
depends_on = None
|
|
||||||
|
|
||||||
|
|
||||||
def upgrade() -> None:
|
|
||||||
conn = op.get_bind()
|
|
||||||
|
|
||||||
# ── Enums ─────────────────────────────────────────────────────────────────
|
|
||||||
conn.execute(sa.text("""
|
|
||||||
DO $$ BEGIN
|
|
||||||
CREATE TYPE execution_status AS ENUM
|
|
||||||
('planned','in_progress','completed','aborted');
|
|
||||||
EXCEPTION WHEN duplicate_object THEN NULL; END $$
|
|
||||||
"""))
|
|
||||||
conn.execute(sa.text("""
|
|
||||||
DO $$ BEGIN
|
|
||||||
CREATE TYPE step_result_status AS ENUM
|
|
||||||
('pending','executing','detected','not_detected','skipped');
|
|
||||||
EXCEPTION WHEN duplicate_object THEN NULL; END $$
|
|
||||||
"""))
|
|
||||||
conn.execute(sa.text("""
|
|
||||||
DO $$ BEGIN
|
|
||||||
CREATE TYPE timeline_actor_side AS ENUM ('red','blue','system');
|
|
||||||
EXCEPTION WHEN duplicate_object THEN NULL; END $$
|
|
||||||
"""))
|
|
||||||
conn.execute(sa.text("""
|
|
||||||
DO $$ BEGIN
|
|
||||||
CREATE TYPE timeline_entry_type AS ENUM
|
|
||||||
('action','detection','note','phase_transition','flag');
|
|
||||||
EXCEPTION WHEN duplicate_object THEN NULL; END $$
|
|
||||||
"""))
|
|
||||||
|
|
||||||
# ── attack_paths ──────────────────────────────────────────────────────────
|
|
||||||
conn.execute(sa.text("""
|
|
||||||
CREATE TABLE IF NOT EXISTS attack_paths (
|
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
||||||
name VARCHAR(300) NOT NULL,
|
|
||||||
description TEXT,
|
|
||||||
objective TEXT,
|
|
||||||
is_template BOOLEAN DEFAULT FALSE,
|
|
||||||
threat_actor_id UUID REFERENCES threat_actors(id) ON DELETE SET NULL,
|
|
||||||
created_by UUID REFERENCES users(id) ON DELETE SET NULL,
|
|
||||||
tags JSONB,
|
|
||||||
is_active BOOLEAN DEFAULT TRUE,
|
|
||||||
created_at TIMESTAMP DEFAULT now(),
|
|
||||||
updated_at TIMESTAMP DEFAULT now()
|
|
||||||
)
|
|
||||||
"""))
|
|
||||||
conn.execute(sa.text(
|
|
||||||
"CREATE INDEX IF NOT EXISTS ix_attack_paths_created_by ON attack_paths (created_by)"
|
|
||||||
))
|
|
||||||
conn.execute(sa.text(
|
|
||||||
"CREATE INDEX IF NOT EXISTS ix_attack_paths_is_template ON attack_paths (is_template)"
|
|
||||||
))
|
|
||||||
|
|
||||||
# ── attack_path_steps ─────────────────────────────────────────────────────
|
|
||||||
conn.execute(sa.text("""
|
|
||||||
CREATE TABLE IF NOT EXISTS attack_path_steps (
|
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
||||||
attack_path_id UUID NOT NULL REFERENCES attack_paths(id) ON DELETE CASCADE,
|
|
||||||
order_index INTEGER NOT NULL DEFAULT 0,
|
|
||||||
kill_chain_phase VARCHAR(60),
|
|
||||||
technique_id UUID REFERENCES techniques(id) ON DELETE SET NULL,
|
|
||||||
test_id UUID REFERENCES tests(id) ON DELETE SET NULL,
|
|
||||||
name VARCHAR(300),
|
|
||||||
description TEXT,
|
|
||||||
expected_detection BOOLEAN DEFAULT TRUE,
|
|
||||||
notes TEXT
|
|
||||||
)
|
|
||||||
"""))
|
|
||||||
conn.execute(sa.text(
|
|
||||||
"CREATE INDEX IF NOT EXISTS ix_ap_steps_path_id ON attack_path_steps (attack_path_id)"
|
|
||||||
))
|
|
||||||
conn.execute(sa.text(
|
|
||||||
"CREATE INDEX IF NOT EXISTS ix_ap_steps_technique_id ON attack_path_steps (technique_id)"
|
|
||||||
))
|
|
||||||
|
|
||||||
# ── attack_path_executions ────────────────────────────────────────────────
|
|
||||||
conn.execute(sa.text("""
|
|
||||||
CREATE TABLE IF NOT EXISTS attack_path_executions (
|
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
||||||
attack_path_id UUID NOT NULL REFERENCES attack_paths(id) ON DELETE CASCADE,
|
|
||||||
status execution_status NOT NULL DEFAULT 'planned',
|
|
||||||
environment VARCHAR(100),
|
|
||||||
red_team_lead UUID REFERENCES users(id) ON DELETE SET NULL,
|
|
||||||
blue_team_lead UUID REFERENCES users(id) ON DELETE SET NULL,
|
|
||||||
started_by UUID REFERENCES users(id) ON DELETE SET NULL,
|
|
||||||
started_at TIMESTAMP,
|
|
||||||
completed_at TIMESTAMP,
|
|
||||||
notes TEXT,
|
|
||||||
created_at TIMESTAMP DEFAULT now(),
|
|
||||||
-- kill-chain metrics (populated on completion)
|
|
||||||
total_steps INTEGER,
|
|
||||||
detected_steps INTEGER,
|
|
||||||
not_detected_steps INTEGER,
|
|
||||||
skipped_steps INTEGER,
|
|
||||||
detection_rate FLOAT,
|
|
||||||
mttd_seconds FLOAT,
|
|
||||||
furthest_undetected_step INTEGER
|
|
||||||
)
|
|
||||||
"""))
|
|
||||||
conn.execute(sa.text(
|
|
||||||
"CREATE INDEX IF NOT EXISTS ix_ap_exec_path_id ON attack_path_executions (attack_path_id)"
|
|
||||||
))
|
|
||||||
conn.execute(sa.text(
|
|
||||||
"CREATE INDEX IF NOT EXISTS ix_ap_exec_status ON attack_path_executions (status)"
|
|
||||||
))
|
|
||||||
|
|
||||||
# ── attack_path_step_results ──────────────────────────────────────────────
|
|
||||||
conn.execute(sa.text("""
|
|
||||||
CREATE TABLE IF NOT EXISTS attack_path_step_results (
|
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
||||||
execution_id UUID NOT NULL
|
|
||||||
REFERENCES attack_path_executions(id) ON DELETE CASCADE,
|
|
||||||
step_id UUID NOT NULL
|
|
||||||
REFERENCES attack_path_steps(id) ON DELETE CASCADE,
|
|
||||||
step_order INTEGER NOT NULL DEFAULT 0,
|
|
||||||
status step_result_status NOT NULL DEFAULT 'pending',
|
|
||||||
executed_by UUID REFERENCES users(id) ON DELETE SET NULL,
|
|
||||||
executed_at TIMESTAMP,
|
|
||||||
detected_at TIMESTAMP,
|
|
||||||
time_to_detect_seconds FLOAT,
|
|
||||||
detection_asset_id UUID
|
|
||||||
REFERENCES detection_assets(id) ON DELETE SET NULL,
|
|
||||||
notes TEXT,
|
|
||||||
evidence_ids JSONB
|
|
||||||
)
|
|
||||||
"""))
|
|
||||||
conn.execute(sa.text(
|
|
||||||
"CREATE INDEX IF NOT EXISTS ix_ap_stepres_exec ON attack_path_step_results (execution_id)"
|
|
||||||
))
|
|
||||||
conn.execute(sa.text(
|
|
||||||
"CREATE INDEX IF NOT EXISTS ix_ap_stepres_step ON attack_path_step_results (step_id)"
|
|
||||||
))
|
|
||||||
|
|
||||||
# ── attack_path_timeline ──────────────────────────────────────────────────
|
|
||||||
conn.execute(sa.text("""
|
|
||||||
CREATE TABLE IF NOT EXISTS attack_path_timeline (
|
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
||||||
execution_id UUID NOT NULL
|
|
||||||
REFERENCES attack_path_executions(id) ON DELETE CASCADE,
|
|
||||||
step_id UUID REFERENCES attack_path_steps(id) ON DELETE SET NULL,
|
|
||||||
timestamp TIMESTAMP NOT NULL DEFAULT now(),
|
|
||||||
actor_side timeline_actor_side NOT NULL,
|
|
||||||
actor_id UUID REFERENCES users(id) ON DELETE SET NULL,
|
|
||||||
entry_type timeline_entry_type NOT NULL,
|
|
||||||
content TEXT NOT NULL,
|
|
||||||
extra JSONB
|
|
||||||
)
|
|
||||||
"""))
|
|
||||||
conn.execute(sa.text(
|
|
||||||
"CREATE INDEX IF NOT EXISTS ix_timeline_execution_id ON attack_path_timeline (execution_id)"
|
|
||||||
))
|
|
||||||
conn.execute(sa.text(
|
|
||||||
"CREATE INDEX IF NOT EXISTS ix_timeline_timestamp ON attack_path_timeline (timestamp)"
|
|
||||||
))
|
|
||||||
|
|
||||||
|
|
||||||
def downgrade() -> None:
|
|
||||||
conn = op.get_bind()
|
|
||||||
conn.execute(sa.text("DROP TABLE IF EXISTS attack_path_timeline"))
|
|
||||||
conn.execute(sa.text("DROP TABLE IF EXISTS attack_path_step_results"))
|
|
||||||
conn.execute(sa.text("DROP TABLE IF EXISTS attack_path_executions"))
|
|
||||||
conn.execute(sa.text("DROP TABLE IF EXISTS attack_path_steps"))
|
|
||||||
conn.execute(sa.text("DROP TABLE IF EXISTS attack_paths"))
|
|
||||||
conn.execute(sa.text("DROP TYPE IF EXISTS timeline_entry_type"))
|
|
||||||
conn.execute(sa.text("DROP TYPE IF EXISTS timeline_actor_side"))
|
|
||||||
conn.execute(sa.text("DROP TYPE IF EXISTS step_result_status"))
|
|
||||||
conn.execute(sa.text("DROP TYPE IF EXISTS execution_status"))
|
|
||||||
@@ -1,106 +0,0 @@
|
|||||||
"""Phase 11: Knowledge Management — Playbooks + Lessons Learned
|
|
||||||
|
|
||||||
Revision ID: b037know
|
|
||||||
Revises: b036atk
|
|
||||||
Create Date: 2026-05-20
|
|
||||||
|
|
||||||
Uses raw SQL to bypass SQLAlchemy DDL hooks (no enum types — string columns
|
|
||||||
with Pydantic-layer validation instead, so no PostgreSQL enums needed).
|
|
||||||
"""
|
|
||||||
|
|
||||||
from typing import Union
|
|
||||||
from alembic import op
|
|
||||||
import sqlalchemy as sa
|
|
||||||
|
|
||||||
revision: str = "b037know"
|
|
||||||
down_revision: Union[str, None] = "b036atk"
|
|
||||||
branch_labels = None
|
|
||||||
depends_on = None
|
|
||||||
|
|
||||||
|
|
||||||
def upgrade() -> None:
|
|
||||||
conn = op.get_bind()
|
|
||||||
|
|
||||||
# ── playbooks ──────────────────────────────────────────────────────────────
|
|
||||||
conn.execute(sa.text("""
|
|
||||||
CREATE TABLE IF NOT EXISTS playbooks (
|
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
||||||
technique_id UUID NOT NULL REFERENCES techniques(id) ON DELETE CASCADE,
|
|
||||||
playbook_type VARCHAR(32) NOT NULL,
|
|
||||||
title VARCHAR(255) NOT NULL,
|
|
||||||
content TEXT NOT NULL DEFAULT '',
|
|
||||||
version INTEGER NOT NULL DEFAULT 1,
|
|
||||||
tools JSONB,
|
|
||||||
prerequisites JSONB,
|
|
||||||
created_by UUID REFERENCES users(id) ON DELETE SET NULL,
|
|
||||||
updated_by UUID REFERENCES users(id) ON DELETE SET NULL,
|
|
||||||
created_at TIMESTAMP DEFAULT now(),
|
|
||||||
updated_at TIMESTAMP DEFAULT now(),
|
|
||||||
is_active BOOLEAN DEFAULT TRUE,
|
|
||||||
CONSTRAINT uq_playbook_technique_type UNIQUE (technique_id, playbook_type)
|
|
||||||
)
|
|
||||||
"""))
|
|
||||||
conn.execute(sa.text(
|
|
||||||
"CREATE INDEX IF NOT EXISTS ix_playbooks_technique_id ON playbooks (technique_id)"
|
|
||||||
))
|
|
||||||
conn.execute(sa.text(
|
|
||||||
"CREATE INDEX IF NOT EXISTS ix_playbooks_type ON playbooks (playbook_type)"
|
|
||||||
))
|
|
||||||
|
|
||||||
# ── playbook_versions ──────────────────────────────────────────────────────
|
|
||||||
conn.execute(sa.text("""
|
|
||||||
CREATE TABLE IF NOT EXISTS playbook_versions (
|
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
||||||
playbook_id UUID NOT NULL REFERENCES playbooks(id) ON DELETE CASCADE,
|
|
||||||
version INTEGER NOT NULL,
|
|
||||||
title VARCHAR(255) NOT NULL,
|
|
||||||
content TEXT NOT NULL DEFAULT '',
|
|
||||||
tools JSONB,
|
|
||||||
prerequisites JSONB,
|
|
||||||
changed_by UUID REFERENCES users(id) ON DELETE SET NULL,
|
|
||||||
change_note VARCHAR(500),
|
|
||||||
created_at TIMESTAMP DEFAULT now()
|
|
||||||
)
|
|
||||||
"""))
|
|
||||||
conn.execute(sa.text(
|
|
||||||
"CREATE INDEX IF NOT EXISTS ix_pb_versions_playbook_id ON playbook_versions (playbook_id)"
|
|
||||||
))
|
|
||||||
conn.execute(sa.text(
|
|
||||||
"CREATE INDEX IF NOT EXISTS ix_pb_versions_version ON playbook_versions (playbook_id, version)"
|
|
||||||
))
|
|
||||||
|
|
||||||
# ── lessons_learned ────────────────────────────────────────────────────────
|
|
||||||
conn.execute(sa.text("""
|
|
||||||
CREATE TABLE IF NOT EXISTS lessons_learned (
|
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
||||||
title VARCHAR(255) NOT NULL,
|
|
||||||
what_happened TEXT NOT NULL DEFAULT '',
|
|
||||||
root_cause TEXT NOT NULL DEFAULT '',
|
|
||||||
fix_applied TEXT,
|
|
||||||
severity VARCHAR(16) NOT NULL DEFAULT 'medium',
|
|
||||||
entity_type VARCHAR(32) NOT NULL DEFAULT 'manual',
|
|
||||||
entity_id UUID,
|
|
||||||
technique_ids JSONB,
|
|
||||||
tags JSONB,
|
|
||||||
created_by UUID REFERENCES users(id) ON DELETE SET NULL,
|
|
||||||
created_at TIMESTAMP DEFAULT now(),
|
|
||||||
updated_at TIMESTAMP DEFAULT now(),
|
|
||||||
is_active BOOLEAN DEFAULT TRUE
|
|
||||||
)
|
|
||||||
"""))
|
|
||||||
conn.execute(sa.text(
|
|
||||||
"CREATE INDEX IF NOT EXISTS ix_ll_entity ON lessons_learned (entity_type, entity_id)"
|
|
||||||
))
|
|
||||||
conn.execute(sa.text(
|
|
||||||
"CREATE INDEX IF NOT EXISTS ix_ll_severity ON lessons_learned (severity)"
|
|
||||||
))
|
|
||||||
conn.execute(sa.text(
|
|
||||||
"CREATE INDEX IF NOT EXISTS ix_ll_created_by ON lessons_learned (created_by)"
|
|
||||||
))
|
|
||||||
|
|
||||||
|
|
||||||
def downgrade() -> None:
|
|
||||||
conn = op.get_bind()
|
|
||||||
conn.execute(sa.text("DROP TABLE IF EXISTS lessons_learned"))
|
|
||||||
conn.execute(sa.text("DROP TABLE IF EXISTS playbook_versions"))
|
|
||||||
conn.execute(sa.text("DROP TABLE IF EXISTS playbooks"))
|
|
||||||
@@ -1,62 +0,0 @@
|
|||||||
"""Phase 12: Risk Intelligence — technique_risk_profiles table
|
|
||||||
|
|
||||||
Revision ID: b038risk
|
|
||||||
Revises: b037know
|
|
||||||
Create Date: 2026-05-20
|
|
||||||
|
|
||||||
Uses raw SQL to bypass SQLAlchemy DDL hooks.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from typing import Union
|
|
||||||
from alembic import op
|
|
||||||
import sqlalchemy as sa
|
|
||||||
|
|
||||||
revision: str = "b038risk"
|
|
||||||
down_revision: Union[str, None] = "b037know"
|
|
||||||
branch_labels = None
|
|
||||||
depends_on = None
|
|
||||||
|
|
||||||
|
|
||||||
def upgrade() -> None:
|
|
||||||
conn = op.get_bind()
|
|
||||||
|
|
||||||
conn.execute(sa.text("""
|
|
||||||
CREATE TABLE IF NOT EXISTS technique_risk_profiles (
|
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
||||||
technique_id UUID NOT NULL REFERENCES techniques(id) ON DELETE CASCADE,
|
|
||||||
risk_score FLOAT NOT NULL DEFAULT 0.0,
|
|
||||||
likelihood FLOAT NOT NULL DEFAULT 0.0,
|
|
||||||
impact FLOAT NOT NULL DEFAULT 0.0,
|
|
||||||
risk_level VARCHAR(16) NOT NULL DEFAULT 'info',
|
|
||||||
detection_gap FLOAT NOT NULL DEFAULT 1.0,
|
|
||||||
threat_actor_count INTEGER NOT NULL DEFAULT 0,
|
|
||||||
osint_signal_count INTEGER NOT NULL DEFAULT 0,
|
|
||||||
test_fail_count INTEGER NOT NULL DEFAULT 0,
|
|
||||||
test_total_count INTEGER NOT NULL DEFAULT 0,
|
|
||||||
test_failure_rate FLOAT NOT NULL DEFAULT 0.0,
|
|
||||||
confidence_level FLOAT NOT NULL DEFAULT 0.0,
|
|
||||||
scoring_breakdown JSONB,
|
|
||||||
recommendations JSONB,
|
|
||||||
computed_at TIMESTAMP DEFAULT now(),
|
|
||||||
is_stale BOOLEAN DEFAULT TRUE,
|
|
||||||
CONSTRAINT uq_risk_profile_technique UNIQUE (technique_id)
|
|
||||||
)
|
|
||||||
"""))
|
|
||||||
|
|
||||||
conn.execute(sa.text(
|
|
||||||
"CREATE INDEX IF NOT EXISTS ix_risk_profiles_risk_score "
|
|
||||||
"ON technique_risk_profiles (risk_score)"
|
|
||||||
))
|
|
||||||
conn.execute(sa.text(
|
|
||||||
"CREATE INDEX IF NOT EXISTS ix_risk_profiles_risk_level "
|
|
||||||
"ON technique_risk_profiles (risk_level)"
|
|
||||||
))
|
|
||||||
conn.execute(sa.text(
|
|
||||||
"CREATE INDEX IF NOT EXISTS ix_risk_profiles_stale "
|
|
||||||
"ON technique_risk_profiles (is_stale)"
|
|
||||||
))
|
|
||||||
|
|
||||||
|
|
||||||
def downgrade() -> None:
|
|
||||||
conn = op.get_bind()
|
|
||||||
conn.execute(sa.text("DROP TABLE IF EXISTS technique_risk_profiles"))
|
|
||||||
@@ -1,77 +0,0 @@
|
|||||||
"""Phase 13: Executive Dashboard — posture_snapshots table.
|
|
||||||
|
|
||||||
Revision ID: b039exec
|
|
||||||
Revises: b038risk
|
|
||||||
Create Date: 2026-05-20
|
|
||||||
"""
|
|
||||||
|
|
||||||
from alembic import op
|
|
||||||
import sqlalchemy as sa
|
|
||||||
|
|
||||||
revision = "b039exec"
|
|
||||||
down_revision = "b038risk"
|
|
||||||
branch_labels = None
|
|
||||||
depends_on = None
|
|
||||||
|
|
||||||
|
|
||||||
def upgrade() -> None:
|
|
||||||
conn = op.get_bind()
|
|
||||||
conn.execute(sa.text("""
|
|
||||||
CREATE TABLE IF NOT EXISTS posture_snapshots (
|
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
||||||
snapshot_date DATE NOT NULL,
|
|
||||||
|
|
||||||
-- Coverage
|
|
||||||
total_techniques INTEGER NOT NULL DEFAULT 0,
|
|
||||||
validated_count INTEGER NOT NULL DEFAULT 0,
|
|
||||||
partial_count INTEGER NOT NULL DEFAULT 0,
|
|
||||||
not_covered_count INTEGER NOT NULL DEFAULT 0,
|
|
||||||
coverage_pct FLOAT NOT NULL DEFAULT 0.0,
|
|
||||||
|
|
||||||
-- Risk
|
|
||||||
avg_risk_score FLOAT NOT NULL DEFAULT 0.0,
|
|
||||||
critical_count INTEGER NOT NULL DEFAULT 0,
|
|
||||||
high_count INTEGER NOT NULL DEFAULT 0,
|
|
||||||
medium_count INTEGER NOT NULL DEFAULT 0,
|
|
||||||
low_count INTEGER NOT NULL DEFAULT 0,
|
|
||||||
|
|
||||||
-- Operations
|
|
||||||
open_queue_items INTEGER NOT NULL DEFAULT 0,
|
|
||||||
orphan_techniques INTEGER NOT NULL DEFAULT 0,
|
|
||||||
|
|
||||||
-- Knowledge
|
|
||||||
playbook_count INTEGER NOT NULL DEFAULT 0,
|
|
||||||
lesson_count INTEGER NOT NULL DEFAULT 0,
|
|
||||||
|
|
||||||
-- MTTD
|
|
||||||
mttd_avg_seconds FLOAT,
|
|
||||||
executions_30d INTEGER NOT NULL DEFAULT 0,
|
|
||||||
detection_rate_30d FLOAT,
|
|
||||||
|
|
||||||
-- Meta
|
|
||||||
created_by UUID REFERENCES users(id) ON DELETE SET NULL,
|
|
||||||
created_at TIMESTAMP WITHOUT TIME ZONE DEFAULT now(),
|
|
||||||
extra JSONB
|
|
||||||
)
|
|
||||||
"""))
|
|
||||||
|
|
||||||
# Unique constraint: one snapshot per calendar day
|
|
||||||
conn.execute(sa.text("""
|
|
||||||
DO $$ BEGIN
|
|
||||||
ALTER TABLE posture_snapshots
|
|
||||||
ADD CONSTRAINT uq_posture_snapshot_date UNIQUE (snapshot_date);
|
|
||||||
EXCEPTION WHEN duplicate_table THEN NULL;
|
|
||||||
WHEN duplicate_object THEN NULL;
|
|
||||||
END $$
|
|
||||||
"""))
|
|
||||||
|
|
||||||
# Index for date-range trend queries
|
|
||||||
conn.execute(sa.text("""
|
|
||||||
CREATE INDEX IF NOT EXISTS ix_posture_snapshots_date
|
|
||||||
ON posture_snapshots (snapshot_date)
|
|
||||||
"""))
|
|
||||||
|
|
||||||
|
|
||||||
def downgrade() -> None:
|
|
||||||
conn = op.get_bind()
|
|
||||||
conn.execute(sa.text("DROP TABLE IF EXISTS posture_snapshots CASCADE"))
|
|
||||||
@@ -1,75 +0,0 @@
|
|||||||
"""Phase 14: Enterprise Readiness — api_keys and sso_configs tables.
|
|
||||||
|
|
||||||
Revision ID: b040ent
|
|
||||||
Revises: b039exec
|
|
||||||
Create Date: 2026-05-20
|
|
||||||
"""
|
|
||||||
|
|
||||||
from alembic import op
|
|
||||||
import sqlalchemy as sa
|
|
||||||
|
|
||||||
revision = "b040ent"
|
|
||||||
down_revision = "b039exec"
|
|
||||||
branch_labels = None
|
|
||||||
depends_on = None
|
|
||||||
|
|
||||||
|
|
||||||
def upgrade() -> None:
|
|
||||||
conn = op.get_bind()
|
|
||||||
|
|
||||||
# ── api_keys ──────────────────────────────────────────────────────────────
|
|
||||||
conn.execute(sa.text("""
|
|
||||||
CREATE TABLE IF NOT EXISTS api_keys (
|
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
||||||
name VARCHAR(200) NOT NULL,
|
|
||||||
description TEXT,
|
|
||||||
key_prefix VARCHAR(13) NOT NULL,
|
|
||||||
key_hash VARCHAR(64) NOT NULL UNIQUE,
|
|
||||||
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
|
||||||
scopes JSONB NOT NULL DEFAULT '["read"]',
|
|
||||||
last_used_at TIMESTAMP WITHOUT TIME ZONE,
|
|
||||||
expires_at TIMESTAMP WITHOUT TIME ZONE,
|
|
||||||
is_active BOOLEAN NOT NULL DEFAULT TRUE,
|
|
||||||
created_at TIMESTAMP WITHOUT TIME ZONE DEFAULT now()
|
|
||||||
)
|
|
||||||
"""))
|
|
||||||
conn.execute(sa.text(
|
|
||||||
"CREATE INDEX IF NOT EXISTS ix_api_keys_user_id ON api_keys (user_id)"
|
|
||||||
))
|
|
||||||
conn.execute(sa.text(
|
|
||||||
"CREATE INDEX IF NOT EXISTS ix_api_keys_key_hash ON api_keys (key_hash)"
|
|
||||||
))
|
|
||||||
conn.execute(sa.text(
|
|
||||||
"CREATE INDEX IF NOT EXISTS ix_api_keys_active ON api_keys (is_active)"
|
|
||||||
))
|
|
||||||
|
|
||||||
# ── sso_configs ───────────────────────────────────────────────────────────
|
|
||||||
conn.execute(sa.text("""
|
|
||||||
CREATE TABLE IF NOT EXISTS sso_configs (
|
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
||||||
is_enabled BOOLEAN NOT NULL DEFAULT FALSE,
|
|
||||||
provider_name VARCHAR(200),
|
|
||||||
sp_entity_id VARCHAR(500),
|
|
||||||
sp_acs_url VARCHAR(500),
|
|
||||||
sp_slo_url VARCHAR(500),
|
|
||||||
sp_certificate TEXT,
|
|
||||||
sp_private_key TEXT,
|
|
||||||
idp_entity_id VARCHAR(500),
|
|
||||||
idp_sso_url VARCHAR(500),
|
|
||||||
idp_slo_url VARCHAR(500),
|
|
||||||
idp_certificate TEXT,
|
|
||||||
attr_email VARCHAR(200) DEFAULT 'email',
|
|
||||||
attr_username VARCHAR(200) DEFAULT 'username',
|
|
||||||
attr_role VARCHAR(200) DEFAULT 'role',
|
|
||||||
default_role VARCHAR(50) DEFAULT 'viewer',
|
|
||||||
auto_provision BOOLEAN NOT NULL DEFAULT TRUE,
|
|
||||||
created_at TIMESTAMP WITHOUT TIME ZONE DEFAULT now(),
|
|
||||||
updated_at TIMESTAMP WITHOUT TIME ZONE DEFAULT now()
|
|
||||||
)
|
|
||||||
"""))
|
|
||||||
|
|
||||||
|
|
||||||
def downgrade() -> None:
|
|
||||||
conn = op.get_bind()
|
|
||||||
conn.execute(sa.text("DROP TABLE IF EXISTS api_keys CASCADE"))
|
|
||||||
conn.execute(sa.text("DROP TABLE IF EXISTS sso_configs CASCADE"))
|
|
||||||
@@ -1,82 +0,0 @@
|
|||||||
"""Phase 13: Operational Alerts — alert_rules and alert_instances tables.
|
|
||||||
|
|
||||||
Revision ID: b041alerts
|
|
||||||
Revises: b040ent
|
|
||||||
Create Date: 2026-05-21
|
|
||||||
"""
|
|
||||||
|
|
||||||
from alembic import op
|
|
||||||
import sqlalchemy as sa
|
|
||||||
|
|
||||||
revision = "b041alerts"
|
|
||||||
down_revision = "b040ent"
|
|
||||||
branch_labels = None
|
|
||||||
depends_on = None
|
|
||||||
|
|
||||||
|
|
||||||
def upgrade() -> None:
|
|
||||||
conn = op.get_bind()
|
|
||||||
|
|
||||||
# ── alert_rules ───────────────────────────────────────────────────────────
|
|
||||||
conn.execute(sa.text("""
|
|
||||||
CREATE TABLE IF NOT EXISTS alert_rules (
|
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
||||||
name VARCHAR(300) NOT NULL,
|
|
||||||
description TEXT,
|
|
||||||
rule_type VARCHAR(50) NOT NULL,
|
|
||||||
severity VARCHAR(20) NOT NULL DEFAULT 'medium',
|
|
||||||
is_enabled BOOLEAN NOT NULL DEFAULT TRUE,
|
|
||||||
is_system BOOLEAN NOT NULL DEFAULT FALSE,
|
|
||||||
config JSONB NOT NULL DEFAULT '{}',
|
|
||||||
notify_in_app BOOLEAN NOT NULL DEFAULT TRUE,
|
|
||||||
notify_webhook BOOLEAN NOT NULL DEFAULT FALSE,
|
|
||||||
webhook_id UUID REFERENCES webhook_configs(id) ON DELETE SET NULL,
|
|
||||||
cooldown_hours INTEGER NOT NULL DEFAULT 24,
|
|
||||||
created_by UUID REFERENCES users(id) ON DELETE SET NULL,
|
|
||||||
created_at TIMESTAMP WITHOUT TIME ZONE DEFAULT now(),
|
|
||||||
last_fired_at TIMESTAMP WITHOUT TIME ZONE
|
|
||||||
)
|
|
||||||
"""))
|
|
||||||
conn.execute(sa.text(
|
|
||||||
"CREATE INDEX IF NOT EXISTS ix_alert_rules_type ON alert_rules (rule_type)"
|
|
||||||
))
|
|
||||||
conn.execute(sa.text(
|
|
||||||
"CREATE INDEX IF NOT EXISTS ix_alert_rules_enabled ON alert_rules (is_enabled)"
|
|
||||||
))
|
|
||||||
|
|
||||||
# ── alert_instances ───────────────────────────────────────────────────────
|
|
||||||
conn.execute(sa.text("""
|
|
||||||
CREATE TABLE IF NOT EXISTS alert_instances (
|
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
||||||
rule_id UUID REFERENCES alert_rules(id) ON DELETE SET NULL,
|
|
||||||
rule_name VARCHAR(300) NOT NULL,
|
|
||||||
rule_type VARCHAR(50) NOT NULL,
|
|
||||||
severity VARCHAR(20) NOT NULL,
|
|
||||||
title VARCHAR(500) NOT NULL,
|
|
||||||
message TEXT NOT NULL,
|
|
||||||
details JSONB,
|
|
||||||
status VARCHAR(20) NOT NULL DEFAULT 'open',
|
|
||||||
acknowledged_by UUID REFERENCES users(id) ON DELETE SET NULL,
|
|
||||||
acknowledged_at TIMESTAMP WITHOUT TIME ZONE,
|
|
||||||
resolved_at TIMESTAMP WITHOUT TIME ZONE,
|
|
||||||
created_at TIMESTAMP WITHOUT TIME ZONE DEFAULT now()
|
|
||||||
)
|
|
||||||
"""))
|
|
||||||
conn.execute(sa.text(
|
|
||||||
"CREATE INDEX IF NOT EXISTS ix_alert_instances_rule_id ON alert_instances (rule_id)"
|
|
||||||
))
|
|
||||||
conn.execute(sa.text(
|
|
||||||
"CREATE INDEX IF NOT EXISTS ix_alert_instances_status ON alert_instances (status)"
|
|
||||||
))
|
|
||||||
conn.execute(sa.text(
|
|
||||||
"CREATE INDEX IF NOT EXISTS ix_alert_instances_severity ON alert_instances (severity)"
|
|
||||||
))
|
|
||||||
conn.execute(sa.text(
|
|
||||||
"CREATE INDEX IF NOT EXISTS ix_alert_instances_created ON alert_instances (created_at)"
|
|
||||||
))
|
|
||||||
|
|
||||||
|
|
||||||
def downgrade() -> None:
|
|
||||||
conn = op.get_bind()
|
|
||||||
conn.execute(sa.text("DROP TABLE IF EXISTS alert_instances CASCADE"))
|
|
||||||
conn.execute(sa.text("DROP TABLE IF EXISTS alert_rules CASCADE"))
|
|
||||||
@@ -1,25 +0,0 @@
|
|||||||
"""Add jira_api_token to users table.
|
|
||||||
|
|
||||||
Revision ID: b042
|
|
||||||
Revises: b041_operational_alerts
|
|
||||||
Create Date: 2026-05-26
|
|
||||||
"""
|
|
||||||
|
|
||||||
from alembic import op
|
|
||||||
import sqlalchemy as sa
|
|
||||||
|
|
||||||
revision = "b042"
|
|
||||||
down_revision = "b041alerts"
|
|
||||||
branch_labels = None
|
|
||||||
depends_on = None
|
|
||||||
|
|
||||||
|
|
||||||
def upgrade() -> None:
|
|
||||||
op.add_column(
|
|
||||||
"users",
|
|
||||||
sa.Column("jira_api_token", sa.String(500), nullable=True),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def downgrade() -> None:
|
|
||||||
op.drop_column("users", "jira_api_token")
|
|
||||||
@@ -1,28 +0,0 @@
|
|||||||
"""Add jira_email to users table.
|
|
||||||
|
|
||||||
Allows each user to specify a separate email for Jira authentication,
|
|
||||||
independent of their Aegis account email.
|
|
||||||
|
|
||||||
Revision ID: b043
|
|
||||||
Revises: b042
|
|
||||||
Create Date: 2026-05-26
|
|
||||||
"""
|
|
||||||
|
|
||||||
from alembic import op
|
|
||||||
import sqlalchemy as sa
|
|
||||||
|
|
||||||
revision = "b043"
|
|
||||||
down_revision = "b042"
|
|
||||||
branch_labels = None
|
|
||||||
depends_on = None
|
|
||||||
|
|
||||||
|
|
||||||
def upgrade() -> None:
|
|
||||||
op.add_column(
|
|
||||||
"users",
|
|
||||||
sa.Column("jira_email", sa.String(255), nullable=True),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def downgrade() -> None:
|
|
||||||
op.drop_column("users", "jira_email")
|
|
||||||
@@ -1,25 +0,0 @@
|
|||||||
"""add tempo_api_token to users
|
|
||||||
|
|
||||||
Revision ID: b044
|
|
||||||
Revises: b043
|
|
||||||
Create Date: 2026-05-27
|
|
||||||
|
|
||||||
"""
|
|
||||||
from alembic import op
|
|
||||||
import sqlalchemy as sa
|
|
||||||
|
|
||||||
revision = "b044"
|
|
||||||
down_revision = "b043"
|
|
||||||
branch_labels = None
|
|
||||||
depends_on = None
|
|
||||||
|
|
||||||
|
|
||||||
def upgrade():
|
|
||||||
op.add_column(
|
|
||||||
"users",
|
|
||||||
sa.Column("tempo_api_token", sa.String(500), nullable=True),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def downgrade():
|
|
||||||
op.drop_column("users", "tempo_api_token")
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
"""Add blue_work_started_at to tests table."""
|
|
||||||
from alembic import op
|
|
||||||
import sqlalchemy as sa
|
|
||||||
|
|
||||||
revision = "b045"
|
|
||||||
down_revision = "b044"
|
|
||||||
branch_labels = None
|
|
||||||
depends_on = None
|
|
||||||
|
|
||||||
|
|
||||||
def upgrade():
|
|
||||||
op.add_column("tests", sa.Column("blue_work_started_at", sa.DateTime(), nullable=True))
|
|
||||||
|
|
||||||
|
|
||||||
def downgrade():
|
|
||||||
op.drop_column("tests", "blue_work_started_at")
|
|
||||||
@@ -1,22 +0,0 @@
|
|||||||
"""Add 'disputed' value to teststate enum.
|
|
||||||
|
|
||||||
Revision ID: b046
|
|
||||||
Revises: b045
|
|
||||||
Create Date: 2026-06-03
|
|
||||||
"""
|
|
||||||
|
|
||||||
from alembic import op
|
|
||||||
|
|
||||||
revision = "b046"
|
|
||||||
down_revision = "b045"
|
|
||||||
branch_labels = None
|
|
||||||
depends_on = None
|
|
||||||
|
|
||||||
|
|
||||||
def upgrade() -> None:
|
|
||||||
op.execute("ALTER TYPE teststate ADD VALUE IF NOT EXISTS 'disputed'")
|
|
||||||
|
|
||||||
|
|
||||||
def downgrade() -> None:
|
|
||||||
# PostgreSQL does not support removing enum values; downgrade is a no-op.
|
|
||||||
pass
|
|
||||||
@@ -1,27 +0,0 @@
|
|||||||
"""Add start_date to campaigns.
|
|
||||||
|
|
||||||
Revision ID: b047
|
|
||||||
Revises: b046
|
|
||||||
Create Date: 2026-06-03
|
|
||||||
"""
|
|
||||||
|
|
||||||
from alembic import op
|
|
||||||
import sqlalchemy as sa
|
|
||||||
|
|
||||||
revision = "b047"
|
|
||||||
down_revision = "b046"
|
|
||||||
branch_labels = None
|
|
||||||
depends_on = None
|
|
||||||
|
|
||||||
|
|
||||||
def upgrade() -> None:
|
|
||||||
op.add_column(
|
|
||||||
"campaigns",
|
|
||||||
sa.Column("start_date", sa.DateTime(), nullable=True),
|
|
||||||
)
|
|
||||||
op.create_index("ix_campaigns_start_date", "campaigns", ["start_date"])
|
|
||||||
|
|
||||||
|
|
||||||
def downgrade() -> None:
|
|
||||||
op.drop_index("ix_campaigns_start_date", table_name="campaigns")
|
|
||||||
op.drop_column("campaigns", "start_date")
|
|
||||||
@@ -1,39 +0,0 @@
|
|||||||
"""Add evaluation_imports table.
|
|
||||||
|
|
||||||
Revision ID: b048
|
|
||||||
Revises: b047
|
|
||||||
Create Date: 2026-06-05
|
|
||||||
"""
|
|
||||||
|
|
||||||
from alembic import op
|
|
||||||
import sqlalchemy as sa
|
|
||||||
from sqlalchemy.dialects.postgresql import UUID
|
|
||||||
|
|
||||||
revision = "b048"
|
|
||||||
down_revision = "b047"
|
|
||||||
branch_labels = None
|
|
||||||
depends_on = None
|
|
||||||
|
|
||||||
|
|
||||||
def upgrade() -> None:
|
|
||||||
op.create_table(
|
|
||||||
"evaluation_imports",
|
|
||||||
sa.Column("id", UUID(as_uuid=True), primary_key=True),
|
|
||||||
sa.Column("adversary_name", sa.String, nullable=False),
|
|
||||||
sa.Column("adversary_display", sa.String, nullable=False),
|
|
||||||
sa.Column("eval_round", sa.Integer, nullable=False),
|
|
||||||
sa.Column("imported_at", sa.DateTime, nullable=False),
|
|
||||||
sa.Column("imported_by", UUID(as_uuid=True), sa.ForeignKey("users.id"), nullable=True),
|
|
||||||
sa.Column("tests_created", sa.Integer, default=0),
|
|
||||||
sa.Column("techniques_covered", sa.Integer, default=0),
|
|
||||||
sa.Column("status", sa.String, default="completed"),
|
|
||||||
sa.Column("notes", sa.Text, nullable=True),
|
|
||||||
)
|
|
||||||
op.create_index("ix_evaluation_imports_adversary", "evaluation_imports", ["adversary_name"])
|
|
||||||
op.create_index("ix_evaluation_imports_round", "evaluation_imports", ["eval_round"])
|
|
||||||
|
|
||||||
|
|
||||||
def downgrade() -> None:
|
|
||||||
op.drop_index("ix_evaluation_imports_round", table_name="evaluation_imports")
|
|
||||||
op.drop_index("ix_evaluation_imports_adversary", table_name="evaluation_imports")
|
|
||||||
op.drop_table("evaluation_imports")
|
|
||||||
@@ -1,26 +0,0 @@
|
|||||||
"""Add external_references column to techniques table.
|
|
||||||
|
|
||||||
Revision ID: b049
|
|
||||||
Revises: b048
|
|
||||||
Create Date: 2026-06-18
|
|
||||||
"""
|
|
||||||
|
|
||||||
from alembic import op
|
|
||||||
import sqlalchemy as sa
|
|
||||||
from sqlalchemy.dialects.postgresql import JSONB
|
|
||||||
|
|
||||||
revision = "b049"
|
|
||||||
down_revision = "b048"
|
|
||||||
branch_labels = None
|
|
||||||
depends_on = None
|
|
||||||
|
|
||||||
|
|
||||||
def upgrade() -> None:
|
|
||||||
op.add_column(
|
|
||||||
"techniques",
|
|
||||||
sa.Column("external_references", JSONB, nullable=True),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def downgrade() -> None:
|
|
||||||
op.drop_column("techniques", "external_references")
|
|
||||||
@@ -1,24 +0,0 @@
|
|||||||
"""Add red_tech_assignee and blue_tech_assignee to tests.
|
|
||||||
|
|
||||||
Revision ID: b050
|
|
||||||
Revises: b049
|
|
||||||
Create Date: 2026-06-19
|
|
||||||
"""
|
|
||||||
from alembic import op
|
|
||||||
import sqlalchemy as sa
|
|
||||||
from sqlalchemy.dialects.postgresql import UUID
|
|
||||||
|
|
||||||
revision = "b050"
|
|
||||||
down_revision = "b049"
|
|
||||||
branch_labels = None
|
|
||||||
depends_on = None
|
|
||||||
|
|
||||||
|
|
||||||
def upgrade() -> None:
|
|
||||||
op.add_column("tests", sa.Column("red_tech_assignee", UUID(as_uuid=True), sa.ForeignKey("users.id"), nullable=True))
|
|
||||||
op.add_column("tests", sa.Column("blue_tech_assignee", UUID(as_uuid=True), sa.ForeignKey("users.id"), nullable=True))
|
|
||||||
|
|
||||||
|
|
||||||
def downgrade() -> None:
|
|
||||||
op.drop_column("tests", "blue_tech_assignee")
|
|
||||||
op.drop_column("tests", "red_tech_assignee")
|
|
||||||
@@ -1,25 +0,0 @@
|
|||||||
"""Add is_on_hold, hold_reason, held_at to tests.
|
|
||||||
|
|
||||||
Revision ID: b051
|
|
||||||
Revises: b050
|
|
||||||
Create Date: 2026-06-19
|
|
||||||
"""
|
|
||||||
from alembic import op
|
|
||||||
import sqlalchemy as sa
|
|
||||||
|
|
||||||
revision = "b051"
|
|
||||||
down_revision = "b050"
|
|
||||||
branch_labels = None
|
|
||||||
depends_on = None
|
|
||||||
|
|
||||||
|
|
||||||
def upgrade() -> None:
|
|
||||||
op.add_column("tests", sa.Column("is_on_hold", sa.Boolean(), nullable=False, server_default="false"))
|
|
||||||
op.add_column("tests", sa.Column("hold_reason", sa.Text(), nullable=True))
|
|
||||||
op.add_column("tests", sa.Column("held_at", sa.DateTime(), nullable=True))
|
|
||||||
|
|
||||||
|
|
||||||
def downgrade() -> None:
|
|
||||||
op.drop_column("tests", "held_at")
|
|
||||||
op.drop_column("tests", "hold_reason")
|
|
||||||
op.drop_column("tests", "is_on_hold")
|
|
||||||
@@ -1,31 +0,0 @@
|
|||||||
"""Add containment_result to tests.
|
|
||||||
|
|
||||||
Revision ID: b052
|
|
||||||
Revises: b051
|
|
||||||
Create Date: 2026-06-24
|
|
||||||
"""
|
|
||||||
from alembic import op
|
|
||||||
import sqlalchemy as sa
|
|
||||||
|
|
||||||
revision = "b052"
|
|
||||||
down_revision = "b051"
|
|
||||||
branch_labels = None
|
|
||||||
depends_on = None
|
|
||||||
|
|
||||||
containment_enum = sa.Enum(
|
|
||||||
"contained", "partially_contained", "not_contained",
|
|
||||||
name="containmentresult",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def upgrade() -> None:
|
|
||||||
containment_enum.create(op.get_bind(), checkfirst=True)
|
|
||||||
op.add_column(
|
|
||||||
"tests",
|
|
||||||
sa.Column("containment_result", containment_enum, nullable=True),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def downgrade() -> None:
|
|
||||||
op.drop_column("tests", "containment_result")
|
|
||||||
containment_enum.drop(op.get_bind(), checkfirst=True)
|
|
||||||
@@ -1,27 +0,0 @@
|
|||||||
"""Add execution_start_time, execution_end_time, detection_time, containment_time to tests.
|
|
||||||
|
|
||||||
Revision ID: b053
|
|
||||||
Revises: b052
|
|
||||||
Create Date: 2026-06-24
|
|
||||||
"""
|
|
||||||
from alembic import op
|
|
||||||
import sqlalchemy as sa
|
|
||||||
|
|
||||||
revision = "b053"
|
|
||||||
down_revision = "b052"
|
|
||||||
branch_labels = None
|
|
||||||
depends_on = None
|
|
||||||
|
|
||||||
|
|
||||||
def upgrade() -> None:
|
|
||||||
op.add_column("tests", sa.Column("execution_start_time", sa.DateTime(), nullable=True))
|
|
||||||
op.add_column("tests", sa.Column("execution_end_time", sa.DateTime(), nullable=True))
|
|
||||||
op.add_column("tests", sa.Column("detection_time", sa.DateTime(), nullable=True))
|
|
||||||
op.add_column("tests", sa.Column("containment_time", sa.DateTime(), nullable=True))
|
|
||||||
|
|
||||||
|
|
||||||
def downgrade() -> None:
|
|
||||||
op.drop_column("tests", "containment_time")
|
|
||||||
op.drop_column("tests", "detection_time")
|
|
||||||
op.drop_column("tests", "execution_end_time")
|
|
||||||
op.drop_column("tests", "execution_start_time")
|
|
||||||
@@ -1,36 +0,0 @@
|
|||||||
"""Add approved_by, approved_at, rejection_reason to campaigns.
|
|
||||||
|
|
||||||
Revision ID: b054
|
|
||||||
Revises: b053
|
|
||||||
Create Date: 2026-07-02
|
|
||||||
"""
|
|
||||||
from alembic import op
|
|
||||||
import sqlalchemy as sa
|
|
||||||
from sqlalchemy.dialects import postgresql
|
|
||||||
|
|
||||||
revision = "b054"
|
|
||||||
down_revision = "b053"
|
|
||||||
branch_labels = None
|
|
||||||
depends_on = None
|
|
||||||
|
|
||||||
|
|
||||||
def upgrade() -> None:
|
|
||||||
op.add_column(
|
|
||||||
"campaigns",
|
|
||||||
sa.Column("approved_by", postgresql.UUID(as_uuid=True), nullable=True),
|
|
||||||
)
|
|
||||||
op.add_column("campaigns", sa.Column("approved_at", sa.DateTime(), nullable=True))
|
|
||||||
op.add_column("campaigns", sa.Column("rejection_reason", sa.Text(), nullable=True))
|
|
||||||
op.create_foreign_key(
|
|
||||||
"fk_campaigns_approved_by_users",
|
|
||||||
"campaigns", "users",
|
|
||||||
["approved_by"], ["id"],
|
|
||||||
ondelete="SET NULL",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def downgrade() -> None:
|
|
||||||
op.drop_constraint("fk_campaigns_approved_by_users", "campaigns", type_="foreignkey")
|
|
||||||
op.drop_column("campaigns", "rejection_reason")
|
|
||||||
op.drop_column("campaigns", "approved_at")
|
|
||||||
op.drop_column("campaigns", "approved_by")
|
|
||||||
@@ -1,59 +0,0 @@
|
|||||||
"""Add campaign_modification_requests table.
|
|
||||||
|
|
||||||
Revision ID: b055
|
|
||||||
Revises: b054
|
|
||||||
Create Date: 2026-07-02
|
|
||||||
"""
|
|
||||||
from alembic import op
|
|
||||||
import sqlalchemy as sa
|
|
||||||
from sqlalchemy.dialects import postgresql
|
|
||||||
|
|
||||||
revision = "b055"
|
|
||||||
down_revision = "b054"
|
|
||||||
branch_labels = None
|
|
||||||
depends_on = None
|
|
||||||
|
|
||||||
|
|
||||||
def upgrade() -> None:
|
|
||||||
op.create_table(
|
|
||||||
"campaign_modification_requests",
|
|
||||||
sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True),
|
|
||||||
sa.Column(
|
|
||||||
"campaign_id", postgresql.UUID(as_uuid=True),
|
|
||||||
sa.ForeignKey("campaigns.id", ondelete="CASCADE"), nullable=False,
|
|
||||||
),
|
|
||||||
sa.Column(
|
|
||||||
"requested_by", postgresql.UUID(as_uuid=True),
|
|
||||||
sa.ForeignKey("users.id", ondelete="SET NULL"), nullable=True,
|
|
||||||
),
|
|
||||||
sa.Column("action", sa.String(), nullable=False),
|
|
||||||
sa.Column(
|
|
||||||
"test_id", postgresql.UUID(as_uuid=True),
|
|
||||||
sa.ForeignKey("tests.id", ondelete="CASCADE"), nullable=False,
|
|
||||||
),
|
|
||||||
sa.Column("order_index", sa.Integer(), nullable=True),
|
|
||||||
sa.Column("phase", sa.String(), nullable=True),
|
|
||||||
sa.Column("justification", sa.Text(), nullable=False),
|
|
||||||
sa.Column("status", sa.String(), nullable=False, server_default="pending"),
|
|
||||||
sa.Column(
|
|
||||||
"reviewed_by", postgresql.UUID(as_uuid=True),
|
|
||||||
sa.ForeignKey("users.id", ondelete="SET NULL"), nullable=True,
|
|
||||||
),
|
|
||||||
sa.Column("reviewed_at", sa.DateTime(), nullable=True),
|
|
||||||
sa.Column("review_notes", sa.Text(), nullable=True),
|
|
||||||
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
|
|
||||||
)
|
|
||||||
op.create_index(
|
|
||||||
"ix_campaign_mod_requests_campaign",
|
|
||||||
"campaign_modification_requests", ["campaign_id"],
|
|
||||||
)
|
|
||||||
op.create_index(
|
|
||||||
"ix_campaign_mod_requests_status",
|
|
||||||
"campaign_modification_requests", ["status"],
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def downgrade() -> None:
|
|
||||||
op.drop_index("ix_campaign_mod_requests_status", table_name="campaign_modification_requests")
|
|
||||||
op.drop_index("ix_campaign_mod_requests_campaign", table_name="campaign_modification_requests")
|
|
||||||
op.drop_table("campaign_modification_requests")
|
|
||||||
@@ -1,50 +0,0 @@
|
|||||||
"""Change campaign_modification_requests.test_id to nullable with ON DELETE SET NULL.
|
|
||||||
|
|
||||||
Approving a "remove_test" modification request deletes the underlying Test
|
|
||||||
row. With the original ON DELETE CASCADE, that delete cascaded and wiped
|
|
||||||
out the modification-request row itself, destroying the audit record
|
|
||||||
(justification, reviewer, decision) the request exists to preserve.
|
|
||||||
|
|
||||||
Revision ID: b056
|
|
||||||
Revises: b055
|
|
||||||
Create Date: 2026-07-03
|
|
||||||
"""
|
|
||||||
from alembic import op
|
|
||||||
import sqlalchemy as sa
|
|
||||||
|
|
||||||
revision = "b056"
|
|
||||||
down_revision = "b055"
|
|
||||||
branch_labels = None
|
|
||||||
depends_on = None
|
|
||||||
|
|
||||||
_FK_NAME = "campaign_modification_requests_test_id_fkey"
|
|
||||||
|
|
||||||
|
|
||||||
def upgrade() -> None:
|
|
||||||
op.alter_column(
|
|
||||||
"campaign_modification_requests", "test_id",
|
|
||||||
existing_type=sa.dialects.postgresql.UUID(as_uuid=True),
|
|
||||||
nullable=True,
|
|
||||||
)
|
|
||||||
op.drop_constraint(_FK_NAME, "campaign_modification_requests", type_="foreignkey")
|
|
||||||
op.create_foreign_key(
|
|
||||||
_FK_NAME,
|
|
||||||
"campaign_modification_requests", "tests",
|
|
||||||
["test_id"], ["id"],
|
|
||||||
ondelete="SET NULL",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def downgrade() -> None:
|
|
||||||
op.drop_constraint(_FK_NAME, "campaign_modification_requests", type_="foreignkey")
|
|
||||||
op.create_foreign_key(
|
|
||||||
_FK_NAME,
|
|
||||||
"campaign_modification_requests", "tests",
|
|
||||||
["test_id"], ["id"],
|
|
||||||
ondelete="CASCADE",
|
|
||||||
)
|
|
||||||
op.alter_column(
|
|
||||||
"campaign_modification_requests", "test_id",
|
|
||||||
existing_type=sa.dialects.postgresql.UUID(as_uuid=True),
|
|
||||||
nullable=False,
|
|
||||||
)
|
|
||||||
@@ -1,44 +0,0 @@
|
|||||||
"""Add red_review, blue_review states and review-assignment columns to tests.
|
|
||||||
|
|
||||||
Revision ID: b057
|
|
||||||
Revises: b056
|
|
||||||
Create Date: 2026-07-06
|
|
||||||
"""
|
|
||||||
|
|
||||||
from alembic import op
|
|
||||||
import sqlalchemy as sa
|
|
||||||
from sqlalchemy.dialects import postgresql
|
|
||||||
|
|
||||||
revision = "b057"
|
|
||||||
down_revision = "b056"
|
|
||||||
branch_labels = None
|
|
||||||
depends_on = None
|
|
||||||
|
|
||||||
|
|
||||||
def upgrade() -> None:
|
|
||||||
op.execute("ALTER TYPE teststate ADD VALUE IF NOT EXISTS 'red_review'")
|
|
||||||
op.execute("ALTER TYPE teststate ADD VALUE IF NOT EXISTS 'blue_review'")
|
|
||||||
|
|
||||||
op.add_column("tests", sa.Column("red_reviewer_assignee", postgresql.UUID(as_uuid=True), sa.ForeignKey("users.id"), nullable=True))
|
|
||||||
op.add_column("tests", sa.Column("red_review_by", postgresql.UUID(as_uuid=True), sa.ForeignKey("users.id"), nullable=True))
|
|
||||||
op.add_column("tests", sa.Column("red_review_at", sa.DateTime(), nullable=True))
|
|
||||||
op.add_column("tests", sa.Column("red_review_notes", sa.Text(), nullable=True))
|
|
||||||
op.add_column("tests", sa.Column("blue_reviewer_assignee", postgresql.UUID(as_uuid=True), sa.ForeignKey("users.id"), nullable=True))
|
|
||||||
op.add_column("tests", sa.Column("blue_review_by", postgresql.UUID(as_uuid=True), sa.ForeignKey("users.id"), nullable=True))
|
|
||||||
op.add_column("tests", sa.Column("blue_review_at", sa.DateTime(), nullable=True))
|
|
||||||
op.add_column("tests", sa.Column("blue_review_notes", sa.Text(), nullable=True))
|
|
||||||
op.add_column("tests", sa.Column("system_gaps", sa.Text(), nullable=True))
|
|
||||||
|
|
||||||
|
|
||||||
def downgrade() -> None:
|
|
||||||
op.drop_column("tests", "system_gaps")
|
|
||||||
op.drop_column("tests", "blue_review_notes")
|
|
||||||
op.drop_column("tests", "blue_review_at")
|
|
||||||
op.drop_column("tests", "blue_review_by")
|
|
||||||
op.drop_column("tests", "blue_reviewer_assignee")
|
|
||||||
op.drop_column("tests", "red_review_notes")
|
|
||||||
op.drop_column("tests", "red_review_at")
|
|
||||||
op.drop_column("tests", "red_review_by")
|
|
||||||
op.drop_column("tests", "red_reviewer_assignee")
|
|
||||||
# PostgreSQL does not support removing enum values; downgrade is a no-op
|
|
||||||
# for the enum part, matching b046_add_disputed_test_state.py.
|
|
||||||
@@ -1,48 +0,0 @@
|
|||||||
"""Convert tests.attack_success from Boolean to a 3-value enum.
|
|
||||||
|
|
||||||
Revision ID: b058
|
|
||||||
Revises: b057
|
|
||||||
Create Date: 2026-07-06
|
|
||||||
"""
|
|
||||||
|
|
||||||
from alembic import op
|
|
||||||
import sqlalchemy as sa
|
|
||||||
|
|
||||||
revision = "b058"
|
|
||||||
down_revision = "b057"
|
|
||||||
branch_labels = None
|
|
||||||
depends_on = None
|
|
||||||
|
|
||||||
_ENUM_NAME = "attacksuccessresult"
|
|
||||||
|
|
||||||
|
|
||||||
def upgrade() -> None:
|
|
||||||
attack_success_enum = sa.Enum(
|
|
||||||
"successful", "not_successful", "partially_successful", name=_ENUM_NAME
|
|
||||||
)
|
|
||||||
attack_success_enum.create(op.get_bind(), checkfirst=True)
|
|
||||||
|
|
||||||
op.add_column("tests", sa.Column("attack_success_new", attack_success_enum, nullable=True))
|
|
||||||
|
|
||||||
op.execute(
|
|
||||||
"UPDATE tests SET attack_success_new = (CASE "
|
|
||||||
"WHEN attack_success = true THEN 'successful' "
|
|
||||||
"WHEN attack_success = false THEN 'not_successful' "
|
|
||||||
f"ELSE NULL END)::{_ENUM_NAME}"
|
|
||||||
)
|
|
||||||
|
|
||||||
op.drop_column("tests", "attack_success")
|
|
||||||
op.alter_column("tests", "attack_success_new", new_column_name="attack_success")
|
|
||||||
|
|
||||||
|
|
||||||
def downgrade() -> None:
|
|
||||||
op.add_column("tests", sa.Column("attack_success_bool", sa.Boolean(), nullable=True))
|
|
||||||
op.execute(
|
|
||||||
"UPDATE tests SET attack_success_bool = CASE "
|
|
||||||
"WHEN attack_success = 'successful' THEN true "
|
|
||||||
"WHEN attack_success = 'not_successful' THEN false "
|
|
||||||
"ELSE NULL END"
|
|
||||||
)
|
|
||||||
op.drop_column("tests", "attack_success")
|
|
||||||
op.alter_column("tests", "attack_success_bool", new_column_name="attack_success")
|
|
||||||
sa.Enum(name=_ENUM_NAME).drop(op.get_bind(), checkfirst=True)
|
|
||||||
@@ -1,41 +0,0 @@
|
|||||||
"""Rename data_classification values to match the org's real scheme.
|
|
||||||
|
|
||||||
public -> public_release, internal -> general_use, sensitive -> confidential.
|
|
||||||
restricted is unchanged. Applies to tests, campaigns, and evidences tables
|
|
||||||
(all plain String(20) columns — no native enum type to alter).
|
|
||||||
|
|
||||||
Revision ID: b059
|
|
||||||
Revises: b058
|
|
||||||
Create Date: 2026-07-07
|
|
||||||
"""
|
|
||||||
|
|
||||||
from alembic import op
|
|
||||||
|
|
||||||
revision = "b059"
|
|
||||||
down_revision = "b058"
|
|
||||||
branch_labels = None
|
|
||||||
depends_on = None
|
|
||||||
|
|
||||||
_TABLES = ("tests", "campaigns", "evidences")
|
|
||||||
|
|
||||||
_RENAMES_UP = {
|
|
||||||
"public": "public_release",
|
|
||||||
"internal": "general_use",
|
|
||||||
"sensitive": "confidential",
|
|
||||||
}
|
|
||||||
|
|
||||||
_RENAMES_DOWN = {v: k for k, v in _RENAMES_UP.items()}
|
|
||||||
|
|
||||||
|
|
||||||
def upgrade() -> None:
|
|
||||||
for table in _TABLES:
|
|
||||||
for old, new in _RENAMES_UP.items():
|
|
||||||
op.execute(f"UPDATE {table} SET data_classification = '{new}' WHERE data_classification = '{old}'")
|
|
||||||
op.alter_column(table, "data_classification", server_default="confidential")
|
|
||||||
|
|
||||||
|
|
||||||
def downgrade() -> None:
|
|
||||||
for table in _TABLES:
|
|
||||||
for new, old in _RENAMES_DOWN.items():
|
|
||||||
op.execute(f"UPDATE {table} SET data_classification = '{old}' WHERE data_classification = '{new}'")
|
|
||||||
op.alter_column(table, "data_classification", server_default="internal")
|
|
||||||
@@ -1,47 +0,0 @@
|
|||||||
"""Rename data_classification values to match Jira's real Data Sensitivity field.
|
|
||||||
|
|
||||||
Jira's actual "Data Sensitivity" custom field (customfield_11814) has 6
|
|
||||||
options — Public, General Use, Internal Use Only, Trusted People, Customer
|
|
||||||
Content, PII — not the 4-tier scheme Aegis invented independently. This
|
|
||||||
migration replicates Jira's scheme so ticket sync stops guessing at a
|
|
||||||
mapping. Applies to tests, campaigns, and evidences tables (all plain
|
|
||||||
String(20) columns — no native enum type to alter).
|
|
||||||
|
|
||||||
public_release -> public, confidential -> internal_use_only,
|
|
||||||
restricted -> pii. general_use is unchanged.
|
|
||||||
|
|
||||||
Revision ID: b060
|
|
||||||
Revises: b059
|
|
||||||
Create Date: 2026-07-09
|
|
||||||
"""
|
|
||||||
|
|
||||||
from alembic import op
|
|
||||||
|
|
||||||
revision = "b060"
|
|
||||||
down_revision = "b059"
|
|
||||||
branch_labels = None
|
|
||||||
depends_on = None
|
|
||||||
|
|
||||||
_TABLES = ("tests", "campaigns", "evidences")
|
|
||||||
|
|
||||||
_RENAMES_UP = {
|
|
||||||
"public_release": "public",
|
|
||||||
"confidential": "internal_use_only",
|
|
||||||
"restricted": "pii",
|
|
||||||
}
|
|
||||||
|
|
||||||
_RENAMES_DOWN = {v: k for k, v in _RENAMES_UP.items()}
|
|
||||||
|
|
||||||
|
|
||||||
def upgrade() -> None:
|
|
||||||
for table in _TABLES:
|
|
||||||
for old, new in _RENAMES_UP.items():
|
|
||||||
op.execute(f"UPDATE {table} SET data_classification = '{new}' WHERE data_classification = '{old}'")
|
|
||||||
op.alter_column(table, "data_classification", server_default="internal_use_only")
|
|
||||||
|
|
||||||
|
|
||||||
def downgrade() -> None:
|
|
||||||
for table in _TABLES:
|
|
||||||
for new, old in _RENAMES_DOWN.items():
|
|
||||||
op.execute(f"UPDATE {table} SET data_classification = '{old}' WHERE data_classification = '{new}'")
|
|
||||||
op.alter_column(table, "data_classification", server_default="confidential")
|
|
||||||
@@ -1,64 +0,0 @@
|
|||||||
"""Archive round data on reopen instead of overwriting it.
|
|
||||||
|
|
||||||
Reopening a test for rework used to reset the live procedure/result fields
|
|
||||||
in place, losing the prior round's data (and clobbering the Phase Timeline,
|
|
||||||
which read those same mutable columns). This adds an append-only
|
|
||||||
``test_round_history`` table that snapshots a round's fields right before
|
|
||||||
they get reset, plus per-team round counters on ``tests`` so each round is
|
|
||||||
numbered.
|
|
||||||
|
|
||||||
Revision ID: b061
|
|
||||||
Revises: b060
|
|
||||||
Create Date: 2026-07-13
|
|
||||||
"""
|
|
||||||
|
|
||||||
import sqlalchemy as sa
|
|
||||||
from alembic import op
|
|
||||||
from sqlalchemy.dialects import postgresql
|
|
||||||
|
|
||||||
revision = "b061"
|
|
||||||
down_revision = "b060"
|
|
||||||
branch_labels = None
|
|
||||||
depends_on = None
|
|
||||||
|
|
||||||
|
|
||||||
def upgrade() -> None:
|
|
||||||
op.add_column("tests", sa.Column("red_round_number", sa.Integer(), nullable=False, server_default="1"))
|
|
||||||
op.add_column("tests", sa.Column("blue_round_number", sa.Integer(), nullable=False, server_default="1"))
|
|
||||||
|
|
||||||
op.create_table(
|
|
||||||
"test_round_history",
|
|
||||||
sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True),
|
|
||||||
sa.Column("test_id", postgresql.UUID(as_uuid=True), sa.ForeignKey("tests.id"), nullable=False),
|
|
||||||
sa.Column("team", sa.String(10), nullable=False),
|
|
||||||
sa.Column("round_number", sa.Integer(), nullable=False),
|
|
||||||
sa.Column("started_at", sa.DateTime(), nullable=True),
|
|
||||||
sa.Column("ended_at", sa.DateTime(), nullable=True),
|
|
||||||
sa.Column("paused_seconds", sa.Integer(), nullable=True),
|
|
||||||
sa.Column("procedure_text", sa.Text(), nullable=True),
|
|
||||||
sa.Column("tool_used", sa.String(), nullable=True),
|
|
||||||
# Plain strings, not the shared Postgres enum types tests.* uses —
|
|
||||||
# this is an archive table with no DB-level constraint needs, and
|
|
||||||
# reusing those native type names here would make Alembic try to
|
|
||||||
# (re)create them.
|
|
||||||
sa.Column("attack_success", sa.String(), nullable=True),
|
|
||||||
sa.Column("red_summary", sa.Text(), nullable=True),
|
|
||||||
sa.Column("execution_start_time", sa.DateTime(), nullable=True),
|
|
||||||
sa.Column("execution_end_time", sa.DateTime(), nullable=True),
|
|
||||||
sa.Column("detection_result", sa.String(), nullable=True),
|
|
||||||
sa.Column("containment_result", sa.String(), nullable=True),
|
|
||||||
sa.Column("detection_time", sa.DateTime(), nullable=True),
|
|
||||||
sa.Column("containment_time", sa.DateTime(), nullable=True),
|
|
||||||
sa.Column("blue_summary", sa.Text(), nullable=True),
|
|
||||||
sa.Column("review_notes", sa.Text(), nullable=True),
|
|
||||||
sa.Column("reviewed_by", postgresql.UUID(as_uuid=True), sa.ForeignKey("users.id"), nullable=True),
|
|
||||||
sa.Column("archived_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
|
|
||||||
)
|
|
||||||
op.create_index("ix_test_round_history_test_id", "test_round_history", ["test_id"])
|
|
||||||
|
|
||||||
|
|
||||||
def downgrade() -> None:
|
|
||||||
op.drop_index("ix_test_round_history_test_id", table_name="test_round_history")
|
|
||||||
op.drop_table("test_round_history")
|
|
||||||
op.drop_column("tests", "blue_round_number")
|
|
||||||
op.drop_column("tests", "red_round_number")
|
|
||||||
@@ -1,58 +0,0 @@
|
|||||||
"""Add detect_procedure fields and procedure_suggestions review table.
|
|
||||||
|
|
||||||
Blue Team gets a "Detect Procedure" field (what they actually did to
|
|
||||||
detect the attack) mirroring Red's procedure_text, plus a matching
|
|
||||||
"suggested" field on test_templates. Commands extracted from either
|
|
||||||
side's procedure text are proposed as template improvements via a new
|
|
||||||
procedure_suggestions table, reviewed and approved/rejected by a lead
|
|
||||||
rather than written automatically.
|
|
||||||
|
|
||||||
Revision ID: b062
|
|
||||||
Revises: b061
|
|
||||||
Create Date: 2026-07-14
|
|
||||||
"""
|
|
||||||
|
|
||||||
import sqlalchemy as sa
|
|
||||||
from alembic import op
|
|
||||||
from sqlalchemy.dialects import postgresql
|
|
||||||
|
|
||||||
revision = "b062"
|
|
||||||
down_revision = "b061"
|
|
||||||
branch_labels = None
|
|
||||||
depends_on = None
|
|
||||||
|
|
||||||
|
|
||||||
def upgrade() -> None:
|
|
||||||
op.add_column("tests", sa.Column("detect_procedure", sa.Text(), nullable=True))
|
|
||||||
op.add_column(
|
|
||||||
"tests",
|
|
||||||
sa.Column("source_template_id", postgresql.UUID(as_uuid=True), sa.ForeignKey("test_templates.id"), nullable=True),
|
|
||||||
)
|
|
||||||
op.add_column("test_templates", sa.Column("detect_suggested_procedure", sa.Text(), nullable=True))
|
|
||||||
op.add_column("test_round_history", sa.Column("detect_procedure", sa.Text(), nullable=True))
|
|
||||||
|
|
||||||
op.create_table(
|
|
||||||
"procedure_suggestions",
|
|
||||||
sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True),
|
|
||||||
sa.Column("template_id", postgresql.UUID(as_uuid=True), sa.ForeignKey("test_templates.id"), nullable=False),
|
|
||||||
sa.Column("team", sa.String(10), nullable=False),
|
|
||||||
sa.Column("suggested_text", sa.Text(), nullable=False),
|
|
||||||
sa.Column("source_test_id", postgresql.UUID(as_uuid=True), sa.ForeignKey("tests.id"), nullable=True),
|
|
||||||
sa.Column("submitted_by", postgresql.UUID(as_uuid=True), sa.ForeignKey("users.id"), nullable=True),
|
|
||||||
sa.Column("status", sa.String(10), nullable=False, server_default="pending"),
|
|
||||||
sa.Column("reviewed_by", postgresql.UUID(as_uuid=True), sa.ForeignKey("users.id"), nullable=True),
|
|
||||||
sa.Column("reviewed_at", sa.DateTime(), nullable=True),
|
|
||||||
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
|
|
||||||
)
|
|
||||||
op.create_index("ix_procedure_suggestions_template_id", "procedure_suggestions", ["template_id"])
|
|
||||||
op.create_index("ix_procedure_suggestions_status", "procedure_suggestions", ["status"])
|
|
||||||
|
|
||||||
|
|
||||||
def downgrade() -> None:
|
|
||||||
op.drop_index("ix_procedure_suggestions_status", table_name="procedure_suggestions")
|
|
||||||
op.drop_index("ix_procedure_suggestions_template_id", table_name="procedure_suggestions")
|
|
||||||
op.drop_table("procedure_suggestions")
|
|
||||||
op.drop_column("test_round_history", "detect_procedure")
|
|
||||||
op.drop_column("test_templates", "detect_suggested_procedure")
|
|
||||||
op.drop_column("tests", "source_template_id")
|
|
||||||
op.drop_column("tests", "detect_procedure")
|
|
||||||
@@ -1,40 +0,0 @@
|
|||||||
"""Merge TestTemplate.detect_suggested_procedure into expected_detection.
|
|
||||||
|
|
||||||
Two overlapping fields on TestTemplate — expected_detection (narrative
|
|
||||||
guidance, populated by imports and leads since long before this feature)
|
|
||||||
and detect_suggested_procedure (concrete commands, populated only via the
|
|
||||||
procedure-suggestion approval workflow) — are consolidated into one:
|
|
||||||
expected_detection. Any existing detect_suggested_procedure text is
|
|
||||||
appended (not overwritten) onto expected_detection before the column is
|
|
||||||
dropped, so nothing already approved is lost.
|
|
||||||
|
|
||||||
Revision ID: b063
|
|
||||||
Revises: b062
|
|
||||||
Create Date: 2026-07-15
|
|
||||||
"""
|
|
||||||
|
|
||||||
import sqlalchemy as sa
|
|
||||||
from alembic import op
|
|
||||||
|
|
||||||
revision = "b063"
|
|
||||||
down_revision = "b062"
|
|
||||||
branch_labels = None
|
|
||||||
depends_on = None
|
|
||||||
|
|
||||||
|
|
||||||
def upgrade() -> None:
|
|
||||||
op.execute(
|
|
||||||
"""
|
|
||||||
UPDATE test_templates
|
|
||||||
SET expected_detection = CASE
|
|
||||||
WHEN expected_detection IS NULL OR expected_detection = '' THEN detect_suggested_procedure
|
|
||||||
ELSE expected_detection || E'\n' || detect_suggested_procedure
|
|
||||||
END
|
|
||||||
WHERE detect_suggested_procedure IS NOT NULL AND detect_suggested_procedure != ''
|
|
||||||
"""
|
|
||||||
)
|
|
||||||
op.drop_column("test_templates", "detect_suggested_procedure")
|
|
||||||
|
|
||||||
|
|
||||||
def downgrade() -> None:
|
|
||||||
op.add_column("test_templates", sa.Column("detect_suggested_procedure", sa.Text(), nullable=True))
|
|
||||||
@@ -1,54 +0,0 @@
|
|||||||
"""Add template_suggestions table for operator template proposals.
|
|
||||||
|
|
||||||
Leads can already create a TestTemplate directly (POST /test-templates).
|
|
||||||
Operators (red_tech/blue_tech) get the same "create template" action, but
|
|
||||||
their proposal lands here pending a lead's review instead of the live
|
|
||||||
catalog.
|
|
||||||
|
|
||||||
Revision ID: b064
|
|
||||||
Revises: b063
|
|
||||||
Create Date: 2026-07-16
|
|
||||||
"""
|
|
||||||
|
|
||||||
import sqlalchemy as sa
|
|
||||||
from alembic import op
|
|
||||||
from sqlalchemy.dialects import postgresql
|
|
||||||
|
|
||||||
revision = "b064"
|
|
||||||
down_revision = "b063"
|
|
||||||
branch_labels = None
|
|
||||||
depends_on = None
|
|
||||||
|
|
||||||
|
|
||||||
def upgrade() -> None:
|
|
||||||
op.create_table(
|
|
||||||
"template_suggestions",
|
|
||||||
sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True),
|
|
||||||
sa.Column("mitre_technique_id", sa.String(), nullable=False),
|
|
||||||
sa.Column("name", sa.String(), nullable=False),
|
|
||||||
sa.Column("description", sa.Text(), nullable=True),
|
|
||||||
sa.Column("source", sa.String(), nullable=False, server_default="custom"),
|
|
||||||
sa.Column("source_url", sa.String(), nullable=True),
|
|
||||||
sa.Column("attack_procedure", sa.Text(), nullable=True),
|
|
||||||
sa.Column("expected_detection", sa.Text(), nullable=True),
|
|
||||||
sa.Column("platform", sa.String(), nullable=True),
|
|
||||||
sa.Column("tool_suggested", sa.String(), nullable=True),
|
|
||||||
sa.Column("severity", sa.String(), nullable=True),
|
|
||||||
sa.Column("atomic_test_id", sa.String(), nullable=True),
|
|
||||||
sa.Column("suggested_remediation", sa.Text(), nullable=True),
|
|
||||||
sa.Column("team", sa.String(10), nullable=False),
|
|
||||||
sa.Column("submitted_by", postgresql.UUID(as_uuid=True), sa.ForeignKey("users.id"), nullable=True),
|
|
||||||
sa.Column("status", sa.String(10), nullable=False, server_default="pending"),
|
|
||||||
sa.Column("reviewed_by", postgresql.UUID(as_uuid=True), sa.ForeignKey("users.id"), nullable=True),
|
|
||||||
sa.Column("reviewed_at", sa.DateTime(), nullable=True),
|
|
||||||
sa.Column("created_template_id", postgresql.UUID(as_uuid=True), sa.ForeignKey("test_templates.id"), nullable=True),
|
|
||||||
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
|
|
||||||
)
|
|
||||||
op.create_index("ix_template_suggestions_status", "template_suggestions", ["status"])
|
|
||||||
op.create_index("ix_template_suggestions_team", "template_suggestions", ["team"])
|
|
||||||
|
|
||||||
|
|
||||||
def downgrade() -> None:
|
|
||||||
op.drop_index("ix_template_suggestions_team", table_name="template_suggestions")
|
|
||||||
op.drop_index("ix_template_suggestions_status", table_name="template_suggestions")
|
|
||||||
op.drop_table("template_suggestions")
|
|
||||||
@@ -1,43 +0,0 @@
|
|||||||
"""Add users.full_name and a password_setup_tokens table.
|
|
||||||
|
|
||||||
Users are now created with a name + email instead of a directly-set
|
|
||||||
password — the admin's "Send Email" action issues a one-time token (this
|
|
||||||
table) for the user to set their own password via a webhook-delivered
|
|
||||||
link. The same mechanism is reused for password resets.
|
|
||||||
|
|
||||||
Revision ID: b065
|
|
||||||
Revises: b064
|
|
||||||
Create Date: 2026-07-17
|
|
||||||
"""
|
|
||||||
|
|
||||||
import sqlalchemy as sa
|
|
||||||
from alembic import op
|
|
||||||
from sqlalchemy.dialects import postgresql
|
|
||||||
|
|
||||||
revision = "b065"
|
|
||||||
down_revision = "b064"
|
|
||||||
branch_labels = None
|
|
||||||
depends_on = None
|
|
||||||
|
|
||||||
|
|
||||||
def upgrade() -> None:
|
|
||||||
op.add_column("users", sa.Column("full_name", sa.String(), nullable=True))
|
|
||||||
|
|
||||||
op.create_table(
|
|
||||||
"password_setup_tokens",
|
|
||||||
sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True),
|
|
||||||
sa.Column("user_id", postgresql.UUID(as_uuid=True), sa.ForeignKey("users.id"), nullable=False),
|
|
||||||
sa.Column("token", sa.String(128), nullable=False),
|
|
||||||
sa.Column("expires_at", sa.DateTime(), nullable=False),
|
|
||||||
sa.Column("used_at", sa.DateTime(), nullable=True),
|
|
||||||
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
|
|
||||||
)
|
|
||||||
op.create_index("ix_password_setup_tokens_token", "password_setup_tokens", ["token"], unique=True)
|
|
||||||
op.create_index("ix_password_setup_tokens_user_id", "password_setup_tokens", ["user_id"])
|
|
||||||
|
|
||||||
|
|
||||||
def downgrade() -> None:
|
|
||||||
op.drop_index("ix_password_setup_tokens_user_id", table_name="password_setup_tokens")
|
|
||||||
op.drop_index("ix_password_setup_tokens_token", table_name="password_setup_tokens")
|
|
||||||
op.drop_table("password_setup_tokens")
|
|
||||||
op.drop_column("users", "full_name")
|
|
||||||
@@ -1,32 +0,0 @@
|
|||||||
"""Add users.extra_roles for multi-role support.
|
|
||||||
|
|
||||||
A user can be granted more than one role, but only ever acts under a
|
|
||||||
single "active" role at a time (``users.role``, unchanged — every
|
|
||||||
existing permission check keeps reading it as-is). ``extra_roles`` holds
|
|
||||||
the *other* roles a user can switch into via the top-bar role switcher;
|
|
||||||
switching swaps the active role with one from this list.
|
|
||||||
|
|
||||||
Revision ID: b066
|
|
||||||
Revises: b065
|
|
||||||
Create Date: 2026-07-20
|
|
||||||
"""
|
|
||||||
|
|
||||||
import sqlalchemy as sa
|
|
||||||
from alembic import op
|
|
||||||
from sqlalchemy.dialects import postgresql
|
|
||||||
|
|
||||||
revision = "b066"
|
|
||||||
down_revision = "b065"
|
|
||||||
branch_labels = None
|
|
||||||
depends_on = None
|
|
||||||
|
|
||||||
|
|
||||||
def upgrade() -> None:
|
|
||||||
op.add_column(
|
|
||||||
"users",
|
|
||||||
sa.Column("extra_roles", postgresql.JSONB(), nullable=False, server_default="[]"),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def downgrade() -> None:
|
|
||||||
op.drop_column("users", "extra_roles")
|
|
||||||
@@ -1,33 +0,0 @@
|
|||||||
"""Make users.email the unique login identifier (NOT NULL + unique index).
|
|
||||||
|
|
||||||
Backfills any missing/blank email from username first, so existing rows
|
|
||||||
(e.g. the seeded admin, which historically had no email) never violate
|
|
||||||
the new constraint.
|
|
||||||
|
|
||||||
Revision ID: b067
|
|
||||||
Revises: b066
|
|
||||||
Create Date: 2026-07-23
|
|
||||||
"""
|
|
||||||
|
|
||||||
from alembic import op
|
|
||||||
import sqlalchemy as sa
|
|
||||||
|
|
||||||
revision = "b067"
|
|
||||||
down_revision = "b066"
|
|
||||||
branch_labels = None
|
|
||||||
depends_on = None
|
|
||||||
|
|
||||||
|
|
||||||
def upgrade() -> None:
|
|
||||||
op.execute(
|
|
||||||
"UPDATE users SET email = username WHERE email IS NULL OR email = ''"
|
|
||||||
)
|
|
||||||
op.alter_column("users", "email", existing_type=sa.String(), nullable=False)
|
|
||||||
op.create_unique_constraint("uq_users_email", "users", ["email"])
|
|
||||||
op.create_index("ix_users_email", "users", ["email"])
|
|
||||||
|
|
||||||
|
|
||||||
def downgrade() -> None:
|
|
||||||
op.drop_index("ix_users_email", table_name="users")
|
|
||||||
op.drop_constraint("uq_users_email", "users", type_="unique")
|
|
||||||
op.alter_column("users", "email", existing_type=sa.String(), nullable=True)
|
|
||||||
Binary file not shown.
|
Before Width: | Height: | Size: 22 KiB |
+8
-40
@@ -39,7 +39,8 @@ class Settings(BaseSettings):
|
|||||||
SECRET_KEY: str = ""
|
SECRET_KEY: str = ""
|
||||||
# Assign ALGORITHM = "HS256"
|
# Assign ALGORITHM = "HS256"
|
||||||
ALGORITHM: str = "HS256"
|
ALGORITHM: str = "HS256"
|
||||||
ACCESS_TOKEN_EXPIRE_MINUTES: int = 480 # 8 hours — /auth/refresh extends active sessions
|
# Assign ACCESS_TOKEN_EXPIRE_MINUTES = 15 # short-lived for security; configurable via env
|
||||||
|
ACCESS_TOKEN_EXPIRE_MINUTES: int = 15 # short-lived for security; configurable via env
|
||||||
|
|
||||||
# ── Redis ─────────────────────────────────────────────────────────
|
# ── Redis ─────────────────────────────────────────────────────────
|
||||||
REDIS_URL: str = "redis://redis:6379/0"
|
REDIS_URL: str = "redis://redis:6379/0"
|
||||||
@@ -56,10 +57,7 @@ class Settings(BaseSettings):
|
|||||||
|
|
||||||
# ── MinIO / S3 ───────────────────────────────────────────────────
|
# ── MinIO / S3 ───────────────────────────────────────────────────
|
||||||
MINIO_ENDPOINT: str = "minio:9000"
|
MINIO_ENDPOINT: str = "minio:9000"
|
||||||
# Public hostname used in presigned URLs returned to browsers.
|
# Assign MINIO_ACCESS_KEY = "minioadmin"
|
||||||
# In production set this to <server-ip>:9000 (or a public FQDN) so
|
|
||||||
# the browser can reach MinIO directly. Defaults to MINIO_ENDPOINT.
|
|
||||||
MINIO_PUBLIC_ENDPOINT: str = ""
|
|
||||||
MINIO_ACCESS_KEY: str = "minioadmin"
|
MINIO_ACCESS_KEY: str = "minioadmin"
|
||||||
# Assign MINIO_SECRET_KEY = "minioadmin"
|
# Assign MINIO_SECRET_KEY = "minioadmin"
|
||||||
MINIO_SECRET_KEY: str = "minioadmin"
|
MINIO_SECRET_KEY: str = "minioadmin"
|
||||||
@@ -83,11 +81,10 @@ class Settings(BaseSettings):
|
|||||||
JIRA_IS_CLOUD: bool = True
|
JIRA_IS_CLOUD: bool = True
|
||||||
# Assign JIRA_DEFAULT_PROJECT = ""
|
# Assign JIRA_DEFAULT_PROJECT = ""
|
||||||
JIRA_DEFAULT_PROJECT: str = ""
|
JIRA_DEFAULT_PROJECT: str = ""
|
||||||
JIRA_ISSUE_TYPE_TEST: str = "Task" # tests (campaign or standalone)
|
# Assign JIRA_ISSUE_TYPE_TEST = "Task"
|
||||||
JIRA_ISSUE_TYPE_CAMPAIGN: str = "Epic" # campaigns (under Initiative)
|
JIRA_ISSUE_TYPE_TEST: str = "Task"
|
||||||
# Jira custom field ID for "Start date" — Jira Cloud team-managed: customfield_10015
|
# Assign JIRA_ISSUE_TYPE_CAMPAIGN = "Epic"
|
||||||
# Override with the correct field ID for your Jira instance if different.
|
JIRA_ISSUE_TYPE_CAMPAIGN: str = "Epic"
|
||||||
JIRA_START_DATE_FIELD: str = "customfield_10015"
|
|
||||||
|
|
||||||
# ── Tempo Integration ─────────────────────────────────────────────
|
# ── Tempo Integration ─────────────────────────────────────────────
|
||||||
TEMPO_ENABLED: bool = False
|
TEMPO_ENABLED: bool = False
|
||||||
@@ -97,9 +94,6 @@ class Settings(BaseSettings):
|
|||||||
TEMPO_API_VERSION: int = 4
|
TEMPO_API_VERSION: int = 4
|
||||||
# Assign TEMPO_DEFAULT_WORK_TYPE = "Red Team"
|
# Assign TEMPO_DEFAULT_WORK_TYPE = "Red Team"
|
||||||
TEMPO_DEFAULT_WORK_TYPE: str = "Red Team"
|
TEMPO_DEFAULT_WORK_TYPE: str = "Red Team"
|
||||||
# Tempo API base URL — use https://api.eu.tempo.io/4 for EU workspaces.
|
|
||||||
# Can also be set via system_configs key "tempo.base_url" at runtime.
|
|
||||||
TEMPO_BASE_URL: str = "" # empty → falls back to https://api.tempo.io/4
|
|
||||||
|
|
||||||
# ── OSINT / Intelligence ────────────────────────────────────────
|
# ── OSINT / Intelligence ────────────────────────────────────────
|
||||||
NVD_API_KEY: str = "" # optional; increases NVD rate limit from 5/30s to 50/30s
|
NVD_API_KEY: str = "" # optional; increases NVD rate limit from 5/30s to 50/30s
|
||||||
@@ -107,29 +101,14 @@ class Settings(BaseSettings):
|
|||||||
STALE_THRESHOLD_DAYS: int = 365 # days before coverage is considered stale
|
STALE_THRESHOLD_DAYS: int = 365 # days before coverage is considered stale
|
||||||
|
|
||||||
# ── Reporting ─────────────────────────────────────────────────────
|
# ── Reporting ─────────────────────────────────────────────────────
|
||||||
# PDF/DOCX/HTML report generation (professional_reports router) is
|
|
||||||
# unfinished — the frontend hides the Reports UI entirely. Defaults
|
|
||||||
# off so a fresh deploy returns a clean 503 instead of a raw 500 from
|
|
||||||
# a half-working render pipeline (e.g. missing weasyprint system deps).
|
|
||||||
REPORTS_ENABLED: bool = False
|
|
||||||
REPORT_TEMPLATES_DIR: str = "app/templates/reports"
|
REPORT_TEMPLATES_DIR: str = "app/templates/reports"
|
||||||
# Assign REPORT_OUTPUT_DIR = "/tmp/aegis_reports"
|
# Assign REPORT_OUTPUT_DIR = "/tmp/aegis_reports"
|
||||||
REPORT_OUTPUT_DIR: str = "/app/reports"
|
REPORT_OUTPUT_DIR: str = "/tmp/aegis_reports"
|
||||||
# Assign COMPANY_NAME = "Organization"
|
# Assign COMPANY_NAME = "Organization"
|
||||||
COMPANY_NAME: str = "Organization"
|
COMPANY_NAME: str = "Organization"
|
||||||
# Assign COMPANY_LOGO_PATH = "app/templates/reports/assets/logo.png"
|
# Assign COMPANY_LOGO_PATH = "app/templates/reports/assets/logo.png"
|
||||||
COMPANY_LOGO_PATH: str = "app/templates/reports/assets/logo.png"
|
COMPANY_LOGO_PATH: str = "app/templates/reports/assets/logo.png"
|
||||||
|
|
||||||
# ── Email / SMTP ──────────────────────────────────────────────────
|
|
||||||
SMTP_ENABLED: bool = False
|
|
||||||
SMTP_HOST: str = ""
|
|
||||||
SMTP_PORT: int = 587
|
|
||||||
SMTP_USERNAME: str = ""
|
|
||||||
SMTP_PASSWORD: str = ""
|
|
||||||
SMTP_FROM_EMAIL: str = "aegis@company.com"
|
|
||||||
SMTP_USE_TLS: bool = True
|
|
||||||
PLATFORM_URL: str = "http://localhost:5173" # base URL for links in emails
|
|
||||||
|
|
||||||
# ── Scoring weights (must sum to 100) ────────────────────────────
|
# ── Scoring weights (must sum to 100) ────────────────────────────
|
||||||
SCORING_WEIGHT_TESTS: int = 40
|
SCORING_WEIGHT_TESTS: int = 40
|
||||||
# Assign SCORING_WEIGHT_DETECTION_RULES = 25
|
# Assign SCORING_WEIGHT_DETECTION_RULES = 25
|
||||||
@@ -216,14 +195,3 @@ if _is_production:
|
|||||||
f"Set a strong value via the {name} environment variable "
|
f"Set a strong value via the {name} environment variable "
|
||||||
f"before running in production."
|
f"before running in production."
|
||||||
)
|
)
|
||||||
|
|
||||||
# PLATFORM_URL has no safe production default — it's baked into every
|
|
||||||
# emailed link (set-password, notifications). Falling back silently to
|
|
||||||
# the dev value (or to some other deployment's hardcoded domain) would
|
|
||||||
# ship broken/wrong links without anyone noticing.
|
|
||||||
if settings.PLATFORM_URL == "http://localhost:5173":
|
|
||||||
raise RuntimeError(
|
|
||||||
"CRITICAL: PLATFORM_URL is not configured. Set it to this "
|
|
||||||
"deployment's real public frontend URL via the PLATFORM_URL "
|
|
||||||
"environment variable before running in production."
|
|
||||||
)
|
|
||||||
|
|||||||
@@ -3,7 +3,6 @@
|
|||||||
Provides:
|
Provides:
|
||||||
- ``get_current_user``: decodes JWT from HttpOnly cookie (preferred) or
|
- ``get_current_user``: decodes JWT from HttpOnly cookie (preferred) or
|
||||||
Authorization header (fallback), fetches user from DB, raises 401 on failure.
|
Authorization header (fallback), fetches user from DB, raises 401 on failure.
|
||||||
Also accepts Aegis API keys (``aegis_…`` prefix) as Bearer tokens.
|
|
||||||
- ``require_role``: factory that returns a dependency enforcing a specific role
|
- ``require_role``: factory that returns a dependency enforcing a specific role
|
||||||
(admins always pass).
|
(admins always pass).
|
||||||
"""
|
"""
|
||||||
@@ -37,7 +36,6 @@ from app.database import get_db
|
|||||||
|
|
||||||
# Import User from app.models.user
|
# Import User from app.models.user
|
||||||
from app.models.user import User
|
from app.models.user import User
|
||||||
from app.models.api_key import KEY_PREFIX
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# OAuth2 scheme (reads Authorization header — used as fallback / Swagger UI)
|
# OAuth2 scheme (reads Authorization header — used as fallback / Swagger UI)
|
||||||
@@ -100,15 +98,7 @@ async def get_current_user(
|
|||||||
# Raise credentials_exception
|
# Raise credentials_exception
|
||||||
raise credentials_exception
|
raise credentials_exception
|
||||||
|
|
||||||
# ── API Key path (Bearer token starts with "aegis_") ──────────────────
|
# Attempt the following; catch errors below
|
||||||
if token.startswith(KEY_PREFIX):
|
|
||||||
from app.services.api_key_service import authenticate_raw_key
|
|
||||||
user = authenticate_raw_key(db, token)
|
|
||||||
if user is None:
|
|
||||||
raise credentials_exception
|
|
||||||
return user
|
|
||||||
|
|
||||||
# ── JWT path ──────────────────────────────────────────────────────────
|
|
||||||
try:
|
try:
|
||||||
# Assign payload = jwt.decode(
|
# Assign payload = jwt.decode(
|
||||||
payload = jwt.decode(
|
payload = jwt.decode(
|
||||||
@@ -172,27 +162,12 @@ async def require_password_changed(
|
|||||||
return current_user
|
return current_user
|
||||||
|
|
||||||
|
|
||||||
def _check_api_key_scope(user: User, required_scope: str) -> None:
|
# Define function require_role
|
||||||
"""Raise 403 if the request was authenticated via an API key that lacks *required_scope*.
|
def require_role(required_role: str) -> Callable[..., object]:
|
||||||
|
|
||||||
When authenticated via JWT (browser session), ``_api_key_scopes`` is not set
|
|
||||||
and the check is skipped — full access is granted based on role alone.
|
|
||||||
"""
|
|
||||||
key_scopes = getattr(user, "_api_key_scopes", None)
|
|
||||||
if key_scopes is not None and required_scope not in key_scopes:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_403_FORBIDDEN,
|
|
||||||
detail=f"API key scope '{required_scope}' required for this operation",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def require_role(required_role: str):
|
|
||||||
"""Return a FastAPI dependency that enforces *required_role*.
|
"""Return a FastAPI dependency that enforces *required_role*.
|
||||||
|
|
||||||
The dependency allows the request to proceed when
|
The dependency allows the request to proceed when
|
||||||
``user.role == required_role`` **or** ``user.role == "admin"``.
|
``user.role == required_role`` **or** ``user.role == "admin"``.
|
||||||
Also enforces API key scopes: admin-role endpoints require the ``admin``
|
|
||||||
scope; all other role-restricted endpoints require ``write``.
|
|
||||||
Otherwise it raises :class:`~fastapi.HTTPException` **403**.
|
Otherwise it raises :class:`~fastapi.HTTPException` **403**.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
@@ -210,8 +185,7 @@ def require_role(required_role: str):
|
|||||||
# Keyword argument: detail
|
# Keyword argument: detail
|
||||||
detail="Not enough permissions",
|
detail="Not enough permissions",
|
||||||
)
|
)
|
||||||
scope = "admin" if required_role == "admin" else "write"
|
# Return current_user
|
||||||
_check_api_key_scope(current_user, scope)
|
|
||||||
return current_user
|
return current_user
|
||||||
|
|
||||||
# Return role_checker
|
# Return role_checker
|
||||||
@@ -222,11 +196,7 @@ def require_role(required_role: str):
|
|||||||
def require_any_role(*roles: str) -> Callable[..., object]:
|
def require_any_role(*roles: str) -> Callable[..., object]:
|
||||||
"""Return a FastAPI dependency that enforces **any** of the given *roles*.
|
"""Return a FastAPI dependency that enforces **any** of the given *roles*.
|
||||||
|
|
||||||
Admins always pass. Also enforces API key scopes: if the only accepted
|
Admins always pass. Usage example::
|
||||||
role is ``admin``, the key must carry the ``admin`` scope; otherwise the
|
|
||||||
``write`` scope is required.
|
|
||||||
|
|
||||||
Usage example::
|
|
||||||
|
|
||||||
@router.patch("/resource", dependencies=[Depends(require_any_role("red_lead", "blue_lead"))])
|
@router.patch("/resource", dependencies=[Depends(require_any_role("red_lead", "blue_lead"))])
|
||||||
"""
|
"""
|
||||||
@@ -245,52 +215,8 @@ def require_any_role(*roles: str) -> Callable[..., object]:
|
|||||||
# Keyword argument: detail
|
# Keyword argument: detail
|
||||||
detail="Not enough permissions",
|
detail="Not enough permissions",
|
||||||
)
|
)
|
||||||
scope = "admin" if set(roles) == {"admin"} else "write"
|
# Return current_user
|
||||||
_check_api_key_scope(current_user, scope)
|
|
||||||
return current_user
|
return current_user
|
||||||
|
|
||||||
# Return role_checker
|
# Return role_checker
|
||||||
return role_checker
|
return role_checker
|
||||||
|
|
||||||
|
|
||||||
def require_any_role_strict(*roles: str) -> Callable[..., object]:
|
|
||||||
"""Return a FastAPI dependency that enforces **any** of the given *roles*.
|
|
||||||
|
|
||||||
Unlike ``require_any_role``, admins do **not** automatically pass — use
|
|
||||||
this for actions that belong exclusively to Red/Blue operators, leads,
|
|
||||||
or managers (e.g. executing, reviewing, validating, or resolving a
|
|
||||||
disputed test), where "admin" must mean site administration only, not
|
|
||||||
a backdoor into the test workflow itself.
|
|
||||||
"""
|
|
||||||
|
|
||||||
async def role_checker(
|
|
||||||
current_user: User = Depends(get_current_user),
|
|
||||||
) -> User:
|
|
||||||
if current_user.role not in roles:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_403_FORBIDDEN,
|
|
||||||
detail="Not enough permissions",
|
|
||||||
)
|
|
||||||
_check_api_key_scope(current_user, "write")
|
|
||||||
return current_user
|
|
||||||
|
|
||||||
return role_checker
|
|
||||||
|
|
||||||
|
|
||||||
def require_scope(scope: str):
|
|
||||||
"""Return a dependency that enforces the API key carries *scope*.
|
|
||||||
|
|
||||||
JWT-authenticated requests (browser sessions) bypass this check entirely.
|
|
||||||
Use on mutation endpoints that don't already use ``require_role`` /
|
|
||||||
``require_any_role``::
|
|
||||||
|
|
||||||
@router.post("/resource", dependencies=[Depends(require_scope("write"))])
|
|
||||||
"""
|
|
||||||
|
|
||||||
async def scope_checker(
|
|
||||||
current_user: User = Depends(get_current_user),
|
|
||||||
) -> User:
|
|
||||||
_check_api_key_scope(current_user, scope)
|
|
||||||
return current_user
|
|
||||||
|
|
||||||
return scope_checker
|
|
||||||
|
|||||||
@@ -33,8 +33,6 @@ class CampaignStatus(str, enum.Enum):
|
|||||||
|
|
||||||
# Assign draft = "draft"
|
# Assign draft = "draft"
|
||||||
draft = "draft"
|
draft = "draft"
|
||||||
# Assign pending_approval = "pending_approval"
|
|
||||||
pending_approval = "pending_approval"
|
|
||||||
# Assign active = "active"
|
# Assign active = "active"
|
||||||
active = "active"
|
active = "active"
|
||||||
# Assign completed = "completed"
|
# Assign completed = "completed"
|
||||||
@@ -59,8 +57,7 @@ class CampaignType(str, enum.Enum):
|
|||||||
|
|
||||||
# Assign VALID_TRANSITIONS = {
|
# Assign VALID_TRANSITIONS = {
|
||||||
VALID_TRANSITIONS: dict[CampaignStatus, list[CampaignStatus]] = {
|
VALID_TRANSITIONS: dict[CampaignStatus, list[CampaignStatus]] = {
|
||||||
CampaignStatus.draft: [CampaignStatus.pending_approval, CampaignStatus.active],
|
CampaignStatus.draft: [CampaignStatus.active],
|
||||||
CampaignStatus.pending_approval: [CampaignStatus.active, CampaignStatus.draft],
|
|
||||||
CampaignStatus.active: [CampaignStatus.completed],
|
CampaignStatus.active: [CampaignStatus.completed],
|
||||||
CampaignStatus.completed: [CampaignStatus.archived],
|
CampaignStatus.completed: [CampaignStatus.archived],
|
||||||
CampaignStatus.archived: [],
|
CampaignStatus.archived: [],
|
||||||
@@ -135,49 +132,6 @@ class CampaignEntity:
|
|||||||
# Assign self.status = CampaignStatus.active
|
# Assign self.status = CampaignStatus.active
|
||||||
self.status = CampaignStatus.active
|
self.status = CampaignStatus.active
|
||||||
|
|
||||||
def submit_for_approval(self) -> None:
|
|
||||||
"""Transition the campaign from ``draft`` to ``pending_approval``.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
None
|
|
||||||
"""
|
|
||||||
if not self.can_transition_to(CampaignStatus.pending_approval):
|
|
||||||
raise InvalidStateTransition(
|
|
||||||
self.status.value, CampaignStatus.pending_approval.value,
|
|
||||||
[s.value for s in VALID_TRANSITIONS[self.status]],
|
|
||||||
)
|
|
||||||
if self.test_count == 0:
|
|
||||||
raise BusinessRuleViolation(
|
|
||||||
"Campaign must have at least one test to submit for approval"
|
|
||||||
)
|
|
||||||
self.status = CampaignStatus.pending_approval
|
|
||||||
|
|
||||||
def approve(self) -> None:
|
|
||||||
"""Transition the campaign from ``pending_approval`` to ``active``.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
None
|
|
||||||
"""
|
|
||||||
if self.status != CampaignStatus.pending_approval:
|
|
||||||
raise InvalidStateTransition(
|
|
||||||
self.status.value, CampaignStatus.active.value,
|
|
||||||
[CampaignStatus.pending_approval.value],
|
|
||||||
)
|
|
||||||
self.status = CampaignStatus.active
|
|
||||||
|
|
||||||
def reject(self) -> None:
|
|
||||||
"""Transition the campaign from ``pending_approval`` back to ``draft``.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
None
|
|
||||||
"""
|
|
||||||
if not self.can_transition_to(CampaignStatus.draft):
|
|
||||||
raise InvalidStateTransition(
|
|
||||||
self.status.value, CampaignStatus.draft.value,
|
|
||||||
[s.value for s in VALID_TRANSITIONS[self.status]],
|
|
||||||
)
|
|
||||||
self.status = CampaignStatus.draft
|
|
||||||
|
|
||||||
# Define function complete
|
# Define function complete
|
||||||
def complete(self) -> None:
|
def complete(self) -> None:
|
||||||
"""Transition the campaign from ``active`` to ``completed``.
|
"""Transition the campaign from ``active`` to ``completed``.
|
||||||
|
|||||||
@@ -221,17 +221,14 @@ class TechniqueEntity:
|
|||||||
) -> TechniqueStatus:
|
) -> TechniqueStatus:
|
||||||
"""Recompute ``status_global`` from a list of (state, detection_result) pairs.
|
"""Recompute ``status_global`` from a list of (state, detection_result) pairs.
|
||||||
|
|
||||||
Rules (v3):
|
Rules (v2):
|
||||||
1. No tests -> not_evaluated
|
1. No tests -> not_evaluated
|
||||||
2. All tests validated -> inspect detection results:
|
2. All validated -> inspect detection results:
|
||||||
a. All detected AND ≥ 1 validated test -> validated
|
- All detected -> validated
|
||||||
b. Any partially_detected -> partial
|
- Any partially_detected -> partial
|
||||||
d. Otherwise (no detected results) -> not_covered
|
- Otherwise -> not_covered
|
||||||
3. Some validated, others in intermediate states -> partial
|
3. Some validated, others in progress -> partial
|
||||||
4. All tests in intermediate states (draft/executing/evaluating/review/rejected)
|
4. All in intermediate states -> in_progress
|
||||||
-> in_progress
|
|
||||||
|
|
||||||
Minimum validated count for "validated": 1 test.
|
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
test_snapshots (list[tuple[str, str | None]]): Each element is a
|
test_snapshots (list[tuple[str, str | None]]): Each element is a
|
||||||
@@ -243,8 +240,7 @@ class TechniqueEntity:
|
|||||||
TechniqueStatus: The newly computed status, which is also stored on
|
TechniqueStatus: The newly computed status, which is also stored on
|
||||||
the entity's ``status_global`` field.
|
the entity's ``status_global`` field.
|
||||||
"""
|
"""
|
||||||
min_validated_for_full = 1 # require ≥ N validated tests for "validated"
|
# Assign tests = [
|
||||||
|
|
||||||
tests = [
|
tests = [
|
||||||
_TestSnapshot(
|
_TestSnapshot(
|
||||||
# Keyword argument: state
|
# Keyword argument: state
|
||||||
@@ -261,23 +257,21 @@ class TechniqueEntity:
|
|||||||
self.status_global = TechniqueStatus.not_evaluated
|
self.status_global = TechniqueStatus.not_evaluated
|
||||||
# Alternative: all(t.state == TestState.validated for t in tests)
|
# Alternative: all(t.state == TestState.validated for t in tests)
|
||||||
elif all(t.state == TestState.validated for t in tests):
|
elif all(t.state == TestState.validated for t in tests):
|
||||||
validated_count = len(tests)
|
# Assign results = [t.detection_result for t in tests if t.detection_result]
|
||||||
results = [t.detection_result for t in tests if t.detection_result]
|
results = [t.detection_result for t in tests if t.detection_result]
|
||||||
# Check: results and all(r == TestResult.detected or r == "detected" for r i...
|
# Check: results and all(r == TestResult.detected or r == "detected" for r i...
|
||||||
if results and all(r == TestResult.detected or r == "detected" for r in results):
|
if results and all(r == TestResult.detected or r == "detected" for r in results):
|
||||||
# Need at least min_validated_for_full tests for "validated"
|
# Assign self.status_global = TechniqueStatus.validated
|
||||||
if validated_count >= min_validated_for_full:
|
|
||||||
self.status_global = TechniqueStatus.validated
|
self.status_global = TechniqueStatus.validated
|
||||||
else:
|
# elif any(
|
||||||
self.status_global = TechniqueStatus.partial
|
|
||||||
elif any(
|
elif any(
|
||||||
r in (TestResult.detected, "detected",
|
# Keyword argument: r
|
||||||
TestResult.partially_detected, "partially_detected")
|
r == TestResult.partially_detected or r == "partially_detected"
|
||||||
for r in results
|
for r in results
|
||||||
):
|
):
|
||||||
# Mix of detected + not_detected, or any partially_detected → partial
|
# Assign self.status_global = TechniqueStatus.partial
|
||||||
self.status_global = TechniqueStatus.partial
|
self.status_global = TechniqueStatus.partial
|
||||||
# Fallback: handle remaining cases (all not_detected)
|
# Fallback: handle remaining cases
|
||||||
else:
|
else:
|
||||||
# Assign self.status_global = TechniqueStatus.not_covered
|
# Assign self.status_global = TechniqueStatus.not_covered
|
||||||
self.status_global = TechniqueStatus.not_covered
|
self.status_global = TechniqueStatus.not_covered
|
||||||
|
|||||||
@@ -35,19 +35,14 @@ class TestState(str, enum.Enum):
|
|||||||
draft = "draft"
|
draft = "draft"
|
||||||
# Assign red_executing = "red_executing"
|
# Assign red_executing = "red_executing"
|
||||||
red_executing = "red_executing"
|
red_executing = "red_executing"
|
||||||
# Red Lead reviews the operator's work before it queues for Blue Team
|
|
||||||
red_review = "red_review"
|
|
||||||
# Assign blue_evaluating = "blue_evaluating"
|
# Assign blue_evaluating = "blue_evaluating"
|
||||||
blue_evaluating = "blue_evaluating"
|
blue_evaluating = "blue_evaluating"
|
||||||
# Blue Lead reviews the operator's work before cross-validation
|
|
||||||
blue_review = "blue_review"
|
|
||||||
# Assign in_review = "in_review"
|
# Assign in_review = "in_review"
|
||||||
in_review = "in_review"
|
in_review = "in_review"
|
||||||
# Assign validated = "validated"
|
# Assign validated = "validated"
|
||||||
validated = "validated"
|
validated = "validated"
|
||||||
# Assign rejected = "rejected"
|
# Assign rejected = "rejected"
|
||||||
rejected = "rejected"
|
rejected = "rejected"
|
||||||
disputed = "disputed" # one lead approved, the other rejected
|
|
||||||
|
|
||||||
|
|
||||||
# Define class TeamSide
|
# Define class TeamSide
|
||||||
@@ -72,40 +67,15 @@ class TestResult(str, enum.Enum):
|
|||||||
partially_detected = "partially_detected"
|
partially_detected = "partially_detected"
|
||||||
|
|
||||||
|
|
||||||
# Define class ContainmentResult
|
|
||||||
class ContainmentResult(str, enum.Enum):
|
|
||||||
"""Outcome of the blue team's containment effort."""
|
|
||||||
|
|
||||||
contained = "contained"
|
|
||||||
partially_contained = "partially_contained"
|
|
||||||
not_contained = "not_contained"
|
|
||||||
|
|
||||||
|
|
||||||
class AttackSuccessResult(str, enum.Enum):
|
|
||||||
"""Outcome of a red-team attack from a success perspective."""
|
|
||||||
|
|
||||||
successful = "successful"
|
|
||||||
not_successful = "not_successful"
|
|
||||||
partially_successful = "partially_successful"
|
|
||||||
|
|
||||||
|
|
||||||
# Define class DataClassification
|
# Define class DataClassification
|
||||||
class DataClassification(str, enum.Enum):
|
class DataClassification(str, enum.Enum):
|
||||||
"""Data sensitivity classification levels for compliance and retention policies.
|
"""Data sensitivity classification levels for compliance and retention policies."""
|
||||||
|
|
||||||
Matches Jira's "Data Sensitivity" field (customfield_11814) exactly, since
|
|
||||||
that is the organization's actual data protection scheme:
|
|
||||||
- public: approved for external/public release
|
|
||||||
- general_use: general internal information, no special restriction
|
|
||||||
- internal_use_only: internal use only
|
|
||||||
- trusted_people: shared only with a limited/trusted audience
|
|
||||||
- customer_content: contains customer data
|
|
||||||
- pii: contains personally identifiable information
|
|
||||||
"""
|
|
||||||
|
|
||||||
|
# Assign public = "public"
|
||||||
public = "public"
|
public = "public"
|
||||||
general_use = "general_use"
|
# Assign internal = "internal"
|
||||||
internal_use_only = "internal_use_only"
|
internal = "internal"
|
||||||
trusted_people = "trusted_people"
|
# Assign sensitive = "sensitive"
|
||||||
customer_content = "customer_content"
|
sensitive = "sensitive"
|
||||||
pii = "pii"
|
# Assign restricted = "restricted"
|
||||||
|
restricted = "restricted"
|
||||||
|
|||||||
@@ -60,33 +60,22 @@ class TestState(str, enum.Enum):
|
|||||||
draft = "draft"
|
draft = "draft"
|
||||||
# Assign red_executing = "red_executing"
|
# Assign red_executing = "red_executing"
|
||||||
red_executing = "red_executing"
|
red_executing = "red_executing"
|
||||||
# Red Lead reviews the operator's work before it queues for Blue Team
|
|
||||||
red_review = "red_review"
|
|
||||||
# Assign blue_evaluating = "blue_evaluating"
|
# Assign blue_evaluating = "blue_evaluating"
|
||||||
blue_evaluating = "blue_evaluating"
|
blue_evaluating = "blue_evaluating"
|
||||||
# Blue Lead reviews the operator's work before cross-validation
|
|
||||||
blue_review = "blue_review"
|
|
||||||
# Assign in_review = "in_review"
|
# Assign in_review = "in_review"
|
||||||
in_review = "in_review"
|
in_review = "in_review"
|
||||||
# Assign validated = "validated"
|
# Assign validated = "validated"
|
||||||
validated = "validated"
|
validated = "validated"
|
||||||
# Assign rejected = "rejected"
|
# Assign rejected = "rejected"
|
||||||
rejected = "rejected"
|
rejected = "rejected"
|
||||||
disputed = "disputed" # one lead approved, the other rejected
|
|
||||||
|
|
||||||
|
|
||||||
# Assign VALID_TRANSITIONS = {
|
# Assign VALID_TRANSITIONS = {
|
||||||
VALID_TRANSITIONS: dict[TestState, list[TestState]] = {
|
VALID_TRANSITIONS: dict[TestState, list[TestState]] = {
|
||||||
TestState.draft: [TestState.red_executing],
|
TestState.draft: [TestState.red_executing],
|
||||||
TestState.red_executing: [TestState.red_review],
|
TestState.red_executing: [TestState.blue_evaluating],
|
||||||
TestState.red_review: [TestState.blue_evaluating, TestState.red_executing],
|
TestState.blue_evaluating: [TestState.in_review],
|
||||||
TestState.blue_evaluating: [TestState.blue_review],
|
TestState.in_review: [TestState.validated, TestState.rejected],
|
||||||
TestState.blue_review: [TestState.in_review, TestState.blue_evaluating],
|
|
||||||
TestState.in_review: [TestState.validated, TestState.rejected, TestState.disputed],
|
|
||||||
TestState.disputed: [
|
|
||||||
TestState.validated, TestState.rejected,
|
|
||||||
TestState.red_executing, TestState.blue_evaluating,
|
|
||||||
],
|
|
||||||
TestState.rejected: [TestState.draft],
|
TestState.rejected: [TestState.draft],
|
||||||
TestState.validated: [],
|
TestState.validated: [],
|
||||||
}
|
}
|
||||||
@@ -372,12 +361,10 @@ class TestEntity:
|
|||||||
|
|
||||||
# Define function submit_red_evidence
|
# Define function submit_red_evidence
|
||||||
def submit_red_evidence(self) -> int:
|
def submit_red_evidence(self) -> int:
|
||||||
"""Transition the test from ``red_executing`` to ``red_review``.
|
"""Transition the test from ``red_executing`` to ``blue_evaluating``.
|
||||||
|
|
||||||
Auto-resumes if paused. Returns paused seconds accumulated
|
Auto-resumes if paused. Returns paused seconds accumulated
|
||||||
during this phase (for worklog calculation). The Blue Team queue
|
during this phase (for worklog calculation).
|
||||||
timer does not start yet — that happens in ``approve_red_review``,
|
|
||||||
once the Red Lead actually releases the test to Blue Team.
|
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
int: Total seconds the red phase was paused.
|
int: Total seconds the red phase was paused.
|
||||||
@@ -385,9 +372,13 @@ class TestEntity:
|
|||||||
# Assign paused_extra = self._auto_resume()
|
# Assign paused_extra = self._auto_resume()
|
||||||
paused_extra = self._auto_resume()
|
paused_extra = self._auto_resume()
|
||||||
# Call self._transition()
|
# Call self._transition()
|
||||||
self._transition(TestState.red_review)
|
self._transition(TestState.blue_evaluating)
|
||||||
# Assign total_paused = self.red_paused_seconds + paused_extra
|
# Assign total_paused = self.red_paused_seconds + paused_extra
|
||||||
total_paused = self.red_paused_seconds + paused_extra
|
total_paused = self.red_paused_seconds + paused_extra
|
||||||
|
# Assign self.blue_started_at = datetime.utcnow()
|
||||||
|
self.blue_started_at = datetime.utcnow()
|
||||||
|
# Assign self.blue_paused_seconds = 0
|
||||||
|
self.blue_paused_seconds = 0
|
||||||
# Call self._events.append()
|
# Call self._events.append()
|
||||||
self._events.append(DomainEvent(
|
self._events.append(DomainEvent(
|
||||||
# Literal argument value
|
# Literal argument value
|
||||||
@@ -397,36 +388,9 @@ class TestEntity:
|
|||||||
# Return total_paused
|
# Return total_paused
|
||||||
return total_paused
|
return total_paused
|
||||||
|
|
||||||
def approve_red_review(self) -> None:
|
|
||||||
"""Transition the test from ``red_review`` to ``blue_evaluating``.
|
|
||||||
|
|
||||||
Called when the assigned Red Lead approves the operator's work.
|
|
||||||
Starts the Blue Team queue timer.
|
|
||||||
"""
|
|
||||||
self._transition(TestState.blue_evaluating)
|
|
||||||
self.blue_started_at = datetime.utcnow()
|
|
||||||
self.blue_paused_seconds = 0
|
|
||||||
self._events.append(DomainEvent("red_review_approved"))
|
|
||||||
|
|
||||||
def reopen_red_review(self) -> None:
|
|
||||||
"""Transition the test from ``red_review`` back to ``red_executing``.
|
|
||||||
|
|
||||||
Called when the assigned Red Lead sends the work back for rework.
|
|
||||||
Resets the red-phase timer for a fresh attempt (the first attempt's
|
|
||||||
worklog was already recorded at submit time, so this loses nothing).
|
|
||||||
Starts the timer PAUSED — the operator hasn't resumed working yet,
|
|
||||||
just because the lead reopened it. It only starts counting once they
|
|
||||||
explicitly resume it, same as coming back from a hold.
|
|
||||||
"""
|
|
||||||
self._transition(TestState.red_executing)
|
|
||||||
self.red_started_at = datetime.utcnow()
|
|
||||||
self.red_paused_seconds = 0
|
|
||||||
self.paused_at = self.red_started_at
|
|
||||||
self._events.append(DomainEvent("red_review_reopened"))
|
|
||||||
|
|
||||||
# Define function submit_blue_evidence
|
# Define function submit_blue_evidence
|
||||||
def submit_blue_evidence(self) -> int:
|
def submit_blue_evidence(self) -> int:
|
||||||
"""Transition the test from ``blue_evaluating`` to ``blue_review``.
|
"""Transition the test from ``blue_evaluating`` to ``in_review``.
|
||||||
|
|
||||||
Auto-resumes if paused. Returns paused seconds accumulated
|
Auto-resumes if paused. Returns paused seconds accumulated
|
||||||
during this phase (for worklog calculation).
|
during this phase (for worklog calculation).
|
||||||
@@ -437,7 +401,7 @@ class TestEntity:
|
|||||||
# Assign paused_extra = self._auto_resume()
|
# Assign paused_extra = self._auto_resume()
|
||||||
paused_extra = self._auto_resume()
|
paused_extra = self._auto_resume()
|
||||||
# Call self._transition()
|
# Call self._transition()
|
||||||
self._transition(TestState.blue_review)
|
self._transition(TestState.in_review)
|
||||||
# Assign total_paused = self.blue_paused_seconds + paused_extra
|
# Assign total_paused = self.blue_paused_seconds + paused_extra
|
||||||
total_paused = self.blue_paused_seconds + paused_extra
|
total_paused = self.blue_paused_seconds + paused_extra
|
||||||
# Call self._events.append()
|
# Call self._events.append()
|
||||||
@@ -449,39 +413,6 @@ class TestEntity:
|
|||||||
# Return total_paused
|
# Return total_paused
|
||||||
return total_paused
|
return total_paused
|
||||||
|
|
||||||
def approve_blue_review(self) -> None:
|
|
||||||
"""Transition the test from ``blue_review`` to ``in_review``.
|
|
||||||
|
|
||||||
Called when the assigned Blue Lead approves the operator's work.
|
|
||||||
"""
|
|
||||||
self._transition(TestState.in_review)
|
|
||||||
self._events.append(DomainEvent("blue_review_approved"))
|
|
||||||
|
|
||||||
def reopen_blue_review(self) -> None:
|
|
||||||
"""Transition the test from ``blue_review`` back to ``blue_evaluating``.
|
|
||||||
|
|
||||||
Called when the assigned Blue Lead sends the work back for rework.
|
|
||||||
Resets the blue-phase timer for a fresh attempt. Note:
|
|
||||||
``blue_work_started_at`` (the pickup timestamp) lives only on the
|
|
||||||
ORM model, not on this entity — the service layer resets it there.
|
|
||||||
"""
|
|
||||||
self._transition(TestState.blue_evaluating)
|
|
||||||
self.blue_started_at = datetime.utcnow()
|
|
||||||
self.blue_paused_seconds = 0
|
|
||||||
self._events.append(DomainEvent("blue_review_reopened"))
|
|
||||||
|
|
||||||
def flag_blue_review_gap(self) -> None:
|
|
||||||
"""Transition the test from ``blue_review`` to ``in_review``.
|
|
||||||
|
|
||||||
Called when the assigned Blue Lead determines the shortfall is a
|
|
||||||
missing capability, not operator error — the test still proceeds
|
|
||||||
to cross-validation since a retry can't fix a capability gap. The
|
|
||||||
gap description itself is stored on the ORM model
|
|
||||||
(``Test.system_gaps``), not on this entity.
|
|
||||||
"""
|
|
||||||
self._transition(TestState.in_review)
|
|
||||||
self._events.append(DomainEvent("blue_review_gap_flagged"))
|
|
||||||
|
|
||||||
# Define function pause_timer
|
# Define function pause_timer
|
||||||
def pause_timer(self) -> None:
|
def pause_timer(self) -> None:
|
||||||
"""Pause the active phase timer.
|
"""Pause the active phase timer.
|
||||||
@@ -634,65 +565,6 @@ class TestEntity:
|
|||||||
# Call self._events.append()
|
# Call self._events.append()
|
||||||
self._events.append(DomainEvent("test_reopened"))
|
self._events.append(DomainEvent("test_reopened"))
|
||||||
|
|
||||||
def resolve_dispute_reject(self, target: str) -> None:
|
|
||||||
"""Resolve a ``disputed`` test by routing rework to the team at fault.
|
|
||||||
|
|
||||||
Called when the lead who originally approved flips their vote to
|
|
||||||
agree with the rejection. Rather than the generic terminal
|
|
||||||
``rejected`` state (which forces a full draft restart for both
|
|
||||||
teams), the flipping lead identifies WHICH side's work needs
|
|
||||||
redoing — the test goes straight back to that team's active queue
|
|
||||||
so the other team's work is left untouched.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
target (str): ``"red"`` or ``"blue"`` — which team must redo work.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
None
|
|
||||||
"""
|
|
||||||
target_state = TestState.red_executing if target == "red" else TestState.blue_evaluating
|
|
||||||
self._transition(target_state)
|
|
||||||
|
|
||||||
# Both leads must re-vote once the test reaches in_review again —
|
|
||||||
# clear decisions but keep notes (context for the team doing rework).
|
|
||||||
self.red_validation_status = None
|
|
||||||
self.red_validated_by = None
|
|
||||||
self.red_validated_at = None
|
|
||||||
self.blue_validation_status = None
|
|
||||||
self.blue_validated_by = None
|
|
||||||
self.blue_validated_at = None
|
|
||||||
self.paused_at = None
|
|
||||||
|
|
||||||
if target == "red":
|
|
||||||
self.red_started_at = datetime.utcnow()
|
|
||||||
self.red_paused_seconds = 0
|
|
||||||
else:
|
|
||||||
self.blue_started_at = datetime.utcnow()
|
|
||||||
self.blue_paused_seconds = 0
|
|
||||||
|
|
||||||
self._events.append(DomainEvent("dispute_resolved_to_rework", {"target": target}))
|
|
||||||
|
|
||||||
def resolve_dispute_by_manager(self, outcome: str) -> None:
|
|
||||||
"""A manager makes the final call on a disputed test, ending the standoff.
|
|
||||||
|
|
||||||
Unlike ``resolve_dispute_reject`` (a lead flipping their own vote),
|
|
||||||
this bypasses both leads entirely — the manager's decision is final
|
|
||||||
and terminal, going straight to ``validated`` or the normal
|
|
||||||
``rejected`` dead-end (which any lead can later reopen via the
|
|
||||||
standard reopen-to-draft flow, same as any other rejection).
|
|
||||||
|
|
||||||
Args:
|
|
||||||
outcome (str): ``"validated"`` or ``"rejected"``.
|
|
||||||
"""
|
|
||||||
if outcome not in ("validated", "rejected"):
|
|
||||||
raise InvalidOperationError("outcome must be 'validated' or 'rejected'")
|
|
||||||
|
|
||||||
target_state = TestState.validated if outcome == "validated" else TestState.rejected
|
|
||||||
self._transition(target_state)
|
|
||||||
self._events.append(
|
|
||||||
DomainEvent("dual_validation_approved" if outcome == "validated" else "dual_validation_rejected")
|
|
||||||
)
|
|
||||||
|
|
||||||
# -- Private -------------------------------------------------------
|
# -- Private -------------------------------------------------------
|
||||||
|
|
||||||
def _auto_resume(self) -> int:
|
def _auto_resume(self) -> int:
|
||||||
@@ -719,23 +591,37 @@ class TestEntity:
|
|||||||
def check_dual_validation(self) -> None:
|
def check_dual_validation(self) -> None:
|
||||||
"""Evaluate both leads' votes and advance state if appropriate.
|
"""Evaluate both leads' votes and advance state if appropriate.
|
||||||
|
|
||||||
Rules (v2 — consensus required):
|
|
||||||
- Both **approved** -> ``validated``
|
- Both **approved** -> ``validated``
|
||||||
- Both **rejected** -> ``rejected``
|
- Either **rejected** -> ``rejected``
|
||||||
- One approved + one rejected -> ``disputed`` (conflict, needs discussion)
|
- Otherwise no change (waiting for the other lead).
|
||||||
- Otherwise (one or both still pending) -> no change
|
|
||||||
|
|
||||||
Called automatically by :meth:`validate_red` and :meth:`validate_blue`.
|
Called automatically by :meth:`validate_red` and :meth:`validate_blue`.
|
||||||
|
Also available as a standalone entry point for backward compatibility
|
||||||
|
when validation fields are set externally.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
None
|
||||||
"""
|
"""
|
||||||
# Call self._check_dual_validation()
|
# Call self._check_dual_validation()
|
||||||
self._check_dual_validation()
|
self._check_dual_validation()
|
||||||
|
|
||||||
# Define function _assert_in_review
|
# Define function _assert_in_review
|
||||||
def _assert_in_review(self, side: str) -> None:
|
def _assert_in_review(self, side: str) -> None:
|
||||||
if self.state not in (TestState.in_review, TestState.disputed):
|
"""Raise InvalidOperationError unless the test is in ``in_review`` state.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
side (str): The team side being validated (``"red"`` or ``"blue"``),
|
||||||
|
used in the error message.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
None
|
||||||
|
"""
|
||||||
|
# Check: self.state != TestState.in_review
|
||||||
|
if self.state != TestState.in_review:
|
||||||
|
# Raise InvalidOperationError
|
||||||
raise InvalidOperationError(
|
raise InvalidOperationError(
|
||||||
f"Cannot validate {side} side while test is in "
|
f"Cannot validate {side} side while test is in "
|
||||||
f"'{self.state.value}' state (must be in_review or disputed)"
|
f"'{self.state.value}' state (must be in_review)"
|
||||||
)
|
)
|
||||||
|
|
||||||
# Apply the @staticmethod decorator
|
# Apply the @staticmethod decorator
|
||||||
@@ -760,31 +646,22 @@ class TestEntity:
|
|||||||
|
|
||||||
# Define function _check_dual_validation
|
# Define function _check_dual_validation
|
||||||
def _check_dual_validation(self) -> None:
|
def _check_dual_validation(self) -> None:
|
||||||
"""Advance the test state once enough leads have voted.
|
"""Advance to ``validated`` or ``rejected`` once both leads have voted.
|
||||||
|
|
||||||
A genuine conflict (one lead's *recorded* decision disagreeing with
|
Returns:
|
||||||
the other's) routes to ``disputed`` — there are two opinions to
|
None
|
||||||
reconcile. A lone rejection while the other side hasn't voted yet
|
|
||||||
isn't a conflict (nothing to disagree with), so it still vetoes
|
|
||||||
straight to ``rejected`` without waiting for the second vote.
|
|
||||||
"""
|
"""
|
||||||
|
# r, b = self.red_validation_status, self.blue_validation_status
|
||||||
r, b = self.red_validation_status, self.blue_validation_status
|
r, b = self.red_validation_status, self.blue_validation_status
|
||||||
|
# Check: r == "rejected" or b == "rejected"
|
||||||
if r == "approved" and b == "approved":
|
if r == "rejected" or b == "rejected":
|
||||||
|
# Assign self.state = TestState.rejected
|
||||||
|
self.state = TestState.rejected
|
||||||
|
# Call self._events.append()
|
||||||
|
self._events.append(DomainEvent("dual_validation_rejected"))
|
||||||
|
# Alternative: r == "approved" and b == "approved"
|
||||||
|
elif r == "approved" and b == "approved":
|
||||||
|
# Assign self.state = TestState.validated
|
||||||
self.state = TestState.validated
|
self.state = TestState.validated
|
||||||
# Call self._events.append()
|
# Call self._events.append()
|
||||||
self._events.append(DomainEvent("dual_validation_approved"))
|
self._events.append(DomainEvent("dual_validation_approved"))
|
||||||
|
|
||||||
elif r == "rejected" and b == "rejected":
|
|
||||||
self.state = TestState.rejected
|
|
||||||
self._events.append(DomainEvent("dual_validation_rejected"))
|
|
||||||
|
|
||||||
elif (r == "approved" and b == "rejected") or (r == "rejected" and b == "approved"):
|
|
||||||
self.state = TestState.disputed
|
|
||||||
self._events.append(DomainEvent("dual_validation_disputed"))
|
|
||||||
|
|
||||||
elif r == "rejected" or b == "rejected":
|
|
||||||
# One side rejected while the other hasn't voted yet — no
|
|
||||||
# disagreement to dispute yet, just a straightforward veto.
|
|
||||||
self.state = TestState.rejected
|
|
||||||
self._events.append(DomainEvent("dual_validation_rejected"))
|
|
||||||
|
|||||||
@@ -12,11 +12,9 @@ sessions.
|
|||||||
|
|
||||||
# Import logging
|
# Import logging
|
||||||
import logging
|
import logging
|
||||||
from datetime import datetime, timedelta, timezone
|
|
||||||
|
|
||||||
# Import BackgroundScheduler from apscheduler.schedulers.background
|
# Import BackgroundScheduler from apscheduler.schedulers.background
|
||||||
from apscheduler.schedulers.background import BackgroundScheduler
|
from apscheduler.schedulers.background import BackgroundScheduler
|
||||||
from apscheduler.events import EVENT_JOB_ERROR
|
|
||||||
|
|
||||||
# Import SessionLocal from app.database
|
# Import SessionLocal from app.database
|
||||||
from app.database import SessionLocal
|
from app.database import SessionLocal
|
||||||
@@ -28,10 +26,7 @@ from app.jobs.jira_sync_job import sync_all_jira_links
|
|||||||
from app.jobs.retention_job import run_retention_job
|
from app.jobs.retention_job import run_retention_job
|
||||||
|
|
||||||
# Import check_and_run_recurring_campaigns from app.services.campaign_scheduler_service
|
# Import check_and_run_recurring_campaigns from app.services.campaign_scheduler_service
|
||||||
from app.services.campaign_scheduler_service import (
|
from app.services.campaign_scheduler_service import check_and_run_recurring_campaigns
|
||||||
check_and_run_recurring_campaigns,
|
|
||||||
sync_due_campaign_jira_tickets,
|
|
||||||
)
|
|
||||||
|
|
||||||
# Import scan_intel from app.services.intel_service
|
# Import scan_intel from app.services.intel_service
|
||||||
from app.services.intel_service import scan_intel
|
from app.services.intel_service import scan_intel
|
||||||
@@ -61,27 +56,6 @@ logger = logging.getLogger(__name__)
|
|||||||
scheduler = BackgroundScheduler()
|
scheduler = BackgroundScheduler()
|
||||||
|
|
||||||
|
|
||||||
def _on_job_error(event) -> None:
|
|
||||||
"""Email admins (opted-in) whenever a scheduled background job raises."""
|
|
||||||
db = SessionLocal()
|
|
||||||
try:
|
|
||||||
from app.services.notification_service import notify_roles_by_email
|
|
||||||
notify_roles_by_email(
|
|
||||||
db, roles=["admin"],
|
|
||||||
preference_key="email_on_system_errors",
|
|
||||||
subject=f"Aegis Background Job Failed: {event.job_id}",
|
|
||||||
message=(
|
|
||||||
f'The scheduled job "{event.job_id}" raised an exception and did '
|
|
||||||
f"not complete:\n\n{event.exception}"
|
|
||||||
),
|
|
||||||
)
|
|
||||||
db.commit()
|
|
||||||
except Exception:
|
|
||||||
logger.exception("Failed to dispatch system-error notification")
|
|
||||||
finally:
|
|
||||||
db.close()
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Job functions
|
# Job functions
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -89,7 +63,7 @@ def _on_job_error(event) -> None:
|
|||||||
|
|
||||||
def _run_mitre_sync() -> None:
|
def _run_mitre_sync() -> None:
|
||||||
"""Execute a MITRE sync inside its own DB session."""
|
"""Execute a MITRE sync inside its own DB session."""
|
||||||
from app.services.webhook_service import dispatch_webhook
|
# Log info: "Scheduled MITRE sync job starting..."
|
||||||
logger.info("Scheduled MITRE sync job starting...")
|
logger.info("Scheduled MITRE sync job starting...")
|
||||||
# Assign db = SessionLocal()
|
# Assign db = SessionLocal()
|
||||||
db = SessionLocal()
|
db = SessionLocal()
|
||||||
@@ -99,7 +73,7 @@ def _run_mitre_sync() -> None:
|
|||||||
summary = sync_mitre(db)
|
summary = sync_mitre(db)
|
||||||
# Log info: "Scheduled MITRE sync job finished — %s", summary
|
# Log info: "Scheduled MITRE sync job finished — %s", summary
|
||||||
logger.info("Scheduled MITRE sync job finished — %s", summary)
|
logger.info("Scheduled MITRE sync job finished — %s", summary)
|
||||||
dispatch_webhook("mitre.synced", {"created": summary.get("created", 0), "updated": summary.get("updated", 0)})
|
# Handle Exception
|
||||||
except Exception:
|
except Exception:
|
||||||
# Log exception: "Scheduled MITRE sync job failed"
|
# Log exception: "Scheduled MITRE sync job failed"
|
||||||
logger.exception("Scheduled MITRE sync job failed")
|
logger.exception("Scheduled MITRE sync job failed")
|
||||||
@@ -189,19 +163,7 @@ def _run_recurring_campaigns() -> None:
|
|||||||
db.close()
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
def _run_due_campaign_jira_sync() -> None:
|
# Define function _run_intel_scan
|
||||||
"""Create Jira tickets for campaigns whose scheduled start_date has arrived."""
|
|
||||||
logger.info("Scheduled due-campaign Jira sync starting...")
|
|
||||||
db = SessionLocal()
|
|
||||||
try:
|
|
||||||
processed = sync_due_campaign_jira_tickets(db)
|
|
||||||
logger.info("Due-campaign Jira sync finished — processed %d campaigns", processed)
|
|
||||||
except Exception:
|
|
||||||
logger.exception("Due-campaign Jira sync failed")
|
|
||||||
finally:
|
|
||||||
db.close()
|
|
||||||
|
|
||||||
|
|
||||||
def _run_intel_scan() -> None:
|
def _run_intel_scan() -> None:
|
||||||
"""Execute an intel scan inside its own DB session."""
|
"""Execute an intel scan inside its own DB session."""
|
||||||
# Log info: "Scheduled intel scan job starting..."
|
# Log info: "Scheduled intel scan job starting..."
|
||||||
@@ -224,83 +186,7 @@ def _run_intel_scan() -> None:
|
|||||||
db.close()
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
def _run_evaluation_round_check() -> None:
|
# Define function _run_osint_enrichment
|
||||||
"""Weekly job: check if a new ATT&CK Evaluation round is available.
|
|
||||||
|
|
||||||
If a new round is found it is imported automatically and an admin
|
|
||||||
notification is created so the team knows new baseline data is available.
|
|
||||||
"""
|
|
||||||
logger.info("ATT&CK Evaluations new-round check starting...")
|
|
||||||
db = SessionLocal()
|
|
||||||
try:
|
|
||||||
from app.services.attck_evaluations_service import check_for_new_round, import_evaluation_round
|
|
||||||
from app.models.user import User as UserModel
|
|
||||||
|
|
||||||
result = check_for_new_round(db)
|
|
||||||
if result.get("error"):
|
|
||||||
logger.warning("ATT&CK Evaluations check failed: %s", result["error"])
|
|
||||||
return
|
|
||||||
|
|
||||||
if not result.get("new_round_available"):
|
|
||||||
logger.info(
|
|
||||||
"ATT&CK Evaluations check — latest round '%s' already imported",
|
|
||||||
result.get("latest_round", {}).get("display_name", "?"),
|
|
||||||
)
|
|
||||||
return
|
|
||||||
|
|
||||||
latest = result["latest_round"]
|
|
||||||
logger.info(
|
|
||||||
"New ATT&CK Evaluation round detected: %s (round %d) — starting auto-import",
|
|
||||||
latest["display_name"], latest["eval_round"],
|
|
||||||
)
|
|
||||||
|
|
||||||
# Use the first admin user as the importer (system action)
|
|
||||||
admin = db.query(UserModel).filter(UserModel.role == "admin").first()
|
|
||||||
if not admin:
|
|
||||||
logger.warning("ATT&CK Evaluations auto-import: no admin user found — skipping")
|
|
||||||
return
|
|
||||||
|
|
||||||
summary = import_evaluation_round(
|
|
||||||
db,
|
|
||||||
latest["name"],
|
|
||||||
latest["display_name"],
|
|
||||||
latest["eval_round"],
|
|
||||||
admin,
|
|
||||||
)
|
|
||||||
logger.info(
|
|
||||||
"ATT&CK Evaluations auto-import complete — round %d (%s): %d tests created",
|
|
||||||
latest["eval_round"], latest["display_name"], summary["created"],
|
|
||||||
)
|
|
||||||
|
|
||||||
# Notify all admins
|
|
||||||
try:
|
|
||||||
from app.services.notification_service import create_notification
|
|
||||||
admins = db.query(UserModel).filter(UserModel.role == "admin").all()
|
|
||||||
for adm in admins:
|
|
||||||
create_notification(
|
|
||||||
db,
|
|
||||||
user_id=adm.id,
|
|
||||||
title="New ATT&CK Evaluation round imported",
|
|
||||||
message=(
|
|
||||||
f"Round {latest['eval_round']} — {latest['display_name']} — "
|
|
||||||
f"has been automatically imported. "
|
|
||||||
f"{summary['created']} tests created in In Review state. "
|
|
||||||
f"Blue Leads must validate each result before it counts as coverage."
|
|
||||||
),
|
|
||||||
notification_type="eval_import",
|
|
||||||
entity_type="evaluation",
|
|
||||||
entity_id=None,
|
|
||||||
)
|
|
||||||
db.commit()
|
|
||||||
except Exception:
|
|
||||||
logger.warning("Failed to send eval import notifications", exc_info=True)
|
|
||||||
|
|
||||||
except Exception:
|
|
||||||
logger.exception("ATT&CK Evaluations round check job failed")
|
|
||||||
finally:
|
|
||||||
db.close()
|
|
||||||
|
|
||||||
|
|
||||||
def _run_osint_enrichment() -> None:
|
def _run_osint_enrichment() -> None:
|
||||||
"""Execute weekly OSINT enrichment inside its own DB session."""
|
"""Execute weekly OSINT enrichment inside its own DB session."""
|
||||||
# Log info: "Scheduled OSINT enrichment job starting..."
|
# Log info: "Scheduled OSINT enrichment job starting..."
|
||||||
@@ -323,61 +209,7 @@ def _run_osint_enrichment() -> None:
|
|||||||
db.close()
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
_FREQUENCY_INTERVALS: dict[str, timedelta] = {
|
# Define function _run_stale_detection
|
||||||
"daily": timedelta(days=1),
|
|
||||||
"weekly": timedelta(weeks=1),
|
|
||||||
"monthly": timedelta(days=30),
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def _run_data_sources_sync() -> None:
|
|
||||||
"""Check all enabled data sources and sync those that are overdue."""
|
|
||||||
logger.info("Scheduled data sources sync check starting...")
|
|
||||||
db = SessionLocal()
|
|
||||||
try:
|
|
||||||
from app.models.data_source import DataSource
|
|
||||||
from app.services.data_source_service import sync_source
|
|
||||||
|
|
||||||
now = datetime.now(timezone.utc)
|
|
||||||
sources = (
|
|
||||||
db.query(DataSource)
|
|
||||||
.filter(DataSource.is_enabled == True) # noqa: E712
|
|
||||||
.all()
|
|
||||||
)
|
|
||||||
synced = 0
|
|
||||||
for ds in sources:
|
|
||||||
freq = ds.sync_frequency
|
|
||||||
if not freq or freq == "manual":
|
|
||||||
continue
|
|
||||||
interval = _FREQUENCY_INTERVALS.get(freq)
|
|
||||||
if interval is None:
|
|
||||||
continue
|
|
||||||
last = ds.last_sync_at
|
|
||||||
if last is None:
|
|
||||||
# Never synced — run it now
|
|
||||||
overdue = True
|
|
||||||
else:
|
|
||||||
# Make last timezone-aware if needed
|
|
||||||
if last.tzinfo is None:
|
|
||||||
last = last.replace(tzinfo=timezone.utc)
|
|
||||||
overdue = now - last >= interval
|
|
||||||
if overdue:
|
|
||||||
logger.info(
|
|
||||||
"Data source '%s' is overdue (freq=%s, last=%s) — syncing",
|
|
||||||
ds.name, freq, last,
|
|
||||||
)
|
|
||||||
try:
|
|
||||||
sync_source(db, str(ds.id))
|
|
||||||
synced += 1
|
|
||||||
except Exception:
|
|
||||||
logger.exception("Failed to sync data source '%s'", ds.name)
|
|
||||||
logger.info("Data sources sync check finished — %d source(s) synced", synced)
|
|
||||||
except Exception:
|
|
||||||
logger.exception("Data sources sync check failed")
|
|
||||||
finally:
|
|
||||||
db.close()
|
|
||||||
|
|
||||||
|
|
||||||
def _run_stale_detection() -> None:
|
def _run_stale_detection() -> None:
|
||||||
"""Execute daily stale coverage detection inside its own DB session."""
|
"""Execute daily stale coverage detection inside its own DB session."""
|
||||||
# Log info: "Scheduled stale coverage detection starting..."
|
# Log info: "Scheduled stale coverage detection starting..."
|
||||||
@@ -400,53 +232,6 @@ def _run_stale_detection() -> None:
|
|||||||
db.close()
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
def _run_decay_engine() -> None:
|
|
||||||
"""Execute the decay engine inside its own DB session."""
|
|
||||||
logger.info("Scheduled decay engine job starting...")
|
|
||||||
db = SessionLocal()
|
|
||||||
try:
|
|
||||||
from app.services.decay_engine_service import run_decay_engine
|
|
||||||
results = run_decay_engine(db)
|
|
||||||
logger.info("Decay engine job finished — %s", results)
|
|
||||||
except Exception:
|
|
||||||
logger.exception("Decay engine job failed")
|
|
||||||
finally:
|
|
||||||
db.close()
|
|
||||||
|
|
||||||
|
|
||||||
def _run_queue_generation() -> None:
|
|
||||||
"""Generate revalidation queue items for analysts — runs after decay engine."""
|
|
||||||
logger.info("Scheduled revalidation queue generation starting...")
|
|
||||||
db = SessionLocal()
|
|
||||||
try:
|
|
||||||
from app.services.revalidation_queue_service import generate_queue_items
|
|
||||||
results = generate_queue_items(db)
|
|
||||||
logger.info("Queue generation finished — %s", results)
|
|
||||||
except Exception:
|
|
||||||
logger.exception("Queue generation job failed")
|
|
||||||
finally:
|
|
||||||
db.close()
|
|
||||||
|
|
||||||
|
|
||||||
def _run_alert_evaluation() -> None:
|
|
||||||
"""Evaluate all enabled operational alert rules (hourly)."""
|
|
||||||
logger.info("Scheduled alert evaluation job starting...")
|
|
||||||
db = SessionLocal()
|
|
||||||
try:
|
|
||||||
from app.services.operational_alert_service import evaluate_all_rules
|
|
||||||
result = evaluate_all_rules(db)
|
|
||||||
logger.info(
|
|
||||||
"Alert evaluation finished — %d rules, %d alerts fired in %.3fs",
|
|
||||||
result["rules_evaluated"],
|
|
||||||
result["alerts_fired"],
|
|
||||||
result["duration_seconds"],
|
|
||||||
)
|
|
||||||
except Exception:
|
|
||||||
logger.exception("Alert evaluation job failed")
|
|
||||||
finally:
|
|
||||||
db.close()
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Scheduler bootstrap
|
# Scheduler bootstrap
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -462,7 +247,6 @@ def start_scheduler() -> None:
|
|||||||
|
|
||||||
Neither job fires immediately on startup.
|
Neither job fires immediately on startup.
|
||||||
"""
|
"""
|
||||||
scheduler.add_listener(_on_job_error, EVENT_JOB_ERROR)
|
|
||||||
# Call scheduler.add_job()
|
# Call scheduler.add_job()
|
||||||
scheduler.add_job(
|
scheduler.add_job(
|
||||||
_run_mitre_sync,
|
_run_mitre_sync,
|
||||||
@@ -523,6 +307,7 @@ def start_scheduler() -> None:
|
|||||||
# Keyword argument: replace_existing
|
# Keyword argument: replace_existing
|
||||||
replace_existing=True,
|
replace_existing=True,
|
||||||
)
|
)
|
||||||
|
# Call scheduler.add_job()
|
||||||
scheduler.add_job(
|
scheduler.add_job(
|
||||||
_run_recurring_campaigns,
|
_run_recurring_campaigns,
|
||||||
# Keyword argument: trigger
|
# Keyword argument: trigger
|
||||||
@@ -536,14 +321,6 @@ def start_scheduler() -> None:
|
|||||||
# Keyword argument: replace_existing
|
# Keyword argument: replace_existing
|
||||||
replace_existing=True,
|
replace_existing=True,
|
||||||
)
|
)
|
||||||
scheduler.add_job(
|
|
||||||
_run_due_campaign_jira_sync,
|
|
||||||
trigger="interval",
|
|
||||||
minutes=15,
|
|
||||||
id="due_campaign_jira_sync",
|
|
||||||
name="Due campaign Jira ticket sync (every 15 min)",
|
|
||||||
replace_existing=True,
|
|
||||||
)
|
|
||||||
# Call scheduler.add_job()
|
# Call scheduler.add_job()
|
||||||
scheduler.add_job(
|
scheduler.add_job(
|
||||||
sync_all_jira_links,
|
sync_all_jira_links,
|
||||||
@@ -600,50 +377,7 @@ def start_scheduler() -> None:
|
|||||||
# Keyword argument: replace_existing
|
# Keyword argument: replace_existing
|
||||||
replace_existing=True,
|
replace_existing=True,
|
||||||
)
|
)
|
||||||
scheduler.add_job(
|
# Call scheduler.start()
|
||||||
_run_data_sources_sync,
|
|
||||||
trigger="interval",
|
|
||||||
hours=6,
|
|
||||||
id="data_sources_sync",
|
|
||||||
name="Data sources auto-sync (every 6h)",
|
|
||||||
replace_existing=True,
|
|
||||||
)
|
|
||||||
scheduler.add_job(
|
|
||||||
_run_decay_engine,
|
|
||||||
trigger="cron",
|
|
||||||
hour=2,
|
|
||||||
minute=0,
|
|
||||||
id="decay_engine",
|
|
||||||
name="Detection decay engine (daily 02:00)",
|
|
||||||
replace_existing=True,
|
|
||||||
)
|
|
||||||
scheduler.add_job(
|
|
||||||
_run_queue_generation,
|
|
||||||
trigger="cron",
|
|
||||||
hour=2,
|
|
||||||
minute=30,
|
|
||||||
id="queue_generation",
|
|
||||||
name="Revalidation queue generation (daily 02:30)",
|
|
||||||
replace_existing=True,
|
|
||||||
)
|
|
||||||
scheduler.add_job(
|
|
||||||
_run_alert_evaluation,
|
|
||||||
trigger="interval",
|
|
||||||
hours=1,
|
|
||||||
id="alert_evaluation",
|
|
||||||
name="Operational alert evaluation (hourly)",
|
|
||||||
replace_existing=True,
|
|
||||||
)
|
|
||||||
scheduler.add_job(
|
|
||||||
_run_evaluation_round_check,
|
|
||||||
trigger="cron",
|
|
||||||
day_of_week="mon",
|
|
||||||
hour=6,
|
|
||||||
minute=0,
|
|
||||||
id="attck_evaluation_check",
|
|
||||||
name="ATT&CK Evaluations new-round check (Mondays 06:00)",
|
|
||||||
replace_existing=True,
|
|
||||||
)
|
|
||||||
scheduler.start()
|
scheduler.start()
|
||||||
# Log info:
|
# Log info:
|
||||||
logger.info(
|
logger.info(
|
||||||
@@ -652,9 +386,9 @@ def start_scheduler() -> None:
|
|||||||
# Literal argument value
|
# Literal argument value
|
||||||
"notification_cleanup (24h), weekly_snapshot (Sundays 00:00), "
|
"notification_cleanup (24h), weekly_snapshot (Sundays 00:00), "
|
||||||
# Literal argument value
|
# Literal argument value
|
||||||
"recurring_campaigns (daily), due_campaign_jira_sync (15m), jira_sync (1h), "
|
"recurring_campaigns (daily), jira_sync (1h), "
|
||||||
# Literal argument value
|
# Literal argument value
|
||||||
"osint_enrichment (weekly), stale_detection (daily), "
|
"osint_enrichment (weekly), stale_detection (daily), "
|
||||||
"retention_policies (daily), data_sources_sync (6h), "
|
# Literal argument value
|
||||||
"alert_evaluation (1h), attck_evaluation_check (Mondays 06:00)"
|
"retention_policies (daily)"
|
||||||
)
|
)
|
||||||
|
|||||||
+89
-93
@@ -38,47 +38,10 @@ from slowapi.errors import RateLimitExceeded
|
|||||||
# Import SQLAlchemyError from sqlalchemy.exc
|
# Import SQLAlchemyError from sqlalchemy.exc
|
||||||
from sqlalchemy.exc import SQLAlchemyError
|
from sqlalchemy.exc import SQLAlchemyError
|
||||||
|
|
||||||
from app.routers import auth as auth_router
|
# Import settings as _settings from app.config
|
||||||
from app.routers import techniques as techniques_router
|
from app.config import settings as _settings
|
||||||
from app.routers import tests as tests_router
|
|
||||||
from app.routers import evidence as evidence_router
|
# Import DomainError from app.domain.errors
|
||||||
from app.routers import test_templates as test_templates_router
|
|
||||||
from app.routers import procedure_suggestions as procedure_suggestions_router
|
|
||||||
from app.routers import template_suggestions as template_suggestions_router
|
|
||||||
from app.routers import system as system_router
|
|
||||||
from app.routers import metrics as metrics_router
|
|
||||||
from app.routers import users as users_router
|
|
||||||
from app.routers import audit as audit_router
|
|
||||||
from app.routers import notifications as notifications_router
|
|
||||||
from app.routers import reports as reports_router
|
|
||||||
from app.routers import data_sources as data_sources_router
|
|
||||||
from app.routers import threat_actors as threat_actors_router
|
|
||||||
from app.routers import d3fend as d3fend_router
|
|
||||||
from app.routers import detection_rules as detection_rules_router
|
|
||||||
from app.routers import campaigns as campaigns_router
|
|
||||||
from app.routers import heatmap as heatmap_router
|
|
||||||
from app.routers import scores as scores_router
|
|
||||||
from app.routers import operational_metrics as operational_metrics_router
|
|
||||||
from app.routers import compliance as compliance_router
|
|
||||||
from app.routers import snapshots as snapshots_router
|
|
||||||
from app.routers import jira as jira_router
|
|
||||||
from app.routers import worklogs as worklogs_router
|
|
||||||
from app.routers import professional_reports as professional_reports_router
|
|
||||||
from app.routers import analytics as analytics_router
|
|
||||||
from app.routers import advanced_metrics as advanced_metrics_router
|
|
||||||
from app.routers import osint as osint_router
|
|
||||||
from app.routers import webhooks as webhooks_router
|
|
||||||
from app.routers import detection_lifecycle as detection_lifecycle_router
|
|
||||||
from app.routers import intel as intel_router
|
|
||||||
from app.routers import admin_config as admin_config_router
|
|
||||||
from app.routers import ownership as ownership_router
|
|
||||||
from app.routers import attack_paths as attack_paths_router
|
|
||||||
from app.routers import knowledge as knowledge_router
|
|
||||||
from app.routers import risk_intelligence as risk_router
|
|
||||||
from app.routers import executive_dashboard as dashboard_router
|
|
||||||
from app.routers import api_keys as api_keys_router
|
|
||||||
from app.routers import sso as sso_router
|
|
||||||
from app.routers import operational_alerts as alerts_router
|
|
||||||
from app.domain.errors import DomainError
|
from app.domain.errors import DomainError
|
||||||
|
|
||||||
# Import scheduler, start_scheduler from app.jobs.mitre_sync_job
|
# Import scheduler, start_scheduler from app.jobs.mitre_sync_job
|
||||||
@@ -95,15 +58,94 @@ from app.middleware.error_handler import domain_exception_handler
|
|||||||
|
|
||||||
# Import RequestContextMiddleware from app.middleware.request_context
|
# Import RequestContextMiddleware from app.middleware.request_context
|
||||||
from app.middleware.request_context import RequestContextMiddleware
|
from app.middleware.request_context import RequestContextMiddleware
|
||||||
|
|
||||||
|
# Import advanced_metrics as advanced_metrics_router from app.routers
|
||||||
|
from app.routers import advanced_metrics as advanced_metrics_router
|
||||||
|
|
||||||
|
# Import analytics as analytics_router from app.routers
|
||||||
|
from app.routers import analytics as analytics_router
|
||||||
|
|
||||||
|
# Import audit as audit_router from app.routers
|
||||||
|
from app.routers import audit as audit_router
|
||||||
|
|
||||||
|
# Import auth as auth_router from app.routers
|
||||||
|
from app.routers import auth as auth_router
|
||||||
|
|
||||||
|
# Import campaigns as campaigns_router from app.routers
|
||||||
|
from app.routers import campaigns as campaigns_router
|
||||||
|
|
||||||
|
# Import compliance as compliance_router from app.routers
|
||||||
|
from app.routers import compliance as compliance_router
|
||||||
|
|
||||||
|
# Import d3fend as d3fend_router from app.routers
|
||||||
|
from app.routers import d3fend as d3fend_router
|
||||||
|
|
||||||
|
# Import data_sources as data_sources_router from app.routers
|
||||||
|
from app.routers import data_sources as data_sources_router
|
||||||
|
|
||||||
|
# Import detection_rules as detection_rules_router from app.routers
|
||||||
|
from app.routers import detection_rules as detection_rules_router
|
||||||
|
|
||||||
|
# Import evidence as evidence_router from app.routers
|
||||||
|
from app.routers import evidence as evidence_router
|
||||||
|
|
||||||
|
# Import heatmap as heatmap_router from app.routers
|
||||||
|
from app.routers import heatmap as heatmap_router
|
||||||
|
|
||||||
|
# Import jira as jira_router from app.routers
|
||||||
|
from app.routers import jira as jira_router
|
||||||
|
|
||||||
|
# Import metrics as metrics_router from app.routers
|
||||||
|
from app.routers import metrics as metrics_router
|
||||||
|
|
||||||
|
# Import notifications as notifications_router from app.routers
|
||||||
|
from app.routers import notifications as notifications_router
|
||||||
|
|
||||||
|
# Import operational_metrics as operational_metrics_router from app.routers
|
||||||
|
from app.routers import operational_metrics as operational_metrics_router
|
||||||
|
|
||||||
|
# Import osint as osint_router from app.routers
|
||||||
|
from app.routers import osint as osint_router
|
||||||
|
|
||||||
|
# Import professional_reports as professional_reports_ro... from app.routers
|
||||||
|
from app.routers import professional_reports as professional_reports_router
|
||||||
|
|
||||||
|
# Import reports as reports_router from app.routers
|
||||||
|
from app.routers import reports as reports_router
|
||||||
|
|
||||||
|
# Import scores as scores_router from app.routers
|
||||||
|
from app.routers import scores as scores_router
|
||||||
|
|
||||||
|
# Import snapshots as snapshots_router from app.routers
|
||||||
|
from app.routers import snapshots as snapshots_router
|
||||||
|
|
||||||
|
# Import system as system_router from app.routers
|
||||||
|
from app.routers import system as system_router
|
||||||
|
|
||||||
|
# Import techniques as techniques_router from app.routers
|
||||||
|
from app.routers import techniques as techniques_router
|
||||||
|
|
||||||
|
# Import test_templates as test_templates_router from app.routers
|
||||||
|
from app.routers import test_templates as test_templates_router
|
||||||
|
|
||||||
|
# Import tests as tests_router from app.routers
|
||||||
|
from app.routers import tests as tests_router
|
||||||
|
|
||||||
|
# Import threat_actors as threat_actors_router from app.routers
|
||||||
|
from app.routers import threat_actors as threat_actors_router
|
||||||
|
|
||||||
|
# Import users as users_router from app.routers
|
||||||
|
from app.routers import users as users_router
|
||||||
|
|
||||||
|
# Import worklogs as worklogs_router from app.routers
|
||||||
|
from app.routers import worklogs as worklogs_router
|
||||||
|
|
||||||
|
# Import ensure_bucket_exists from app.storage
|
||||||
from app.storage import ensure_bucket_exists
|
from app.storage import ensure_bucket_exists
|
||||||
from app.config import settings as _settings
|
|
||||||
from starlette.middleware.base import BaseHTTPMiddleware
|
|
||||||
|
|
||||||
# Configure structured logging before any module initialises its own logger
|
# Configure structured logging before any module initialises its own logger
|
||||||
setup_logging()
|
setup_logging()
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
# ── Environment detection ─────────────────────────────────────────────────
|
# ── Environment detection ─────────────────────────────────────────────────
|
||||||
_IS_PRODUCTION = os.environ.get("AEGIS_ENV", "").lower() == "production"
|
_IS_PRODUCTION = os.environ.get("AEGIS_ENV", "").lower() == "production"
|
||||||
|
|
||||||
@@ -123,25 +165,7 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
|
|||||||
ensure_bucket_exists()
|
ensure_bucket_exists()
|
||||||
# Call start_scheduler()
|
# Call start_scheduler()
|
||||||
start_scheduler()
|
start_scheduler()
|
||||||
# Seed decay policies
|
# Yield value
|
||||||
from app.database import SessionLocal
|
|
||||||
from app.seed_decay_policies import seed_decay_policies
|
|
||||||
db = SessionLocal()
|
|
||||||
try:
|
|
||||||
seed_decay_policies(db)
|
|
||||||
except Exception as e:
|
|
||||||
logger.warning("seed_decay_policies failed at startup: %s", e)
|
|
||||||
finally:
|
|
||||||
db.close()
|
|
||||||
# Seed operational alert system rules
|
|
||||||
db2 = SessionLocal()
|
|
||||||
try:
|
|
||||||
from app.services.operational_alert_service import seed_system_rules
|
|
||||||
seed_system_rules(db2)
|
|
||||||
except Exception as e:
|
|
||||||
logger.warning("seed_system_rules failed at startup: %s", e)
|
|
||||||
finally:
|
|
||||||
db2.close()
|
|
||||||
yield
|
yield
|
||||||
# Graceful shutdown of the background scheduler
|
# Graceful shutdown of the background scheduler
|
||||||
scheduler.shutdown(wait=False)
|
scheduler.shutdown(wait=False)
|
||||||
@@ -169,21 +193,6 @@ app.add_exception_handler(RateLimitExceeded, _rate_limit_exceeded_handler)
|
|||||||
# Call app.add_middleware()
|
# Call app.add_middleware()
|
||||||
app.add_middleware(RequestContextMiddleware)
|
app.add_middleware(RequestContextMiddleware)
|
||||||
|
|
||||||
|
|
||||||
# ── No-cache middleware for all /api/ responses ───────────────────────────
|
|
||||||
# Prevents Cloudflare and browser caches from storing API responses,
|
|
||||||
# which would cause stale/empty data to be served after backend restarts.
|
|
||||||
class NoCacheAPIMiddleware(BaseHTTPMiddleware):
|
|
||||||
async def dispatch(self, request: Request, call_next):
|
|
||||||
response = await call_next(request)
|
|
||||||
if request.url.path.startswith("/api/"):
|
|
||||||
response.headers["Cache-Control"] = "no-store, no-cache, must-revalidate"
|
|
||||||
response.headers["Pragma"] = "no-cache"
|
|
||||||
return response
|
|
||||||
|
|
||||||
app.add_middleware(NoCacheAPIMiddleware)
|
|
||||||
|
|
||||||
|
|
||||||
# ── Domain exception → HTTP mapping ──────────────────────────────────────
|
# ── Domain exception → HTTP mapping ──────────────────────────────────────
|
||||||
app.add_exception_handler(DomainError, domain_exception_handler)
|
app.add_exception_handler(DomainError, domain_exception_handler)
|
||||||
|
|
||||||
@@ -215,8 +224,6 @@ app.include_router(tests_router.router, prefix="/api/v1")
|
|||||||
app.include_router(evidence_router.router, prefix="/api/v1")
|
app.include_router(evidence_router.router, prefix="/api/v1")
|
||||||
# Call app.include_router()
|
# Call app.include_router()
|
||||||
app.include_router(test_templates_router.router, prefix="/api/v1")
|
app.include_router(test_templates_router.router, prefix="/api/v1")
|
||||||
app.include_router(procedure_suggestions_router.router, prefix="/api/v1")
|
|
||||||
app.include_router(template_suggestions_router.router, prefix="/api/v1")
|
|
||||||
# Call app.include_router()
|
# Call app.include_router()
|
||||||
app.include_router(system_router.router, prefix="/api/v1")
|
app.include_router(system_router.router, prefix="/api/v1")
|
||||||
# Call app.include_router()
|
# Call app.include_router()
|
||||||
@@ -247,8 +254,7 @@ app.include_router(scores_router.router, prefix="/api/v1")
|
|||||||
app.include_router(operational_metrics_router.router, prefix="/api/v1")
|
app.include_router(operational_metrics_router.router, prefix="/api/v1")
|
||||||
# Call app.include_router()
|
# Call app.include_router()
|
||||||
app.include_router(compliance_router.router, prefix="/api/v1")
|
app.include_router(compliance_router.router, prefix="/api/v1")
|
||||||
app.include_router(intel_router.router, prefix="/api/v1")
|
# Call app.include_router()
|
||||||
app.include_router(admin_config_router.router, prefix="/api/v1")
|
|
||||||
app.include_router(snapshots_router.router, prefix="/api/v1")
|
app.include_router(snapshots_router.router, prefix="/api/v1")
|
||||||
# Call app.include_router()
|
# Call app.include_router()
|
||||||
app.include_router(jira_router.router, prefix="/api/v1")
|
app.include_router(jira_router.router, prefix="/api/v1")
|
||||||
@@ -262,16 +268,6 @@ app.include_router(analytics_router.router, prefix="/api/v1")
|
|||||||
app.include_router(advanced_metrics_router.router, prefix="/api/v1")
|
app.include_router(advanced_metrics_router.router, prefix="/api/v1")
|
||||||
# Call app.include_router()
|
# Call app.include_router()
|
||||||
app.include_router(osint_router.router, prefix="/api/v1")
|
app.include_router(osint_router.router, prefix="/api/v1")
|
||||||
app.include_router(webhooks_router.router, prefix="/api/v1")
|
|
||||||
app.include_router(detection_lifecycle_router.router, prefix="/api/v1")
|
|
||||||
app.include_router(ownership_router.router, prefix="/api/v1")
|
|
||||||
app.include_router(attack_paths_router.router, prefix="/api/v1")
|
|
||||||
app.include_router(knowledge_router.router, prefix="/api/v1")
|
|
||||||
app.include_router(risk_router.router, prefix="/api/v1")
|
|
||||||
app.include_router(dashboard_router.router, prefix="/api/v1")
|
|
||||||
app.include_router(api_keys_router.router, prefix="/api/v1")
|
|
||||||
app.include_router(sso_router.router, prefix="/api/v1")
|
|
||||||
app.include_router(alerts_router.router, prefix="/api/v1")
|
|
||||||
|
|
||||||
|
|
||||||
# Apply the @app.get decorator
|
# Apply the @app.get decorator
|
||||||
|
|||||||
@@ -1,55 +1,73 @@
|
|||||||
"""SQLAlchemy ORM model definitions for all database tables."""
|
"""SQLAlchemy ORM model definitions for all database tables."""
|
||||||
# Import all models here so Alembic can detect them
|
# Import all models here so Alembic can detect them
|
||||||
from app.models.audit import AuditLog
|
from app.models.audit import AuditLog
|
||||||
from app.models.notification import Notification
|
|
||||||
from app.models.data_source import DataSource
|
# Import Campaign, CampaignTest from app.models.campaign
|
||||||
from app.models.detection_rule import DetectionRule
|
from app.models.campaign import Campaign, CampaignTest
|
||||||
from app.models.threat_actor import ThreatActor, ThreatActorTechnique
|
|
||||||
from app.models.defensive_technique import DefensiveTechnique, DefensiveTechniqueMapping
|
# Import from app.models.compliance
|
||||||
from app.models.test_template_detection_rule import TestTemplateDetectionRule
|
from app.models.compliance import (
|
||||||
from app.models.test_detection_result import TestDetectionResult
|
ComplianceControl,
|
||||||
from app.models.campaign import Campaign, CampaignTest, CampaignModificationRequest
|
ComplianceControlMapping,
|
||||||
from app.models.compliance import ComplianceFramework, ComplianceControl, ComplianceControlMapping
|
ComplianceFramework,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Import CoverageSnapshot, SnapshotTechniqueState from app.models.coverage_snapshot
|
||||||
from app.models.coverage_snapshot import CoverageSnapshot, SnapshotTechniqueState
|
from app.models.coverage_snapshot import CoverageSnapshot, SnapshotTechniqueState
|
||||||
from app.models.jira_link import JiraLink, JiraLinkEntityType, JiraSyncDirection
|
|
||||||
from app.models.worklog import Worklog
|
# Import DataSource from app.models.data_source
|
||||||
from app.models.osint_item import OsintItem
|
from app.models.data_source import DataSource
|
||||||
from app.models.scoring_config import ScoringConfig
|
|
||||||
from app.models.enums import TechniqueStatus, TestState, TestResult, TeamSide
|
# Import DefensiveTechnique, DefensiveTechniqueMapping from app.models.defensive_technique
|
||||||
from app.models.webhook_config import WebhookConfig
|
from app.models.defensive_technique import DefensiveTechnique, DefensiveTechniqueMapping
|
||||||
from app.models.system_config import SystemConfig
|
|
||||||
from app.models.detection_lifecycle import (
|
# Import DetectionRule from app.models.detection_rule
|
||||||
DetectionAsset, DetectionTechniqueMapping, DetectionValidation,
|
from app.models.detection_rule import DetectionRule
|
||||||
TechniqueConfidenceScore, InfrastructureChangeLog,
|
|
||||||
DetectionConfidence, DetectionHealthStatus, InvalidationReason,
|
# Import TeamSide, TechniqueStatus, TestResult, TestState from app.models.enums
|
||||||
)
|
from app.models.enums import TeamSide, TechniqueStatus, TestResult, TestState
|
||||||
from app.models.decay_policy import DecayPolicy
|
|
||||||
from app.models.ownership_queue import (
|
# Import Evidence from app.models.evidence
|
||||||
TechniqueOwnership, RevalidationQueueItem,
|
|
||||||
QueuePriority, QueueStatus, QueueReason,
|
|
||||||
)
|
|
||||||
from app.models.attack_path import (
|
|
||||||
AttackPath, AttackPathStep, AttackPathExecution,
|
|
||||||
AttackPathStepResult, TimelineEntry,
|
|
||||||
ExecutionStatus, StepResultStatus, TimelineActorSide, TimelineEntryType,
|
|
||||||
)
|
|
||||||
from app.models.knowledge import Playbook, PlaybookVersion, LessonLearned
|
|
||||||
from app.models.risk_intelligence import TechniqueRiskProfile
|
|
||||||
from app.models.executive_dashboard import PostureSnapshot
|
|
||||||
from app.models.api_key import ApiKey
|
|
||||||
from app.models.sso_config import SsoConfig
|
|
||||||
from app.models.operational_alert import AlertRule, AlertInstance
|
|
||||||
from app.models.evidence import Evidence
|
from app.models.evidence import Evidence
|
||||||
|
|
||||||
|
# Import IntelItem from app.models.intel
|
||||||
from app.models.intel import IntelItem
|
from app.models.intel import IntelItem
|
||||||
|
|
||||||
|
# Import JiraLink, JiraLinkEntityType, JiraSyncDirection from app.models.jira_link
|
||||||
|
from app.models.jira_link import JiraLink, JiraLinkEntityType, JiraSyncDirection
|
||||||
|
|
||||||
|
# Import Notification from app.models.notification
|
||||||
|
from app.models.notification import Notification
|
||||||
|
|
||||||
|
# Import OsintItem from app.models.osint_item
|
||||||
|
from app.models.osint_item import OsintItem
|
||||||
|
|
||||||
|
# Import ScoringConfig from app.models.scoring_config
|
||||||
|
from app.models.scoring_config import ScoringConfig
|
||||||
|
|
||||||
|
# Import Technique from app.models.technique
|
||||||
from app.models.technique import Technique
|
from app.models.technique import Technique
|
||||||
|
|
||||||
|
# Import Test from app.models.test
|
||||||
from app.models.test import Test
|
from app.models.test import Test
|
||||||
from app.models.test_round_history import TestRoundHistory
|
|
||||||
|
# Import TestDetectionResult from app.models.test_detection_result
|
||||||
|
from app.models.test_detection_result import TestDetectionResult
|
||||||
|
|
||||||
|
# Import TestTemplate from app.models.test_template
|
||||||
from app.models.test_template import TestTemplate
|
from app.models.test_template import TestTemplate
|
||||||
from app.models.procedure_suggestion import ProcedureSuggestion
|
|
||||||
from app.models.template_suggestion import TemplateSuggestion
|
# Import TestTemplateDetectionRule from app.models.test_template_detection_rule
|
||||||
|
from app.models.test_template_detection_rule import TestTemplateDetectionRule
|
||||||
|
|
||||||
|
# Import ThreatActor, ThreatActorTechnique from app.models.threat_actor
|
||||||
|
from app.models.threat_actor import ThreatActor, ThreatActorTechnique
|
||||||
|
|
||||||
|
# Import User from app.models.user
|
||||||
from app.models.user import User
|
from app.models.user import User
|
||||||
from app.models.password_setup_token import PasswordSetupToken
|
|
||||||
from app.models.evaluation_import import EvaluationImport
|
# Import Worklog from app.models.worklog
|
||||||
|
from app.models.worklog import Worklog
|
||||||
|
|
||||||
# Assign __all__ = [
|
# Assign __all__ = [
|
||||||
__all__ = [
|
__all__ = [
|
||||||
@@ -64,7 +82,7 @@ __all__ = [
|
|||||||
# Literal argument value
|
# Literal argument value
|
||||||
"TestTemplateDetectionRule", "TestDetectionResult",
|
"TestTemplateDetectionRule", "TestDetectionResult",
|
||||||
# Literal argument value
|
# Literal argument value
|
||||||
"Campaign", "CampaignTest", "CampaignModificationRequest",
|
"Campaign", "CampaignTest",
|
||||||
# Literal argument value
|
# Literal argument value
|
||||||
"ComplianceFramework", "ComplianceControl", "ComplianceControlMapping",
|
"ComplianceFramework", "ComplianceControl", "ComplianceControlMapping",
|
||||||
# Literal argument value
|
# Literal argument value
|
||||||
@@ -75,25 +93,4 @@ __all__ = [
|
|||||||
"Worklog", "OsintItem", "ScoringConfig",
|
"Worklog", "OsintItem", "ScoringConfig",
|
||||||
# Literal argument value
|
# Literal argument value
|
||||||
"TechniqueStatus", "TestState", "TestResult", "TeamSide",
|
"TechniqueStatus", "TestState", "TestResult", "TeamSide",
|
||||||
"WebhookConfig", "SystemConfig",
|
|
||||||
"DetectionAsset", "DetectionTechniqueMapping", "DetectionValidation",
|
|
||||||
"TechniqueConfidenceScore", "InfrastructureChangeLog",
|
|
||||||
"DetectionConfidence", "DetectionHealthStatus", "InvalidationReason", "DecayPolicy",
|
|
||||||
"TechniqueOwnership", "RevalidationQueueItem",
|
|
||||||
"QueuePriority", "QueueStatus", "QueueReason",
|
|
||||||
"AttackPath", "AttackPathStep", "AttackPathExecution",
|
|
||||||
"AttackPathStepResult", "TimelineEntry",
|
|
||||||
"ExecutionStatus", "StepResultStatus", "TimelineActorSide", "TimelineEntryType",
|
|
||||||
"Playbook", "PlaybookVersion", "LessonLearned",
|
|
||||||
"TechniqueRiskProfile",
|
|
||||||
"PostureSnapshot",
|
|
||||||
"ApiKey",
|
|
||||||
"SsoConfig",
|
|
||||||
"AlertRule",
|
|
||||||
"AlertInstance",
|
|
||||||
"TestRoundHistory",
|
|
||||||
"ProcedureSuggestion",
|
|
||||||
"TemplateSuggestion",
|
|
||||||
"PasswordSetupToken",
|
|
||||||
"EvaluationImport",
|
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -1,81 +0,0 @@
|
|||||||
"""Phase 14: API Key model for programmatic access."""
|
|
||||||
|
|
||||||
import hashlib
|
|
||||||
import secrets
|
|
||||||
import uuid
|
|
||||||
from datetime import datetime
|
|
||||||
|
|
||||||
from sqlalchemy import Boolean, Column, DateTime, ForeignKey, Index, String, Text
|
|
||||||
from sqlalchemy.dialects.postgresql import JSONB, UUID
|
|
||||||
from sqlalchemy.orm import relationship
|
|
||||||
|
|
||||||
from app.database import Base
|
|
||||||
|
|
||||||
# ── Key generation constants ──────────────────────────────────────────────────
|
|
||||||
KEY_PREFIX = "aegis_"
|
|
||||||
KEY_BYTES = 32 # 32 random bytes → 64 hex chars → 70-char key total
|
|
||||||
DISPLAY_LEN = 12 # chars stored as prefix for UI display
|
|
||||||
|
|
||||||
|
|
||||||
def generate_raw_key() -> str:
|
|
||||||
"""Generate a fresh raw API key (must be shown to user only once)."""
|
|
||||||
return KEY_PREFIX + secrets.token_hex(KEY_BYTES)
|
|
||||||
|
|
||||||
|
|
||||||
def hash_key(raw_key: str) -> str:
|
|
||||||
"""SHA-256 hash of a raw API key for secure storage."""
|
|
||||||
return hashlib.sha256(raw_key.encode()).hexdigest()
|
|
||||||
|
|
||||||
|
|
||||||
def key_prefix_display(raw_key: str) -> str:
|
|
||||||
"""First DISPLAY_LEN characters of the raw key (safe for UI)."""
|
|
||||||
return raw_key[:DISPLAY_LEN]
|
|
||||||
|
|
||||||
|
|
||||||
# ── Valid scopes ──────────────────────────────────────────────────────────────
|
|
||||||
VALID_SCOPES = {"read", "write", "admin"}
|
|
||||||
|
|
||||||
|
|
||||||
class ApiKey(Base):
|
|
||||||
"""
|
|
||||||
Scoped API key for programmatic / BI / SOAR access.
|
|
||||||
|
|
||||||
The full raw key is **never stored** — only a SHA-256 hash.
|
|
||||||
The first 12 characters (``key_prefix``) are retained for display.
|
|
||||||
"""
|
|
||||||
|
|
||||||
__tablename__ = "api_keys"
|
|
||||||
|
|
||||||
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
|
||||||
name = Column(String(200), nullable=False)
|
|
||||||
description = Column(Text, nullable=True)
|
|
||||||
|
|
||||||
# Display only — never use for auth
|
|
||||||
key_prefix = Column(String(DISPLAY_LEN + 1), nullable=False)
|
|
||||||
|
|
||||||
# Auth token — SHA-256 of the full raw key
|
|
||||||
key_hash = Column(String(64), nullable=False, unique=True)
|
|
||||||
|
|
||||||
# Owner
|
|
||||||
user_id = Column(
|
|
||||||
UUID(as_uuid=True),
|
|
||||||
ForeignKey("users.id", ondelete="CASCADE"),
|
|
||||||
nullable=False,
|
|
||||||
)
|
|
||||||
|
|
||||||
# Permissions
|
|
||||||
scopes = Column(JSONB, nullable=False, default=["read"]) # ["read","write","admin"]
|
|
||||||
|
|
||||||
# Lifecycle
|
|
||||||
last_used_at = Column(DateTime, nullable=True)
|
|
||||||
expires_at = Column(DateTime, nullable=True)
|
|
||||||
is_active = Column(Boolean, nullable=False, default=True)
|
|
||||||
created_at = Column(DateTime, default=datetime.utcnow)
|
|
||||||
|
|
||||||
user = relationship("User", foreign_keys=[user_id])
|
|
||||||
|
|
||||||
__table_args__ = (
|
|
||||||
Index("ix_api_keys_user_id", "user_id"),
|
|
||||||
Index("ix_api_keys_key_hash", "key_hash"),
|
|
||||||
Index("ix_api_keys_active", "is_active"),
|
|
||||||
)
|
|
||||||
@@ -1,253 +0,0 @@
|
|||||||
"""Phase 10: Attack Paths & Advanced Purple Team models."""
|
|
||||||
|
|
||||||
import enum
|
|
||||||
import uuid
|
|
||||||
from datetime import datetime
|
|
||||||
|
|
||||||
from sqlalchemy import (
|
|
||||||
Boolean, Column, DateTime, Enum, Float, ForeignKey,
|
|
||||||
Index, Integer, String, Text,
|
|
||||||
)
|
|
||||||
from sqlalchemy.dialects.postgresql import UUID, JSONB
|
|
||||||
from sqlalchemy.orm import relationship
|
|
||||||
|
|
||||||
from app.database import Base
|
|
||||||
|
|
||||||
|
|
||||||
class ExecutionStatus(str, enum.Enum):
|
|
||||||
planned = "planned"
|
|
||||||
in_progress = "in_progress"
|
|
||||||
completed = "completed"
|
|
||||||
aborted = "aborted"
|
|
||||||
|
|
||||||
|
|
||||||
class StepResultStatus(str, enum.Enum):
|
|
||||||
pending = "pending"
|
|
||||||
executing = "executing"
|
|
||||||
detected = "detected"
|
|
||||||
not_detected = "not_detected"
|
|
||||||
skipped = "skipped"
|
|
||||||
|
|
||||||
|
|
||||||
class TimelineActorSide(str, enum.Enum):
|
|
||||||
red = "red"
|
|
||||||
blue = "blue"
|
|
||||||
system = "system"
|
|
||||||
|
|
||||||
|
|
||||||
class TimelineEntryType(str, enum.Enum):
|
|
||||||
action = "action"
|
|
||||||
detection = "detection"
|
|
||||||
note = "note"
|
|
||||||
phase_transition = "phase_transition"
|
|
||||||
flag = "flag"
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
class AttackPath(Base):
|
|
||||||
"""
|
|
||||||
A reusable attack scenario composed of ordered kill-chain steps.
|
|
||||||
Can be a template (shared) or a one-off scenario.
|
|
||||||
"""
|
|
||||||
__tablename__ = "attack_paths"
|
|
||||||
|
|
||||||
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
|
||||||
name = Column(String(300), nullable=False)
|
|
||||||
description = Column(Text, nullable=True)
|
|
||||||
objective = Column(Text, nullable=True) # what the attacker aims to achieve
|
|
||||||
is_template = Column(Boolean, default=False) # reusable template flag
|
|
||||||
threat_actor_id = Column(
|
|
||||||
UUID(as_uuid=True), ForeignKey("threat_actors.id", ondelete="SET NULL"), nullable=True
|
|
||||||
)
|
|
||||||
created_by = Column(
|
|
||||||
UUID(as_uuid=True), ForeignKey("users.id", ondelete="SET NULL"), nullable=True
|
|
||||||
)
|
|
||||||
tags = Column(JSONB, nullable=True, default=list)
|
|
||||||
is_active = Column(Boolean, default=True)
|
|
||||||
created_at = Column(DateTime, default=datetime.utcnow)
|
|
||||||
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
|
||||||
|
|
||||||
steps = relationship(
|
|
||||||
"AttackPathStep", back_populates="attack_path",
|
|
||||||
cascade="all, delete-orphan",
|
|
||||||
order_by="AttackPathStep.order_index",
|
|
||||||
)
|
|
||||||
executions = relationship("AttackPathExecution", back_populates="attack_path")
|
|
||||||
creator = relationship("User", foreign_keys=[created_by])
|
|
||||||
threat_actor = relationship("ThreatActor", foreign_keys=[threat_actor_id])
|
|
||||||
|
|
||||||
__table_args__ = (
|
|
||||||
Index("ix_attack_paths_created_by", "created_by"),
|
|
||||||
Index("ix_attack_paths_is_template", "is_template"),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class AttackPathStep(Base):
|
|
||||||
"""One step in an attack path — maps to a kill-chain phase + technique."""
|
|
||||||
|
|
||||||
__tablename__ = "attack_path_steps"
|
|
||||||
|
|
||||||
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
|
||||||
attack_path_id = Column(
|
|
||||||
UUID(as_uuid=True), ForeignKey("attack_paths.id", ondelete="CASCADE"), nullable=False
|
|
||||||
)
|
|
||||||
order_index = Column(Integer, nullable=False, default=0)
|
|
||||||
kill_chain_phase = Column(String(60), nullable=True) # initial_access, execution, …
|
|
||||||
technique_id = Column(
|
|
||||||
UUID(as_uuid=True), ForeignKey("techniques.id", ondelete="SET NULL"), nullable=True
|
|
||||||
)
|
|
||||||
test_id = Column(
|
|
||||||
UUID(as_uuid=True), ForeignKey("tests.id", ondelete="SET NULL"), nullable=True
|
|
||||||
)
|
|
||||||
name = Column(String(300), nullable=True) # human label for the step
|
|
||||||
description = Column(Text, nullable=True)
|
|
||||||
expected_detection = Column(Boolean, default=True) # do we expect blue to detect this?
|
|
||||||
notes = Column(Text, nullable=True)
|
|
||||||
|
|
||||||
attack_path = relationship("AttackPath", back_populates="steps")
|
|
||||||
technique = relationship("Technique", foreign_keys=[technique_id])
|
|
||||||
test = relationship("Test", foreign_keys=[test_id])
|
|
||||||
|
|
||||||
__table_args__ = (
|
|
||||||
Index("ix_ap_steps_path_id", "attack_path_id"),
|
|
||||||
Index("ix_ap_steps_technique_id", "technique_id"),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class AttackPathExecution(Base):
|
|
||||||
"""
|
|
||||||
A single run of an attack path.
|
|
||||||
Tracks Red/Blue participants, timing, and aggregated kill-chain metrics.
|
|
||||||
"""
|
|
||||||
__tablename__ = "attack_path_executions"
|
|
||||||
|
|
||||||
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
|
||||||
attack_path_id = Column(
|
|
||||||
UUID(as_uuid=True), ForeignKey("attack_paths.id", ondelete="CASCADE"), nullable=False
|
|
||||||
)
|
|
||||||
status = Column(
|
|
||||||
Enum(ExecutionStatus, name="execution_status"), nullable=False,
|
|
||||||
default=ExecutionStatus.planned,
|
|
||||||
)
|
|
||||||
environment = Column(String(100), nullable=True) # prod, staging, lab
|
|
||||||
red_team_lead = Column(
|
|
||||||
UUID(as_uuid=True), ForeignKey("users.id", ondelete="SET NULL"), nullable=True
|
|
||||||
)
|
|
||||||
blue_team_lead = Column(
|
|
||||||
UUID(as_uuid=True), ForeignKey("users.id", ondelete="SET NULL"), nullable=True
|
|
||||||
)
|
|
||||||
started_by = Column(
|
|
||||||
UUID(as_uuid=True), ForeignKey("users.id", ondelete="SET NULL"), nullable=True
|
|
||||||
)
|
|
||||||
started_at = Column(DateTime, nullable=True)
|
|
||||||
completed_at = Column(DateTime, nullable=True)
|
|
||||||
notes = Column(Text, nullable=True)
|
|
||||||
created_at = Column(DateTime, default=datetime.utcnow)
|
|
||||||
|
|
||||||
# ── Computed kill-chain metrics (written on complete) ─────────────────
|
|
||||||
total_steps = Column(Integer, nullable=True)
|
|
||||||
detected_steps = Column(Integer, nullable=True)
|
|
||||||
not_detected_steps = Column(Integer, nullable=True)
|
|
||||||
skipped_steps = Column(Integer, nullable=True)
|
|
||||||
detection_rate = Column(Float, nullable=True) # 0.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,15 +73,8 @@ class Campaign(Base):
|
|||||||
# Keyword argument: nullable
|
# Keyword argument: nullable
|
||||||
nullable=True,
|
nullable=True,
|
||||||
)
|
)
|
||||||
start_date = Column(DateTime, nullable=True) # campaign won't activate before this date
|
# Assign scheduled_at = Column(DateTime, nullable=True)
|
||||||
scheduled_at = Column(DateTime, nullable=True)
|
scheduled_at = Column(DateTime, nullable=True)
|
||||||
approved_by = Column(
|
|
||||||
UUID(as_uuid=True),
|
|
||||||
ForeignKey("users.id", ondelete="SET NULL"),
|
|
||||||
nullable=True,
|
|
||||||
)
|
|
||||||
approved_at = Column(DateTime, nullable=True)
|
|
||||||
rejection_reason = Column(Text, nullable=True)
|
|
||||||
# Assign completed_at = Column(DateTime, nullable=True)
|
# Assign completed_at = Column(DateTime, nullable=True)
|
||||||
completed_at = Column(DateTime, nullable=True)
|
completed_at = Column(DateTime, nullable=True)
|
||||||
# Assign target_platform = Column(String, nullable=True)
|
# Assign target_platform = Column(String, nullable=True)
|
||||||
@@ -90,8 +83,8 @@ class Campaign(Base):
|
|||||||
tags = Column(JSONB, nullable=True, default=[])
|
tags = Column(JSONB, nullable=True, default=[])
|
||||||
# Assign created_at = Column(DateTime(timezone=True), server_default=func.now())
|
# Assign created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||||
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||||
# Assign data_classification = Column(String(20), nullable=False, server_default="internal_use_only")
|
# Assign data_classification = Column(String(20), nullable=False, server_default="internal")
|
||||||
data_classification = Column(String(20), nullable=False, server_default="internal_use_only")
|
data_classification = Column(String(20), nullable=False, server_default="internal")
|
||||||
|
|
||||||
# Recurring scheduling fields
|
# Recurring scheduling fields
|
||||||
is_recurring = Column(Boolean, default=False)
|
is_recurring = Column(Boolean, default=False)
|
||||||
@@ -236,57 +229,3 @@ class CampaignTest(Base):
|
|||||||
Index('ix_campaign_tests_campaign', 'campaign_id'),
|
Index('ix_campaign_tests_campaign', 'campaign_id'),
|
||||||
Index('ix_campaign_tests_test', 'test_id'),
|
Index('ix_campaign_tests_test', 'test_id'),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
class CampaignModificationRequest(Base):
|
|
||||||
"""A lead's request to add or remove a test from an already-active campaign.
|
|
||||||
|
|
||||||
The underlying ``CampaignTest`` row is only created/deleted once a
|
|
||||||
manager approves the request — see ``approve_modification_request``
|
|
||||||
in ``campaign_crud_service.py``.
|
|
||||||
"""
|
|
||||||
__tablename__ = "campaign_modification_requests"
|
|
||||||
|
|
||||||
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
|
||||||
campaign_id = Column(
|
|
||||||
UUID(as_uuid=True),
|
|
||||||
ForeignKey("campaigns.id", ondelete="CASCADE"),
|
|
||||||
nullable=False,
|
|
||||||
)
|
|
||||||
requested_by = Column(
|
|
||||||
UUID(as_uuid=True),
|
|
||||||
ForeignKey("users.id", ondelete="SET NULL"),
|
|
||||||
nullable=True,
|
|
||||||
)
|
|
||||||
action = Column(String, nullable=False) # add_test, remove_test
|
|
||||||
# SET NULL (not CASCADE): approving a "remove_test" request deletes the
|
|
||||||
# underlying Test row (see remove_test_from_campaign), and this request
|
|
||||||
# row is the audit record of that decision — it must survive the test's
|
|
||||||
# deletion, not be wiped out along with it.
|
|
||||||
test_id = Column(
|
|
||||||
UUID(as_uuid=True),
|
|
||||||
ForeignKey("tests.id", ondelete="SET NULL"),
|
|
||||||
nullable=True,
|
|
||||||
)
|
|
||||||
order_index = Column(Integer, nullable=True)
|
|
||||||
phase = Column(String, nullable=True)
|
|
||||||
justification = Column(Text, nullable=False)
|
|
||||||
status = Column(String, nullable=False, default="pending") # pending, approved, rejected
|
|
||||||
reviewed_by = Column(
|
|
||||||
UUID(as_uuid=True),
|
|
||||||
ForeignKey("users.id", ondelete="SET NULL"),
|
|
||||||
nullable=True,
|
|
||||||
)
|
|
||||||
reviewed_at = Column(DateTime, nullable=True)
|
|
||||||
review_notes = Column(Text, nullable=True)
|
|
||||||
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
|
||||||
|
|
||||||
campaign = relationship("Campaign")
|
|
||||||
test = relationship("Test")
|
|
||||||
requester = relationship("User", foreign_keys=[requested_by])
|
|
||||||
reviewer = relationship("User", foreign_keys=[reviewed_by])
|
|
||||||
|
|
||||||
__table_args__ = (
|
|
||||||
Index("ix_campaign_mod_requests_campaign", "campaign_id"),
|
|
||||||
Index("ix_campaign_mod_requests_status", "status"),
|
|
||||||
)
|
|
||||||
|
|||||||
@@ -1,32 +0,0 @@
|
|||||||
"""Decay Policy model — configurable detection validity rules."""
|
|
||||||
|
|
||||||
import uuid
|
|
||||||
from datetime import datetime
|
|
||||||
from sqlalchemy import Column, String, Integer, Float, Boolean, DateTime, Text
|
|
||||||
from sqlalchemy.dialects.postgresql import UUID
|
|
||||||
from app.database import Base
|
|
||||||
|
|
||||||
|
|
||||||
class DecayPolicy(Base):
|
|
||||||
__tablename__ = "decay_policies"
|
|
||||||
|
|
||||||
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
|
||||||
name = Column(String(200), nullable=False)
|
|
||||||
description = Column(Text)
|
|
||||||
applies_to_platform = Column(String(100))
|
|
||||||
applies_to_asset_type = Column(String(50))
|
|
||||||
applies_to_tactic = Column(String(100))
|
|
||||||
fresh_days = Column(Integer, default=90, server_default='90')
|
|
||||||
aging_days = Column(Integer, default=180, server_default='180')
|
|
||||||
stale_days = Column(Integer, default=365, server_default='365')
|
|
||||||
default_validity_days = Column(Integer, default=180, server_default='180')
|
|
||||||
silent_threshold_days = Column(Integer, default=30, server_default='30')
|
|
||||||
noisy_threshold_daily = Column(Integer, default=100, server_default='100')
|
|
||||||
recency_weight = Column(Float, default=0.3, server_default='0.3')
|
|
||||||
coverage_weight = Column(Float, default=0.3, server_default='0.3')
|
|
||||||
health_weight = Column(Float, default=0.25, server_default='0.25')
|
|
||||||
diversity_weight = Column(Float, default=0.15, server_default='0.15')
|
|
||||||
is_default = Column(Boolean, default=False, server_default='false')
|
|
||||||
is_active = Column(Boolean, default=True, server_default='true')
|
|
||||||
created_at = Column(DateTime, default=datetime.utcnow)
|
|
||||||
updated_at = Column(DateTime, default=datetime.utcnow)
|
|
||||||
@@ -1,168 +0,0 @@
|
|||||||
"""Detection Lifecycle Management models."""
|
|
||||||
|
|
||||||
import uuid
|
|
||||||
import enum
|
|
||||||
from datetime import datetime
|
|
||||||
from sqlalchemy import (
|
|
||||||
Column, String, Integer, Float, Boolean, DateTime,
|
|
||||||
ForeignKey, Text, Enum as SQLEnum
|
|
||||||
)
|
|
||||||
from sqlalchemy.dialects.postgresql import UUID, JSONB
|
|
||||||
from sqlalchemy.orm import relationship
|
|
||||||
from app.database import Base
|
|
||||||
|
|
||||||
|
|
||||||
class DetectionConfidence(str, enum.Enum):
|
|
||||||
fresh = "fresh"
|
|
||||||
aging = "aging"
|
|
||||||
stale = "stale"
|
|
||||||
broken = "broken"
|
|
||||||
unknown = "unknown"
|
|
||||||
|
|
||||||
|
|
||||||
class DetectionHealthStatus(str, enum.Enum):
|
|
||||||
healthy = "healthy"
|
|
||||||
silent = "silent"
|
|
||||||
noisy = "noisy"
|
|
||||||
orphan = "orphan"
|
|
||||||
deprecated = "deprecated"
|
|
||||||
untested = "untested"
|
|
||||||
|
|
||||||
|
|
||||||
class InvalidationReason(str, enum.Enum):
|
|
||||||
time_decay = "time_decay"
|
|
||||||
mitre_update = "mitre_update"
|
|
||||||
log_source_change = "log_source_change"
|
|
||||||
siem_update = "siem_update"
|
|
||||||
edr_update = "edr_update"
|
|
||||||
infrastructure_change = "infrastructure_change"
|
|
||||||
parser_change = "parser_change"
|
|
||||||
manual = "manual"
|
|
||||||
rule_modified = "rule_modified"
|
|
||||||
|
|
||||||
|
|
||||||
class DetectionAsset(Base):
|
|
||||||
__tablename__ = "detection_assets"
|
|
||||||
|
|
||||||
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
|
||||||
name = Column(String(500), nullable=False)
|
|
||||||
description = Column(Text)
|
|
||||||
asset_type = Column(String(50), nullable=False)
|
|
||||||
platform = Column(String(100))
|
|
||||||
rule_content = Column(Text)
|
|
||||||
rule_language = Column(String(50))
|
|
||||||
rule_repository_url = Column(Text)
|
|
||||||
rule_file_path = Column(String(500))
|
|
||||||
rule_version = Column(String(50))
|
|
||||||
rule_hash = Column(String(64))
|
|
||||||
last_rule_change_at = Column(DateTime)
|
|
||||||
log_source_name = Column(String(200))
|
|
||||||
log_source_version = Column(String(50))
|
|
||||||
log_source_config = Column(JSONB, server_default='{}')
|
|
||||||
infrastructure_hash = Column(String(64))
|
|
||||||
infrastructure_details = Column(JSONB, server_default='{}')
|
|
||||||
health_status = Column(
|
|
||||||
SQLEnum(DetectionHealthStatus, name="detectionhealthstatus"),
|
|
||||||
default=DetectionHealthStatus.untested,
|
|
||||||
nullable=False,
|
|
||||||
server_default="untested",
|
|
||||||
)
|
|
||||||
last_alert_at = Column(DateTime)
|
|
||||||
alert_count_30d = Column(Integer, default=0, server_default='0')
|
|
||||||
false_positive_rate = Column(Float)
|
|
||||||
expected_alert_frequency = Column(String(50))
|
|
||||||
owner_id = Column(UUID(as_uuid=True), ForeignKey("users.id", ondelete="SET NULL"), nullable=True)
|
|
||||||
backup_owner_id = Column(UUID(as_uuid=True), ForeignKey("users.id", ondelete="SET NULL"), nullable=True)
|
|
||||||
team = Column(String(100))
|
|
||||||
is_active = Column(Boolean, default=True, nullable=False, server_default='true')
|
|
||||||
tags = Column(JSONB, server_default='[]')
|
|
||||||
asset_metadata = Column(JSONB, server_default='{}')
|
|
||||||
created_by = Column(UUID(as_uuid=True), ForeignKey("users.id", ondelete="SET NULL"), nullable=True)
|
|
||||||
created_at = Column(DateTime(timezone=True), server_default='now()')
|
|
||||||
updated_at = Column(DateTime(timezone=True), server_default='now()')
|
|
||||||
|
|
||||||
technique_mappings = relationship("DetectionTechniqueMapping", back_populates="detection_asset", cascade="all, delete-orphan")
|
|
||||||
validations = relationship("DetectionValidation", back_populates="detection_asset", cascade="all, delete-orphan")
|
|
||||||
|
|
||||||
|
|
||||||
class DetectionTechniqueMapping(Base):
|
|
||||||
__tablename__ = "detection_technique_mappings"
|
|
||||||
|
|
||||||
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
|
||||||
detection_asset_id = Column(UUID(as_uuid=True), ForeignKey("detection_assets.id", ondelete="CASCADE"), nullable=False)
|
|
||||||
technique_id = Column(UUID(as_uuid=True), ForeignKey("techniques.id", ondelete="CASCADE"), nullable=False)
|
|
||||||
coverage_type = Column(String(50), default="detect", server_default="detect")
|
|
||||||
confidence_level = Column(String(20), default="medium", server_default="medium")
|
|
||||||
notes = Column(Text)
|
|
||||||
created_at = Column(DateTime(timezone=True), server_default='now()')
|
|
||||||
|
|
||||||
detection_asset = relationship("DetectionAsset", back_populates="technique_mappings")
|
|
||||||
|
|
||||||
|
|
||||||
class DetectionValidation(Base):
|
|
||||||
__tablename__ = "detection_validations"
|
|
||||||
|
|
||||||
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
|
||||||
detection_asset_id = Column(UUID(as_uuid=True), ForeignKey("detection_assets.id", ondelete="CASCADE"), nullable=False)
|
|
||||||
technique_id = Column(UUID(as_uuid=True), ForeignKey("techniques.id", ondelete="SET NULL"), nullable=True)
|
|
||||||
test_id = Column(UUID(as_uuid=True), ForeignKey("tests.id", ondelete="SET NULL"), nullable=True)
|
|
||||||
validated_at = Column(DateTime, default=datetime.utcnow)
|
|
||||||
expires_at = Column(DateTime, nullable=False)
|
|
||||||
is_valid = Column(Boolean, default=True, nullable=False, server_default='true')
|
|
||||||
validation_result = Column(String(50))
|
|
||||||
validation_method = Column(String(100))
|
|
||||||
rule_hash_at_validation = Column(String(64))
|
|
||||||
log_source_version_at_validation = Column(String(50))
|
|
||||||
infrastructure_hash_at_validation = Column(String(64))
|
|
||||||
environment_snapshot = Column(JSONB, server_default='{}')
|
|
||||||
invalidated_at = Column(DateTime)
|
|
||||||
invalidation_reason = Column(SQLEnum(InvalidationReason, name="invalidationreason"), nullable=True)
|
|
||||||
invalidation_details = Column(Text)
|
|
||||||
invalidated_by = Column(UUID(as_uuid=True), ForeignKey("users.id", ondelete="SET NULL"), nullable=True)
|
|
||||||
validated_by = Column(UUID(as_uuid=True), ForeignKey("users.id", ondelete="SET NULL"), nullable=False)
|
|
||||||
integrity_hash = Column(String(64))
|
|
||||||
notes = Column(Text)
|
|
||||||
evidence_ids = Column(JSONB, server_default='[]')
|
|
||||||
|
|
||||||
detection_asset = relationship("DetectionAsset", back_populates="validations")
|
|
||||||
|
|
||||||
|
|
||||||
class TechniqueConfidenceScore(Base):
|
|
||||||
__tablename__ = "technique_confidence_scores"
|
|
||||||
|
|
||||||
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
|
||||||
technique_id = Column(UUID(as_uuid=True), ForeignKey("techniques.id", ondelete="CASCADE"), nullable=False, unique=True)
|
|
||||||
confidence_level = Column(
|
|
||||||
SQLEnum(DetectionConfidence, name="detectionconfidence"),
|
|
||||||
default=DetectionConfidence.unknown,
|
|
||||||
server_default="unknown",
|
|
||||||
)
|
|
||||||
confidence_score = Column(Float, default=0.0, server_default='0.0')
|
|
||||||
detection_count = Column(Integer, default=0, server_default='0')
|
|
||||||
valid_detection_count = Column(Integer, default=0, server_default='0')
|
|
||||||
last_validated_at = Column(DateTime)
|
|
||||||
next_validation_due = Column(DateTime)
|
|
||||||
last_recalculated_at = Column(DateTime, default=datetime.utcnow)
|
|
||||||
recency_factor = Column(Float, default=0.0, server_default='0.0')
|
|
||||||
coverage_factor = Column(Float, default=0.0, server_default='0.0')
|
|
||||||
health_factor = Column(Float, default=0.0, server_default='0.0')
|
|
||||||
diversity_factor = Column(Float, default=0.0, server_default='0.0')
|
|
||||||
score_breakdown = Column(JSONB, server_default='{}')
|
|
||||||
risk_factors = Column(JSONB, server_default='[]')
|
|
||||||
updated_at = Column(DateTime, default=datetime.utcnow)
|
|
||||||
|
|
||||||
|
|
||||||
class InfrastructureChangeLog(Base):
|
|
||||||
__tablename__ = "infrastructure_change_logs"
|
|
||||||
|
|
||||||
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
|
||||||
change_type = Column(String(100), nullable=False)
|
|
||||||
description = Column(Text, nullable=False)
|
|
||||||
affected_platforms = Column(JSONB, server_default='[]')
|
|
||||||
affected_log_sources = Column(JSONB, server_default='[]')
|
|
||||||
change_date = Column(DateTime, default=datetime.utcnow)
|
|
||||||
reported_by = Column(UUID(as_uuid=True), ForeignKey("users.id", ondelete="SET NULL"), nullable=True)
|
|
||||||
auto_invalidate = Column(Boolean, default=True, server_default='true')
|
|
||||||
invalidated_count = Column(Integer, default=0, server_default='0')
|
|
||||||
change_metadata = Column(JSONB, server_default='{}')
|
|
||||||
created_at = Column(DateTime, default=datetime.utcnow)
|
|
||||||
@@ -7,8 +7,6 @@ working with ``from app.models.enums import ...``.
|
|||||||
|
|
||||||
# Import # noqa: F401 from app.domain.enums
|
# Import # noqa: F401 from app.domain.enums
|
||||||
from app.domain.enums import ( # noqa: F401
|
from app.domain.enums import ( # noqa: F401
|
||||||
AttackSuccessResult,
|
|
||||||
ContainmentResult,
|
|
||||||
DataClassification,
|
DataClassification,
|
||||||
TeamSide,
|
TeamSide,
|
||||||
TechniqueStatus,
|
TechniqueStatus,
|
||||||
|
|||||||
@@ -1,34 +0,0 @@
|
|||||||
"""SQLAlchemy model for tracking imported ATT&CK Evaluation rounds."""
|
|
||||||
import uuid
|
|
||||||
from datetime import datetime
|
|
||||||
|
|
||||||
from sqlalchemy import Column, String, Integer, DateTime, Text, ForeignKey, Index
|
|
||||||
from sqlalchemy.dialects.postgresql import UUID
|
|
||||||
|
|
||||||
from app.database import Base
|
|
||||||
|
|
||||||
|
|
||||||
class EvaluationImport(Base):
|
|
||||||
"""Tracks which ATT&CK Evaluation rounds have been imported into the platform.
|
|
||||||
|
|
||||||
Each row represents one vendor+adversary combination that has been processed
|
|
||||||
and turned into Test records. Used to avoid duplicate imports and to show
|
|
||||||
the admin panel which rounds are available vs imported.
|
|
||||||
"""
|
|
||||||
__tablename__ = "evaluation_imports"
|
|
||||||
|
|
||||||
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
|
||||||
adversary_name = Column(String, nullable=False) # "apt29", "turla"
|
|
||||||
adversary_display = Column(String, nullable=False) # "APT29", "Turla"
|
|
||||||
eval_round = Column(Integer, nullable=False) # 1, 2, 3 …
|
|
||||||
imported_at = Column(DateTime, nullable=False, default=datetime.utcnow)
|
|
||||||
imported_by = Column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=True)
|
|
||||||
tests_created = Column(Integer, default=0)
|
|
||||||
techniques_covered = Column(Integer, default=0)
|
|
||||||
status = Column(String, default="completed") # "completed" | "failed"
|
|
||||||
notes = Column(Text, nullable=True)
|
|
||||||
|
|
||||||
__table_args__ = (
|
|
||||||
Index("ix_evaluation_imports_adversary", "adversary_name"),
|
|
||||||
Index("ix_evaluation_imports_round", "eval_round"),
|
|
||||||
)
|
|
||||||
@@ -50,8 +50,8 @@ class Evidence(Base):
|
|||||||
team = Column(Enum(TeamSide, name="teamside"), nullable=False, default=TeamSide.red)
|
team = Column(Enum(TeamSide, name="teamside"), nullable=False, default=TeamSide.red)
|
||||||
# Assign notes = Column(Text, nullable=True)
|
# Assign notes = Column(Text, nullable=True)
|
||||||
notes = Column(Text, nullable=True)
|
notes = Column(Text, nullable=True)
|
||||||
# Assign data_classification = Column(String(20), nullable=False, server_default="internal_use_only")
|
# Assign data_classification = Column(String(20), nullable=False, server_default="internal")
|
||||||
data_classification = Column(String(20), nullable=False, server_default="internal_use_only")
|
data_classification = Column(String(20), nullable=False, server_default="internal")
|
||||||
|
|
||||||
# Relationships
|
# Relationships
|
||||||
test = relationship("Test", back_populates="evidences")
|
test = relationship("Test", back_populates="evidences")
|
||||||
|
|||||||
@@ -1,68 +0,0 @@
|
|||||||
"""Phase 13: Executive Dashboard — PostureSnapshot model."""
|
|
||||||
|
|
||||||
import uuid
|
|
||||||
from datetime import datetime
|
|
||||||
|
|
||||||
from sqlalchemy import (
|
|
||||||
Column, Date, DateTime, Float, ForeignKey,
|
|
||||||
Index, Integer, UniqueConstraint,
|
|
||||||
)
|
|
||||||
from sqlalchemy.dialects.postgresql import UUID, JSONB
|
|
||||||
from sqlalchemy.orm import relationship
|
|
||||||
|
|
||||||
from app.database import Base
|
|
||||||
|
|
||||||
|
|
||||||
class PostureSnapshot(Base):
|
|
||||||
"""
|
|
||||||
Daily point-in-time capture of the organisation's security posture.
|
|
||||||
|
|
||||||
Aggregates data from all phases (coverage, risk, ownership, knowledge,
|
|
||||||
attack-paths) into a single row that can be trended over time.
|
|
||||||
"""
|
|
||||||
|
|
||||||
__tablename__ = "posture_snapshots"
|
|
||||||
|
|
||||||
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
|
||||||
snapshot_date = Column(Date, nullable=False) # one per calendar day
|
|
||||||
|
|
||||||
# ── Coverage ──────────────────────────────────────────────────────────────
|
|
||||||
total_techniques = Column(Integer, nullable=False, default=0)
|
|
||||||
validated_count = Column(Integer, nullable=False, default=0)
|
|
||||||
partial_count = Column(Integer, nullable=False, default=0)
|
|
||||||
not_covered_count = Column(Integer, nullable=False, default=0)
|
|
||||||
coverage_pct = Column(Float, nullable=False, default=0.0) # 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"),
|
|
||||||
)
|
|
||||||
@@ -1,129 +0,0 @@
|
|||||||
"""Phase 11: Knowledge Management models — Playbooks + Lessons Learned."""
|
|
||||||
|
|
||||||
import uuid
|
|
||||||
from datetime import datetime
|
|
||||||
|
|
||||||
from sqlalchemy import (
|
|
||||||
Boolean, Column, DateTime, ForeignKey,
|
|
||||||
Index, Integer, String, Text, UniqueConstraint,
|
|
||||||
)
|
|
||||||
from sqlalchemy.dialects.postgresql import UUID, JSONB
|
|
||||||
from sqlalchemy.orm import relationship
|
|
||||||
|
|
||||||
from app.database import Base
|
|
||||||
|
|
||||||
|
|
||||||
# ── Playbooks ──────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
class Playbook(Base):
|
|
||||||
"""
|
|
||||||
Structured runbook for a specific technique and playbook type.
|
|
||||||
|
|
||||||
playbook_type: attack | detect | investigate | respond | hunt
|
|
||||||
One playbook per (technique, type). Edits increment ``version``
|
|
||||||
and save a snapshot to ``PlaybookVersion``.
|
|
||||||
"""
|
|
||||||
__tablename__ = "playbooks"
|
|
||||||
|
|
||||||
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
|
||||||
technique_id = Column(
|
|
||||||
UUID(as_uuid=True), ForeignKey("techniques.id", ondelete="CASCADE"), nullable=False
|
|
||||||
)
|
|
||||||
playbook_type = Column(String(32), nullable=False) # attack/detect/investigate/respond/hunt
|
|
||||||
title = Column(String(255), nullable=False)
|
|
||||||
content = Column(Text, nullable=False, default="")
|
|
||||||
version = Column(Integer, default=1, nullable=False)
|
|
||||||
tools = Column(JSONB, default=list) # list of tool name strings
|
|
||||||
prerequisites = Column(JSONB, default=list) # list of prerequisite strings
|
|
||||||
created_by = Column(
|
|
||||||
UUID(as_uuid=True), ForeignKey("users.id", ondelete="SET NULL"), nullable=True
|
|
||||||
)
|
|
||||||
updated_by = Column(
|
|
||||||
UUID(as_uuid=True), ForeignKey("users.id", ondelete="SET NULL"), nullable=True
|
|
||||||
)
|
|
||||||
created_at = Column(DateTime, default=datetime.utcnow)
|
|
||||||
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
|
||||||
is_active = Column(Boolean, default=True)
|
|
||||||
|
|
||||||
# Relationships
|
|
||||||
technique = relationship("Technique", foreign_keys=[technique_id])
|
|
||||||
creator = relationship("User", foreign_keys=[created_by])
|
|
||||||
updater = relationship("User", foreign_keys=[updated_by])
|
|
||||||
versions = relationship(
|
|
||||||
"PlaybookVersion", back_populates="playbook",
|
|
||||||
cascade="all, delete-orphan",
|
|
||||||
order_by="PlaybookVersion.version.desc()",
|
|
||||||
)
|
|
||||||
|
|
||||||
__table_args__ = (
|
|
||||||
UniqueConstraint("technique_id", "playbook_type", name="uq_playbook_technique_type"),
|
|
||||||
Index("ix_playbooks_technique_id", "technique_id"),
|
|
||||||
Index("ix_playbooks_type", "playbook_type"),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class PlaybookVersion(Base):
|
|
||||||
"""Immutable snapshot of a playbook at a given version number."""
|
|
||||||
|
|
||||||
__tablename__ = "playbook_versions"
|
|
||||||
|
|
||||||
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
|
||||||
playbook_id = Column(
|
|
||||||
UUID(as_uuid=True), ForeignKey("playbooks.id", ondelete="CASCADE"), nullable=False
|
|
||||||
)
|
|
||||||
version = Column(Integer, nullable=False)
|
|
||||||
title = Column(String(255), nullable=False)
|
|
||||||
content = Column(Text, nullable=False, default="")
|
|
||||||
tools = Column(JSONB, default=list)
|
|
||||||
prerequisites = Column(JSONB, default=list)
|
|
||||||
changed_by = Column(
|
|
||||||
UUID(as_uuid=True), ForeignKey("users.id", ondelete="SET NULL"), nullable=True
|
|
||||||
)
|
|
||||||
change_note = Column(String(500), nullable=True)
|
|
||||||
created_at = Column(DateTime, default=datetime.utcnow)
|
|
||||||
|
|
||||||
playbook = relationship("Playbook", back_populates="versions")
|
|
||||||
changer = relationship("User", foreign_keys=[changed_by])
|
|
||||||
|
|
||||||
__table_args__ = (
|
|
||||||
Index("ix_pb_versions_playbook_id", "playbook_id"),
|
|
||||||
Index("ix_pb_versions_version", "playbook_id", "version"),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
# ── Lessons Learned ────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
class LessonLearned(Base):
|
|
||||||
"""
|
|
||||||
Immutable post-mortem record linked to a test, campaign, attack-path or
|
|
||||||
created manually.
|
|
||||||
|
|
||||||
severity: critical | high | medium | low | info
|
|
||||||
entity_type: test | campaign | attack_path | manual
|
|
||||||
"""
|
|
||||||
__tablename__ = "lessons_learned"
|
|
||||||
|
|
||||||
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
|
||||||
title = Column(String(255), nullable=False)
|
|
||||||
what_happened = Column(Text, nullable=False, default="")
|
|
||||||
root_cause = Column(Text, nullable=False, default="")
|
|
||||||
fix_applied = Column(Text, nullable=True)
|
|
||||||
severity = Column(String(16), nullable=False, default="medium")
|
|
||||||
entity_type = Column(String(32), nullable=False, default="manual")
|
|
||||||
entity_id = Column(UUID(as_uuid=True), nullable=True)
|
|
||||||
technique_ids = Column(JSONB, default=list) # list of UUID strings
|
|
||||||
tags = Column(JSONB, default=list)
|
|
||||||
created_by = Column(
|
|
||||||
UUID(as_uuid=True), ForeignKey("users.id", ondelete="SET NULL"), nullable=True
|
|
||||||
)
|
|
||||||
created_at = Column(DateTime, default=datetime.utcnow)
|
|
||||||
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
|
||||||
is_active = Column(Boolean, default=True) # soft-delete (admin only)
|
|
||||||
|
|
||||||
creator = relationship("User", foreign_keys=[created_by])
|
|
||||||
|
|
||||||
__table_args__ = (
|
|
||||||
Index("ix_ll_entity", "entity_type", "entity_id"),
|
|
||||||
Index("ix_ll_severity", "severity"),
|
|
||||||
Index("ix_ll_created_by", "created_by"),
|
|
||||||
)
|
|
||||||
@@ -1,144 +0,0 @@
|
|||||||
"""Phase 13: Operational Alerts — AlertRule and AlertInstance models."""
|
|
||||||
|
|
||||||
import enum
|
|
||||||
import uuid
|
|
||||||
from datetime import datetime
|
|
||||||
|
|
||||||
from sqlalchemy import (
|
|
||||||
Boolean, Column, DateTime, ForeignKey,
|
|
||||||
Index, Integer, String, Text,
|
|
||||||
)
|
|
||||||
from sqlalchemy.dialects.postgresql import JSONB, UUID
|
|
||||||
from sqlalchemy.orm import relationship
|
|
||||||
|
|
||||||
from app.database import Base
|
|
||||||
|
|
||||||
|
|
||||||
# ── Enumerations ──────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
class AlertSeverity(str, enum.Enum):
|
|
||||||
critical = "critical"
|
|
||||||
high = "high"
|
|
||||||
medium = "medium"
|
|
||||||
low = "low"
|
|
||||||
info = "info"
|
|
||||||
|
|
||||||
|
|
||||||
class AlertStatus(str, enum.Enum):
|
|
||||||
open = "open"
|
|
||||||
acknowledged = "acknowledged"
|
|
||||||
resolved = "resolved"
|
|
||||||
dismissed = "dismissed"
|
|
||||||
|
|
||||||
|
|
||||||
class AlertRuleType(str, enum.Enum):
|
|
||||||
high_risk = "high_risk" # risk_score >= threshold
|
|
||||||
stale_technique = "stale_technique" # not validated in N days
|
|
||||||
coverage_regression = "coverage_regression" # coverage_pct dropped
|
|
||||||
low_coverage = "low_coverage" # coverage below min
|
|
||||||
expiry_wave = "expiry_wave" # many pending queue items
|
|
||||||
new_technique = "new_technique" # new MITRE techniques added
|
|
||||||
orphan_spike = "orphan_spike" # many unowned techniques
|
|
||||||
custom = "custom" # future extension placeholder
|
|
||||||
|
|
||||||
|
|
||||||
# ── AlertRule ─────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
class AlertRule(Base):
|
|
||||||
"""
|
|
||||||
Defines a condition that, when satisfied, fires an AlertInstance.
|
|
||||||
|
|
||||||
System rules (is_system=True) are seeded at startup and cannot be deleted.
|
|
||||||
Custom rules (is_system=False) can be created by admins.
|
|
||||||
"""
|
|
||||||
|
|
||||||
__tablename__ = "alert_rules"
|
|
||||||
|
|
||||||
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
|
||||||
name = Column(String(300), nullable=False)
|
|
||||||
description = Column(Text, nullable=True)
|
|
||||||
rule_type = Column(String(50), nullable=False)
|
|
||||||
severity = Column(String(20), nullable=False, default=AlertSeverity.medium.value)
|
|
||||||
is_enabled = Column(Boolean, nullable=False, default=True)
|
|
||||||
is_system = Column(Boolean, nullable=False, default=False) # seeded, not deletable
|
|
||||||
|
|
||||||
# Rule-specific thresholds/config (varies by rule_type)
|
|
||||||
config = Column(JSONB, nullable=False, default={})
|
|
||||||
|
|
||||||
# Delivery
|
|
||||||
notify_in_app = Column(Boolean, nullable=False, default=True)
|
|
||||||
notify_webhook = Column(Boolean, nullable=False, default=False)
|
|
||||||
webhook_id = Column(
|
|
||||||
UUID(as_uuid=True),
|
|
||||||
ForeignKey("webhook_configs.id", ondelete="SET NULL"),
|
|
||||||
nullable=True,
|
|
||||||
)
|
|
||||||
|
|
||||||
# Cooldown — don't re-fire within N hours of last firing
|
|
||||||
cooldown_hours = Column(Integer, nullable=False, default=24)
|
|
||||||
|
|
||||||
# Meta
|
|
||||||
created_by = Column(
|
|
||||||
UUID(as_uuid=True),
|
|
||||||
ForeignKey("users.id", ondelete="SET NULL"),
|
|
||||||
nullable=True,
|
|
||||||
)
|
|
||||||
created_at = Column(DateTime, default=datetime.utcnow)
|
|
||||||
last_fired_at = Column(DateTime, nullable=True)
|
|
||||||
|
|
||||||
creator = relationship("User", foreign_keys=[created_by])
|
|
||||||
instances = relationship("AlertInstance", back_populates="rule",
|
|
||||||
cascade="all, delete-orphan")
|
|
||||||
|
|
||||||
__table_args__ = (
|
|
||||||
Index("ix_alert_rules_type", "rule_type"),
|
|
||||||
Index("ix_alert_rules_enabled", "is_enabled"),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
# ── AlertInstance ─────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
class AlertInstance(Base):
|
|
||||||
"""
|
|
||||||
A single firing of an AlertRule.
|
|
||||||
|
|
||||||
Transitions: open → acknowledged → resolved
|
|
||||||
open → dismissed
|
|
||||||
"""
|
|
||||||
|
|
||||||
__tablename__ = "alert_instances"
|
|
||||||
|
|
||||||
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
|
||||||
rule_id = Column(
|
|
||||||
UUID(as_uuid=True),
|
|
||||||
ForeignKey("alert_rules.id", ondelete="SET NULL"),
|
|
||||||
nullable=True,
|
|
||||||
)
|
|
||||||
# Denormalised fields kept for history even after rule deletion
|
|
||||||
rule_name = Column(String(300), nullable=False)
|
|
||||||
rule_type = Column(String(50), nullable=False)
|
|
||||||
severity = Column(String(20), nullable=False)
|
|
||||||
|
|
||||||
title = Column(String(500), nullable=False)
|
|
||||||
message = Column(Text, nullable=False)
|
|
||||||
details = Column(JSONB, nullable=True) # structured context
|
|
||||||
|
|
||||||
status = Column(String(20), nullable=False, default=AlertStatus.open.value)
|
|
||||||
acknowledged_by = Column(
|
|
||||||
UUID(as_uuid=True),
|
|
||||||
ForeignKey("users.id", ondelete="SET NULL"),
|
|
||||||
nullable=True,
|
|
||||||
)
|
|
||||||
acknowledged_at = Column(DateTime, nullable=True)
|
|
||||||
resolved_at = Column(DateTime, nullable=True)
|
|
||||||
created_at = Column(DateTime, default=datetime.utcnow)
|
|
||||||
|
|
||||||
rule = relationship("AlertRule", back_populates="instances")
|
|
||||||
acknowledger = relationship("User", foreign_keys=[acknowledged_by])
|
|
||||||
|
|
||||||
__table_args__ = (
|
|
||||||
Index("ix_alert_instances_rule_id", "rule_id"),
|
|
||||||
Index("ix_alert_instances_status", "status"),
|
|
||||||
Index("ix_alert_instances_severity", "severity"),
|
|
||||||
Index("ix_alert_instances_created", "created_at"),
|
|
||||||
)
|
|
||||||
@@ -1,136 +0,0 @@
|
|||||||
"""Phase 9: Ownership & Revalidation Queue models."""
|
|
||||||
|
|
||||||
import enum
|
|
||||||
import uuid
|
|
||||||
from datetime import datetime
|
|
||||||
|
|
||||||
from sqlalchemy import Column, DateTime, Enum, ForeignKey, Index, String, Text
|
|
||||||
from sqlalchemy.dialects.postgresql import UUID, JSONB
|
|
||||||
from sqlalchemy.orm import relationship
|
|
||||||
|
|
||||||
from app.database import Base
|
|
||||||
|
|
||||||
|
|
||||||
class QueuePriority(str, enum.Enum):
|
|
||||||
critical = "critical"
|
|
||||||
high = "high"
|
|
||||||
medium = "medium"
|
|
||||||
low = "low"
|
|
||||||
|
|
||||||
|
|
||||||
class QueueStatus(str, enum.Enum):
|
|
||||||
pending = "pending"
|
|
||||||
in_progress = "in_progress"
|
|
||||||
completed = "completed"
|
|
||||||
dismissed = "dismissed"
|
|
||||||
|
|
||||||
|
|
||||||
class QueueReason(str, enum.Enum):
|
|
||||||
validation_expired = "validation_expired"
|
|
||||||
infra_change = "infra_change"
|
|
||||||
osint_alert = "osint_alert"
|
|
||||||
mitre_update = "mitre_update"
|
|
||||||
rule_modified = "rule_modified"
|
|
||||||
low_confidence = "low_confidence"
|
|
||||||
manual = "manual"
|
|
||||||
|
|
||||||
|
|
||||||
class TechniqueOwnership(Base):
|
|
||||||
"""Ownership assignment for a MITRE technique."""
|
|
||||||
|
|
||||||
__tablename__ = "technique_ownerships"
|
|
||||||
|
|
||||||
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
|
||||||
technique_id = Column(
|
|
||||||
UUID(as_uuid=True),
|
|
||||||
ForeignKey("techniques.id", ondelete="CASCADE"),
|
|
||||||
nullable=False,
|
|
||||||
unique=True,
|
|
||||||
)
|
|
||||||
owner_id = Column(
|
|
||||||
UUID(as_uuid=True),
|
|
||||||
ForeignKey("users.id", ondelete="SET NULL"),
|
|
||||||
nullable=True,
|
|
||||||
)
|
|
||||||
backup_owner_id = Column(
|
|
||||||
UUID(as_uuid=True),
|
|
||||||
ForeignKey("users.id", ondelete="SET NULL"),
|
|
||||||
nullable=True,
|
|
||||||
)
|
|
||||||
team = Column(String(200), nullable=True)
|
|
||||||
notes = Column(Text, nullable=True)
|
|
||||||
assigned_at = Column(DateTime, nullable=True)
|
|
||||||
assigned_by = Column(
|
|
||||||
UUID(as_uuid=True),
|
|
||||||
ForeignKey("users.id", ondelete="SET NULL"),
|
|
||||||
nullable=True,
|
|
||||||
)
|
|
||||||
created_at = Column(DateTime, default=datetime.utcnow)
|
|
||||||
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
|
||||||
|
|
||||||
technique = relationship("Technique", foreign_keys=[technique_id])
|
|
||||||
owner = relationship("User", foreign_keys=[owner_id])
|
|
||||||
backup_owner = relationship("User", foreign_keys=[backup_owner_id])
|
|
||||||
|
|
||||||
|
|
||||||
class RevalidationQueueItem(Base):
|
|
||||||
"""A prioritised work item for the analyst's daily queue."""
|
|
||||||
|
|
||||||
__tablename__ = "revalidation_queue_items"
|
|
||||||
|
|
||||||
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
|
||||||
technique_id = Column(
|
|
||||||
UUID(as_uuid=True),
|
|
||||||
ForeignKey("techniques.id", ondelete="CASCADE"),
|
|
||||||
nullable=True,
|
|
||||||
)
|
|
||||||
detection_asset_id = Column(
|
|
||||||
UUID(as_uuid=True),
|
|
||||||
ForeignKey("detection_assets.id", ondelete="CASCADE"),
|
|
||||||
nullable=True,
|
|
||||||
)
|
|
||||||
|
|
||||||
priority = Column(
|
|
||||||
Enum(QueuePriority, name="queue_priority"),
|
|
||||||
nullable=False,
|
|
||||||
default=QueuePriority.medium,
|
|
||||||
)
|
|
||||||
reason = Column(
|
|
||||||
Enum(QueueReason, name="queue_reason"),
|
|
||||||
nullable=False,
|
|
||||||
)
|
|
||||||
reason_detail = Column(Text, nullable=True)
|
|
||||||
status = Column(
|
|
||||||
Enum(QueueStatus, name="queue_status"),
|
|
||||||
nullable=False,
|
|
||||||
default=QueueStatus.pending,
|
|
||||||
)
|
|
||||||
|
|
||||||
assigned_to = Column(
|
|
||||||
UUID(as_uuid=True),
|
|
||||||
ForeignKey("users.id", ondelete="SET NULL"),
|
|
||||||
nullable=True,
|
|
||||||
)
|
|
||||||
due_date = Column(DateTime, nullable=True)
|
|
||||||
created_at = Column(DateTime, default=datetime.utcnow)
|
|
||||||
completed_at = Column(DateTime, nullable=True)
|
|
||||||
dismissed_at = Column(DateTime, nullable=True)
|
|
||||||
completed_by = Column(
|
|
||||||
UUID(as_uuid=True),
|
|
||||||
ForeignKey("users.id", ondelete="SET NULL"),
|
|
||||||
nullable=True,
|
|
||||||
)
|
|
||||||
extra = Column(JSONB, nullable=True) # arbitrary metadata
|
|
||||||
|
|
||||||
technique = relationship("Technique", foreign_keys=[technique_id])
|
|
||||||
detection_asset = relationship("DetectionAsset", foreign_keys=[detection_asset_id])
|
|
||||||
assignee = relationship("User", foreign_keys=[assigned_to])
|
|
||||||
|
|
||||||
|
|
||||||
# Indexes
|
|
||||||
Index("ix_rqueue_status", RevalidationQueueItem.status)
|
|
||||||
Index("ix_rqueue_priority", RevalidationQueueItem.priority)
|
|
||||||
Index("ix_rqueue_assigned_to", RevalidationQueueItem.assigned_to)
|
|
||||||
Index("ix_rqueue_technique_id", RevalidationQueueItem.technique_id)
|
|
||||||
Index("ix_rqueue_asset_id", RevalidationQueueItem.detection_asset_id)
|
|
||||||
Index("ix_techown_owner_id", TechniqueOwnership.owner_id)
|
|
||||||
@@ -1,30 +0,0 @@
|
|||||||
"""SQLAlchemy model for one-time password setup/reset tokens.
|
|
||||||
|
|
||||||
Issued when an admin sends a "set your password" email — a passwordless
|
|
||||||
user (freshly created, or one whose password an admin wants to reset)
|
|
||||||
uses the token to pick their own password without ever having a temporary
|
|
||||||
one shared with them.
|
|
||||||
"""
|
|
||||||
|
|
||||||
import uuid
|
|
||||||
|
|
||||||
from sqlalchemy import Column, DateTime, ForeignKey, String, func
|
|
||||||
from sqlalchemy.dialects.postgresql import UUID
|
|
||||||
from sqlalchemy.orm import relationship
|
|
||||||
|
|
||||||
from app.database import Base
|
|
||||||
|
|
||||||
|
|
||||||
class PasswordSetupToken(Base):
|
|
||||||
"""A one-time token letting its owning user set their own password."""
|
|
||||||
|
|
||||||
__tablename__ = "password_setup_tokens"
|
|
||||||
|
|
||||||
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
|
||||||
user_id = Column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=False)
|
|
||||||
token = Column(String(128), unique=True, nullable=False, index=True)
|
|
||||||
expires_at = Column(DateTime, nullable=False)
|
|
||||||
used_at = Column(DateTime, nullable=True)
|
|
||||||
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
|
||||||
|
|
||||||
user = relationship("User", foreign_keys=[user_id])
|
|
||||||
@@ -1,41 +0,0 @@
|
|||||||
"""SQLAlchemy model for procedure-improvement suggestions.
|
|
||||||
|
|
||||||
When an operator submits a round with a filled-in procedure field
|
|
||||||
(``procedure_text`` for Red, ``detect_procedure`` for Blue), the command(s)
|
|
||||||
in it are extracted heuristically and proposed as an update to the
|
|
||||||
originating template's suggested-procedure field. Nothing is written to
|
|
||||||
the template automatically — a lead reviews and approves or rejects each
|
|
||||||
suggestion, so a junior who later picks up the same template only ever
|
|
||||||
sees vetted guidance.
|
|
||||||
"""
|
|
||||||
|
|
||||||
import uuid
|
|
||||||
|
|
||||||
from sqlalchemy import Column, DateTime, ForeignKey, String, Text, func
|
|
||||||
from sqlalchemy.dialects.postgresql import UUID
|
|
||||||
from sqlalchemy.orm import relationship
|
|
||||||
|
|
||||||
from app.database import Base
|
|
||||||
|
|
||||||
|
|
||||||
class ProcedureSuggestion(Base):
|
|
||||||
"""A proposed update to a TestTemplate's suggested procedure, pending lead review."""
|
|
||||||
|
|
||||||
__tablename__ = "procedure_suggestions"
|
|
||||||
|
|
||||||
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
|
||||||
template_id = Column(UUID(as_uuid=True), ForeignKey("test_templates.id"), nullable=False)
|
|
||||||
team = Column(String(10), nullable=False) # "red" or "blue"
|
|
||||||
suggested_text = Column(Text, nullable=False)
|
|
||||||
source_test_id = Column(UUID(as_uuid=True), ForeignKey("tests.id"), nullable=True)
|
|
||||||
submitted_by = Column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=True)
|
|
||||||
|
|
||||||
status = Column(String(10), nullable=False, default="pending", server_default="pending") # pending/approved/rejected
|
|
||||||
reviewed_by = Column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=True)
|
|
||||||
reviewed_at = Column(DateTime, nullable=True)
|
|
||||||
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
|
||||||
|
|
||||||
template = relationship("TestTemplate", foreign_keys=[template_id])
|
|
||||||
source_test = relationship("Test", foreign_keys=[source_test_id])
|
|
||||||
submitter = relationship("User", foreign_keys=[submitted_by])
|
|
||||||
reviewer = relationship("User", foreign_keys=[reviewed_by])
|
|
||||||
@@ -1,69 +0,0 @@
|
|||||||
"""Phase 12: Risk Intelligence model — per-technique risk scoring."""
|
|
||||||
|
|
||||||
import uuid
|
|
||||||
from datetime import datetime
|
|
||||||
|
|
||||||
from sqlalchemy import (
|
|
||||||
Boolean, Column, DateTime, Float, ForeignKey,
|
|
||||||
Index, Integer, String, UniqueConstraint,
|
|
||||||
)
|
|
||||||
from sqlalchemy.dialects.postgresql import UUID, JSONB
|
|
||||||
from sqlalchemy.orm import relationship
|
|
||||||
|
|
||||||
from app.database import Base
|
|
||||||
|
|
||||||
|
|
||||||
class TechniqueRiskProfile(Base):
|
|
||||||
"""
|
|
||||||
Aggregated risk profile for one technique.
|
|
||||||
|
|
||||||
Combines four weighted factors:
|
|
||||||
• detection_gap (35 %) — 0=fully covered → 1=no coverage
|
|
||||||
• threat_actor_rel (30 %) — normalised actor count
|
|
||||||
• osint_signals (20 %) — normalised recent OSINT items (30 d)
|
|
||||||
• test_failure_rate (15 %) — proportion of tests where blue didn't detect
|
|
||||||
|
|
||||||
risk_score = weighted sum × 100 → 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"),
|
|
||||||
)
|
|
||||||
@@ -1,49 +0,0 @@
|
|||||||
"""Phase 14: SSO / SAML 2.0 configuration model."""
|
|
||||||
|
|
||||||
import uuid
|
|
||||||
from datetime import datetime
|
|
||||||
|
|
||||||
from sqlalchemy import Boolean, Column, DateTime, String, Text
|
|
||||||
from sqlalchemy.dialects.postgresql import UUID
|
|
||||||
|
|
||||||
from app.database import Base
|
|
||||||
|
|
||||||
|
|
||||||
class SsoConfig(Base):
|
|
||||||
"""
|
|
||||||
SAML 2.0 Identity Provider configuration.
|
|
||||||
|
|
||||||
Exactly one row is expected (use upsert). The SP metadata endpoint
|
|
||||||
reads from this row to generate XML for IdP registration.
|
|
||||||
"""
|
|
||||||
|
|
||||||
__tablename__ = "sso_configs"
|
|
||||||
|
|
||||||
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
|
||||||
is_enabled = Column(Boolean, nullable=False, default=False)
|
|
||||||
provider_name = Column(String(200), nullable=True) # e.g., "Okta", "Azure AD"
|
|
||||||
|
|
||||||
# ── Service Provider (Aegis) settings ────────────────────────────────────
|
|
||||||
sp_entity_id = Column(String(500), nullable=True) # e.g., https://aegis.co/api/v1/sso/metadata
|
|
||||||
sp_acs_url = Column(String(500), nullable=True) # Assertion Consumer Service URL
|
|
||||||
sp_slo_url = Column(String(500), nullable=True) # Single Logout URL (optional)
|
|
||||||
sp_certificate = Column(Text, nullable=True) # SP public cert for signed requests
|
|
||||||
sp_private_key = Column(Text, nullable=True) # SP private key (stored encrypted in future)
|
|
||||||
|
|
||||||
# ── Identity Provider settings ────────────────────────────────────────────
|
|
||||||
idp_entity_id = Column(String(500), nullable=True)
|
|
||||||
idp_sso_url = Column(String(500), nullable=True) # IdP redirect/POST binding URL
|
|
||||||
idp_slo_url = Column(String(500), nullable=True) # IdP SLO URL
|
|
||||||
idp_certificate = Column(Text, nullable=True) # IdP X.509 cert for response validation
|
|
||||||
|
|
||||||
# ── Attribute mapping ─────────────────────────────────────────────────────
|
|
||||||
# SAML attribute name → Aegis field
|
|
||||||
attr_email = Column(String(200), nullable=True, default="email")
|
|
||||||
attr_username = Column(String(200), nullable=True, default="username")
|
|
||||||
attr_role = Column(String(200), nullable=True, default="role")
|
|
||||||
default_role = Column(String(50), nullable=True, default="viewer")
|
|
||||||
auto_provision = Column(Boolean, nullable=False, default=True) # create user on first login
|
|
||||||
|
|
||||||
# ── Meta ─────────────────────────────────────────────────────────────────
|
|
||||||
created_at = Column(DateTime, default=datetime.utcnow)
|
|
||||||
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
|
||||||
@@ -1,26 +0,0 @@
|
|||||||
"""SystemConfig model — runtime key-value configuration store."""
|
|
||||||
|
|
||||||
import uuid
|
|
||||||
|
|
||||||
from sqlalchemy import Column, String, Text, DateTime, func
|
|
||||||
from sqlalchemy.dialects.postgresql import UUID
|
|
||||||
|
|
||||||
from app.database import Base
|
|
||||||
|
|
||||||
|
|
||||||
class SystemConfig(Base):
|
|
||||||
"""Generic key-value store for runtime system configuration.
|
|
||||||
|
|
||||||
Currently used for:
|
|
||||||
- SMTP email settings (overrides .env values when present)
|
|
||||||
|
|
||||||
Keys are namespaced by convention: ``smtp.host``, ``smtp.port``, etc.
|
|
||||||
"""
|
|
||||||
|
|
||||||
__tablename__ = "system_configs"
|
|
||||||
|
|
||||||
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
|
||||||
key = Column(String(200), unique=True, nullable=False, index=True)
|
|
||||||
value = Column(Text, nullable=True)
|
|
||||||
description = Column(String(500), nullable=True)
|
|
||||||
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
|
|
||||||
@@ -59,8 +59,6 @@ class Technique(Base):
|
|||||||
review_required = Column(Boolean, default=False)
|
review_required = Column(Boolean, default=False)
|
||||||
# Assign last_review_date = Column(DateTime, nullable=True)
|
# Assign last_review_date = Column(DateTime, nullable=True)
|
||||||
last_review_date = Column(DateTime, nullable=True)
|
last_review_date = Column(DateTime, nullable=True)
|
||||||
# Assign external_references = Column(JSONB, nullable=True, default=list)
|
|
||||||
external_references = Column(JSONB, nullable=True, default=list)
|
|
||||||
|
|
||||||
# Relationships
|
# Relationships
|
||||||
tests = relationship("Test", back_populates="technique")
|
tests = relationship("Test", back_populates="technique")
|
||||||
|
|||||||
@@ -1,53 +0,0 @@
|
|||||||
"""SQLAlchemy model for operator-proposed test templates, pending lead review.
|
|
||||||
|
|
||||||
Leads create templates directly (see TestTemplateCreate router). An
|
|
||||||
operator (red_tech/blue_tech) gets the same "create template" action, but
|
|
||||||
their submission lands here instead of the live catalog — a lead on their
|
|
||||||
team reviews it, optionally edits any field, and either approves it (which
|
|
||||||
creates the real TestTemplate) or discards it.
|
|
||||||
"""
|
|
||||||
|
|
||||||
import uuid
|
|
||||||
|
|
||||||
from sqlalchemy import Column, DateTime, ForeignKey, String, Text, func
|
|
||||||
from sqlalchemy.dialects.postgresql import UUID
|
|
||||||
from sqlalchemy.orm import relationship
|
|
||||||
|
|
||||||
from app.database import Base
|
|
||||||
|
|
||||||
|
|
||||||
class TemplateSuggestion(Base):
|
|
||||||
"""A proposed new TestTemplate submitted by an operator, pending lead review."""
|
|
||||||
|
|
||||||
__tablename__ = "template_suggestions"
|
|
||||||
|
|
||||||
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
|
||||||
|
|
||||||
# Proposed TestTemplate fields — mirrors TestTemplateCreate.
|
|
||||||
mitre_technique_id = Column(String, nullable=False)
|
|
||||||
name = Column(String, nullable=False)
|
|
||||||
description = Column(Text, nullable=True)
|
|
||||||
source = Column(String, nullable=False, default="custom", server_default="custom")
|
|
||||||
source_url = Column(String, nullable=True)
|
|
||||||
attack_procedure = Column(Text, nullable=True)
|
|
||||||
expected_detection = Column(Text, nullable=True)
|
|
||||||
platform = Column(String, nullable=True)
|
|
||||||
tool_suggested = Column(String, nullable=True)
|
|
||||||
severity = Column(String, nullable=True)
|
|
||||||
atomic_test_id = Column(String, nullable=True)
|
|
||||||
suggested_remediation = Column(Text, nullable=True)
|
|
||||||
|
|
||||||
# "red" or "blue" — derived from the submitter's role, determines which
|
|
||||||
# lead reviews it (admins can review both).
|
|
||||||
team = Column(String(10), nullable=False)
|
|
||||||
submitted_by = Column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=True)
|
|
||||||
|
|
||||||
status = Column(String(10), nullable=False, default="pending", server_default="pending") # pending/approved/rejected
|
|
||||||
reviewed_by = Column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=True)
|
|
||||||
reviewed_at = Column(DateTime, nullable=True)
|
|
||||||
created_template_id = Column(UUID(as_uuid=True), ForeignKey("test_templates.id"), nullable=True)
|
|
||||||
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
|
||||||
|
|
||||||
submitter = relationship("User", foreign_keys=[submitted_by])
|
|
||||||
reviewer = relationship("User", foreign_keys=[reviewed_by])
|
|
||||||
created_template = relationship("TestTemplate", foreign_keys=[created_template_id])
|
|
||||||
@@ -26,8 +26,8 @@ from sqlalchemy.orm import relationship
|
|||||||
# Import Base from app.database
|
# Import Base from app.database
|
||||||
from app.database import Base
|
from app.database import Base
|
||||||
|
|
||||||
# Import ContainmentResult, TestResult, TestState from app.models.enums
|
# Import TestResult, TestState from app.models.enums
|
||||||
from app.models.enums import AttackSuccessResult, ContainmentResult, TestResult, TestState
|
from app.models.enums import TestResult, TestState
|
||||||
|
|
||||||
|
|
||||||
# Define class Test
|
# Define class Test
|
||||||
@@ -59,12 +59,6 @@ class Test(Base):
|
|||||||
execution_date = Column(DateTime, nullable=True)
|
execution_date = Column(DateTime, nullable=True)
|
||||||
# Assign created_by = Column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=True)
|
# Assign created_by = Column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=True)
|
||||||
created_by = Column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=True)
|
created_by = Column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=True)
|
||||||
# The template this test was instantiated from, if any (null for
|
|
||||||
# standalone/manually-created/RT-imported tests). Lets a procedure
|
|
||||||
# suggestion know exactly which template to propose improving —
|
|
||||||
# matching purely by technique would be ambiguous when a technique has
|
|
||||||
# multiple templates.
|
|
||||||
source_template_id = Column(UUID(as_uuid=True), ForeignKey("test_templates.id"), nullable=True)
|
|
||||||
# Assign result = Column(Enum(TestResult, name="testresult"), nullable=True)
|
# Assign result = Column(Enum(TestResult, name="testresult"), nullable=True)
|
||||||
result = Column(Enum(TestResult, name="testresult"), nullable=True)
|
result = Column(Enum(TestResult, name="testresult"), nullable=True)
|
||||||
# Assign state = Column(Enum(TestState, name="teststate"), default=TestState.draft)
|
# Assign state = Column(Enum(TestState, name="teststate"), default=TestState.draft)
|
||||||
@@ -75,9 +69,7 @@ class Test(Base):
|
|||||||
# ── Red Team fields ─────────────────────────────────────────────
|
# ── Red Team fields ─────────────────────────────────────────────
|
||||||
red_summary = Column(Text, nullable=True)
|
red_summary = Column(Text, nullable=True)
|
||||||
# Assign attack_success = Column(Boolean, nullable=True)
|
# Assign attack_success = Column(Boolean, nullable=True)
|
||||||
attack_success = Column(Enum(AttackSuccessResult, name="attacksuccessresult"), nullable=True)
|
attack_success = Column(Boolean, nullable=True)
|
||||||
execution_start_time = Column(DateTime, nullable=True)
|
|
||||||
execution_end_time = Column(DateTime, nullable=True)
|
|
||||||
# Assign red_validated_by = Column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=True)
|
# Assign red_validated_by = Column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=True)
|
||||||
red_validated_by = Column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=True)
|
red_validated_by = Column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=True)
|
||||||
# Assign red_validated_at = Column(DateTime, nullable=True)
|
# Assign red_validated_at = Column(DateTime, nullable=True)
|
||||||
@@ -89,14 +81,8 @@ class Test(Base):
|
|||||||
|
|
||||||
# ── Blue Team fields ────────────────────────────────────────────
|
# ── Blue Team fields ────────────────────────────────────────────
|
||||||
blue_summary = Column(Text, nullable=True)
|
blue_summary = Column(Text, nullable=True)
|
||||||
# What Blue actually did to detect the attack — Blue's counterpart to
|
|
||||||
# procedure_text. Free text; may be parsed for a procedure suggestion.
|
|
||||||
detect_procedure = Column(Text, nullable=True)
|
|
||||||
# Assign detection_result = Column(Enum(TestResult, name="testresult"), nullable=True)
|
# Assign detection_result = Column(Enum(TestResult, name="testresult"), nullable=True)
|
||||||
detection_result = Column(Enum(TestResult, name="testresult"), nullable=True)
|
detection_result = Column(Enum(TestResult, name="testresult"), nullable=True)
|
||||||
containment_result = Column(Enum(ContainmentResult, name="containmentresult"), nullable=True)
|
|
||||||
detection_time = Column(DateTime, nullable=True)
|
|
||||||
containment_time = Column(DateTime, nullable=True)
|
|
||||||
# Assign blue_validated_by = Column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=True)
|
# Assign blue_validated_by = Column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=True)
|
||||||
blue_validated_by = Column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=True)
|
blue_validated_by = Column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=True)
|
||||||
# Assign blue_validated_at = Column(DateTime, nullable=True)
|
# Assign blue_validated_at = Column(DateTime, nullable=True)
|
||||||
@@ -110,17 +96,13 @@ class Test(Base):
|
|||||||
red_started_at = Column(DateTime, nullable=True)
|
red_started_at = Column(DateTime, nullable=True)
|
||||||
# Assign blue_started_at = Column(DateTime, nullable=True)
|
# Assign blue_started_at = Column(DateTime, nullable=True)
|
||||||
blue_started_at = Column(DateTime, nullable=True)
|
blue_started_at = Column(DateTime, nullable=True)
|
||||||
blue_work_started_at = Column(DateTime, nullable=True) # when blue tech picks up (Tempo start)
|
# Assign paused_at = Column(DateTime, nullable=True)
|
||||||
paused_at = Column(DateTime, nullable=True)
|
paused_at = Column(DateTime, nullable=True)
|
||||||
# Assign red_paused_seconds = Column(Integer, default=0)
|
# Assign red_paused_seconds = Column(Integer, default=0)
|
||||||
red_paused_seconds = Column(Integer, default=0)
|
red_paused_seconds = Column(Integer, default=0)
|
||||||
# Assign blue_paused_seconds = Column(Integer, default=0)
|
# Assign blue_paused_seconds = Column(Integer, default=0)
|
||||||
blue_paused_seconds = Column(Integer, default=0)
|
blue_paused_seconds = Column(Integer, default=0)
|
||||||
|
|
||||||
# ── Round tracking (bumped on each reopen-for-rework) ────────────
|
|
||||||
red_round_number = Column(Integer, nullable=False, default=1, server_default="1")
|
|
||||||
blue_round_number = Column(Integer, nullable=False, default=1, server_default="1")
|
|
||||||
|
|
||||||
# ── Remediation fields ───────────────────────────────────────────
|
# ── Remediation fields ───────────────────────────────────────────
|
||||||
remediation_steps = Column(Text, nullable=True)
|
remediation_steps = Column(Text, nullable=True)
|
||||||
# Assign remediation_status = Column(String, nullable=True) # pending / in_progress / completed ...
|
# Assign remediation_status = Column(String, nullable=True) # pending / in_progress / completed ...
|
||||||
@@ -132,17 +114,13 @@ class Test(Base):
|
|||||||
retest_of = Column(UUID(as_uuid=True), ForeignKey("tests.id"), nullable=True)
|
retest_of = Column(UUID(as_uuid=True), ForeignKey("tests.id"), nullable=True)
|
||||||
# Assign retest_count = Column(Integer, default=0)
|
# Assign retest_count = Column(Integer, default=0)
|
||||||
retest_count = Column(Integer, default=0)
|
retest_count = Column(Integer, default=0)
|
||||||
# Assign data_classification = Column(String(20), nullable=False, server_default="internal_use_only")
|
# Assign data_classification = Column(String(20), nullable=False, server_default="internal")
|
||||||
data_classification = Column(String(20), nullable=False, server_default="internal_use_only")
|
data_classification = Column(String(20), nullable=False, server_default="internal")
|
||||||
|
|
||||||
# ── Relationships ───────────────────────────────────────────────
|
# ── Relationships ───────────────────────────────────────────────
|
||||||
technique = relationship("Technique", back_populates="tests")
|
technique = relationship("Technique", back_populates="tests")
|
||||||
# Assign evidences = relationship("Evidence", back_populates="test")
|
# Assign evidences = relationship("Evidence", back_populates="test")
|
||||||
evidences = relationship("Evidence", back_populates="test")
|
evidences = relationship("Evidence", back_populates="test")
|
||||||
round_history = relationship(
|
|
||||||
"TestRoundHistory", back_populates="test",
|
|
||||||
order_by="TestRoundHistory.round_number", cascade="all, delete-orphan",
|
|
||||||
)
|
|
||||||
# Assign creator = relationship("User", foreign_keys=[created_by])
|
# Assign creator = relationship("User", foreign_keys=[created_by])
|
||||||
creator = relationship("User", foreign_keys=[created_by])
|
creator = relationship("User", foreign_keys=[created_by])
|
||||||
# Assign red_validator = relationship("User", foreign_keys=[red_validated_by])
|
# Assign red_validator = relationship("User", foreign_keys=[red_validated_by])
|
||||||
@@ -151,36 +129,6 @@ class Test(Base):
|
|||||||
blue_validator = relationship("User", foreign_keys=[blue_validated_by])
|
blue_validator = relationship("User", foreign_keys=[blue_validated_by])
|
||||||
# Assign remediation_user = relationship("User", foreign_keys=[remediation_assignee])
|
# Assign remediation_user = relationship("User", foreign_keys=[remediation_assignee])
|
||||||
remediation_user = relationship("User", foreign_keys=[remediation_assignee])
|
remediation_user = relationship("User", foreign_keys=[remediation_assignee])
|
||||||
|
|
||||||
# ── Assignment fields ──────────────────────────────────────────
|
|
||||||
red_tech_assignee = Column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=True)
|
|
||||||
blue_tech_assignee = Column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=True)
|
|
||||||
|
|
||||||
# ── Review assignment fields ────────────────────────────────────
|
|
||||||
red_reviewer_assignee = Column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=True)
|
|
||||||
red_review_by = Column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=True)
|
|
||||||
red_review_at = Column(DateTime, nullable=True)
|
|
||||||
red_review_notes = Column(Text, nullable=True)
|
|
||||||
|
|
||||||
blue_reviewer_assignee = Column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=True)
|
|
||||||
blue_review_by = Column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=True)
|
|
||||||
blue_review_at = Column(DateTime, nullable=True)
|
|
||||||
blue_review_notes = Column(Text, nullable=True)
|
|
||||||
system_gaps = Column(Text, nullable=True)
|
|
||||||
|
|
||||||
# ── On-hold fields ─────────────────────────────────────────────
|
|
||||||
is_on_hold = Column(Boolean, default=False, nullable=False, server_default="false")
|
|
||||||
hold_reason = Column(Text, nullable=True)
|
|
||||||
held_at = Column(DateTime, nullable=True)
|
|
||||||
|
|
||||||
red_tech_assigned_user = relationship("User", foreign_keys=[red_tech_assignee])
|
|
||||||
blue_tech_assigned_user = relationship("User", foreign_keys=[blue_tech_assignee])
|
|
||||||
|
|
||||||
red_reviewer = relationship("User", foreign_keys=[red_reviewer_assignee])
|
|
||||||
red_review_actor = relationship("User", foreign_keys=[red_review_by])
|
|
||||||
blue_reviewer = relationship("User", foreign_keys=[blue_reviewer_assignee])
|
|
||||||
blue_review_actor = relationship("User", foreign_keys=[blue_review_by])
|
|
||||||
|
|
||||||
# Assign original_test = relationship("Test", remote_side="Test.id", foreign_keys=[retest_of])
|
# Assign original_test = relationship("Test", remote_side="Test.id", foreign_keys=[retest_of])
|
||||||
original_test = relationship("Test", remote_side="Test.id", foreign_keys=[retest_of])
|
original_test = relationship("Test", remote_side="Test.id", foreign_keys=[retest_of])
|
||||||
# Assign retests = relationship("Test", foreign_keys=[retest_of], back_populates="orig...
|
# Assign retests = relationship("Test", foreign_keys=[retest_of], back_populates="orig...
|
||||||
|
|||||||
@@ -1,61 +0,0 @@
|
|||||||
"""SQLAlchemy model for archived test rounds.
|
|
||||||
|
|
||||||
Each row is a snapshot of one team's round of work (procedure/results for
|
|
||||||
Red, detection/containment for Blue) taken right before a lead reopens the
|
|
||||||
test for rework. Reopening resets the live fields on ``Test`` for a fresh
|
|
||||||
attempt, but the prior attempt's data must not be lost — it's archived here
|
|
||||||
so the full history stays visible (e.g. to Blue Team, who need to see what
|
|
||||||
Red actually did on earlier rounds, not just the latest one).
|
|
||||||
"""
|
|
||||||
|
|
||||||
import uuid
|
|
||||||
|
|
||||||
from sqlalchemy import Column, DateTime, Enum, ForeignKey, Integer, String, Text, func
|
|
||||||
from sqlalchemy.dialects.postgresql import UUID
|
|
||||||
from sqlalchemy.orm import relationship
|
|
||||||
|
|
||||||
from app.database import Base
|
|
||||||
from app.models.enums import AttackSuccessResult, ContainmentResult, TestResult
|
|
||||||
|
|
||||||
|
|
||||||
class TestRoundHistory(Base):
|
|
||||||
"""Archived snapshot of one red/blue round of a test, taken on reopen."""
|
|
||||||
|
|
||||||
__tablename__ = "test_round_history"
|
|
||||||
|
|
||||||
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
|
||||||
test_id = Column(UUID(as_uuid=True), ForeignKey("tests.id"), nullable=False)
|
|
||||||
team = Column(String(10), nullable=False) # "red" or "blue"
|
|
||||||
round_number = Column(Integer, nullable=False)
|
|
||||||
|
|
||||||
started_at = Column(DateTime, nullable=True)
|
|
||||||
ended_at = Column(DateTime, nullable=True)
|
|
||||||
paused_seconds = Column(Integer, default=0)
|
|
||||||
|
|
||||||
# Red-side round output
|
|
||||||
procedure_text = Column(Text, nullable=True)
|
|
||||||
tool_used = Column(String, nullable=True)
|
|
||||||
# native_enum=False — stored as plain VARCHAR, not the shared Postgres
|
|
||||||
# enum type used by tests.attack_success. This is an archive table with
|
|
||||||
# no DB-level constraint needs, and reusing the native type name here
|
|
||||||
# would make Alembic try to (re)create it.
|
|
||||||
attack_success = Column(Enum(AttackSuccessResult, name="test_round_history_attack_success", native_enum=False), nullable=True)
|
|
||||||
red_summary = Column(Text, nullable=True)
|
|
||||||
execution_start_time = Column(DateTime, nullable=True)
|
|
||||||
execution_end_time = Column(DateTime, nullable=True)
|
|
||||||
|
|
||||||
# Blue-side round output
|
|
||||||
detection_result = Column(Enum(TestResult, name="test_round_history_detection_result", native_enum=False), nullable=True)
|
|
||||||
containment_result = Column(Enum(ContainmentResult, name="test_round_history_containment_result", native_enum=False), nullable=True)
|
|
||||||
detection_time = Column(DateTime, nullable=True)
|
|
||||||
containment_time = Column(DateTime, nullable=True)
|
|
||||||
blue_summary = Column(Text, nullable=True)
|
|
||||||
detect_procedure = Column(Text, nullable=True)
|
|
||||||
|
|
||||||
# Why the round was closed
|
|
||||||
review_notes = Column(Text, nullable=True)
|
|
||||||
reviewed_by = Column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=True)
|
|
||||||
archived_at = Column(DateTime(timezone=True), server_default=func.now())
|
|
||||||
|
|
||||||
test = relationship("Test", back_populates="round_history")
|
|
||||||
reviewer = relationship("User", foreign_keys=[reviewed_by])
|
|
||||||
@@ -41,12 +41,8 @@ class TestTemplate(Base):
|
|||||||
source_url = Column(String, nullable=True)
|
source_url = Column(String, nullable=True)
|
||||||
# Assign attack_procedure = Column(Text, nullable=True) # Suggested attack procedure
|
# Assign attack_procedure = Column(Text, nullable=True) # Suggested attack procedure
|
||||||
attack_procedure = Column(Text, nullable=True) # Suggested attack procedure
|
attack_procedure = Column(Text, nullable=True) # Suggested attack procedure
|
||||||
# What blue team should detect — narrative guidance from imports/leads,
|
# Assign expected_detection = Column(Text, nullable=True) # What blue team should detect
|
||||||
# plus concrete commands appended via approved procedure suggestions
|
expected_detection = Column(Text, nullable=True) # What blue team should detect
|
||||||
# (Blue's counterpart to attack_procedure). External syncs never touch
|
|
||||||
# an existing row (they only insert brand-new ones), so anything added
|
|
||||||
# here is safe across re-syncs.
|
|
||||||
expected_detection = Column(Text, nullable=True)
|
|
||||||
# Assign platform = Column(String, nullable=True) # windows / linux...
|
# Assign platform = Column(String, nullable=True) # windows / linux...
|
||||||
platform = Column(String, nullable=True) # windows / linux / macos
|
platform = Column(String, nullable=True) # windows / linux / macos
|
||||||
# Assign tool_suggested = Column(String, nullable=True)
|
# Assign tool_suggested = Column(String, nullable=True)
|
||||||
|
|||||||
@@ -2,8 +2,12 @@
|
|||||||
|
|
||||||
# Import uuid
|
# Import uuid
|
||||||
import uuid
|
import uuid
|
||||||
from sqlalchemy import Column, String, Boolean, DateTime, func
|
|
||||||
from sqlalchemy.dialects.postgresql import UUID, JSONB
|
# Import Boolean, Column, DateTime, String, func from sqlalchemy
|
||||||
|
from sqlalchemy import Boolean, Column, DateTime, String, func
|
||||||
|
|
||||||
|
# Import UUID from sqlalchemy.dialects.postgresql
|
||||||
|
from sqlalchemy.dialects.postgresql import UUID
|
||||||
|
|
||||||
# Import Base from app.database
|
# Import Base from app.database
|
||||||
from app.database import Base
|
from app.database import Base
|
||||||
@@ -26,26 +30,14 @@ class User(Base):
|
|||||||
|
|
||||||
# Assign id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
# Assign id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||||
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||||
# Internal login identifier — always kept equal to `email` (see below).
|
# Assign username = Column(String, unique=True, nullable=False)
|
||||||
# Kept as a separate column (rather than removed) since the JWT `sub`
|
|
||||||
# claim, audit logs, Jira actor attribution, and SSO provisioning all
|
|
||||||
# still key off it; changing all of those to read `email` directly
|
|
||||||
# would be a much larger, riskier refactor for no behavioral gain now
|
|
||||||
# that the two are always identical.
|
|
||||||
username = Column(String, unique=True, nullable=False)
|
username = Column(String, unique=True, nullable=False)
|
||||||
# The unique identifier a user logs in with. Every user must have one.
|
# Assign email = Column(String, nullable=True)
|
||||||
email = Column(String, unique=True, nullable=False, index=True)
|
email = Column(String, nullable=True)
|
||||||
# Display name shown everywhere in the UI instead of username (which is
|
|
||||||
# now just an internal login identifier, auto-set to the user's email).
|
|
||||||
full_name = Column(String, nullable=True)
|
|
||||||
# Assign hashed_password = Column(String, nullable=False)
|
# Assign hashed_password = Column(String, nullable=False)
|
||||||
hashed_password = Column(String, nullable=False)
|
hashed_password = Column(String, nullable=False)
|
||||||
# Assign role = Column(String, nullable=False, default="viewer")
|
# Assign role = Column(String, nullable=False, default="viewer")
|
||||||
role = Column(String, nullable=False, default="viewer")
|
role = Column(String, nullable=False, default="viewer")
|
||||||
# Other roles this user can switch into (the currently-active role
|
|
||||||
# lives in `role` above and is what every permission check reads —
|
|
||||||
# switching just swaps which one is active, never grants both at once).
|
|
||||||
extra_roles = Column(JSONB, nullable=False, default=list, server_default="[]")
|
|
||||||
# Assign is_active = Column(Boolean, default=True)
|
# Assign is_active = Column(Boolean, default=True)
|
||||||
is_active = Column(Boolean, default=True)
|
is_active = Column(Boolean, default=True)
|
||||||
# Assign must_change_password = Column(Boolean, default=True)
|
# Assign must_change_password = Column(Boolean, default=True)
|
||||||
@@ -54,8 +46,3 @@ class User(Base):
|
|||||||
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||||
# Assign last_login = Column(DateTime, nullable=True)
|
# Assign last_login = Column(DateTime, nullable=True)
|
||||||
last_login = Column(DateTime, nullable=True)
|
last_login = Column(DateTime, nullable=True)
|
||||||
notification_preferences = Column(JSONB, nullable=True, server_default='{"email_on_test_validated": true, "email_on_campaign_completed": true, "email_on_new_mitre_techniques": false, "in_app_all": true}')
|
|
||||||
jira_account_id = Column(String(100), nullable=True)
|
|
||||||
jira_api_token = Column(String(500), nullable=True) # personal Atlassian token
|
|
||||||
jira_email = Column(String(255), nullable=True) # Atlassian email (overrides account email)
|
|
||||||
tempo_api_token = Column(String(500), nullable=True) # personal Tempo API token
|
|
||||||
|
|||||||
@@ -1,18 +0,0 @@
|
|||||||
"""WebhookConfig model — outbound HTTP notification endpoints."""
|
|
||||||
import uuid
|
|
||||||
from sqlalchemy import Column, String, Boolean, DateTime, Integer, Text, ForeignKey, func
|
|
||||||
from sqlalchemy.dialects.postgresql import UUID, JSONB
|
|
||||||
from app.database import Base
|
|
||||||
|
|
||||||
class WebhookConfig(Base):
|
|
||||||
__tablename__ = "webhook_configs"
|
|
||||||
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
|
||||||
name = Column(String(200), nullable=False)
|
|
||||||
url = Column(Text, nullable=False)
|
|
||||||
secret = Column(String(256), nullable=True) # HMAC signature key
|
|
||||||
events = Column(JSONB, nullable=False, server_default="[]") # list of event types
|
|
||||||
is_active = Column(Boolean, default=True, nullable=False)
|
|
||||||
created_by = Column(UUID(as_uuid=True), ForeignKey("users.id", ondelete="SET NULL"), nullable=True)
|
|
||||||
last_triggered_at = Column(DateTime, nullable=True)
|
|
||||||
failure_count = Column(Integer, default=0, nullable=False)
|
|
||||||
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
|
||||||
@@ -1,352 +0,0 @@
|
|||||||
"""Admin configuration export/import — single-file migration bundle.
|
|
||||||
|
|
||||||
GET /admin/export-config — download JSON bundle (admin only)
|
|
||||||
POST /admin/import-config — upload JSON bundle and restore (admin only)
|
|
||||||
|
|
||||||
What is exported (and what is NOT):
|
|
||||||
✓ system_configs — email / jira settings (passwords REDACTED)
|
|
||||||
✓ webhook_configs — notification webhooks (secrets REDACTED)
|
|
||||||
✓ sso_configs — SAML/SSO config (private keys REDACTED)
|
|
||||||
✓ scoring_config — technique scoring weights
|
|
||||||
✓ test_templates — CUSTOM templates only (source='custom')
|
|
||||||
✓ users — username / email / role (no passwords / tokens)
|
|
||||||
✗ atomic/sigma/elastic templates, techniques, tests, campaigns, reports
|
|
||||||
"""
|
|
||||||
|
|
||||||
from datetime import datetime
|
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Request
|
|
||||||
from fastapi.responses import JSONResponse
|
|
||||||
from sqlalchemy.orm import Session
|
|
||||||
|
|
||||||
from app.auth import hash_password
|
|
||||||
from app.database import get_db
|
|
||||||
from app.dependencies.auth import require_role
|
|
||||||
from app.models.scoring_config import ScoringConfig
|
|
||||||
from app.models.sso_config import SsoConfig
|
|
||||||
from app.models.system_config import SystemConfig
|
|
||||||
from app.models.test_template import TestTemplate
|
|
||||||
from app.models.user import User
|
|
||||||
from app.models.webhook_config import WebhookConfig
|
|
||||||
|
|
||||||
router = APIRouter(prefix="/admin", tags=["admin"])
|
|
||||||
|
|
||||||
# Keys whose values contain secrets and must be redacted in the export.
|
|
||||||
# Must be kept in sync with the actual SystemConfig keys written by
|
|
||||||
# routers/system.py's _JIRA_KEYS map and the SMTP settings section —
|
|
||||||
# a stale/mismatched name here silently exports a live credential in
|
|
||||||
# plaintext instead of redacting it.
|
|
||||||
_REDACTED_KEYS = {
|
|
||||||
"smtp.password",
|
|
||||||
"jira.admin_api_token",
|
|
||||||
"tempo.admin_token",
|
|
||||||
"email_webhook.api_key",
|
|
||||||
# Older/alternate key names kept defensively in case a prior schema
|
|
||||||
# version wrote under these instead.
|
|
||||||
"jira.api_token",
|
|
||||||
"jira.password",
|
|
||||||
"tempo.api_token",
|
|
||||||
}
|
|
||||||
|
|
||||||
_EXPORT_VERSION = "1.0"
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Helpers
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
def _redact(key: str, value: Any) -> Any:
|
|
||||||
if key in _REDACTED_KEYS:
|
|
||||||
return "[REDACTED]"
|
|
||||||
return value
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# GET /admin/export-config
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/export-config")
|
|
||||||
def export_config(
|
|
||||||
db: Session = Depends(get_db),
|
|
||||||
current_user: User = Depends(require_role("admin")),
|
|
||||||
):
|
|
||||||
"""Export all platform configuration as a downloadable JSON bundle."""
|
|
||||||
|
|
||||||
# ── 1. system_configs ────────────────────────────────────────────
|
|
||||||
system_configs = [
|
|
||||||
{
|
|
||||||
"key": r.key,
|
|
||||||
"value": _redact(r.key, r.value),
|
|
||||||
"description": r.description,
|
|
||||||
}
|
|
||||||
for r in db.query(SystemConfig).order_by(SystemConfig.key).all()
|
|
||||||
]
|
|
||||||
|
|
||||||
# ── 2. webhook_configs ───────────────────────────────────────────
|
|
||||||
webhooks = [
|
|
||||||
{
|
|
||||||
"name": w.name,
|
|
||||||
"url": w.url,
|
|
||||||
"secret": "[REDACTED]" if w.secret else None,
|
|
||||||
"events": w.events or [],
|
|
||||||
"is_active": w.is_active,
|
|
||||||
}
|
|
||||||
for w in db.query(WebhookConfig).order_by(WebhookConfig.name).all()
|
|
||||||
]
|
|
||||||
|
|
||||||
# ── 3. SSO config (single row) ───────────────────────────────────
|
|
||||||
sso_row = db.query(SsoConfig).first()
|
|
||||||
sso = None
|
|
||||||
if sso_row:
|
|
||||||
sso = {
|
|
||||||
"is_enabled": sso_row.is_enabled,
|
|
||||||
"provider_name": sso_row.provider_name,
|
|
||||||
"sp_entity_id": sso_row.sp_entity_id,
|
|
||||||
"sp_acs_url": sso_row.sp_acs_url,
|
|
||||||
"sp_slo_url": sso_row.sp_slo_url,
|
|
||||||
"sp_certificate": sso_row.sp_certificate,
|
|
||||||
"sp_private_key": "[REDACTED]", # never export private keys
|
|
||||||
"idp_entity_id": sso_row.idp_entity_id,
|
|
||||||
"idp_sso_url": getattr(sso_row, "idp_sso_url", None),
|
|
||||||
"idp_slo_url": getattr(sso_row, "idp_slo_url", None),
|
|
||||||
"idp_certificate": getattr(sso_row, "idp_certificate", None),
|
|
||||||
"attr_email": getattr(sso_row, "attr_email", None),
|
|
||||||
"attr_username": getattr(sso_row, "attr_username", None),
|
|
||||||
"attr_role": getattr(sso_row, "attr_role", None),
|
|
||||||
"default_role": getattr(sso_row, "default_role", None),
|
|
||||||
"auto_provision": getattr(sso_row, "auto_provision", False),
|
|
||||||
}
|
|
||||||
|
|
||||||
# ── 4. Scoring config (single row) ──────────────────────────────
|
|
||||||
sc = db.query(ScoringConfig).first()
|
|
||||||
scoring = None
|
|
||||||
if sc:
|
|
||||||
scoring = {
|
|
||||||
"weight_tests": sc.weight_tests,
|
|
||||||
"weight_detection_rules": sc.weight_detection_rules,
|
|
||||||
"weight_d3fend": sc.weight_d3fend,
|
|
||||||
"weight_recency": sc.weight_recency,
|
|
||||||
"weight_severity": sc.weight_severity,
|
|
||||||
}
|
|
||||||
|
|
||||||
# ── 5. Custom test templates only ───────────────────────────────
|
|
||||||
templates = [
|
|
||||||
{
|
|
||||||
"mitre_technique_id": t.mitre_technique_id,
|
|
||||||
"name": t.name,
|
|
||||||
"description": t.description,
|
|
||||||
"source": t.source,
|
|
||||||
"source_url": t.source_url,
|
|
||||||
"attack_procedure": t.attack_procedure,
|
|
||||||
"expected_detection": t.expected_detection,
|
|
||||||
"platform": t.platform,
|
|
||||||
"tool_suggested": t.tool_suggested,
|
|
||||||
"severity": t.severity,
|
|
||||||
"suggested_remediation": t.suggested_remediation,
|
|
||||||
"is_active": t.is_active,
|
|
||||||
}
|
|
||||||
for t in db.query(TestTemplate).filter(TestTemplate.source == "custom").all()
|
|
||||||
]
|
|
||||||
|
|
||||||
# ── 6. Users (sanitized — no passwords/tokens) ───────────────────
|
|
||||||
users = [
|
|
||||||
{
|
|
||||||
"username": u.username,
|
|
||||||
"email": u.email if hasattr(u, "email") else None,
|
|
||||||
"role": u.role,
|
|
||||||
"is_active": u.is_active,
|
|
||||||
"must_change_password": True, # force password reset on new instance # nosec B105
|
|
||||||
}
|
|
||||||
for u in db.query(User).order_by(User.username).all()
|
|
||||||
]
|
|
||||||
|
|
||||||
bundle = {
|
|
||||||
"_meta": {
|
|
||||||
"version": _EXPORT_VERSION,
|
|
||||||
"exported_at": datetime.utcnow().isoformat() + "Z",
|
|
||||||
"exported_by": current_user.username,
|
|
||||||
"note": (
|
|
||||||
"Sensitive values (passwords, API tokens, private keys) are REDACTED. "
|
|
||||||
"Re-enter them manually after import. "
|
|
||||||
"User passwords are NOT exported — users must reset passwords on first login."
|
|
||||||
),
|
|
||||||
},
|
|
||||||
"system_configs": system_configs,
|
|
||||||
"webhooks": webhooks,
|
|
||||||
"sso": sso,
|
|
||||||
"scoring": scoring,
|
|
||||||
"custom_templates": templates,
|
|
||||||
"users": users,
|
|
||||||
}
|
|
||||||
|
|
||||||
filename = f"aegis-config-{datetime.utcnow().strftime('%Y%m%d-%H%M%S')}.json"
|
|
||||||
return JSONResponse(
|
|
||||||
content=bundle,
|
|
||||||
headers={
|
|
||||||
"Content-Disposition": f'attachment; filename="{filename}"',
|
|
||||||
"X-Export-Version": _EXPORT_VERSION,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# POST /admin/import-config
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/import-config")
|
|
||||||
async def import_config(
|
|
||||||
request: Request,
|
|
||||||
db: Session = Depends(get_db),
|
|
||||||
current_user: User = Depends(require_role("admin")),
|
|
||||||
):
|
|
||||||
"""Restore platform configuration from a previously exported JSON bundle.
|
|
||||||
|
|
||||||
Idempotent: safe to run multiple times. Existing records are updated,
|
|
||||||
missing ones are created. REDACTED values are skipped (left as-is).
|
|
||||||
User passwords are set to a random temp value with must_change_password=True.
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
bundle = await request.json()
|
|
||||||
except Exception:
|
|
||||||
raise HTTPException(status_code=400, detail="Invalid JSON body")
|
|
||||||
|
|
||||||
meta = bundle.get("_meta", {})
|
|
||||||
version = meta.get("version", "unknown")
|
|
||||||
summary: dict[str, int] = {
|
|
||||||
"system_configs": 0,
|
|
||||||
"webhooks": 0,
|
|
||||||
"custom_templates": 0,
|
|
||||||
"users_created": 0,
|
|
||||||
"users_updated": 0,
|
|
||||||
"users_skipped_no_email": 0,
|
|
||||||
}
|
|
||||||
|
|
||||||
# ── 1. system_configs ────────────────────────────────────────────
|
|
||||||
for item in bundle.get("system_configs", []):
|
|
||||||
key = item.get("key")
|
|
||||||
value = item.get("value")
|
|
||||||
if not key or value == "[REDACTED]":
|
|
||||||
continue
|
|
||||||
row = db.query(SystemConfig).filter(SystemConfig.key == key).first()
|
|
||||||
if row:
|
|
||||||
row.value = value
|
|
||||||
row.description = item.get("description") or row.description
|
|
||||||
else:
|
|
||||||
db.add(SystemConfig(key=key, value=value, description=item.get("description")))
|
|
||||||
summary["system_configs"] += 1
|
|
||||||
|
|
||||||
# ── 2. webhooks ──────────────────────────────────────────────────
|
|
||||||
for item in bundle.get("webhooks", []):
|
|
||||||
name = item.get("name")
|
|
||||||
url = item.get("url")
|
|
||||||
if not name or not url:
|
|
||||||
continue
|
|
||||||
existing = db.query(WebhookConfig).filter(WebhookConfig.name == name).first()
|
|
||||||
if existing:
|
|
||||||
existing.url = url
|
|
||||||
existing.events = item.get("events", [])
|
|
||||||
existing.is_active = item.get("is_active", True)
|
|
||||||
existing.failure_count = 0
|
|
||||||
else:
|
|
||||||
db.add(WebhookConfig(
|
|
||||||
name=name,
|
|
||||||
url=url,
|
|
||||||
secret=None, # never restore secrets
|
|
||||||
events=item.get("events", []),
|
|
||||||
is_active=item.get("is_active", True),
|
|
||||||
created_by=current_user.id,
|
|
||||||
failure_count=0,
|
|
||||||
))
|
|
||||||
summary["webhooks"] += 1
|
|
||||||
|
|
||||||
# ── 3. SSO config ────────────────────────────────────────────────
|
|
||||||
sso_data = bundle.get("sso")
|
|
||||||
if sso_data:
|
|
||||||
sso_row = db.query(SsoConfig).first()
|
|
||||||
if sso_row:
|
|
||||||
for field, val in sso_data.items():
|
|
||||||
if val == "[REDACTED]":
|
|
||||||
continue
|
|
||||||
if hasattr(sso_row, field):
|
|
||||||
setattr(sso_row, field, val)
|
|
||||||
else:
|
|
||||||
clean = {k: v for k, v in sso_data.items() if v != "[REDACTED]"}
|
|
||||||
clean.pop("sp_private_key", None)
|
|
||||||
db.add(SsoConfig(**clean))
|
|
||||||
|
|
||||||
# ── 4. Scoring config ────────────────────────────────────────────
|
|
||||||
scoring_data = bundle.get("scoring")
|
|
||||||
if scoring_data:
|
|
||||||
sc = db.query(ScoringConfig).first()
|
|
||||||
if sc:
|
|
||||||
for field, val in scoring_data.items():
|
|
||||||
if hasattr(sc, field) and val is not None:
|
|
||||||
setattr(sc, field, val)
|
|
||||||
else:
|
|
||||||
db.add(ScoringConfig(**scoring_data))
|
|
||||||
|
|
||||||
# ── 5. Custom templates ──────────────────────────────────────────
|
|
||||||
for item in bundle.get("custom_templates", []):
|
|
||||||
name = item.get("name")
|
|
||||||
mitre_id = item.get("mitre_technique_id")
|
|
||||||
if not name or not mitre_id:
|
|
||||||
continue
|
|
||||||
existing = (
|
|
||||||
db.query(TestTemplate)
|
|
||||||
.filter(TestTemplate.name == name, TestTemplate.source == "custom")
|
|
||||||
.first()
|
|
||||||
)
|
|
||||||
if existing:
|
|
||||||
for field, val in item.items():
|
|
||||||
if hasattr(existing, field):
|
|
||||||
setattr(existing, field, val)
|
|
||||||
else:
|
|
||||||
db.add(TestTemplate(**{k: v for k, v in item.items()
|
|
||||||
if k not in ("id", "created_at")}))
|
|
||||||
summary["custom_templates"] += 1
|
|
||||||
|
|
||||||
# ── 6. Users ─────────────────────────────────────────────────────
|
|
||||||
# Email is the unique login identifier — a bundle entry with no email
|
|
||||||
# (e.g. exported from an older instance, before this requirement) can't
|
|
||||||
# be created; it's skipped rather than crashing the whole import.
|
|
||||||
import secrets as _secrets
|
|
||||||
for item in bundle.get("users", []):
|
|
||||||
email = item.get("email")
|
|
||||||
if not email:
|
|
||||||
summary["users_skipped_no_email"] += 1
|
|
||||||
continue
|
|
||||||
existing = db.query(User).filter(User.email == email).first()
|
|
||||||
if existing:
|
|
||||||
existing.role = item.get("role", existing.role)
|
|
||||||
existing.is_active = item.get("is_active", existing.is_active)
|
|
||||||
summary["users_updated"] += 1
|
|
||||||
else:
|
|
||||||
# Create with random temp password — user must reset on login
|
|
||||||
temp_pw = _secrets.token_urlsafe(16) + "Aa1!"
|
|
||||||
new_user = User(
|
|
||||||
username=email,
|
|
||||||
email=email,
|
|
||||||
hashed_password=hash_password(temp_pw),
|
|
||||||
role=item.get("role", "viewer"),
|
|
||||||
is_active=item.get("is_active", True),
|
|
||||||
must_change_password=True,
|
|
||||||
)
|
|
||||||
db.add(new_user)
|
|
||||||
summary["users_created"] += 1
|
|
||||||
|
|
||||||
db.commit()
|
|
||||||
|
|
||||||
return {
|
|
||||||
"status": "ok",
|
|
||||||
"imported_from_version": version,
|
|
||||||
"summary": summary,
|
|
||||||
"warnings": [
|
|
||||||
"REDACTED values were skipped — re-enter passwords/tokens manually.",
|
|
||||||
"All imported users have must_change_password=True.",
|
|
||||||
"SSO private key was not restored — re-upload it manually.",
|
|
||||||
],
|
|
||||||
}
|
|
||||||
@@ -1,104 +0,0 @@
|
|||||||
"""Phase 14: API Key management router."""
|
|
||||||
|
|
||||||
from typing import List
|
|
||||||
from uuid import UUID
|
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, Query
|
|
||||||
from sqlalchemy.orm import Session
|
|
||||||
|
|
||||||
from app.database import get_db
|
|
||||||
from app.dependencies.auth import get_current_user, require_any_role
|
|
||||||
from app.models.user import User
|
|
||||||
from app.schemas.api_key_schema import (
|
|
||||||
ApiKeyCreate, ApiKeyCreated, ApiKeyOut, ApiKeyUpdate,
|
|
||||||
)
|
|
||||||
import app.services.api_key_service as svc
|
|
||||||
|
|
||||||
router = APIRouter(prefix="/api-keys", tags=["API Keys"])
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("", response_model=ApiKeyCreated, status_code=201)
|
|
||||||
def create_key(
|
|
||||||
body: ApiKeyCreate,
|
|
||||||
db: Session = Depends(get_db),
|
|
||||||
current_user: User = Depends(get_current_user),
|
|
||||||
):
|
|
||||||
"""
|
|
||||||
Create a scoped API key.
|
|
||||||
|
|
||||||
The ``raw_key`` field in the response is shown **exactly once** and
|
|
||||||
cannot be retrieved later. Store it securely.
|
|
||||||
"""
|
|
||||||
key, raw_key = svc.create_api_key(
|
|
||||||
db,
|
|
||||||
user_id = current_user.id,
|
|
||||||
name = body.name,
|
|
||||||
scopes = body.scopes,
|
|
||||||
description = body.description,
|
|
||||||
expires_at = body.expires_at,
|
|
||||||
)
|
|
||||||
out = ApiKeyOut.model_validate(key)
|
|
||||||
return ApiKeyCreated(**out.model_dump(), raw_key=raw_key)
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("", response_model=List[ApiKeyOut])
|
|
||||||
def list_keys(
|
|
||||||
include_inactive: bool = Query(False),
|
|
||||||
db: Session = Depends(get_db),
|
|
||||||
current_user: User = Depends(get_current_user),
|
|
||||||
):
|
|
||||||
"""List API keys owned by the current user."""
|
|
||||||
# Admins can see all keys; others only see their own
|
|
||||||
user_id = None if current_user.role == "admin" else current_user.id
|
|
||||||
return svc.list_api_keys(db, user_id=user_id, include_inactive=include_inactive)
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{key_id}", response_model=ApiKeyOut)
|
|
||||||
def get_key(
|
|
||||||
key_id: UUID,
|
|
||||||
db: Session = Depends(get_db),
|
|
||||||
current_user: User = Depends(get_current_user),
|
|
||||||
):
|
|
||||||
"""Get a single API key (owner or admin)."""
|
|
||||||
user_id = None if current_user.role == "admin" else current_user.id
|
|
||||||
return svc.get_api_key(db, key_id, user_id=user_id)
|
|
||||||
|
|
||||||
|
|
||||||
@router.patch("/{key_id}", response_model=ApiKeyOut)
|
|
||||||
def update_key(
|
|
||||||
key_id: UUID,
|
|
||||||
body: ApiKeyUpdate,
|
|
||||||
db: Session = Depends(get_db),
|
|
||||||
current_user: User = Depends(get_current_user),
|
|
||||||
):
|
|
||||||
"""Update name, description, scopes, expiry, or active status."""
|
|
||||||
user_id = None if current_user.role == "admin" else current_user.id
|
|
||||||
return svc.update_api_key(
|
|
||||||
db, key_id, user_id,
|
|
||||||
name = body.name,
|
|
||||||
description = body.description,
|
|
||||||
scopes = body.scopes,
|
|
||||||
expires_at = body.expires_at,
|
|
||||||
is_active = body.is_active,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/{key_id}/revoke", response_model=ApiKeyOut)
|
|
||||||
def revoke_key(
|
|
||||||
key_id: UUID,
|
|
||||||
db: Session = Depends(get_db),
|
|
||||||
current_user: User = Depends(get_current_user),
|
|
||||||
):
|
|
||||||
"""Revoke an API key (soft-delete — sets is_active=False)."""
|
|
||||||
user_id = None if current_user.role == "admin" else current_user.id
|
|
||||||
return svc.revoke_api_key(db, key_id, user_id=user_id)
|
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/{key_id}", status_code=204)
|
|
||||||
def delete_key(
|
|
||||||
key_id: UUID,
|
|
||||||
db: Session = Depends(get_db),
|
|
||||||
current_user: User = Depends(require_any_role("admin")),
|
|
||||||
):
|
|
||||||
"""Permanently delete an API key (admin only)."""
|
|
||||||
svc.delete_api_key(db, key_id)
|
|
||||||
@@ -1,249 +0,0 @@
|
|||||||
"""Phase 10: Attack Paths & Advanced Purple Team router."""
|
|
||||||
|
|
||||||
from typing import Optional
|
|
||||||
from uuid import UUID
|
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends
|
|
||||||
from sqlalchemy.orm import Session
|
|
||||||
|
|
||||||
from app.database import get_db
|
|
||||||
from app.dependencies.auth import get_current_user, require_any_role
|
|
||||||
from app.schemas.attack_path_schema import (
|
|
||||||
AttackPathCreate, AttackPathUpdate, AttackPathOut,
|
|
||||||
AttackPathStepCreate, AttackPathStepUpdate, AttackPathStepOut,
|
|
||||||
ExecutionCreate, ExecutionOut,
|
|
||||||
StepExecuteRequest, StepResultOut,
|
|
||||||
TimelineEntryCreate, TimelineEntryOut,
|
|
||||||
)
|
|
||||||
from app.services import attack_path_service as svc
|
|
||||||
|
|
||||||
router = APIRouter(prefix="/attack-paths", tags=["attack-paths"])
|
|
||||||
|
|
||||||
|
|
||||||
# ── Attack Paths CRUD ─────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
@router.post("", response_model=AttackPathOut, status_code=201)
|
|
||||||
def create_attack_path(
|
|
||||||
body: AttackPathCreate,
|
|
||||||
db: Session = Depends(get_db),
|
|
||||||
user=Depends(get_current_user),
|
|
||||||
):
|
|
||||||
return svc.create_attack_path(db, body.model_dump(), user.id)
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("", response_model=list[AttackPathOut])
|
|
||||||
def list_attack_paths(
|
|
||||||
is_template: Optional[bool] = None,
|
|
||||||
technique_id: Optional[UUID] = None,
|
|
||||||
is_active: Optional[bool] = True,
|
|
||||||
db: Session = Depends(get_db),
|
|
||||||
user=Depends(get_current_user),
|
|
||||||
):
|
|
||||||
paths = svc.list_attack_paths(db, is_template=is_template,
|
|
||||||
technique_id=technique_id, is_active=is_active)
|
|
||||||
# Inject step_count
|
|
||||||
result = []
|
|
||||||
for p in paths:
|
|
||||||
d = AttackPathOut.model_validate(p)
|
|
||||||
d.step_count = len(p.steps)
|
|
||||||
result.append(d)
|
|
||||||
return result
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{path_id}", response_model=AttackPathOut)
|
|
||||||
def get_attack_path(
|
|
||||||
path_id: UUID,
|
|
||||||
db: Session = Depends(get_db),
|
|
||||||
user=Depends(get_current_user),
|
|
||||||
):
|
|
||||||
p = svc.get_attack_path(db, path_id)
|
|
||||||
d = AttackPathOut.model_validate(p)
|
|
||||||
d.step_count = len(p.steps)
|
|
||||||
return d
|
|
||||||
|
|
||||||
|
|
||||||
@router.patch("/{path_id}", response_model=AttackPathOut)
|
|
||||||
def update_attack_path(
|
|
||||||
path_id: UUID,
|
|
||||||
body: AttackPathUpdate,
|
|
||||||
db: Session = Depends(get_db),
|
|
||||||
user=Depends(get_current_user),
|
|
||||||
):
|
|
||||||
return svc.update_attack_path(db, path_id, body.model_dump(exclude_unset=True), user.id)
|
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/{path_id}", status_code=204)
|
|
||||||
def delete_attack_path(
|
|
||||||
path_id: UUID,
|
|
||||||
db: Session = Depends(get_db),
|
|
||||||
user=Depends(require_any_role("admin", "red_lead", "blue_lead")),
|
|
||||||
):
|
|
||||||
svc.delete_attack_path(db, path_id, user.id)
|
|
||||||
|
|
||||||
|
|
||||||
# ── Steps ─────────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
@router.get("/{path_id}/steps", response_model=list[AttackPathStepOut])
|
|
||||||
def list_steps(
|
|
||||||
path_id: UUID,
|
|
||||||
db: Session = Depends(get_db),
|
|
||||||
user=Depends(get_current_user),
|
|
||||||
):
|
|
||||||
path = svc.get_attack_path(db, path_id)
|
|
||||||
return path.steps
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/{path_id}/steps", response_model=AttackPathStepOut, status_code=201)
|
|
||||||
def add_step(
|
|
||||||
path_id: UUID,
|
|
||||||
body: AttackPathStepCreate,
|
|
||||||
db: Session = Depends(get_db),
|
|
||||||
user=Depends(get_current_user),
|
|
||||||
):
|
|
||||||
return svc.add_step(db, path_id, body.model_dump(), user.id)
|
|
||||||
|
|
||||||
|
|
||||||
@router.patch("/{path_id}/steps/{step_id}", response_model=AttackPathStepOut)
|
|
||||||
def update_step(
|
|
||||||
path_id: UUID,
|
|
||||||
step_id: UUID,
|
|
||||||
body: AttackPathStepUpdate,
|
|
||||||
db: Session = Depends(get_db),
|
|
||||||
user=Depends(get_current_user),
|
|
||||||
):
|
|
||||||
return svc.update_step(db, step_id, body.model_dump(exclude_unset=True), user.id)
|
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/{path_id}/steps/{step_id}", status_code=204)
|
|
||||||
def delete_step(
|
|
||||||
path_id: UUID,
|
|
||||||
step_id: UUID,
|
|
||||||
db: Session = Depends(get_db),
|
|
||||||
user=Depends(get_current_user),
|
|
||||||
):
|
|
||||||
svc.delete_step(db, step_id, user.id)
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/{path_id}/steps/reorder", response_model=list[AttackPathStepOut])
|
|
||||||
def reorder_steps(
|
|
||||||
path_id: UUID,
|
|
||||||
step_ids: list[UUID],
|
|
||||||
db: Session = Depends(get_db),
|
|
||||||
user=Depends(get_current_user),
|
|
||||||
):
|
|
||||||
"""Pass an ordered list of step UUIDs to reorder the steps."""
|
|
||||||
return svc.reorder_steps(db, path_id, step_ids, user.id)
|
|
||||||
|
|
||||||
|
|
||||||
# ── Executions ────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
@router.post("/{path_id}/executions", response_model=ExecutionOut, status_code=201)
|
|
||||||
def create_execution(
|
|
||||||
path_id: UUID,
|
|
||||||
body: ExecutionCreate,
|
|
||||||
db: Session = Depends(get_db),
|
|
||||||
user=Depends(get_current_user),
|
|
||||||
):
|
|
||||||
return svc.create_execution(db, path_id, body.model_dump(), user.id)
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{path_id}/executions", response_model=list[ExecutionOut])
|
|
||||||
def list_executions(
|
|
||||||
path_id: UUID,
|
|
||||||
db: Session = Depends(get_db),
|
|
||||||
user=Depends(get_current_user),
|
|
||||||
):
|
|
||||||
return svc.list_executions(db, path_id)
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/executions/{execution_id}", response_model=ExecutionOut)
|
|
||||||
def get_execution(
|
|
||||||
execution_id: UUID,
|
|
||||||
db: Session = Depends(get_db),
|
|
||||||
user=Depends(get_current_user),
|
|
||||||
):
|
|
||||||
return svc.get_execution(db, execution_id)
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/executions/{execution_id}/start", response_model=ExecutionOut)
|
|
||||||
def start_execution(
|
|
||||||
execution_id: UUID,
|
|
||||||
db: Session = Depends(get_db),
|
|
||||||
user=Depends(get_current_user),
|
|
||||||
):
|
|
||||||
return svc.start_execution(db, execution_id, user.id)
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/executions/{execution_id}/steps/{step_id}", response_model=StepResultOut)
|
|
||||||
def execute_step(
|
|
||||||
execution_id: UUID,
|
|
||||||
step_id: UUID,
|
|
||||||
body: StepExecuteRequest,
|
|
||||||
db: Session = Depends(get_db),
|
|
||||||
user=Depends(get_current_user),
|
|
||||||
):
|
|
||||||
"""Record the result of one step (detected / not_detected / skipped)."""
|
|
||||||
return svc.execute_step(db, execution_id, step_id, body.model_dump(), user.id)
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/executions/{execution_id}/steps", response_model=list[StepResultOut])
|
|
||||||
def list_step_results(
|
|
||||||
execution_id: UUID,
|
|
||||||
db: Session = Depends(get_db),
|
|
||||||
user=Depends(get_current_user),
|
|
||||||
):
|
|
||||||
ex = svc.get_execution(db, execution_id)
|
|
||||||
return ex.step_results
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/executions/{execution_id}/complete", response_model=ExecutionOut)
|
|
||||||
def complete_execution(
|
|
||||||
execution_id: UUID,
|
|
||||||
db: Session = Depends(get_db),
|
|
||||||
user=Depends(get_current_user),
|
|
||||||
):
|
|
||||||
"""Mark execution as complete and compute kill-chain metrics."""
|
|
||||||
return svc.complete_execution(db, execution_id, user.id)
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/executions/{execution_id}/abort", response_model=ExecutionOut)
|
|
||||||
def abort_execution(
|
|
||||||
execution_id: UUID,
|
|
||||||
db: Session = Depends(get_db),
|
|
||||||
user=Depends(require_any_role("admin", "red_lead", "blue_lead")),
|
|
||||||
):
|
|
||||||
return svc.abort_execution(db, execution_id, user.id)
|
|
||||||
|
|
||||||
|
|
||||||
# ── Timeline ──────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
@router.post("/executions/{execution_id}/timeline",
|
|
||||||
response_model=TimelineEntryOut, status_code=201)
|
|
||||||
def add_timeline_entry(
|
|
||||||
execution_id: UUID,
|
|
||||||
body: TimelineEntryCreate,
|
|
||||||
db: Session = Depends(get_db),
|
|
||||||
user=Depends(get_current_user),
|
|
||||||
):
|
|
||||||
return svc.add_timeline_entry(db, execution_id, body.model_dump(), user.id)
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/executions/{execution_id}/timeline", response_model=list[TimelineEntryOut])
|
|
||||||
def get_timeline(
|
|
||||||
execution_id: UUID,
|
|
||||||
db: Session = Depends(get_db),
|
|
||||||
user=Depends(get_current_user),
|
|
||||||
):
|
|
||||||
return svc.get_timeline(db, execution_id)
|
|
||||||
|
|
||||||
|
|
||||||
# ── Kill-Chain Metrics ────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
@router.get("/executions/{execution_id}/metrics")
|
|
||||||
def get_metrics(
|
|
||||||
execution_id: UUID,
|
|
||||||
db: Session = Depends(get_db),
|
|
||||||
user=Depends(get_current_user),
|
|
||||||
):
|
|
||||||
"""Return full kill-chain metrics for a completed (or partial) execution."""
|
|
||||||
return svc.get_kill_chain_metrics(db, execution_id)
|
|
||||||
+11
-141
@@ -16,18 +16,14 @@ from fastapi import APIRouter, Cookie, Depends, Request, Response
|
|||||||
# Import OAuth2PasswordRequestForm from fastapi.security
|
# Import OAuth2PasswordRequestForm from fastapi.security
|
||||||
from fastapi.security import OAuth2PasswordRequestForm
|
from fastapi.security import OAuth2PasswordRequestForm
|
||||||
|
|
||||||
# Import datetime, timezone from datetime
|
|
||||||
from datetime import datetime, timezone
|
|
||||||
|
|
||||||
# Import jwt (PyJWT)
|
# Import jwt (PyJWT)
|
||||||
import jwt
|
import jwt
|
||||||
from jwt.exceptions import PyJWTError as JWTError
|
|
||||||
|
|
||||||
# Import Session from sqlalchemy.orm
|
# Import Session from sqlalchemy.orm
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
# Import blacklist_token, create_access_token, verify_pa... from app.auth
|
# Import blacklist_token, create_access_token, verify_pa... from app.auth
|
||||||
from app.auth import blacklist_token, create_access_token, is_token_blacklisted, verify_password
|
from app.auth import blacklist_token, create_access_token, verify_password
|
||||||
|
|
||||||
# Import settings from app.config
|
# Import settings from app.config
|
||||||
from app.config import settings
|
from app.config import settings
|
||||||
@@ -57,7 +53,7 @@ from app.models.user import User
|
|||||||
from app.schemas.auth import TokenResponse, UserOut
|
from app.schemas.auth import TokenResponse, UserOut
|
||||||
|
|
||||||
# Import PasswordChange from app.schemas.user
|
# Import PasswordChange from app.schemas.user
|
||||||
from app.schemas.user import PasswordChange, SetPasswordRequest, SetPasswordTokenValidateOut
|
from app.schemas.user import PasswordChange
|
||||||
|
|
||||||
# Import log_action from app.services.audit_service
|
# Import log_action from app.services.audit_service
|
||||||
from app.services.audit_service import log_action
|
from app.services.audit_service import log_action
|
||||||
@@ -72,25 +68,12 @@ from app.services.auth_service import (
|
|||||||
change_password as auth_change_password,
|
change_password as auth_change_password,
|
||||||
)
|
)
|
||||||
|
|
||||||
from app.domain.errors import EntityNotFoundError
|
|
||||||
from app.services.password_setup_service import (
|
|
||||||
consume_token_and_set_password,
|
|
||||||
validate_token as validate_password_setup_token,
|
|
||||||
)
|
|
||||||
|
|
||||||
# Assign router = APIRouter(prefix="/auth", tags=["auth"])
|
# Assign router = APIRouter(prefix="/auth", tags=["auth"])
|
||||||
router = APIRouter(prefix="/auth", tags=["auth"])
|
router = APIRouter(prefix="/auth", tags=["auth"])
|
||||||
|
|
||||||
# SECURE_COOKIES desacopla la seguridad de la cookie del entorno de ejecucion.
|
# Assign _IS_HTTPS = os.environ.get("AEGIS_ENV", "").lower() == "production"
|
||||||
# Por defecto activo en produccion; ponlo en "false" para servidores HTTP.
|
_IS_HTTPS = os.environ.get("AEGIS_ENV", "").lower() == "production"
|
||||||
_aegis_env = os.environ.get("AEGIS_ENV", "development").lower()
|
# Assign _COOKIE_NAME = "aegis_token"
|
||||||
_secure_cookie_env = os.environ.get("SECURE_COOKIES", "auto").lower()
|
|
||||||
if _secure_cookie_env == "false":
|
|
||||||
_IS_HTTPS = False
|
|
||||||
elif _secure_cookie_env == "true":
|
|
||||||
_IS_HTTPS = True
|
|
||||||
else: # "auto" — activo solo si AEGIS_ENV=production
|
|
||||||
_IS_HTTPS = _aegis_env == "production"
|
|
||||||
_COOKIE_NAME = "aegis_token"
|
_COOKIE_NAME = "aegis_token"
|
||||||
|
|
||||||
|
|
||||||
@@ -114,10 +97,8 @@ def login(
|
|||||||
Rate-limited to **5 attempts per minute per IP**. Failed and successful
|
Rate-limited to **5 attempts per minute per IP**. Failed and successful
|
||||||
logins are recorded in the audit log (SEC-009).
|
logins are recorded in the audit log (SEC-009).
|
||||||
"""
|
"""
|
||||||
# OAuth2PasswordRequestForm's field is spec-named "username" but the
|
# Assign user = db.query(User).filter(User.username == form_data.username).first()
|
||||||
# value a user actually types in is their email — email is the unique
|
user = db.query(User).filter(User.username == form_data.username).first()
|
||||||
# login identifier (username is an internal id, always kept == email).
|
|
||||||
user = db.query(User).filter(User.email == form_data.username).first()
|
|
||||||
# Assign target_hash = user.hashed_password if user else _DUMMY_HASH
|
# Assign target_hash = user.hashed_password if user else _DUMMY_HASH
|
||||||
target_hash = user.hashed_password if user else _DUMMY_HASH
|
target_hash = user.hashed_password if user else _DUMMY_HASH
|
||||||
# Assign password_valid = verify_password(form_data.password, target_hash)
|
# Assign password_valid = verify_password(form_data.password, target_hash)
|
||||||
@@ -142,7 +123,7 @@ def login(
|
|||||||
# Keyword argument: details
|
# Keyword argument: details
|
||||||
details={
|
details={
|
||||||
# Literal argument value
|
# Literal argument value
|
||||||
"email": form_data.username,
|
"username": form_data.username,
|
||||||
# Literal argument value
|
# Literal argument value
|
||||||
"ip": ip,
|
"ip": ip,
|
||||||
# Literal argument value
|
# Literal argument value
|
||||||
@@ -154,7 +135,7 @@ def login(
|
|||||||
# Call uow.commit()
|
# Call uow.commit()
|
||||||
uow.commit()
|
uow.commit()
|
||||||
# Raise BusinessRuleViolation
|
# Raise BusinessRuleViolation
|
||||||
raise BusinessRuleViolation("Incorrect email or password")
|
raise BusinessRuleViolation("Incorrect username or password")
|
||||||
|
|
||||||
# Check: not user.is_active
|
# Check: not user.is_active
|
||||||
if not user.is_active:
|
if not user.is_active:
|
||||||
@@ -176,21 +157,13 @@ def login(
|
|||||||
"auth",
|
"auth",
|
||||||
str(user.id),
|
str(user.id),
|
||||||
# Keyword argument: details
|
# Keyword argument: details
|
||||||
details={"email": user.email, "ip": ip},
|
details={"username": user.username, "ip": ip},
|
||||||
# Keyword argument: ip_address
|
# Keyword argument: ip_address
|
||||||
ip_address=ip,
|
ip_address=ip,
|
||||||
)
|
)
|
||||||
# Call uow.commit()
|
# Call uow.commit()
|
||||||
uow.commit()
|
uow.commit()
|
||||||
|
|
||||||
# Auto-discover the user's Jira account ID on every login (non-fatal)
|
|
||||||
try:
|
|
||||||
from app.services.jira_service import lookup_user_jira_account_id
|
|
||||||
if lookup_user_jira_account_id(db, user):
|
|
||||||
db.commit()
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
# Call response.set_cookie()
|
# Call response.set_cookie()
|
||||||
response.set_cookie(
|
response.set_cookie(
|
||||||
# Keyword argument: key
|
# Keyword argument: key
|
||||||
@@ -283,76 +256,7 @@ def logout(
|
|||||||
return {"detail": "Logged out"}
|
return {"detail": "Logged out"}
|
||||||
|
|
||||||
|
|
||||||
# A token that has *just* expired can still be refreshed within this window.
|
# Apply the @router.get decorator
|
||||||
# Without this grace period, `/auth/refresh` decodes the same token with the
|
|
||||||
# same strict expiry check as every other endpoint — so by the time a 401
|
|
||||||
# triggers a refresh attempt, the refresh call itself has also expired and
|
|
||||||
# the session is unrecoverable. The grace window only widens the refresh
|
|
||||||
# check; a brand-new token is always issued with the full expiry.
|
|
||||||
_REFRESH_GRACE_SECONDS = 15 * 60
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/refresh", response_model=TokenResponse)
|
|
||||||
def refresh_token(
|
|
||||||
response: Response,
|
|
||||||
aegis_token: str | None = Cookie(None),
|
|
||||||
db: Session = Depends(get_db),
|
|
||||||
):
|
|
||||||
"""Issue a new access token if the current one is valid or recently expired.
|
|
||||||
|
|
||||||
Called automatically by the frontend when it detects an expired
|
|
||||||
session while the user is actively using the app. Expiry is checked
|
|
||||||
manually (with `_REFRESH_GRACE_SECONDS` of leeway) instead of relying on
|
|
||||||
PyJWT's built-in check, so a token that expired moments ago can still
|
|
||||||
be refreshed. Revoked (blacklisted) tokens are never refreshable.
|
|
||||||
"""
|
|
||||||
if not aegis_token:
|
|
||||||
raise PermissionViolation("No active session")
|
|
||||||
|
|
||||||
try:
|
|
||||||
payload = jwt.decode(
|
|
||||||
aegis_token,
|
|
||||||
settings.SECRET_KEY,
|
|
||||||
algorithms=[settings.ALGORITHM],
|
|
||||||
options={"verify_exp": False},
|
|
||||||
)
|
|
||||||
except JWTError:
|
|
||||||
raise PermissionViolation("Session expired — please log in again")
|
|
||||||
|
|
||||||
jti: str | None = payload.get("jti")
|
|
||||||
if jti and is_token_blacklisted(jti):
|
|
||||||
raise PermissionViolation("Session expired — please log in again")
|
|
||||||
|
|
||||||
exp = payload.get("exp")
|
|
||||||
now = datetime.now(timezone.utc).timestamp()
|
|
||||||
if exp is None or now - exp > _REFRESH_GRACE_SECONDS:
|
|
||||||
raise PermissionViolation("Session expired — please log in again")
|
|
||||||
|
|
||||||
username: str | None = payload.get("sub")
|
|
||||||
if not username:
|
|
||||||
raise PermissionViolation("Invalid session")
|
|
||||||
|
|
||||||
user = db.query(User).filter(User.username == username).first()
|
|
||||||
if user is None or not user.is_active:
|
|
||||||
raise PermissionViolation("Account not found or disabled")
|
|
||||||
|
|
||||||
if getattr(user, "must_change_password", False):
|
|
||||||
raise PermissionViolation("Password change required before refreshing session")
|
|
||||||
|
|
||||||
# Issue a fresh token with a new expiry
|
|
||||||
new_token = create_access_token(data={"sub": user.username})
|
|
||||||
response.set_cookie(
|
|
||||||
key=_COOKIE_NAME,
|
|
||||||
value=new_token,
|
|
||||||
httponly=True,
|
|
||||||
secure=_IS_HTTPS,
|
|
||||||
samesite="strict",
|
|
||||||
max_age=settings.ACCESS_TOKEN_EXPIRE_MINUTES * 60,
|
|
||||||
path="/",
|
|
||||||
)
|
|
||||||
return TokenResponse(access_token=new_token)
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/me", response_model=UserOut)
|
@router.get("/me", response_model=UserOut)
|
||||||
# Define function read_current_user
|
# Define function read_current_user
|
||||||
def read_current_user(current_user: User = Depends(get_current_user)) -> UserOut:
|
def read_current_user(current_user: User = Depends(get_current_user)) -> UserOut:
|
||||||
@@ -389,37 +293,3 @@ def change_password(
|
|||||||
|
|
||||||
# Return {"detail": "Password changed successfully"}
|
# Return {"detail": "Password changed successfully"}
|
||||||
return {"detail": "Password changed successfully"}
|
return {"detail": "Password changed successfully"}
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Set password via a one-time emailed link — public (no auth), rate-limited
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/set-password/validate", response_model=SetPasswordTokenValidateOut)
|
|
||||||
@limiter.limit("20/minute")
|
|
||||||
def validate_set_password_token(request: Request, token: str, db: Session = Depends(get_db)) -> SetPasswordTokenValidateOut:
|
|
||||||
"""Check a set-password token before rendering the form. Public endpoint."""
|
|
||||||
try:
|
|
||||||
user = validate_password_setup_token(db, token)
|
|
||||||
except (EntityNotFoundError, BusinessRuleViolation):
|
|
||||||
return SetPasswordTokenValidateOut(valid=False)
|
|
||||||
return SetPasswordTokenValidateOut(valid=True, full_name=user.full_name)
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/set-password")
|
|
||||||
@limiter.limit("10/minute")
|
|
||||||
def set_password(request: Request, body: SetPasswordRequest, db: Session = Depends(get_db)) -> dict:
|
|
||||||
"""Complete a password setup/reset using a one-time token. Public endpoint."""
|
|
||||||
with UnitOfWork(db) as uow:
|
|
||||||
user = consume_token_and_set_password(db, body.token, body.new_password)
|
|
||||||
log_action(
|
|
||||||
db,
|
|
||||||
user.id,
|
|
||||||
"SET_PASSWORD_VIA_TOKEN",
|
|
||||||
"auth",
|
|
||||||
str(user.id),
|
|
||||||
details={},
|
|
||||||
)
|
|
||||||
uow.commit()
|
|
||||||
return {"detail": "Password set successfully — you can now log in"}
|
|
||||||
|
|||||||
@@ -9,7 +9,8 @@ import logging
|
|||||||
|
|
||||||
# Import uuid
|
# Import uuid
|
||||||
import uuid
|
import uuid
|
||||||
from datetime import datetime
|
|
||||||
|
# Import Optional from typing
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
# Import APIRouter, Depends, Query from fastapi
|
# Import APIRouter, Depends, Query from fastapi
|
||||||
@@ -25,16 +26,23 @@ from sqlalchemy.orm import Session
|
|||||||
from app.database import get_db
|
from app.database import get_db
|
||||||
|
|
||||||
# Import get_current_user, require_any_role from app.dependencies.auth
|
# Import get_current_user, require_any_role from app.dependencies.auth
|
||||||
from app.dependencies.auth import get_current_user, require_any_role, require_any_role_strict
|
from app.dependencies.auth import get_current_user, require_any_role
|
||||||
|
|
||||||
# Import UnitOfWork from app.domain.unit_of_work
|
# Import UnitOfWork from app.domain.unit_of_work
|
||||||
from app.domain.unit_of_work import UnitOfWork
|
from app.domain.unit_of_work import UnitOfWork
|
||||||
|
|
||||||
# Import User from app.models.user
|
# Import User from app.models.user
|
||||||
from app.models.user import User
|
from app.models.user import User
|
||||||
from app.models.campaign import Campaign, CampaignTest
|
|
||||||
from app.models.test import Test
|
# Import log_action from app.services.audit_service
|
||||||
from app.services.campaign_service import generate_campaign_from_threat_actor
|
from app.services.audit_service import log_action
|
||||||
|
|
||||||
|
# Import from app.services.campaign_crud_service
|
||||||
|
from app.services.campaign_crud_service import (
|
||||||
|
activate_campaign as crud_activate,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Import from app.services.campaign_crud_service
|
||||||
from app.services.campaign_crud_service import (
|
from app.services.campaign_crud_service import (
|
||||||
add_test_to_campaign as crud_add_test,
|
add_test_to_campaign as crud_add_test,
|
||||||
)
|
)
|
||||||
@@ -47,7 +55,10 @@ from app.services.campaign_crud_service import (
|
|||||||
# Import from app.services.campaign_crud_service
|
# Import from app.services.campaign_crud_service
|
||||||
from app.services.campaign_crud_service import (
|
from app.services.campaign_crud_service import (
|
||||||
create_campaign as crud_create,
|
create_campaign as crud_create,
|
||||||
delete_campaign as crud_delete,
|
)
|
||||||
|
|
||||||
|
# Import from app.services.campaign_crud_service
|
||||||
|
from app.services.campaign_crud_service import (
|
||||||
get_campaign_detail as crud_get_detail,
|
get_campaign_detail as crud_get_detail,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -56,10 +67,6 @@ from app.services.campaign_crud_service import (
|
|||||||
get_campaign_history as crud_get_history,
|
get_campaign_history as crud_get_history,
|
||||||
)
|
)
|
||||||
|
|
||||||
from app.services.campaign_crud_service import (
|
|
||||||
get_campaign_timeline as crud_get_timeline,
|
|
||||||
)
|
|
||||||
|
|
||||||
# Import from app.services.campaign_crud_service
|
# Import from app.services.campaign_crud_service
|
||||||
from app.services.campaign_crud_service import (
|
from app.services.campaign_crud_service import (
|
||||||
get_campaign_progress_data as crud_get_progress,
|
get_campaign_progress_data as crud_get_progress,
|
||||||
@@ -90,42 +97,11 @@ from app.services.campaign_crud_service import (
|
|||||||
update_campaign as crud_update,
|
update_campaign as crud_update,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Import activate_campaign from app.services.campaign_crud_service
|
# Import generate_campaign_from_threat_actor from app.services.campaign_service
|
||||||
from app.services.campaign_crud_service import (
|
from app.services.campaign_service import generate_campaign_from_threat_actor
|
||||||
activate_campaign as crud_activate,
|
|
||||||
)
|
|
||||||
|
|
||||||
from app.services.campaign_crud_service import (
|
|
||||||
submit_campaign_for_approval as crud_submit,
|
|
||||||
)
|
|
||||||
from app.services.campaign_crud_service import (
|
|
||||||
approve_campaign as crud_approve,
|
|
||||||
)
|
|
||||||
from app.services.campaign_crud_service import (
|
|
||||||
reject_campaign as crud_reject,
|
|
||||||
)
|
|
||||||
from app.services.campaign_crud_service import (
|
|
||||||
create_modification_request as crud_create_mod_request,
|
|
||||||
)
|
|
||||||
from app.services.campaign_crud_service import (
|
|
||||||
approve_modification_request as crud_approve_mod_request,
|
|
||||||
)
|
|
||||||
from app.services.campaign_crud_service import (
|
|
||||||
reject_modification_request as crud_reject_mod_request,
|
|
||||||
)
|
|
||||||
from app.services.campaign_crud_service import (
|
|
||||||
list_modification_requests as crud_list_mod_requests,
|
|
||||||
)
|
|
||||||
from app.services.campaign_crud_service import (
|
|
||||||
serialize_modification_request as crud_serialize_mod_request,
|
|
||||||
)
|
|
||||||
|
|
||||||
# Import log_action from app.services.audit_service
|
|
||||||
from app.services.audit_service import log_action
|
|
||||||
|
|
||||||
# Import notify_role from app.services.notification_service
|
# Import notify_role from app.services.notification_service
|
||||||
from app.services.notification_service import notify_role, notify_roles_by_email
|
from app.services.notification_service import notify_role
|
||||||
from app.services.webhook_service import dispatch_webhook
|
|
||||||
|
|
||||||
# Assign logger = logging.getLogger(__name__)
|
# Assign logger = logging.getLogger(__name__)
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -134,24 +110,6 @@ logger = logging.getLogger(__name__)
|
|||||||
router = APIRouter(prefix="/campaigns", tags=["campaigns"])
|
router = APIRouter(prefix="/campaigns", tags=["campaigns"])
|
||||||
|
|
||||||
|
|
||||||
def _create_jira_tickets_for_campaign(db: Session, campaign: Campaign, campaign_id: str, user: User) -> None:
|
|
||||||
"""Create Jira tickets for *campaign* now, if its start_date has arrived.
|
|
||||||
|
|
||||||
Shared by both paths that bring a campaign to ``active``: the admin-only
|
|
||||||
emergency `/activate` override and the normal manager `/approve` flow.
|
|
||||||
If ``start_date`` is still in the future, ticket creation is skipped
|
|
||||||
here and left to the periodic ``sync_due_campaign_jira_tickets`` job,
|
|
||||||
which creates them once that date actually arrives — this is what makes
|
|
||||||
the campaign's real scheduled date/time authoritative for when tickets
|
|
||||||
(and their Jira start-date field) appear, instead of always at approval
|
|
||||||
time.
|
|
||||||
"""
|
|
||||||
if campaign.start_date and campaign.start_date > datetime.utcnow():
|
|
||||||
return
|
|
||||||
from app.services.jira_service import ensure_campaign_jira_tickets
|
|
||||||
ensure_campaign_jira_tickets(db, campaign, user)
|
|
||||||
|
|
||||||
|
|
||||||
# ── Pydantic schemas ─────────────────────────────────────────────────
|
# ── Pydantic schemas ─────────────────────────────────────────────────
|
||||||
|
|
||||||
class CampaignCreate(BaseModel):
|
class CampaignCreate(BaseModel):
|
||||||
@@ -171,11 +129,6 @@ class CampaignCreate(BaseModel):
|
|||||||
tags: Optional[list[str]] = Field(default_factory=list)
|
tags: Optional[list[str]] = Field(default_factory=list)
|
||||||
# Assign scheduled_at = None
|
# Assign scheduled_at = None
|
||||||
scheduled_at: Optional[str] = None
|
scheduled_at: Optional[str] = None
|
||||||
# Only honored when the creator is a manager — see create_campaign():
|
|
||||||
# a manager's own campaign is auto-approved on creation instead of
|
|
||||||
# going through the draft -> submit -> approve queue, so they need to
|
|
||||||
# supply the start_date up front instead of via a later /approve call.
|
|
||||||
start_date: Optional[str] = None
|
|
||||||
|
|
||||||
|
|
||||||
# Define class CampaignUpdate
|
# Define class CampaignUpdate
|
||||||
@@ -222,34 +175,6 @@ class SchedulePayload(BaseModel):
|
|||||||
next_run_at: Optional[str] = None
|
next_run_at: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
class ApprovePayload(BaseModel):
|
|
||||||
"""Payload for a manager approving a pending campaign."""
|
|
||||||
|
|
||||||
start_date: str # ISO date/datetime — required, campaign won't activate without it
|
|
||||||
|
|
||||||
|
|
||||||
class RejectPayload(BaseModel):
|
|
||||||
"""Payload for a manager rejecting a pending campaign."""
|
|
||||||
|
|
||||||
reason: str
|
|
||||||
|
|
||||||
|
|
||||||
class ModificationRequestPayload(BaseModel):
|
|
||||||
"""Payload for a lead requesting to add/remove a test on an active campaign."""
|
|
||||||
|
|
||||||
action: str # add_test | remove_test
|
|
||||||
test_id: str
|
|
||||||
justification: str
|
|
||||||
order_index: Optional[int] = None
|
|
||||||
phase: Optional[str] = None
|
|
||||||
|
|
||||||
|
|
||||||
class RejectModificationPayload(BaseModel):
|
|
||||||
"""Payload for a manager rejecting a modification request."""
|
|
||||||
|
|
||||||
review_notes: str
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# GET /campaigns — List campaigns with filters
|
# GET /campaigns — List campaigns with filters
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -273,7 +198,7 @@ def list_campaigns(
|
|||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
# Entry: current_user
|
# Entry: current_user
|
||||||
current_user: User = Depends(get_current_user),
|
current_user: User = Depends(get_current_user),
|
||||||
) -> dict:
|
) -> list:
|
||||||
"""List campaigns with optional filters and pagination.
|
"""List campaigns with optional filters and pagination.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
@@ -319,28 +244,18 @@ def create_campaign(
|
|||||||
# Entry: db
|
# Entry: db
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
# Entry: current_user
|
# Entry: current_user
|
||||||
# Strict variant — admin must NOT get a free pass here. Admin
|
current_user: User = Depends(require_any_role("red_lead", "blue_lead")),
|
||||||
# administers the site, not campaign content; the only admin path to
|
|
||||||
# an active campaign is the emergency /activate override below.
|
|
||||||
current_user: User = Depends(require_any_role_strict("red_lead", "blue_lead", "manager")),
|
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""Create a new campaign.
|
"""Create a new campaign.
|
||||||
|
|
||||||
A manager's campaign is auto-approved on creation — a manager is the
|
|
||||||
same role that would otherwise approve it, so routing it through the
|
|
||||||
draft -> submit -> pending_approval queue would just mean approving
|
|
||||||
their own submission. red_lead/blue_lead campaigns still go through
|
|
||||||
that queue as before.
|
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
payload (CampaignCreate): Fields for the new campaign (name, type, threat actor, etc.).
|
payload (CampaignCreate): Fields for the new campaign (name, type, threat actor, etc.).
|
||||||
db (Session): SQLAlchemy database session.
|
db (Session): SQLAlchemy database session.
|
||||||
current_user (User): Authenticated red_lead, blue_lead, or manager creating the campaign.
|
current_user (User): Authenticated red_lead or blue_lead creating the campaign.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
dict: Serialised representation of the newly created campaign.
|
dict: Serialised representation of the newly created campaign.
|
||||||
"""
|
"""
|
||||||
is_manager_create = current_user.role == "manager"
|
|
||||||
# Open context manager
|
# Open context manager
|
||||||
with UnitOfWork(db) as uow:
|
with UnitOfWork(db) as uow:
|
||||||
# Assign result = crud_create(
|
# Assign result = crud_create(
|
||||||
@@ -362,32 +277,24 @@ def create_campaign(
|
|||||||
tags=payload.tags,
|
tags=payload.tags,
|
||||||
# Keyword argument: scheduled_at
|
# Keyword argument: scheduled_at
|
||||||
scheduled_at=payload.scheduled_at,
|
scheduled_at=payload.scheduled_at,
|
||||||
auto_approve=is_manager_create,
|
|
||||||
start_date=payload.start_date if is_manager_create else None,
|
|
||||||
approver_id=current_user.id if is_manager_create else None,
|
|
||||||
)
|
)
|
||||||
campaign_id = result["id"]
|
# Call log_action()
|
||||||
log_action(
|
log_action(
|
||||||
db,
|
db,
|
||||||
# Keyword argument: user_id
|
# Keyword argument: user_id
|
||||||
user_id=current_user.id,
|
user_id=current_user.id,
|
||||||
# Keyword argument: action
|
# Keyword argument: action
|
||||||
action="create_campaign_auto_approved" if is_manager_create else "create_campaign",
|
action="create_campaign",
|
||||||
# Keyword argument: entity_type
|
# Keyword argument: entity_type
|
||||||
entity_type="campaign",
|
entity_type="campaign",
|
||||||
entity_id=campaign_id,
|
# Keyword argument: entity_id
|
||||||
|
entity_id=result["id"],
|
||||||
|
# Keyword argument: details
|
||||||
details={"name": payload.name, "type": payload.type},
|
details={"name": payload.name, "type": payload.type},
|
||||||
)
|
)
|
||||||
# Call uow.commit()
|
# Call uow.commit()
|
||||||
uow.commit()
|
uow.commit()
|
||||||
|
|
||||||
if is_manager_create:
|
|
||||||
campaign = db.query(Campaign).filter(Campaign.id == uuid.UUID(campaign_id)).first()
|
|
||||||
db.refresh(campaign)
|
|
||||||
# Create Jira tickets now if the manager's chosen start_date is
|
|
||||||
# already due — mirrors the normal manager /approve path.
|
|
||||||
_create_jira_tickets_for_campaign(db, campaign, campaign_id, current_user)
|
|
||||||
|
|
||||||
# Return result
|
# Return result
|
||||||
return result
|
return result
|
||||||
|
|
||||||
@@ -434,19 +341,15 @@ def update_campaign(
|
|||||||
# Entry: db
|
# Entry: db
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
# Entry: current_user
|
# Entry: current_user
|
||||||
current_user: User = Depends(require_any_role("red_lead", "blue_lead", "manager")),
|
current_user: User = Depends(require_any_role("red_lead", "blue_lead")),
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""Update a campaign. Only allowed in draft or active state.
|
"""Update a campaign. Only allowed in draft or active state.
|
||||||
|
|
||||||
A manager may only edit a campaign that's sitting in draft with a
|
|
||||||
rejection_reason set (i.e. one they previously rejected) — see
|
|
||||||
``update_campaign()``'s ownership check.
|
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
campaign_id (str): UUID string of the campaign to update.
|
campaign_id (str): UUID string of the campaign to update.
|
||||||
payload (CampaignUpdate): Partial update payload; only set fields are applied.
|
payload (CampaignUpdate): Partial update payload; only set fields are applied.
|
||||||
db (Session): SQLAlchemy database session.
|
db (Session): SQLAlchemy database session.
|
||||||
current_user (User): Authenticated red_lead, blue_lead, or manager performing the update.
|
current_user (User): Authenticated red_lead or blue_lead performing the update.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
dict: Serialised representation of the updated campaign.
|
dict: Serialised representation of the updated campaign.
|
||||||
@@ -486,269 +389,6 @@ def update_campaign(
|
|||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# POST /campaigns/{id}/submit — Submit draft for manager approval
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
@router.post("/{campaign_id}/submit")
|
|
||||||
def submit_campaign(
|
|
||||||
campaign_id: str,
|
|
||||||
db: Session = Depends(get_db),
|
|
||||||
current_user: User = Depends(require_any_role("red_lead", "blue_lead")),
|
|
||||||
) -> dict:
|
|
||||||
"""Submit a draft campaign into the manager's approval queue."""
|
|
||||||
with UnitOfWork(db) as uow:
|
|
||||||
campaign = crud_submit(
|
|
||||||
db, campaign_id,
|
|
||||||
submitter_id=current_user.id,
|
|
||||||
submitter_role=current_user.role,
|
|
||||||
)
|
|
||||||
log_action(
|
|
||||||
db,
|
|
||||||
user_id=current_user.id,
|
|
||||||
action="submit_campaign_for_approval",
|
|
||||||
entity_type="campaign",
|
|
||||||
entity_id=campaign.id,
|
|
||||||
details={"name": campaign.name},
|
|
||||||
)
|
|
||||||
uow.commit()
|
|
||||||
db.refresh(campaign)
|
|
||||||
return serialize_campaign(db, campaign)
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# POST /campaigns/{id}/approve — Manager approves a pending campaign
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
@router.post("/{campaign_id}/approve")
|
|
||||||
def approve_campaign_endpoint(
|
|
||||||
campaign_id: str,
|
|
||||||
payload: ApprovePayload,
|
|
||||||
db: Session = Depends(get_db),
|
|
||||||
# admin passes automatically via require_any_role's built-in bypass — do not add "admin" here
|
|
||||||
current_user: User = Depends(require_any_role("manager")),
|
|
||||||
) -> dict:
|
|
||||||
"""Manager approves a pending campaign, fixing its start date and activating it."""
|
|
||||||
with UnitOfWork(db) as uow:
|
|
||||||
campaign = crud_approve(
|
|
||||||
db, campaign_id,
|
|
||||||
approver_id=current_user.id,
|
|
||||||
start_date=payload.start_date,
|
|
||||||
)
|
|
||||||
log_action(
|
|
||||||
db,
|
|
||||||
user_id=current_user.id,
|
|
||||||
action="approve_campaign",
|
|
||||||
entity_type="campaign",
|
|
||||||
entity_id=campaign.id,
|
|
||||||
details={"start_date": payload.start_date},
|
|
||||||
)
|
|
||||||
notify_roles_by_email(
|
|
||||||
db, roles=["red_tech"],
|
|
||||||
preference_key="email_on_assigned_to_campaign",
|
|
||||||
subject=f"Campaign Activated: {campaign.name}",
|
|
||||||
message=f'Campaign "{campaign.name}" has been approved and activated. You may have tests assigned.',
|
|
||||||
)
|
|
||||||
uow.commit()
|
|
||||||
db.refresh(campaign)
|
|
||||||
|
|
||||||
# Create Jira tickets for campaign and its already-linked tests (non-fatal).
|
|
||||||
# Mirrors the admin-only /activate override — this is the normal path.
|
|
||||||
_create_jira_tickets_for_campaign(db, campaign, campaign_id, current_user)
|
|
||||||
|
|
||||||
return serialize_campaign(db, campaign)
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# POST /campaigns/{id}/reject — Manager rejects a pending campaign
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
@router.post("/{campaign_id}/reject")
|
|
||||||
def reject_campaign_endpoint(
|
|
||||||
campaign_id: str,
|
|
||||||
payload: RejectPayload,
|
|
||||||
db: Session = Depends(get_db),
|
|
||||||
# admin passes automatically via require_any_role's built-in bypass — do not add "admin" here
|
|
||||||
current_user: User = Depends(require_any_role("manager")),
|
|
||||||
) -> dict:
|
|
||||||
"""Manager rejects a pending campaign, returning it to draft with a reason."""
|
|
||||||
with UnitOfWork(db) as uow:
|
|
||||||
campaign = crud_reject(
|
|
||||||
db, campaign_id,
|
|
||||||
rejecter_id=current_user.id,
|
|
||||||
reason=payload.reason,
|
|
||||||
)
|
|
||||||
log_action(
|
|
||||||
db,
|
|
||||||
user_id=current_user.id,
|
|
||||||
action="reject_campaign",
|
|
||||||
entity_type="campaign",
|
|
||||||
entity_id=campaign.id,
|
|
||||||
details={"reason": payload.reason},
|
|
||||||
)
|
|
||||||
uow.commit()
|
|
||||||
db.refresh(campaign)
|
|
||||||
return serialize_campaign(db, campaign)
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# POST /campaigns/{id}/modification-requests — Request a test add/remove
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
@router.post("/{campaign_id}/modification-requests", status_code=201)
|
|
||||||
def create_modification_request_endpoint(
|
|
||||||
campaign_id: str,
|
|
||||||
payload: ModificationRequestPayload,
|
|
||||||
db: Session = Depends(get_db),
|
|
||||||
current_user: User = Depends(require_any_role("red_lead", "blue_lead")),
|
|
||||||
) -> dict:
|
|
||||||
"""File a request to add/remove a test on an active campaign — needs manager approval."""
|
|
||||||
with UnitOfWork(db) as uow:
|
|
||||||
request = crud_create_mod_request(
|
|
||||||
db, campaign_id,
|
|
||||||
requester_id=current_user.id,
|
|
||||||
action=payload.action,
|
|
||||||
test_id=payload.test_id,
|
|
||||||
justification=payload.justification,
|
|
||||||
order_index=payload.order_index,
|
|
||||||
phase=payload.phase,
|
|
||||||
)
|
|
||||||
log_action(
|
|
||||||
db,
|
|
||||||
user_id=current_user.id,
|
|
||||||
action="request_campaign_modification",
|
|
||||||
entity_type="campaign",
|
|
||||||
entity_id=campaign_id,
|
|
||||||
details={
|
|
||||||
"action": payload.action,
|
|
||||||
"test_id": payload.test_id,
|
|
||||||
"justification": payload.justification,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
uow.commit()
|
|
||||||
return crud_serialize_mod_request(db, request)
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# GET /campaigns/{id}/modification-requests — List requests for one campaign
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
@router.get("/{campaign_id}/modification-requests")
|
|
||||||
def list_campaign_modification_requests_endpoint(
|
|
||||||
campaign_id: str,
|
|
||||||
db: Session = Depends(get_db),
|
|
||||||
current_user: User = Depends(get_current_user),
|
|
||||||
) -> list:
|
|
||||||
"""List modification requests filed against a specific campaign."""
|
|
||||||
return crud_list_mod_requests(db, campaign_id=campaign_id)
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# GET /campaigns/modification-requests/pending — Manager's global queue
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
@router.get("/modification-requests/pending")
|
|
||||||
def list_pending_modification_requests_endpoint(
|
|
||||||
db: Session = Depends(get_db),
|
|
||||||
# admin passes automatically via require_any_role's built-in bypass — do not add "admin" here
|
|
||||||
current_user: User = Depends(require_any_role("manager")),
|
|
||||||
) -> list:
|
|
||||||
"""List all modification requests awaiting manager review, across all campaigns."""
|
|
||||||
return crud_list_mod_requests(db, status="pending")
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# POST /campaigns/modification-requests/{id}/approve — Apply the requested change
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
@router.post("/modification-requests/{request_id}/approve")
|
|
||||||
def approve_modification_request_endpoint(
|
|
||||||
request_id: str,
|
|
||||||
db: Session = Depends(get_db),
|
|
||||||
# admin passes automatically via require_any_role's built-in bypass — do not add "admin" here
|
|
||||||
current_user: User = Depends(require_any_role("manager")),
|
|
||||||
) -> dict:
|
|
||||||
"""Manager approves a modification request — the test change is applied now."""
|
|
||||||
with UnitOfWork(db) as uow:
|
|
||||||
request = crud_approve_mod_request(db, request_id, reviewer_id=current_user.id)
|
|
||||||
log_action(
|
|
||||||
db,
|
|
||||||
user_id=current_user.id,
|
|
||||||
action="approve_campaign_modification",
|
|
||||||
entity_type="campaign",
|
|
||||||
entity_id=str(request.campaign_id),
|
|
||||||
details={
|
|
||||||
"request_id": request_id,
|
|
||||||
"action": request.action,
|
|
||||||
"test_id": str(request.test_id) if request.test_id else None,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
uow.commit()
|
|
||||||
return crud_serialize_mod_request(db, request)
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# POST /campaigns/modification-requests/{id}/reject — Deny the requested change
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
@router.post("/modification-requests/{request_id}/reject")
|
|
||||||
def reject_modification_request_endpoint(
|
|
||||||
request_id: str,
|
|
||||||
payload: RejectModificationPayload,
|
|
||||||
db: Session = Depends(get_db),
|
|
||||||
# admin passes automatically via require_any_role's built-in bypass — do not add "admin" here
|
|
||||||
current_user: User = Depends(require_any_role("manager")),
|
|
||||||
) -> dict:
|
|
||||||
"""Manager rejects a modification request. No change is applied."""
|
|
||||||
with UnitOfWork(db) as uow:
|
|
||||||
request = crud_reject_mod_request(
|
|
||||||
db, request_id,
|
|
||||||
reviewer_id=current_user.id,
|
|
||||||
review_notes=payload.review_notes,
|
|
||||||
)
|
|
||||||
log_action(
|
|
||||||
db,
|
|
||||||
user_id=current_user.id,
|
|
||||||
action="reject_campaign_modification",
|
|
||||||
entity_type="campaign",
|
|
||||||
entity_id=str(request.campaign_id),
|
|
||||||
details={"request_id": request_id, "review_notes": payload.review_notes},
|
|
||||||
)
|
|
||||||
uow.commit()
|
|
||||||
return crud_serialize_mod_request(db, request)
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# DELETE /campaigns/{id} — Delete campaign
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
@router.delete("/{campaign_id}", status_code=204)
|
|
||||||
def delete_campaign(
|
|
||||||
campaign_id: str,
|
|
||||||
delete_tests: bool = Query(False, description="Also delete associated tests"),
|
|
||||||
db: Session = Depends(get_db),
|
|
||||||
current_user: User = Depends(get_current_user),
|
|
||||||
):
|
|
||||||
"""Delete a campaign. Only draft campaigns can be deleted (admins can delete any)."""
|
|
||||||
with UnitOfWork(db) as uow:
|
|
||||||
crud_delete(
|
|
||||||
db,
|
|
||||||
campaign_id,
|
|
||||||
deleter_id=current_user.id,
|
|
||||||
deleter_role=current_user.role,
|
|
||||||
delete_tests=delete_tests,
|
|
||||||
)
|
|
||||||
log_action(
|
|
||||||
db,
|
|
||||||
user_id=current_user.id,
|
|
||||||
action="delete_campaign",
|
|
||||||
entity_type="campaign",
|
|
||||||
entity_id=campaign_id,
|
|
||||||
details={"delete_tests": delete_tests},
|
|
||||||
)
|
|
||||||
uow.commit()
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# POST /campaigns/{id}/tests — Add test to campaign
|
# POST /campaigns/{id}/tests — Add test to campaign
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -793,7 +433,7 @@ def add_test_to_campaign(
|
|||||||
)
|
)
|
||||||
# Call uow.commit()
|
# Call uow.commit()
|
||||||
uow.commit()
|
uow.commit()
|
||||||
|
# Return result
|
||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
||||||
@@ -843,18 +483,22 @@ def remove_test_from_campaign(
|
|||||||
def activate_campaign(
|
def activate_campaign(
|
||||||
# Entry: campaign_id
|
# Entry: campaign_id
|
||||||
campaign_id: str,
|
campaign_id: str,
|
||||||
|
# Entry: db
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
# admin passes automatically via require_any_role's built-in bypass — do not add "admin" here
|
# Entry: current_user
|
||||||
current_user: User = Depends(require_any_role("admin")),
|
current_user: User = Depends(require_any_role("red_lead", "blue_lead")),
|
||||||
):
|
) -> dict:
|
||||||
"""Admin-only emergency override: activate a draft campaign directly, bypassing the approval queue.
|
"""Activate a campaign, moving it from draft to active.
|
||||||
|
|
||||||
Every other path to 'active' goes through /submit -> /approve, where the
|
Args:
|
||||||
manager sets start_date. A draft campaign never has a start_date set
|
campaign_id (str): UUID string of the campaign to activate.
|
||||||
(only /approve sets it, in the same call that moves the campaign to
|
db (Session): SQLAlchemy database session.
|
||||||
'active'), so there is no "scheduled for the future" case to guard
|
current_user (User): Authenticated red_lead or blue_lead activating the campaign.
|
||||||
against here anymore.
|
|
||||||
|
Returns:
|
||||||
|
dict: Serialised representation of the activated campaign.
|
||||||
"""
|
"""
|
||||||
|
# Open context manager
|
||||||
with UnitOfWork(db) as uow:
|
with UnitOfWork(db) as uow:
|
||||||
# Assign campaign = crud_activate(db, campaign_id)
|
# Assign campaign = crud_activate(db, campaign_id)
|
||||||
campaign = crud_activate(db, campaign_id)
|
campaign = crud_activate(db, campaign_id)
|
||||||
@@ -874,12 +518,6 @@ def activate_campaign(
|
|||||||
# Keyword argument: entity_id
|
# Keyword argument: entity_id
|
||||||
entity_id=campaign.id,
|
entity_id=campaign.id,
|
||||||
)
|
)
|
||||||
notify_roles_by_email(
|
|
||||||
db, roles=["red_tech"],
|
|
||||||
preference_key="email_on_assigned_to_campaign",
|
|
||||||
subject=f"Campaign Activated: {campaign.name}",
|
|
||||||
message=f'Campaign "{campaign.name}" has been activated. You may have tests assigned.',
|
|
||||||
)
|
|
||||||
# Call log_action()
|
# Call log_action()
|
||||||
log_action(
|
log_action(
|
||||||
db,
|
db,
|
||||||
@@ -899,10 +537,7 @@ def activate_campaign(
|
|||||||
# Reload ORM object attributes from the database
|
# Reload ORM object attributes from the database
|
||||||
db.refresh(campaign)
|
db.refresh(campaign)
|
||||||
|
|
||||||
# Create Jira tickets for campaign and tests at activation time (non-fatal).
|
# Return serialize_campaign(db, campaign)
|
||||||
# Campaign ticket is created here if it doesn't already exist (deferred from creation).
|
|
||||||
_create_jira_tickets_for_campaign(db, campaign, campaign_id, current_user)
|
|
||||||
|
|
||||||
return serialize_campaign(db, campaign)
|
return serialize_campaign(db, campaign)
|
||||||
|
|
||||||
|
|
||||||
@@ -952,7 +587,6 @@ def complete_campaign(
|
|||||||
uow.commit()
|
uow.commit()
|
||||||
# Reload ORM object attributes from the database
|
# Reload ORM object attributes from the database
|
||||||
db.refresh(campaign)
|
db.refresh(campaign)
|
||||||
dispatch_webhook("campaign.completed", {"campaign_id": str(campaign.id), "name": campaign.name})
|
|
||||||
|
|
||||||
# Return serialize_campaign(db, campaign)
|
# Return serialize_campaign(db, campaign)
|
||||||
return serialize_campaign(db, campaign)
|
return serialize_campaign(db, campaign)
|
||||||
@@ -990,21 +624,15 @@ def get_campaign_progress_endpoint(
|
|||||||
# POST /campaigns/from-threat-actor/{actor_id} — Auto-generate campaign
|
# POST /campaigns/from-threat-actor/{actor_id} — Auto-generate campaign
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
class GenerateFromActorPayload(BaseModel):
|
|
||||||
start_date: Optional[datetime] = None
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/from-threat-actor/{actor_id}", status_code=201)
|
@router.post("/from-threat-actor/{actor_id}", status_code=201)
|
||||||
# Define function generate_campaign_from_actor
|
# Define function generate_campaign_from_actor
|
||||||
def generate_campaign_from_actor(
|
def generate_campaign_from_actor(
|
||||||
# Entry: actor_id
|
# Entry: actor_id
|
||||||
actor_id: str,
|
actor_id: str,
|
||||||
payload: GenerateFromActorPayload = GenerateFromActorPayload(),
|
# Entry: db
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
# Entry: current_user
|
# Entry: current_user
|
||||||
# Strict variant — admin must NOT get a free pass here, same as the
|
current_user: User = Depends(require_any_role("red_lead", "blue_lead")),
|
||||||
# plain create_campaign endpoint above.
|
|
||||||
current_user: User = Depends(require_any_role_strict("red_lead", "blue_lead")),
|
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""Auto-generate a campaign from a threat actor's uncovered techniques.
|
"""Auto-generate a campaign from a threat actor's uncovered techniques.
|
||||||
|
|
||||||
@@ -1019,11 +647,11 @@ def generate_campaign_from_actor(
|
|||||||
Returns:
|
Returns:
|
||||||
dict: Serialised representation of the newly generated campaign.
|
dict: Serialised representation of the newly generated campaign.
|
||||||
"""
|
"""
|
||||||
|
# Assign campaign = generate_campaign_from_threat_actor(
|
||||||
campaign = generate_campaign_from_threat_actor(
|
campaign = generate_campaign_from_threat_actor(
|
||||||
db,
|
db,
|
||||||
uuid.UUID(actor_id),
|
uuid.UUID(actor_id),
|
||||||
current_user,
|
current_user,
|
||||||
start_date=payload.start_date,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
# Open context manager
|
# Open context manager
|
||||||
@@ -1151,111 +779,3 @@ def get_campaign_history(
|
|||||||
"""
|
"""
|
||||||
# Return crud_get_history(db, campaign_id)
|
# Return crud_get_history(db, campaign_id)
|
||||||
return crud_get_history(db, campaign_id)
|
return crud_get_history(db, campaign_id)
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# GET /campaigns/{id}/timeline — Audit-log history for this campaign
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
@router.get("/{campaign_id}/timeline")
|
|
||||||
def get_campaign_timeline_endpoint(
|
|
||||||
campaign_id: str,
|
|
||||||
db: Session = Depends(get_db),
|
|
||||||
current_user: User = Depends(get_current_user),
|
|
||||||
) -> list:
|
|
||||||
"""Return the chronological audit-log history for a campaign."""
|
|
||||||
return crud_get_timeline(db, campaign_id)
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# GET /campaigns/{id}/timing-summary — Aggregated timing across campaign tests
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
def _seconds_between(start: datetime | None, end: datetime | None) -> int:
|
|
||||||
"""Return elapsed seconds between two datetimes; 0 if either is None."""
|
|
||||||
if not start or not end:
|
|
||||||
return 0
|
|
||||||
diff = (end - start).total_seconds()
|
|
||||||
return max(0, int(diff))
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{campaign_id}/timing-summary")
|
|
||||||
def get_campaign_timing_summary(
|
|
||||||
campaign_id: str,
|
|
||||||
db: Session = Depends(get_db),
|
|
||||||
current_user: User = Depends(get_current_user),
|
|
||||||
):
|
|
||||||
"""Return aggregated Red/Blue timing metrics for all tests in a campaign.
|
|
||||||
|
|
||||||
For each test we calculate:
|
|
||||||
- red_execution_secs : red_started_at → blue_started_at (minus red_paused_seconds)
|
|
||||||
- blue_queue_secs : blue_started_at → blue_work_started_at (waiting for Blue pick-up)
|
|
||||||
- blue_evaluation_secs: blue_work_started_at → first validation timestamp (minus blue_paused_seconds)
|
|
||||||
- total_secs : sum of the three phases
|
|
||||||
|
|
||||||
Returns totals + per-test breakdown.
|
|
||||||
"""
|
|
||||||
# Load campaign
|
|
||||||
campaign = db.query(Campaign).filter(Campaign.id == campaign_id).first()
|
|
||||||
if not campaign:
|
|
||||||
from fastapi import HTTPException
|
|
||||||
raise HTTPException(status_code=404, detail="Campaign not found")
|
|
||||||
|
|
||||||
# Load all tests for this campaign
|
|
||||||
test_ids = [
|
|
||||||
ct.test_id
|
|
||||||
for ct in db.query(CampaignTest).filter(CampaignTest.campaign_id == campaign.id).all()
|
|
||||||
]
|
|
||||||
tests = db.query(Test).filter(Test.id.in_(test_ids)).all() if test_ids else []
|
|
||||||
|
|
||||||
breakdown = []
|
|
||||||
total_red = 0
|
|
||||||
total_queue = 0
|
|
||||||
total_blue = 0
|
|
||||||
|
|
||||||
for t in tests:
|
|
||||||
# Red execution: from start-execution to submit-to-blue, minus paused time
|
|
||||||
red_secs = max(
|
|
||||||
0,
|
|
||||||
_seconds_between(t.red_started_at, t.blue_started_at) - (t.red_paused_seconds or 0),
|
|
||||||
)
|
|
||||||
|
|
||||||
# Blue queue: from receiving the test to actually starting evaluation
|
|
||||||
queue_secs = _seconds_between(t.blue_started_at, t.blue_work_started_at)
|
|
||||||
|
|
||||||
# Blue evaluation: from starting evaluation to first validation, minus paused time
|
|
||||||
eval_end = t.red_validated_at or t.blue_validated_at
|
|
||||||
blue_secs = max(
|
|
||||||
0,
|
|
||||||
_seconds_between(t.blue_work_started_at, eval_end) - (t.blue_paused_seconds or 0),
|
|
||||||
)
|
|
||||||
|
|
||||||
total_red += red_secs
|
|
||||||
total_queue += queue_secs
|
|
||||||
total_blue += blue_secs
|
|
||||||
|
|
||||||
breakdown.append({
|
|
||||||
"test_id": str(t.id),
|
|
||||||
"test_name": t.name,
|
|
||||||
"state": t.state.value if t.state else None,
|
|
||||||
"red_execution_secs": red_secs,
|
|
||||||
"blue_queue_secs": queue_secs,
|
|
||||||
"blue_evaluation_secs": blue_secs,
|
|
||||||
"total_secs": red_secs + queue_secs + blue_secs,
|
|
||||||
"has_timing": bool(t.red_started_at),
|
|
||||||
})
|
|
||||||
|
|
||||||
total_secs = total_red + total_queue + total_blue
|
|
||||||
|
|
||||||
return {
|
|
||||||
"campaign_id": campaign_id,
|
|
||||||
"campaign_name": campaign.name,
|
|
||||||
"tests_total": len(tests),
|
|
||||||
"tests_with_timing": sum(1 for b in breakdown if b["has_timing"]),
|
|
||||||
"red_execution_secs": total_red,
|
|
||||||
"blue_queue_secs": total_queue,
|
|
||||||
"blue_evaluation_secs": total_blue,
|
|
||||||
"total_secs": total_secs,
|
|
||||||
"breakdown": sorted(breakdown, key=lambda x: -(x["total_secs"])),
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -26,9 +26,6 @@ from app.models.user import User
|
|||||||
# Import from app.services.compliance_import_service
|
# Import from app.services.compliance_import_service
|
||||||
from app.services.compliance_import_service import (
|
from app.services.compliance_import_service import (
|
||||||
import_cis_controls_v8_mappings,
|
import_cis_controls_v8_mappings,
|
||||||
import_dora_mappings,
|
|
||||||
import_iso_27001_mappings,
|
|
||||||
import_iso_42001_mappings,
|
|
||||||
import_nist_800_53_mappings,
|
import_nist_800_53_mappings,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -235,33 +232,3 @@ def import_cis(
|
|||||||
result = import_cis_controls_v8_mappings(db)
|
result = import_cis_controls_v8_mappings(db)
|
||||||
# Return result
|
# Return result
|
||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
||||||
@router.post("/import/dora")
|
|
||||||
def import_dora(
|
|
||||||
db: Session = Depends(get_db),
|
|
||||||
current_user: User = Depends(require_role("admin")),
|
|
||||||
):
|
|
||||||
"""Import DORA (EU 2022/2554) compliance mappings (admin only)."""
|
|
||||||
result = import_dora_mappings(db)
|
|
||||||
return result
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/import/iso-27001")
|
|
||||||
def import_iso27001(
|
|
||||||
db: Session = Depends(get_db),
|
|
||||||
current_user: User = Depends(require_role("admin")),
|
|
||||||
):
|
|
||||||
"""Import ISO/IEC 27001:2022 Annex A compliance mappings (admin only)."""
|
|
||||||
result = import_iso_27001_mappings(db)
|
|
||||||
return result
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/import/iso-42001")
|
|
||||||
def import_iso42001(
|
|
||||||
db: Session = Depends(get_db),
|
|
||||||
current_user: User = Depends(require_role("admin")),
|
|
||||||
):
|
|
||||||
"""Import ISO/IEC 42001:2023 AI Management System compliance mappings (admin only)."""
|
|
||||||
result = import_iso_42001_mappings(db)
|
|
||||||
return result
|
|
||||||
|
|||||||
@@ -64,7 +64,7 @@ def list_defensive_techniques(
|
|||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
# Entry: current_user
|
# Entry: current_user
|
||||||
current_user: User = Depends(get_current_user),
|
current_user: User = Depends(get_current_user),
|
||||||
) -> dict:
|
) -> list:
|
||||||
"""List all D3FEND defensive techniques with optional filters."""
|
"""List all D3FEND defensive techniques with optional filters."""
|
||||||
# Return list_defensive_techniques_svc(
|
# Return list_defensive_techniques_svc(
|
||||||
return list_defensive_techniques_svc(
|
return list_defensive_techniques_svc(
|
||||||
@@ -102,7 +102,7 @@ def get_defenses_for_attack_technique_endpoint(
|
|||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
# Entry: current_user
|
# Entry: current_user
|
||||||
current_user: User = Depends(get_current_user),
|
current_user: User = Depends(get_current_user),
|
||||||
) -> dict:
|
) -> list:
|
||||||
"""Get all D3FEND defensive techniques mapped to a given ATT&CK technique."""
|
"""Get all D3FEND defensive techniques mapped to a given ATT&CK technique."""
|
||||||
# Return get_defenses_for_attack_technique(db, mitre_id)
|
# Return get_defenses_for_attack_technique(db, mitre_id)
|
||||||
return get_defenses_for_attack_technique(db, mitre_id)
|
return get_defenses_for_attack_technique(db, mitre_id)
|
||||||
|
|||||||
@@ -1,319 +0,0 @@
|
|||||||
"""Detection Lifecycle Management router."""
|
|
||||||
|
|
||||||
import hashlib
|
|
||||||
from datetime import datetime, timedelta
|
|
||||||
from typing import Optional
|
|
||||||
from uuid import UUID
|
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, Query
|
|
||||||
from sqlalchemy import func
|
|
||||||
from sqlalchemy.orm import Session
|
|
||||||
|
|
||||||
from app.database import get_db
|
|
||||||
from app.dependencies.auth import get_current_user, require_any_role
|
|
||||||
from app.domain.exceptions import EntityNotFoundError
|
|
||||||
from app.models.detection_lifecycle import (
|
|
||||||
DetectionAsset, DetectionTechniqueMapping, DetectionValidation,
|
|
||||||
TechniqueConfidenceScore, InfrastructureChangeLog,
|
|
||||||
)
|
|
||||||
from app.schemas.detection_lifecycle_schema import (
|
|
||||||
DetectionAssetCreate, DetectionAssetUpdate, DetectionAssetOut,
|
|
||||||
DetectionValidationCreate, DetectionValidationOut,
|
|
||||||
TechniqueConfidenceOut,
|
|
||||||
InfrastructureChangeCreate, InfrastructureChangeOut,
|
|
||||||
)
|
|
||||||
from app.services import detection_asset_service, decay_engine_service, audit_service
|
|
||||||
|
|
||||||
router = APIRouter(prefix="/detection-lifecycle", tags=["detection-lifecycle"])
|
|
||||||
|
|
||||||
|
|
||||||
def _now() -> datetime:
|
|
||||||
return datetime.utcnow()
|
|
||||||
|
|
||||||
|
|
||||||
# ── Detection Assets ─────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
@router.post("/assets", response_model=DetectionAssetOut, status_code=201)
|
|
||||||
def create_asset(body: DetectionAssetCreate, db: Session = Depends(get_db), user=Depends(get_current_user)):
|
|
||||||
asset = detection_asset_service.create_detection_asset(db, body.model_dump(), user.id)
|
|
||||||
return asset
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/assets", response_model=list[DetectionAssetOut])
|
|
||||||
def list_assets(
|
|
||||||
platform: Optional[str] = None,
|
|
||||||
asset_type: Optional[str] = None,
|
|
||||||
health_status: Optional[str] = None,
|
|
||||||
technique_id: Optional[UUID] = None,
|
|
||||||
is_active: Optional[bool] = True,
|
|
||||||
db: Session = Depends(get_db),
|
|
||||||
user=Depends(get_current_user),
|
|
||||||
):
|
|
||||||
return detection_asset_service.list_assets(db, platform=platform, asset_type=asset_type, health_status=health_status, technique_id=technique_id, is_active=is_active)
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/assets/{asset_id}", response_model=DetectionAssetOut)
|
|
||||||
def get_asset(asset_id: UUID, db: Session = Depends(get_db), user=Depends(get_current_user)):
|
|
||||||
return detection_asset_service.get_asset_with_details(db, asset_id)
|
|
||||||
|
|
||||||
|
|
||||||
@router.patch("/assets/{asset_id}", response_model=DetectionAssetOut)
|
|
||||||
def update_asset(asset_id: UUID, body: DetectionAssetUpdate, db: Session = Depends(get_db), user=Depends(get_current_user)):
|
|
||||||
return detection_asset_service.update_detection_asset(db, asset_id, body.model_dump(exclude_unset=True), user.id)
|
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/assets/{asset_id}", status_code=204)
|
|
||||||
def delete_asset(asset_id: UUID, db: Session = Depends(get_db), user=Depends(require_any_role("red_lead", "blue_lead"))):
|
|
||||||
asset = db.query(DetectionAsset).filter(DetectionAsset.id == asset_id).first()
|
|
||||||
if not asset:
|
|
||||||
raise EntityNotFoundError("DetectionAsset", str(asset_id))
|
|
||||||
asset.is_active = False
|
|
||||||
db.commit()
|
|
||||||
|
|
||||||
|
|
||||||
# ── Technique Mappings ───────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
@router.post("/assets/{asset_id}/techniques/{technique_id}")
|
|
||||||
def map_technique(
|
|
||||||
asset_id: UUID, technique_id: UUID,
|
|
||||||
coverage_type: str = Query("detect"),
|
|
||||||
confidence_level: str = Query("medium"),
|
|
||||||
db: Session = Depends(get_db),
|
|
||||||
user=Depends(get_current_user),
|
|
||||||
):
|
|
||||||
# Validate asset exists
|
|
||||||
asset = db.query(DetectionAsset).filter(DetectionAsset.id == asset_id).first()
|
|
||||||
if not asset:
|
|
||||||
raise EntityNotFoundError("DetectionAsset", str(asset_id))
|
|
||||||
|
|
||||||
# Prevent duplicate mappings
|
|
||||||
existing = db.query(DetectionTechniqueMapping).filter(
|
|
||||||
DetectionTechniqueMapping.detection_asset_id == asset_id,
|
|
||||||
DetectionTechniqueMapping.technique_id == technique_id,
|
|
||||||
).first()
|
|
||||||
if existing:
|
|
||||||
# Update coverage/confidence on existing mapping instead of duplicating
|
|
||||||
existing.coverage_type = coverage_type
|
|
||||||
existing.confidence_level = confidence_level
|
|
||||||
db.commit()
|
|
||||||
return {"message": "Technique mapping updated", "mapping_id": str(existing.id)}
|
|
||||||
|
|
||||||
mapping = DetectionTechniqueMapping(
|
|
||||||
detection_asset_id=asset_id, technique_id=technique_id,
|
|
||||||
coverage_type=coverage_type, confidence_level=confidence_level,
|
|
||||||
)
|
|
||||||
db.add(mapping)
|
|
||||||
db.commit()
|
|
||||||
return {"message": "Technique mapped", "mapping_id": str(mapping.id)}
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/techniques/{technique_id}/detections")
|
|
||||||
def get_technique_detections(technique_id: UUID, db: Session = Depends(get_db), user=Depends(get_current_user)):
|
|
||||||
return detection_asset_service.get_technique_detection_summary(db, technique_id)
|
|
||||||
|
|
||||||
|
|
||||||
# ── Validations ──────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
@router.post("/validations", response_model=DetectionValidationOut, status_code=201)
|
|
||||||
def create_validation(body: DetectionValidationCreate, db: Session = Depends(get_db), user=Depends(get_current_user)):
|
|
||||||
asset = db.query(DetectionAsset).filter(DetectionAsset.id == body.detection_asset_id).first()
|
|
||||||
if not asset:
|
|
||||||
raise EntityNotFoundError("DetectionAsset", str(body.detection_asset_id))
|
|
||||||
|
|
||||||
now = _now()
|
|
||||||
validation = DetectionValidation(
|
|
||||||
detection_asset_id=body.detection_asset_id,
|
|
||||||
technique_id=body.technique_id,
|
|
||||||
test_id=body.test_id,
|
|
||||||
validation_result=body.validation_result,
|
|
||||||
validation_method=body.validation_method,
|
|
||||||
notes=body.notes,
|
|
||||||
evidence_ids=[str(e) for e in (body.evidence_ids or [])],
|
|
||||||
validated_by=user.id,
|
|
||||||
validated_at=now,
|
|
||||||
expires_at=now + timedelta(days=body.validity_days),
|
|
||||||
rule_hash_at_validation=asset.rule_hash,
|
|
||||||
log_source_version_at_validation=asset.log_source_version,
|
|
||||||
infrastructure_hash_at_validation=asset.infrastructure_hash,
|
|
||||||
)
|
|
||||||
data = f"{validation.detection_asset_id}:{validation.validated_by}:{validation.validation_result}:{validation.validated_at}"
|
|
||||||
validation.integrity_hash = hashlib.sha256(data.encode()).hexdigest()
|
|
||||||
|
|
||||||
db.add(validation)
|
|
||||||
db.commit()
|
|
||||||
db.refresh(validation)
|
|
||||||
|
|
||||||
if body.technique_id:
|
|
||||||
decay_engine_service.calculate_confidence_for_technique(db, body.technique_id)
|
|
||||||
|
|
||||||
audit_service.log_action(db, user.id, "DETECTION_VALIDATED", "detection_validation", str(validation.id),
|
|
||||||
details={"asset_id": str(body.detection_asset_id), "result": body.validation_result, "validity_days": body.validity_days})
|
|
||||||
|
|
||||||
return validation
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/validations", response_model=list[DetectionValidationOut])
|
|
||||||
def list_validations(
|
|
||||||
asset_id: Optional[UUID] = None,
|
|
||||||
technique_id: Optional[UUID] = None,
|
|
||||||
is_valid: Optional[bool] = None,
|
|
||||||
db: Session = Depends(get_db),
|
|
||||||
user=Depends(get_current_user),
|
|
||||||
):
|
|
||||||
query = db.query(DetectionValidation)
|
|
||||||
if asset_id:
|
|
||||||
query = query.filter(DetectionValidation.detection_asset_id == asset_id)
|
|
||||||
if technique_id:
|
|
||||||
query = query.filter(DetectionValidation.technique_id == technique_id)
|
|
||||||
if is_valid is not None:
|
|
||||||
query = query.filter(DetectionValidation.is_valid == is_valid)
|
|
||||||
return query.order_by(DetectionValidation.validated_at.desc()).all()
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/validations/{validation_id}/invalidate")
|
|
||||||
def invalidate_validation(
|
|
||||||
validation_id: UUID,
|
|
||||||
reason: str = Query(...),
|
|
||||||
details: Optional[str] = None,
|
|
||||||
db: Session = Depends(get_db),
|
|
||||||
user=Depends(require_any_role("admin", "blue_lead")),
|
|
||||||
):
|
|
||||||
validation = db.query(DetectionValidation).filter(DetectionValidation.id == validation_id).first()
|
|
||||||
if not validation:
|
|
||||||
raise EntityNotFoundError("DetectionValidation", str(validation_id))
|
|
||||||
|
|
||||||
from app.models.detection_lifecycle import InvalidationReason
|
|
||||||
try:
|
|
||||||
reason_enum = InvalidationReason(reason)
|
|
||||||
except ValueError:
|
|
||||||
reason_enum = InvalidationReason.manual
|
|
||||||
|
|
||||||
validation.is_valid = False
|
|
||||||
validation.invalidated_at = _now()
|
|
||||||
validation.invalidation_reason = reason_enum
|
|
||||||
validation.invalidation_details = details
|
|
||||||
validation.invalidated_by = user.id
|
|
||||||
db.commit()
|
|
||||||
return {"message": "Validation invalidated"}
|
|
||||||
|
|
||||||
|
|
||||||
# ── Confidence Scores ────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
@router.get("/confidence", response_model=list[TechniqueConfidenceOut])
|
|
||||||
def list_confidence_scores(
|
|
||||||
confidence_level: Optional[str] = None,
|
|
||||||
min_score: Optional[float] = None,
|
|
||||||
max_score: Optional[float] = None,
|
|
||||||
db: Session = Depends(get_db),
|
|
||||||
user=Depends(get_current_user),
|
|
||||||
):
|
|
||||||
query = db.query(TechniqueConfidenceScore)
|
|
||||||
if confidence_level:
|
|
||||||
query = query.filter(TechniqueConfidenceScore.confidence_level == confidence_level)
|
|
||||||
if min_score is not None:
|
|
||||||
query = query.filter(TechniqueConfidenceScore.confidence_score >= min_score)
|
|
||||||
if max_score is not None:
|
|
||||||
query = query.filter(TechniqueConfidenceScore.confidence_score <= max_score)
|
|
||||||
return query.order_by(TechniqueConfidenceScore.confidence_score.asc()).all()
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/confidence/{technique_id}", response_model=TechniqueConfidenceOut)
|
|
||||||
def get_technique_confidence(
|
|
||||||
technique_id: UUID,
|
|
||||||
recalculate: bool = Query(False),
|
|
||||||
db: Session = Depends(get_db),
|
|
||||||
user=Depends(get_current_user),
|
|
||||||
):
|
|
||||||
if recalculate:
|
|
||||||
return decay_engine_service.calculate_confidence_for_technique(db, technique_id)
|
|
||||||
score = db.query(TechniqueConfidenceScore).filter(TechniqueConfidenceScore.technique_id == technique_id).first()
|
|
||||||
if not score:
|
|
||||||
return decay_engine_service.calculate_confidence_for_technique(db, technique_id)
|
|
||||||
return score
|
|
||||||
|
|
||||||
|
|
||||||
# ── Infrastructure Changes ───────────────────────────────────────────────────
|
|
||||||
|
|
||||||
@router.post("/infrastructure-changes", response_model=InfrastructureChangeOut, status_code=201)
|
|
||||||
def report_infrastructure_change(
|
|
||||||
body: InfrastructureChangeCreate,
|
|
||||||
db: Session = Depends(get_db),
|
|
||||||
user=Depends(require_any_role("admin", "blue_lead")),
|
|
||||||
):
|
|
||||||
change = InfrastructureChangeLog(
|
|
||||||
change_type=body.change_type,
|
|
||||||
description=body.description,
|
|
||||||
affected_platforms=body.affected_platforms,
|
|
||||||
affected_log_sources=body.affected_log_sources,
|
|
||||||
change_date=body.change_date or _now(),
|
|
||||||
auto_invalidate=body.auto_invalidate,
|
|
||||||
reported_by=user.id,
|
|
||||||
)
|
|
||||||
db.add(change)
|
|
||||||
db.commit()
|
|
||||||
db.refresh(change)
|
|
||||||
|
|
||||||
if change.auto_invalidate:
|
|
||||||
decay_engine_service.process_infrastructure_change(db, change.id)
|
|
||||||
db.refresh(change)
|
|
||||||
|
|
||||||
audit_service.log_action(db, user.id, "INFRASTRUCTURE_CHANGE_REPORTED", "infrastructure_change", str(change.id),
|
|
||||||
details={"type": body.change_type, "invalidated_count": change.invalidated_count})
|
|
||||||
|
|
||||||
return change
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/infrastructure-changes", response_model=list[InfrastructureChangeOut])
|
|
||||||
def list_infrastructure_changes(
|
|
||||||
days: int = Query(90, ge=1, le=730),
|
|
||||||
db: Session = Depends(get_db),
|
|
||||||
user=Depends(get_current_user),
|
|
||||||
):
|
|
||||||
cutoff = _now() - timedelta(days=days)
|
|
||||||
return db.query(InfrastructureChangeLog).filter(InfrastructureChangeLog.change_date >= cutoff).order_by(InfrastructureChangeLog.change_date.desc()).all()
|
|
||||||
|
|
||||||
|
|
||||||
# ── Decay Engine Control ─────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
@router.post("/decay-engine/run")
|
|
||||||
def trigger_decay_engine(db: Session = Depends(get_db), user=Depends(require_any_role("admin"))):
|
|
||||||
results = decay_engine_service.run_decay_engine(db)
|
|
||||||
return {"message": "Decay engine completed", "results": results}
|
|
||||||
|
|
||||||
|
|
||||||
# ── Dashboard ────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
@router.get("/dashboard")
|
|
||||||
def lifecycle_dashboard(db: Session = Depends(get_db), user=Depends(get_current_user)):
|
|
||||||
now = _now()
|
|
||||||
|
|
||||||
health_dist = dict(
|
|
||||||
db.query(DetectionAsset.health_status, func.count(DetectionAsset.id))
|
|
||||||
.filter(DetectionAsset.is_active == True)
|
|
||||||
.group_by(DetectionAsset.health_status)
|
|
||||||
.all()
|
|
||||||
)
|
|
||||||
confidence_dist = dict(
|
|
||||||
db.query(TechniqueConfidenceScore.confidence_level, func.count(TechniqueConfidenceScore.id))
|
|
||||||
.group_by(TechniqueConfidenceScore.confidence_level)
|
|
||||||
.all()
|
|
||||||
)
|
|
||||||
expiring_soon = db.query(func.count(DetectionValidation.id)).filter(
|
|
||||||
DetectionValidation.is_valid == True,
|
|
||||||
DetectionValidation.expires_at <= (now + timedelta(days=7)),
|
|
||||||
).scalar() or 0
|
|
||||||
|
|
||||||
total_assets = db.query(func.count(DetectionAsset.id)).filter(DetectionAsset.is_active == True).scalar() or 0
|
|
||||||
total_valid = db.query(func.count(DetectionValidation.id)).filter(DetectionValidation.is_valid == True).scalar() or 0
|
|
||||||
recent_changes = db.query(func.count(InfrastructureChangeLog.id)).filter(
|
|
||||||
InfrastructureChangeLog.change_date >= (now - timedelta(days=30))
|
|
||||||
).scalar() or 0
|
|
||||||
|
|
||||||
return {
|
|
||||||
"total_detection_assets": total_assets,
|
|
||||||
"total_valid_validations": total_valid,
|
|
||||||
"health_distribution": {k.value if hasattr(k, "value") else str(k): v for k, v in health_dist.items()},
|
|
||||||
"confidence_distribution": {k.value if hasattr(k, "value") else str(k): v for k, v in confidence_dist.items()},
|
|
||||||
"validations_expiring_7d": expiring_soon,
|
|
||||||
"infrastructure_changes_30d": recent_changes,
|
|
||||||
}
|
|
||||||
@@ -80,7 +80,7 @@ def list_detection_rules(
|
|||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
# Entry: current_user
|
# Entry: current_user
|
||||||
current_user: User = Depends(get_current_user),
|
current_user: User = Depends(get_current_user),
|
||||||
) -> dict:
|
) -> list:
|
||||||
"""List detection rules with optional filters and pagination."""
|
"""List detection rules with optional filters and pagination."""
|
||||||
# Return list_rules(
|
# Return list_rules(
|
||||||
return list_rules(
|
return list_rules(
|
||||||
@@ -112,7 +112,7 @@ def get_detection_rules_for_template(
|
|||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
# Entry: current_user
|
# Entry: current_user
|
||||||
current_user: User = Depends(get_current_user),
|
current_user: User = Depends(get_current_user),
|
||||||
) -> dict:
|
) -> list:
|
||||||
"""Get detection rules associated with a test template."""
|
"""Get detection rules associated with a test template."""
|
||||||
# Return get_rules_for_template(db, template_id)
|
# Return get_rules_for_template(db, template_id)
|
||||||
return get_rules_for_template(db, template_id)
|
return get_rules_for_template(db, template_id)
|
||||||
@@ -151,7 +151,7 @@ def get_detection_rules_for_test(
|
|||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
# Entry: current_user
|
# Entry: current_user
|
||||||
current_user: User = Depends(get_current_user),
|
current_user: User = Depends(get_current_user),
|
||||||
) -> dict:
|
) -> list:
|
||||||
"""Get detection rules relevant to a test, along with their evaluation results.
|
"""Get detection rules relevant to a test, along with their evaluation results.
|
||||||
|
|
||||||
Finds rules by matching the test's technique_id to detection rules,
|
Finds rules by matching the test's technique_id to detection rules,
|
||||||
|
|||||||
@@ -4,8 +4,7 @@ Endpoints
|
|||||||
---------
|
---------
|
||||||
POST /tests/{test_id}/evidence — upload evidence (with team=red/blue)
|
POST /tests/{test_id}/evidence — upload evidence (with team=red/blue)
|
||||||
GET /tests/{test_id}/evidence — list evidences (filterable by team)
|
GET /tests/{test_id}/evidence — list evidences (filterable by team)
|
||||||
GET /evidence/{id} — metadata + download_url
|
GET /evidence/{id} — presigned download URL
|
||||||
GET /evidence/{id}/file — proxy download (streams file through backend)
|
|
||||||
DELETE /evidence/{id} — delete evidence (only in editable states)
|
DELETE /evidence/{id} — delete evidence (only in editable states)
|
||||||
|
|
||||||
Access Control
|
Access Control
|
||||||
@@ -22,17 +21,20 @@ Access Control
|
|||||||
|
|
||||||
# Import hashlib
|
# Import hashlib
|
||||||
import hashlib
|
import hashlib
|
||||||
import logging
|
|
||||||
|
# Import os
|
||||||
import os
|
import os
|
||||||
|
|
||||||
# Import uuid
|
# Import uuid
|
||||||
import uuid as _uuid
|
import uuid as _uuid
|
||||||
from datetime import datetime
|
|
||||||
|
# Import Optional from typing
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
# Import APIRouter, Depends, File, Form, Query, Request,... from fastapi
|
# Import APIRouter, Depends, File, Form, Query, Request,... from fastapi
|
||||||
from fastapi import APIRouter, Depends, File, Form, Query, Request, UploadFile, status
|
from fastapi import APIRouter, Depends, File, Form, Query, Request, UploadFile, status
|
||||||
from fastapi.responses import StreamingResponse
|
|
||||||
|
# Import Session from sqlalchemy.orm
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
# Import get_db from app.database
|
# Import get_db from app.database
|
||||||
@@ -72,9 +74,9 @@ from app.services.evidence_service import (
|
|||||||
validate_file,
|
validate_file,
|
||||||
validate_upload_permission,
|
validate_upload_permission,
|
||||||
)
|
)
|
||||||
from app.storage import download_file, upload_file
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
# Import get_presigned_url, upload_file from app.storage
|
||||||
|
from app.storage import get_presigned_url, upload_file
|
||||||
|
|
||||||
# Assign router = APIRouter(tags=["evidence"])
|
# Assign router = APIRouter(tags=["evidence"])
|
||||||
router = APIRouter(tags=["evidence"])
|
router = APIRouter(tags=["evidence"])
|
||||||
@@ -85,11 +87,8 @@ router = APIRouter(tags=["evidence"])
|
|||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
def _evidence_to_out(evidence: Evidence) -> EvidenceOut:
|
def _evidence_to_out(evidence: Evidence) -> EvidenceOut:
|
||||||
"""Convert an ORM ``Evidence`` to the API schema.
|
"""Convert an ORM ``Evidence`` to the API schema, injecting a presigned URL."""
|
||||||
|
# Return EvidenceOut(
|
||||||
``download_url`` points to the backend proxy endpoint so the browser
|
|
||||||
never needs direct access to MinIO.
|
|
||||||
"""
|
|
||||||
return EvidenceOut(
|
return EvidenceOut(
|
||||||
# Keyword argument: id
|
# Keyword argument: id
|
||||||
id=evidence.id,
|
id=evidence.id,
|
||||||
@@ -107,7 +106,8 @@ def _evidence_to_out(evidence: Evidence) -> EvidenceOut:
|
|||||||
team=evidence.team,
|
team=evidence.team,
|
||||||
# Keyword argument: notes
|
# Keyword argument: notes
|
||||||
notes=evidence.notes,
|
notes=evidence.notes,
|
||||||
download_url=f"/api/v1/evidence/{evidence.id}/file",
|
# Keyword argument: download_url
|
||||||
|
download_url=get_presigned_url(evidence.file_path),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -185,7 +185,7 @@ async def upload_evidence(
|
|||||||
sha256_hash=sha256,
|
sha256_hash=sha256,
|
||||||
# Keyword argument: uploaded_by
|
# Keyword argument: uploaded_by
|
||||||
uploaded_by=current_user.id,
|
uploaded_by=current_user.id,
|
||||||
uploaded_at=datetime.utcnow(), # set explicitly — DB column has no server default
|
# Keyword argument: team
|
||||||
team=team,
|
team=team,
|
||||||
# Keyword argument: notes
|
# Keyword argument: notes
|
||||||
notes=notes,
|
notes=notes,
|
||||||
@@ -222,43 +222,10 @@ async def upload_evidence(
|
|||||||
# Reload ORM object attributes from the database
|
# Reload ORM object attributes from the database
|
||||||
db.refresh(evidence)
|
db.refresh(evidence)
|
||||||
|
|
||||||
# 7. Attach to Jira ticket if one exists (non-fatal)
|
# Return _evidence_to_out(evidence)
|
||||||
_attach_evidence_to_jira(db, test_id, content, safe_name, current_user)
|
|
||||||
|
|
||||||
return _evidence_to_out(evidence)
|
return _evidence_to_out(evidence)
|
||||||
|
|
||||||
|
|
||||||
def _attach_evidence_to_jira(
|
|
||||||
db,
|
|
||||||
test_id: _uuid.UUID,
|
|
||||||
content: bytes,
|
|
||||||
file_name: str,
|
|
||||||
actor,
|
|
||||||
) -> None:
|
|
||||||
"""Attach uploaded evidence to the linked Jira ticket (non-fatal)."""
|
|
||||||
try:
|
|
||||||
from app.services.jira_service import get_test_jira_key, get_admin_jira_client, has_admin_jira_configured
|
|
||||||
if not has_admin_jira_configured(db):
|
|
||||||
return
|
|
||||||
issue_key = get_test_jira_key(db, test_id)
|
|
||||||
if not issue_key:
|
|
||||||
return
|
|
||||||
import io
|
|
||||||
jira = get_admin_jira_client(db)
|
|
||||||
buf = io.BytesIO(content)
|
|
||||||
buf.name = file_name # requests uses .name as the multipart filename
|
|
||||||
jira.add_attachment_object(issue_key, buf)
|
|
||||||
import logging
|
|
||||||
logging.getLogger(__name__).info(
|
|
||||||
"Attached evidence '%s' to Jira ticket %s", file_name, issue_key
|
|
||||||
)
|
|
||||||
except Exception as exc:
|
|
||||||
import logging
|
|
||||||
logging.getLogger(__name__).warning(
|
|
||||||
"Failed to attach evidence '%s' to Jira: %s", file_name, exc, exc_info=True
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# GET /tests/{test_id}/evidence — list (with optional team filter)
|
# GET /tests/{test_id}/evidence — list (with optional team filter)
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -286,7 +253,7 @@ def list_evidence(
|
|||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# GET /evidence/{id} — metadata + proxy download URL
|
# GET /evidence/{id} — presigned download URL
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
@@ -299,50 +266,14 @@ def get_evidence(
|
|||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
# Entry: current_user
|
# Entry: current_user
|
||||||
current_user: User = Depends(get_current_user),
|
current_user: User = Depends(get_current_user),
|
||||||
):
|
) -> EvidenceOut:
|
||||||
"""Return evidence metadata. ``download_url`` is a backend proxy URL."""
|
"""Return evidence metadata together with a presigned download URL."""
|
||||||
|
# Assign evidence = get_evidence_or_raise(db, evidence_id)
|
||||||
evidence = get_evidence_or_raise(db, evidence_id)
|
evidence = get_evidence_or_raise(db, evidence_id)
|
||||||
# Return _evidence_to_out(evidence)
|
# Return _evidence_to_out(evidence)
|
||||||
return _evidence_to_out(evidence)
|
return _evidence_to_out(evidence)
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# GET /evidence/{id}/file — proxy download (streams file via backend)
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/evidence/{evidence_id}/file")
|
|
||||||
def download_evidence_file(
|
|
||||||
evidence_id: _uuid.UUID,
|
|
||||||
db: Session = Depends(get_db),
|
|
||||||
current_user: User = Depends(get_current_user),
|
|
||||||
):
|
|
||||||
"""Stream the evidence file through the backend.
|
|
||||||
|
|
||||||
The browser calls this endpoint (authenticated via JWT cookie/header).
|
|
||||||
The backend fetches the file from MinIO internally and streams it back,
|
|
||||||
so MinIO never needs to be publicly accessible.
|
|
||||||
"""
|
|
||||||
import mimetypes
|
|
||||||
|
|
||||||
evidence = get_evidence_or_raise(db, evidence_id)
|
|
||||||
content = download_file(evidence.file_path)
|
|
||||||
|
|
||||||
mime_type, _ = mimetypes.guess_type(evidence.file_name)
|
|
||||||
if not mime_type:
|
|
||||||
mime_type = "application/octet-stream"
|
|
||||||
|
|
||||||
safe_name = evidence.file_name.replace('"', '\\"')
|
|
||||||
return StreamingResponse(
|
|
||||||
iter([content]),
|
|
||||||
media_type=mime_type,
|
|
||||||
headers={
|
|
||||||
"Content-Disposition": f'inline; filename="{safe_name}"',
|
|
||||||
"Content-Length": str(len(content)),
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# DELETE /evidence/{id} — delete evidence (editable states only)
|
# DELETE /evidence/{id} — delete evidence (editable states only)
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -1,123 +0,0 @@
|
|||||||
"""Phase 13: Executive Dashboard router."""
|
|
||||||
|
|
||||||
from typing import List
|
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, Query
|
|
||||||
from sqlalchemy.orm import Session
|
|
||||||
|
|
||||||
from app.database import get_db
|
|
||||||
from app.dependencies.auth import get_current_user, require_any_role
|
|
||||||
from app.schemas.executive_dashboard_schema import (
|
|
||||||
PostureSnapshotOut,
|
|
||||||
ExecutiveSummary,
|
|
||||||
KpiBlock,
|
|
||||||
CoverageByTactic,
|
|
||||||
PostureHistoryEntry,
|
|
||||||
ActivityEntry,
|
|
||||||
)
|
|
||||||
import app.services.executive_dashboard_service as svc
|
|
||||||
|
|
||||||
router = APIRouter(prefix="/dashboard", tags=["Executive Dashboard"])
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/executive", response_model=ExecutiveSummary)
|
|
||||||
def executive_view(
|
|
||||||
db: Session = Depends(get_db),
|
|
||||||
user=Depends(get_current_user),
|
|
||||||
):
|
|
||||||
"""
|
|
||||||
Full executive view — snapshot, 30-day trends, top risks,
|
|
||||||
coverage by tactic, and recent activity feed.
|
|
||||||
"""
|
|
||||||
data = svc.get_executive_summary(db)
|
|
||||||
snap = data["snapshot"]
|
|
||||||
return ExecutiveSummary(
|
|
||||||
snapshot=PostureSnapshotOut.model_validate(snap),
|
|
||||||
coverage_trend=data["coverage_trend"],
|
|
||||||
risk_trend=data["risk_trend"],
|
|
||||||
top_risks=data["top_risks"],
|
|
||||||
coverage_by_tactic=data["coverage_by_tactic"],
|
|
||||||
recent_activity=data["recent_activity"],
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/kpis", response_model=KpiBlock)
|
|
||||||
def kpis(
|
|
||||||
db: Session = Depends(get_db),
|
|
||||||
user=Depends(get_current_user),
|
|
||||||
):
|
|
||||||
"""Compact KPI block — live aggregation without persisting a snapshot."""
|
|
||||||
live = svc.get_live_kpis(db)
|
|
||||||
|
|
||||||
# Try to find today's snapshot id; fall back to None
|
|
||||||
from datetime import date
|
|
||||||
from app.models.executive_dashboard import PostureSnapshot
|
|
||||||
today_snap = db.query(PostureSnapshot).filter(
|
|
||||||
PostureSnapshot.snapshot_date == date.today()
|
|
||||||
).first()
|
|
||||||
|
|
||||||
return KpiBlock(
|
|
||||||
coverage_pct=live["coverage_pct"],
|
|
||||||
avg_risk_score=live["avg_risk_score"],
|
|
||||||
critical_count=live["critical_count"],
|
|
||||||
open_queue_items=live["open_queue_items"],
|
|
||||||
orphan_techniques=live["orphan_techniques"],
|
|
||||||
mttd_avg_seconds=live.get("mttd_avg_seconds"),
|
|
||||||
detection_rate_30d=live.get("detection_rate_30d"),
|
|
||||||
playbook_count=live["playbook_count"],
|
|
||||||
lesson_count=live["lesson_count"],
|
|
||||||
snapshot_date=live["snapshot_date"],
|
|
||||||
snapshot_id=today_snap.id if today_snap else None,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/coverage-by-tactic", response_model=List[CoverageByTactic])
|
|
||||||
def coverage_by_tactic(
|
|
||||||
db: Session = Depends(get_db),
|
|
||||||
user=Depends(get_current_user),
|
|
||||||
):
|
|
||||||
"""Per-tactic validated / partial / not_covered breakdown."""
|
|
||||||
return svc.get_coverage_by_tactic(db)
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/posture-history", response_model=List[PostureHistoryEntry])
|
|
||||||
def posture_history(
|
|
||||||
days: int = Query(30, ge=1, le=365),
|
|
||||||
db: Session = Depends(get_db),
|
|
||||||
user=Depends(get_current_user),
|
|
||||||
):
|
|
||||||
"""Historical posture snapshots for trend charts (default last 30 days)."""
|
|
||||||
snaps = svc.get_posture_history(db, days=days)
|
|
||||||
return [
|
|
||||||
PostureHistoryEntry(
|
|
||||||
snapshot_date=s.snapshot_date,
|
|
||||||
coverage_pct=s.coverage_pct,
|
|
||||||
avg_risk_score=s.avg_risk_score,
|
|
||||||
critical_count=s.critical_count,
|
|
||||||
open_queue_items=s.open_queue_items,
|
|
||||||
)
|
|
||||||
for s in snaps
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/posture-snapshot", response_model=PostureSnapshotOut, status_code=201)
|
|
||||||
def create_posture_snapshot(
|
|
||||||
db: Session = Depends(get_db),
|
|
||||||
user=Depends(require_any_role("admin", "red_lead", "blue_lead")),
|
|
||||||
):
|
|
||||||
"""
|
|
||||||
Take (or refresh) today's posture snapshot — admin / leads only.
|
|
||||||
Aggregates live data from all phases into a single PostureSnapshot row.
|
|
||||||
"""
|
|
||||||
snap = svc.take_posture_snapshot(db, created_by=user.id)
|
|
||||||
return PostureSnapshotOut.model_validate(snap)
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/activity", response_model=List[ActivityEntry])
|
|
||||||
def recent_activity(
|
|
||||||
limit: int = Query(20, ge=1, le=100),
|
|
||||||
db: Session = Depends(get_db),
|
|
||||||
user=Depends(get_current_user),
|
|
||||||
):
|
|
||||||
"""Recent activity feed — tests, attack-path executions, OSINT signals."""
|
|
||||||
return svc.get_recent_activity(db, limit=limit)
|
|
||||||
@@ -1,54 +0,0 @@
|
|||||||
"""Intel items endpoints — list and manage threat intelligence items."""
|
|
||||||
|
|
||||||
import uuid
|
|
||||||
from typing import Optional
|
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, Query
|
|
||||||
from pydantic import BaseModel
|
|
||||||
from sqlalchemy.orm import Session
|
|
||||||
|
|
||||||
from app.database import get_db
|
|
||||||
from app.dependencies.auth import get_current_user
|
|
||||||
from app.models.intel import IntelItem
|
|
||||||
from app.models.user import User
|
|
||||||
|
|
||||||
router = APIRouter(prefix="/intel", tags=["intel"])
|
|
||||||
|
|
||||||
|
|
||||||
class IntelItemOut(BaseModel):
|
|
||||||
id: uuid.UUID
|
|
||||||
technique_id: Optional[uuid.UUID] = None
|
|
||||||
url: str
|
|
||||||
title: Optional[str] = None
|
|
||||||
source: Optional[str] = None
|
|
||||||
detected_at: Optional[str] = None
|
|
||||||
reviewed: bool
|
|
||||||
|
|
||||||
class Config:
|
|
||||||
from_attributes = True
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/items", response_model=list[IntelItemOut])
|
|
||||||
def list_intel_items(
|
|
||||||
technique_id: Optional[uuid.UUID] = Query(None, description="Filter by technique"),
|
|
||||||
limit: int = Query(50, ge=1, le=200),
|
|
||||||
db: Session = Depends(get_db),
|
|
||||||
current_user: User = Depends(get_current_user),
|
|
||||||
):
|
|
||||||
"""List threat intelligence items, optionally filtered by technique."""
|
|
||||||
query = db.query(IntelItem).order_by(IntelItem.detected_at.desc())
|
|
||||||
if technique_id:
|
|
||||||
query = query.filter(IntelItem.technique_id == technique_id)
|
|
||||||
items = query.limit(limit).all()
|
|
||||||
return [
|
|
||||||
IntelItemOut(
|
|
||||||
id=item.id,
|
|
||||||
technique_id=item.technique_id,
|
|
||||||
url=item.url,
|
|
||||||
title=item.title,
|
|
||||||
source=item.source,
|
|
||||||
detected_at=item.detected_at.isoformat() if item.detected_at else None,
|
|
||||||
reviewed=item.reviewed,
|
|
||||||
)
|
|
||||||
for item in items
|
|
||||||
]
|
|
||||||
@@ -129,19 +129,19 @@ def list_links(
|
|||||||
entity_type: Optional[JiraLinkEntityType] = None,
|
entity_type: Optional[JiraLinkEntityType] = None,
|
||||||
# Entry: entity_id
|
# Entry: entity_id
|
||||||
entity_id: Optional[UUID] = None,
|
entity_id: Optional[UUID] = None,
|
||||||
entity_ids: Optional[list[UUID]] = Query(default=None, description="Filter by multiple entity IDs"),
|
# Entry: db
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
# Entry: user
|
# Entry: user
|
||||||
user: User = Depends(get_current_user),
|
user: User = Depends(get_current_user),
|
||||||
):
|
) -> list[JiraLinkOut]:
|
||||||
"""List Jira links, optionally filtered by entity or a list of entity IDs."""
|
"""List Jira links, optionally filtered by entity."""
|
||||||
|
# Return jira_service.list_links(
|
||||||
return jira_service.list_links(
|
return jira_service.list_links(
|
||||||
db,
|
db,
|
||||||
# Keyword argument: entity_type
|
# Keyword argument: entity_type
|
||||||
entity_type=entity_type,
|
entity_type=entity_type,
|
||||||
# Keyword argument: entity_id
|
# Keyword argument: entity_id
|
||||||
entity_id=entity_id,
|
entity_id=entity_id,
|
||||||
entity_ids=entity_ids,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,206 +0,0 @@
|
|||||||
"""Phase 11: Knowledge Management router — Playbooks + Lessons Learned."""
|
|
||||||
|
|
||||||
from typing import List, Optional
|
|
||||||
from uuid import UUID
|
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends
|
|
||||||
from sqlalchemy.orm import Session
|
|
||||||
|
|
||||||
from app.database import get_db
|
|
||||||
from app.dependencies.auth import get_current_user, require_any_role
|
|
||||||
from app.schemas.knowledge_schema import (
|
|
||||||
PlaybookCreate, PlaybookUpdate, PlaybookOut, PlaybookVersionOut,
|
|
||||||
LessonLearnedCreate, LessonLearnedUpdate, LessonLearnedOut,
|
|
||||||
)
|
|
||||||
from app.services import playbook_service as pb_svc
|
|
||||||
from app.services import lesson_learned_service as ll_svc
|
|
||||||
|
|
||||||
router = APIRouter(prefix="/knowledge", tags=["knowledge"])
|
|
||||||
|
|
||||||
|
|
||||||
# ══════════════════════════════════════════════════════════════════════════════
|
|
||||||
# Playbooks
|
|
||||||
# ══════════════════════════════════════════════════════════════════════════════
|
|
||||||
|
|
||||||
@router.get("/playbooks", response_model=List[PlaybookOut])
|
|
||||||
def list_playbooks(
|
|
||||||
technique_id: Optional[UUID] = None,
|
|
||||||
playbook_type: Optional[str] = None,
|
|
||||||
include_inactive: bool = False,
|
|
||||||
db: Session = Depends(get_db),
|
|
||||||
user=Depends(get_current_user),
|
|
||||||
):
|
|
||||||
return pb_svc.list_playbooks(
|
|
||||||
db,
|
|
||||||
technique_id=technique_id,
|
|
||||||
playbook_type=playbook_type,
|
|
||||||
include_inactive=include_inactive,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/playbooks", response_model=PlaybookOut, status_code=201)
|
|
||||||
def create_playbook(
|
|
||||||
body: PlaybookCreate,
|
|
||||||
db: Session = Depends(get_db),
|
|
||||||
user=Depends(require_any_role("admin", "red_lead", "blue_lead")),
|
|
||||||
):
|
|
||||||
return pb_svc.create_playbook(db, body.model_dump(), user.id)
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/playbooks/{playbook_id}", response_model=PlaybookOut)
|
|
||||||
def get_playbook(
|
|
||||||
playbook_id: UUID,
|
|
||||||
db: Session = Depends(get_db),
|
|
||||||
user=Depends(get_current_user),
|
|
||||||
):
|
|
||||||
return pb_svc.get_playbook(db, playbook_id)
|
|
||||||
|
|
||||||
|
|
||||||
@router.patch("/playbooks/{playbook_id}", response_model=PlaybookOut)
|
|
||||||
def update_playbook(
|
|
||||||
playbook_id: UUID,
|
|
||||||
body: PlaybookUpdate,
|
|
||||||
db: Session = Depends(get_db),
|
|
||||||
user=Depends(require_any_role("admin", "red_lead", "blue_lead")),
|
|
||||||
):
|
|
||||||
return pb_svc.update_playbook(db, playbook_id, body.model_dump(exclude_unset=True), user.id)
|
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/playbooks/{playbook_id}", status_code=204)
|
|
||||||
def delete_playbook(
|
|
||||||
playbook_id: UUID,
|
|
||||||
db: Session = Depends(get_db),
|
|
||||||
user=Depends(require_any_role("admin", "red_lead", "blue_lead")),
|
|
||||||
):
|
|
||||||
pb_svc.delete_playbook(db, playbook_id, user.id)
|
|
||||||
|
|
||||||
|
|
||||||
# ── Versions ──────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
@router.get("/playbooks/{playbook_id}/versions", response_model=List[PlaybookVersionOut])
|
|
||||||
def list_versions(
|
|
||||||
playbook_id: UUID,
|
|
||||||
db: Session = Depends(get_db),
|
|
||||||
user=Depends(get_current_user),
|
|
||||||
):
|
|
||||||
return pb_svc.get_playbook_versions(db, playbook_id)
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/playbooks/{playbook_id}/restore/{version}", response_model=PlaybookOut)
|
|
||||||
def restore_version(
|
|
||||||
playbook_id: UUID,
|
|
||||||
version: int,
|
|
||||||
db: Session = Depends(get_db),
|
|
||||||
user=Depends(require_any_role("admin", "red_lead", "blue_lead")),
|
|
||||||
):
|
|
||||||
"""Roll the playbook back to a specific historical version."""
|
|
||||||
return pb_svc.restore_version(db, playbook_id, version, user.id)
|
|
||||||
|
|
||||||
|
|
||||||
# ── By technique (convenience) ────────────────────────────────────────────────
|
|
||||||
|
|
||||||
@router.get(
|
|
||||||
"/techniques/{technique_id}/playbooks",
|
|
||||||
response_model=List[PlaybookOut],
|
|
||||||
)
|
|
||||||
def playbooks_for_technique(
|
|
||||||
technique_id: UUID,
|
|
||||||
db: Session = Depends(get_db),
|
|
||||||
user=Depends(get_current_user),
|
|
||||||
):
|
|
||||||
"""List all active playbooks for a specific technique."""
|
|
||||||
return pb_svc.list_playbooks(db, technique_id=technique_id)
|
|
||||||
|
|
||||||
|
|
||||||
@router.get(
|
|
||||||
"/techniques/{technique_id}/playbooks/{playbook_type}",
|
|
||||||
response_model=PlaybookOut,
|
|
||||||
)
|
|
||||||
def get_playbook_by_technique_type(
|
|
||||||
technique_id: UUID,
|
|
||||||
playbook_type: str,
|
|
||||||
db: Session = Depends(get_db),
|
|
||||||
user=Depends(get_current_user),
|
|
||||||
):
|
|
||||||
pb = pb_svc.get_playbook_by_technique_type(db, technique_id, playbook_type)
|
|
||||||
if not pb:
|
|
||||||
from app.domain.errors import EntityNotFoundError
|
|
||||||
raise EntityNotFoundError("Playbook", f"{technique_id}/{playbook_type}")
|
|
||||||
return pb
|
|
||||||
|
|
||||||
|
|
||||||
# ══════════════════════════════════════════════════════════════════════════════
|
|
||||||
# Lessons Learned
|
|
||||||
# ══════════════════════════════════════════════════════════════════════════════
|
|
||||||
|
|
||||||
@router.get("/lessons", response_model=List[LessonLearnedOut])
|
|
||||||
def list_lessons(
|
|
||||||
entity_type: Optional[str] = None,
|
|
||||||
entity_id: Optional[UUID] = None,
|
|
||||||
severity: Optional[str] = None,
|
|
||||||
tag: Optional[str] = None,
|
|
||||||
technique_id: Optional[str] = None,
|
|
||||||
include_inactive: bool = False,
|
|
||||||
db: Session = Depends(get_db),
|
|
||||||
user=Depends(get_current_user),
|
|
||||||
):
|
|
||||||
return ll_svc.list_lessons_learned(
|
|
||||||
db,
|
|
||||||
entity_type=entity_type,
|
|
||||||
entity_id=entity_id,
|
|
||||||
severity=severity,
|
|
||||||
tag=tag,
|
|
||||||
technique_id=technique_id,
|
|
||||||
include_inactive=include_inactive,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/lessons", response_model=LessonLearnedOut, status_code=201)
|
|
||||||
def create_lesson(
|
|
||||||
body: LessonLearnedCreate,
|
|
||||||
db: Session = Depends(get_db),
|
|
||||||
user=Depends(require_any_role("admin", "red_lead", "blue_lead")),
|
|
||||||
):
|
|
||||||
return ll_svc.create_lesson_learned(db, body.model_dump(), user.id)
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/lessons/{lesson_id}", response_model=LessonLearnedOut)
|
|
||||||
def get_lesson(
|
|
||||||
lesson_id: UUID,
|
|
||||||
db: Session = Depends(get_db),
|
|
||||||
user=Depends(get_current_user),
|
|
||||||
):
|
|
||||||
return ll_svc.get_lesson_learned(db, lesson_id)
|
|
||||||
|
|
||||||
|
|
||||||
@router.patch("/lessons/{lesson_id}", response_model=LessonLearnedOut)
|
|
||||||
def update_lesson(
|
|
||||||
lesson_id: UUID,
|
|
||||||
body: LessonLearnedUpdate,
|
|
||||||
db: Session = Depends(get_db),
|
|
||||||
user=Depends(require_any_role("admin", "red_lead", "blue_lead")),
|
|
||||||
):
|
|
||||||
return ll_svc.update_lesson_learned(
|
|
||||||
db, lesson_id, body.model_dump(exclude_unset=True), user.id
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/lessons/{lesson_id}", status_code=204)
|
|
||||||
def delete_lesson(
|
|
||||||
lesson_id: UUID,
|
|
||||||
db: Session = Depends(get_db),
|
|
||||||
user=Depends(require_any_role("admin", "red_lead", "blue_lead")),
|
|
||||||
):
|
|
||||||
"""Soft-delete a lesson (admin / lead only)."""
|
|
||||||
ll_svc.delete_lesson_learned(db, lesson_id, user.id)
|
|
||||||
|
|
||||||
|
|
||||||
# ── Stats ─────────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
@router.get("/stats")
|
|
||||||
def knowledge_stats(
|
|
||||||
db: Session = Depends(get_db),
|
|
||||||
user=Depends(get_current_user),
|
|
||||||
):
|
|
||||||
"""Summary counts: total playbooks, lessons by severity, playbooks by type."""
|
|
||||||
return ll_svc.get_knowledge_stats(db)
|
|
||||||
@@ -1,191 +0,0 @@
|
|||||||
"""Phase 13: Operational Alerts router."""
|
|
||||||
|
|
||||||
from typing import List, Optional
|
|
||||||
from uuid import UUID
|
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, Query
|
|
||||||
from sqlalchemy.orm import Session
|
|
||||||
|
|
||||||
from app.database import get_db
|
|
||||||
from app.dependencies.auth import get_current_user, require_any_role
|
|
||||||
from app.models.user import User
|
|
||||||
from app.schemas.operational_alert_schema import (
|
|
||||||
AlertRuleCreate, AlertRuleOut, AlertRuleUpdate,
|
|
||||||
AlertInstanceOut, EvaluationResult, AlertSummary,
|
|
||||||
)
|
|
||||||
import app.services.operational_alert_service as svc
|
|
||||||
|
|
||||||
router = APIRouter(prefix="/alerts", tags=["Operational Alerts"])
|
|
||||||
|
|
||||||
|
|
||||||
# ── Evaluation ────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
@router.post("/evaluate", response_model=EvaluationResult, status_code=202)
|
|
||||||
def evaluate_rules(
|
|
||||||
db: Session = Depends(get_db),
|
|
||||||
user=Depends(require_any_role("admin", "red_lead", "blue_lead")),
|
|
||||||
):
|
|
||||||
"""
|
|
||||||
Run the alert evaluation engine against all enabled rules.
|
|
||||||
|
|
||||||
Fires AlertInstances for rules whose conditions are met and are not in cooldown.
|
|
||||||
Admin / leads only.
|
|
||||||
"""
|
|
||||||
result = svc.evaluate_all_rules(db)
|
|
||||||
return EvaluationResult(
|
|
||||||
rules_evaluated = result["rules_evaluated"],
|
|
||||||
alerts_fired = result["alerts_fired"],
|
|
||||||
alerts = [AlertInstanceOut.model_validate(a) for a in result["alerts"]],
|
|
||||||
duration_seconds = result["duration_seconds"],
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
# ── Alert instances ───────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
@router.get("", response_model=List[AlertInstanceOut])
|
|
||||||
def list_alerts(
|
|
||||||
status: Optional[str] = Query(None),
|
|
||||||
severity: Optional[str] = Query(None),
|
|
||||||
rule_type: Optional[str] = Query(None),
|
|
||||||
limit: int = Query(50, ge=1, le=200),
|
|
||||||
offset: int = Query(0, ge=0),
|
|
||||||
db: Session = Depends(get_db),
|
|
||||||
user=Depends(get_current_user),
|
|
||||||
):
|
|
||||||
"""List alert instances with optional filters."""
|
|
||||||
return svc.list_instances(db, status=status, severity=severity,
|
|
||||||
rule_type=rule_type, limit=limit, offset=offset)
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/summary", response_model=AlertSummary)
|
|
||||||
def alert_summary(
|
|
||||||
db: Session = Depends(get_db),
|
|
||||||
user=Depends(get_current_user),
|
|
||||||
):
|
|
||||||
"""Aggregate counts by status, severity, and rule type."""
|
|
||||||
data = svc.get_summary(db)
|
|
||||||
return AlertSummary(
|
|
||||||
total_open = data["total_open"],
|
|
||||||
total_acknowledged = data["total_acknowledged"],
|
|
||||||
total_resolved = data["total_resolved"],
|
|
||||||
by_severity = data["by_severity"],
|
|
||||||
by_rule_type = data["by_rule_type"],
|
|
||||||
recent_alerts = [AlertInstanceOut.model_validate(a) for a in data["recent_alerts"]],
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{alert_id}", response_model=AlertInstanceOut)
|
|
||||||
def get_alert(
|
|
||||||
alert_id: UUID,
|
|
||||||
db: Session = Depends(get_db),
|
|
||||||
user=Depends(get_current_user),
|
|
||||||
):
|
|
||||||
"""Get a single alert instance."""
|
|
||||||
return svc.get_instance(db, alert_id)
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/{alert_id}/acknowledge", response_model=AlertInstanceOut)
|
|
||||||
def acknowledge_alert(
|
|
||||||
alert_id: UUID,
|
|
||||||
db: Session = Depends(get_db),
|
|
||||||
current_user: User = Depends(require_any_role("admin", "red_lead", "blue_lead")),
|
|
||||||
):
|
|
||||||
"""Acknowledge an open alert (admin / lead roles only)."""
|
|
||||||
return svc.acknowledge(db, alert_id, current_user.id)
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/{alert_id}/resolve", response_model=AlertInstanceOut)
|
|
||||||
def resolve_alert(
|
|
||||||
alert_id: UUID,
|
|
||||||
db: Session = Depends(get_db),
|
|
||||||
current_user: User = Depends(require_any_role("admin", "red_lead", "blue_lead")),
|
|
||||||
):
|
|
||||||
"""Mark an alert as resolved (admin / lead roles only)."""
|
|
||||||
return svc.resolve(db, alert_id, current_user.id)
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/{alert_id}/dismiss", response_model=AlertInstanceOut)
|
|
||||||
def dismiss_alert(
|
|
||||||
alert_id: UUID,
|
|
||||||
db: Session = Depends(get_db),
|
|
||||||
current_user: User = Depends(require_any_role("admin", "red_lead", "blue_lead")),
|
|
||||||
):
|
|
||||||
"""Dismiss an alert (admin / lead roles only — won't re-fire until cooldown resets)."""
|
|
||||||
return svc.dismiss(db, alert_id, current_user.id)
|
|
||||||
|
|
||||||
|
|
||||||
# ── Alert rules ───────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
@router.get("/rules/list", response_model=List[AlertRuleOut])
|
|
||||||
def list_rules(
|
|
||||||
rule_type: Optional[str] = Query(None),
|
|
||||||
include_disabled: bool = Query(False),
|
|
||||||
db: Session = Depends(get_db),
|
|
||||||
user=Depends(get_current_user),
|
|
||||||
):
|
|
||||||
"""List alert rules (all users can read; admins/leads manage them)."""
|
|
||||||
return svc.list_rules(db, rule_type=rule_type, include_disabled=include_disabled)
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/rules", response_model=AlertRuleOut, status_code=201)
|
|
||||||
def create_rule(
|
|
||||||
body: AlertRuleCreate,
|
|
||||||
db: Session = Depends(get_db),
|
|
||||||
current_user: User = Depends(require_any_role("admin", "red_lead", "blue_lead")),
|
|
||||||
):
|
|
||||||
"""Create a custom alert rule."""
|
|
||||||
return svc.create_rule(
|
|
||||||
db,
|
|
||||||
created_by = current_user.id,
|
|
||||||
name = body.name,
|
|
||||||
description = body.description,
|
|
||||||
rule_type = body.rule_type,
|
|
||||||
severity = body.severity,
|
|
||||||
config = body.config,
|
|
||||||
notify_in_app = body.notify_in_app,
|
|
||||||
notify_webhook = body.notify_webhook,
|
|
||||||
webhook_id = body.webhook_id,
|
|
||||||
cooldown_hours = body.cooldown_hours,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/rules/{rule_id}", response_model=AlertRuleOut)
|
|
||||||
def get_rule(
|
|
||||||
rule_id: UUID,
|
|
||||||
db: Session = Depends(get_db),
|
|
||||||
user=Depends(get_current_user),
|
|
||||||
):
|
|
||||||
"""Get a single alert rule."""
|
|
||||||
return svc.get_rule(db, rule_id)
|
|
||||||
|
|
||||||
|
|
||||||
@router.patch("/rules/{rule_id}", response_model=AlertRuleOut)
|
|
||||||
def update_rule(
|
|
||||||
rule_id: UUID,
|
|
||||||
body: AlertRuleUpdate,
|
|
||||||
db: Session = Depends(get_db),
|
|
||||||
user=Depends(require_any_role("admin", "red_lead", "blue_lead")),
|
|
||||||
):
|
|
||||||
"""Update an alert rule (enable/disable, thresholds, cooldown)."""
|
|
||||||
return svc.update_rule(
|
|
||||||
db, rule_id,
|
|
||||||
name = body.name,
|
|
||||||
description = body.description,
|
|
||||||
severity = body.severity,
|
|
||||||
is_enabled = body.is_enabled,
|
|
||||||
config = body.config,
|
|
||||||
notify_in_app = body.notify_in_app,
|
|
||||||
notify_webhook = body.notify_webhook,
|
|
||||||
webhook_id = body.webhook_id,
|
|
||||||
cooldown_hours = body.cooldown_hours,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/rules/{rule_id}", status_code=204)
|
|
||||||
def delete_rule(
|
|
||||||
rule_id: UUID,
|
|
||||||
db: Session = Depends(get_db),
|
|
||||||
user=Depends(require_any_role("admin")),
|
|
||||||
):
|
|
||||||
"""Delete a custom alert rule (system rules cannot be deleted)."""
|
|
||||||
svc.delete_rule(db, rule_id)
|
|
||||||
@@ -19,11 +19,8 @@ from app.dependencies.auth import get_current_user
|
|||||||
# Import User from app.models.user
|
# Import User from app.models.user
|
||||||
from app.models.user import User
|
from app.models.user import User
|
||||||
|
|
||||||
from datetime import datetime, date
|
|
||||||
|
|
||||||
# Import from app.services.operational_metrics_service
|
# Import from app.services.operational_metrics_service
|
||||||
from app.services.operational_metrics_service import (
|
from app.services.operational_metrics_service import (
|
||||||
get_all_operational_metrics,
|
|
||||||
get_metrics_by_team,
|
get_metrics_by_team,
|
||||||
get_operational_trend,
|
get_operational_trend,
|
||||||
)
|
)
|
||||||
@@ -36,20 +33,18 @@ router = APIRouter(prefix="/metrics/operational", tags=["operational-metrics"])
|
|||||||
|
|
||||||
|
|
||||||
@router.get("")
|
@router.get("")
|
||||||
|
# Define function operational_metrics
|
||||||
def operational_metrics(
|
def operational_metrics(
|
||||||
|
# Entry: db
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
|
# Entry: current_user
|
||||||
current_user: User = Depends(get_current_user),
|
current_user: User = Depends(get_current_user),
|
||||||
since: str | None = Query(None, description="ISO date YYYY-MM-DD — filter metrics to tests on or after this date"),
|
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""Get all operational metrics (MTTD, MTTR, etc.). Uses cache when no time filter is set."""
|
"""Get all operational metrics (MTTD, MTTR, etc.) — cached for 5 min."""
|
||||||
if since:
|
# Import get_operational_metrics_cached from app.services.score_cache
|
||||||
try:
|
|
||||||
since_dt = datetime.combine(date.fromisoformat(since), datetime.min.time())
|
|
||||||
except ValueError:
|
|
||||||
since_dt = None
|
|
||||||
return get_all_operational_metrics(db, since_dt)
|
|
||||||
|
|
||||||
from app.services.score_cache import get_operational_metrics_cached
|
from app.services.score_cache import get_operational_metrics_cached
|
||||||
|
|
||||||
|
# Return get_operational_metrics_cached(db)
|
||||||
return get_operational_metrics_cached(db)
|
return get_operational_metrics_cached(db)
|
||||||
|
|
||||||
|
|
||||||
@@ -75,16 +70,13 @@ def operational_trend(
|
|||||||
|
|
||||||
|
|
||||||
@router.get("/by-team")
|
@router.get("/by-team")
|
||||||
|
# Define function metrics_by_team
|
||||||
def metrics_by_team(
|
def metrics_by_team(
|
||||||
|
# Entry: db
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
|
# Entry: current_user
|
||||||
current_user: User = Depends(get_current_user),
|
current_user: User = Depends(get_current_user),
|
||||||
since: str | None = Query(None, description="ISO date YYYY-MM-DD — filter to tests on or after this date"),
|
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""Get metrics broken down by Red Team vs Blue Team."""
|
"""Get metrics broken down by Red Team vs Blue Team."""
|
||||||
since_dt = None
|
# Return get_metrics_by_team(db)
|
||||||
if since:
|
return get_metrics_by_team(db)
|
||||||
try:
|
|
||||||
since_dt = datetime.combine(date.fromisoformat(since), datetime.min.time())
|
|
||||||
except ValueError:
|
|
||||||
pass
|
|
||||||
return get_metrics_by_team(db, since_dt)
|
|
||||||
|
|||||||
@@ -97,7 +97,7 @@ def list_osint_items(
|
|||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
# Entry: user
|
# Entry: user
|
||||||
user: User = Depends(get_current_user),
|
user: User = Depends(get_current_user),
|
||||||
) -> dict:
|
) -> list:
|
||||||
"""List OSINT items with optional filters.
|
"""List OSINT items with optional filters.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
|
|||||||
@@ -1,215 +0,0 @@
|
|||||||
"""Phase 9: Ownership & Daily Operations router."""
|
|
||||||
|
|
||||||
from typing import Optional
|
|
||||||
from uuid import UUID
|
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, Query
|
|
||||||
from sqlalchemy.orm import Session
|
|
||||||
|
|
||||||
from app.database import get_db
|
|
||||||
from app.dependencies.auth import get_current_user, require_any_role
|
|
||||||
from app.domain.exceptions import EntityNotFoundError
|
|
||||||
from app.schemas.ownership_queue_schema import (
|
|
||||||
TechniqueOwnershipSet, TechniqueOwnershipOut,
|
|
||||||
DetectionAssetOwnershipPatch,
|
|
||||||
BulkAssignRequest, BulkAssignResult,
|
|
||||||
QueueItemCreate, QueueItemPatch, QueueItemOut,
|
|
||||||
)
|
|
||||||
from app.services import ownership_service, revalidation_queue_service
|
|
||||||
from app.models.ownership_queue import RevalidationQueueItem
|
|
||||||
|
|
||||||
router = APIRouter(prefix="/ownership", tags=["ownership"])
|
|
||||||
|
|
||||||
|
|
||||||
# ── Technique Ownership ───────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
@router.get("/techniques/{technique_id}", response_model=TechniqueOwnershipOut)
|
|
||||||
def get_technique_ownership(
|
|
||||||
technique_id: UUID,
|
|
||||||
db: Session = Depends(get_db),
|
|
||||||
user=Depends(get_current_user),
|
|
||||||
):
|
|
||||||
ownership = ownership_service.get_technique_ownership(db, technique_id)
|
|
||||||
if not ownership:
|
|
||||||
raise EntityNotFoundError("TechniqueOwnership", str(technique_id))
|
|
||||||
return ownership
|
|
||||||
|
|
||||||
|
|
||||||
@router.put("/techniques/{technique_id}", response_model=TechniqueOwnershipOut)
|
|
||||||
def set_technique_ownership(
|
|
||||||
technique_id: UUID,
|
|
||||||
body: TechniqueOwnershipSet,
|
|
||||||
db: Session = Depends(get_db),
|
|
||||||
user=Depends(require_any_role("admin", "blue_lead", "red_lead")),
|
|
||||||
):
|
|
||||||
return ownership_service.set_technique_ownership(
|
|
||||||
db, technique_id,
|
|
||||||
owner_id=body.owner_id,
|
|
||||||
backup_owner_id=body.backup_owner_id,
|
|
||||||
team=body.team,
|
|
||||||
notes=body.notes,
|
|
||||||
assigned_by=user.id,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
# ── Detection Asset Ownership ─────────────────────────────────────────────────
|
|
||||||
|
|
||||||
@router.patch("/assets/{asset_id}", response_model=dict)
|
|
||||||
def set_asset_ownership(
|
|
||||||
asset_id: UUID,
|
|
||||||
body: DetectionAssetOwnershipPatch,
|
|
||||||
db: Session = Depends(get_db),
|
|
||||||
user=Depends(require_any_role("admin", "blue_lead")),
|
|
||||||
):
|
|
||||||
ownership_service.set_asset_ownership(
|
|
||||||
db, asset_id,
|
|
||||||
owner_id=body.owner_id,
|
|
||||||
backup_owner_id=body.backup_owner_id,
|
|
||||||
team=body.team,
|
|
||||||
user_id=user.id,
|
|
||||||
)
|
|
||||||
return {"message": "Asset ownership updated"}
|
|
||||||
|
|
||||||
|
|
||||||
# ── Orphan Reports ────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
@router.get("/orphans/techniques", response_model=list[dict])
|
|
||||||
def orphan_techniques(
|
|
||||||
db: Session = Depends(get_db),
|
|
||||||
user=Depends(get_current_user),
|
|
||||||
):
|
|
||||||
"""Return techniques with no assigned owner."""
|
|
||||||
return ownership_service.get_orphan_techniques(db)
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/orphans/assets", response_model=list[dict])
|
|
||||||
def orphan_assets(
|
|
||||||
db: Session = Depends(get_db),
|
|
||||||
user=Depends(get_current_user),
|
|
||||||
):
|
|
||||||
"""Return active detection assets with no assigned owner."""
|
|
||||||
return ownership_service.get_orphan_assets(db)
|
|
||||||
|
|
||||||
|
|
||||||
# ── Bulk Assignment ───────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
@router.post("/bulk-assign", response_model=BulkAssignResult)
|
|
||||||
def bulk_assign(
|
|
||||||
body: BulkAssignRequest,
|
|
||||||
db: Session = Depends(get_db),
|
|
||||||
user=Depends(require_any_role("admin", "blue_lead", "red_lead")),
|
|
||||||
):
|
|
||||||
"""
|
|
||||||
Bulk-assign ownership.
|
|
||||||
- If `tactic` is set → assigns technique ownership for all techniques of that tactic.
|
|
||||||
- If `platform` is set → assigns asset ownership for all assets on that platform.
|
|
||||||
At least one of tactic/platform must be provided.
|
|
||||||
"""
|
|
||||||
if not body.tactic and not body.platform:
|
|
||||||
from fastapi import HTTPException
|
|
||||||
raise HTTPException(status_code=422, detail="Provide at least one of: tactic, platform")
|
|
||||||
|
|
||||||
if body.tactic:
|
|
||||||
result = ownership_service.bulk_assign_techniques_by_tactic(
|
|
||||||
db, body.tactic,
|
|
||||||
owner_id=body.owner_id,
|
|
||||||
backup_owner_id=body.backup_owner_id,
|
|
||||||
team=body.team,
|
|
||||||
overwrite=body.overwrite,
|
|
||||||
user_id=user.id,
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
result = ownership_service.bulk_assign_assets_by_platform(
|
|
||||||
db, body.platform,
|
|
||||||
owner_id=body.owner_id,
|
|
||||||
backup_owner_id=body.backup_owner_id,
|
|
||||||
team=body.team,
|
|
||||||
overwrite=body.overwrite,
|
|
||||||
user_id=user.id,
|
|
||||||
)
|
|
||||||
|
|
||||||
return BulkAssignResult(**result)
|
|
||||||
|
|
||||||
|
|
||||||
# ── Revalidation Queue ────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
@router.get("/queue", response_model=list[QueueItemOut])
|
|
||||||
def list_queue(
|
|
||||||
status: Optional[str] = Query(None),
|
|
||||||
priority: Optional[str] = Query(None),
|
|
||||||
reason: Optional[str] = Query(None),
|
|
||||||
assigned_to: Optional[UUID] = Query(None),
|
|
||||||
technique_id: Optional[UUID] = Query(None),
|
|
||||||
detection_asset_id: Optional[UUID] = Query(None),
|
|
||||||
limit: int = Query(100, ge=1, le=500),
|
|
||||||
offset: int = Query(0, ge=0),
|
|
||||||
db: Session = Depends(get_db),
|
|
||||||
user=Depends(get_current_user),
|
|
||||||
):
|
|
||||||
return revalidation_queue_service.list_queue(
|
|
||||||
db, status=status, priority=priority, reason=reason,
|
|
||||||
assigned_to=assigned_to, technique_id=technique_id,
|
|
||||||
detection_asset_id=detection_asset_id, limit=limit, offset=offset,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/queue", response_model=QueueItemOut, status_code=201)
|
|
||||||
def create_queue_item(
|
|
||||||
body: QueueItemCreate,
|
|
||||||
db: Session = Depends(get_db),
|
|
||||||
user=Depends(get_current_user),
|
|
||||||
):
|
|
||||||
return revalidation_queue_service.create_queue_item(db, body.model_dump(), user.id)
|
|
||||||
|
|
||||||
|
|
||||||
@router.patch("/queue/{item_id}", response_model=QueueItemOut)
|
|
||||||
def update_queue_item(
|
|
||||||
item_id: UUID,
|
|
||||||
body: QueueItemPatch,
|
|
||||||
db: Session = Depends(get_db),
|
|
||||||
user=Depends(get_current_user),
|
|
||||||
):
|
|
||||||
return revalidation_queue_service.update_queue_item(db, item_id, body.model_dump(exclude_unset=True), user.id)
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/queue/generate", response_model=dict)
|
|
||||||
def generate_queue(
|
|
||||||
db: Session = Depends(get_db),
|
|
||||||
user=Depends(require_any_role("admin", "blue_lead")),
|
|
||||||
):
|
|
||||||
"""Scan the system and create new revalidation queue items."""
|
|
||||||
return revalidation_queue_service.generate_queue_items(db)
|
|
||||||
|
|
||||||
|
|
||||||
# ── Analyst Dashboard ─────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
@router.get("/analyst-dashboard")
|
|
||||||
def analyst_dashboard(
|
|
||||||
db: Session = Depends(get_db),
|
|
||||||
user=Depends(get_current_user),
|
|
||||||
):
|
|
||||||
"""Personalised daily workday view: my queue, expiring validations, infra changes, low-confidence techniques."""
|
|
||||||
dashboard = revalidation_queue_service.get_analyst_dashboard(db, user.id)
|
|
||||||
|
|
||||||
# Serialize queue items to dicts (ORM objects → plain dicts)
|
|
||||||
def _item_to_dict(item: RevalidationQueueItem) -> dict:
|
|
||||||
return {
|
|
||||||
"id": str(item.id),
|
|
||||||
"technique_id": str(item.technique_id) if item.technique_id else None,
|
|
||||||
"detection_asset_id": str(item.detection_asset_id) if item.detection_asset_id else None,
|
|
||||||
"priority": item.priority.value if hasattr(item.priority, "value") else item.priority,
|
|
||||||
"reason": item.reason.value if hasattr(item.reason, "value") else item.reason,
|
|
||||||
"reason_detail": item.reason_detail,
|
|
||||||
"status": item.status.value if hasattr(item.status, "value") else item.status,
|
|
||||||
"assigned_to": str(item.assigned_to) if item.assigned_to else None,
|
|
||||||
"due_date": item.due_date.isoformat() if item.due_date else None,
|
|
||||||
"created_at": item.created_at.isoformat() if item.created_at else None,
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
"my_pending_items": [_item_to_dict(i) for i in dashboard["my_pending_items"]],
|
|
||||||
"expiring_validations_7d": dashboard["expiring_validations_7d"],
|
|
||||||
"recent_infra_changes": dashboard["recent_infra_changes"],
|
|
||||||
"my_low_confidence_techniques": dashboard["my_low_confidence_techniques"],
|
|
||||||
"summary": dashboard["summary"],
|
|
||||||
}
|
|
||||||
@@ -1,158 +0,0 @@
|
|||||||
"""Router for procedure-improvement suggestion review.
|
|
||||||
|
|
||||||
Endpoints
|
|
||||||
---------
|
|
||||||
GET /procedure-suggestions — list pending suggestions (lead/admin)
|
|
||||||
POST /procedure-suggestions/{id}/approve — write suggestion into template (lead/admin)
|
|
||||||
POST /procedure-suggestions/{id}/reject — dismiss suggestion (lead/admin)
|
|
||||||
|
|
||||||
A red_lead only ever sees/acts on "red" team suggestions, a blue_lead only
|
|
||||||
"blue" — admins can see and act on both.
|
|
||||||
"""
|
|
||||||
|
|
||||||
import uuid
|
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException
|
|
||||||
|
|
||||||
from app.database import get_db
|
|
||||||
from app.dependencies.auth import require_any_role_strict
|
|
||||||
from app.domain.errors import EntityNotFoundError, InvalidOperationError
|
|
||||||
from app.domain.unit_of_work import UnitOfWork
|
|
||||||
from app.models.procedure_suggestion import ProcedureSuggestion
|
|
||||||
from app.models.test_template import TestTemplate
|
|
||||||
from app.models.user import User
|
|
||||||
from app.schemas.procedure_suggestion import ProcedureSuggestionOut
|
|
||||||
from app.services.audit_service import log_action
|
|
||||||
from app.services.procedure_suggestion_service import (
|
|
||||||
approve_suggestion as approve_suggestion_svc,
|
|
||||||
)
|
|
||||||
from app.services.procedure_suggestion_service import (
|
|
||||||
list_pending_suggestions,
|
|
||||||
)
|
|
||||||
from app.services.procedure_suggestion_service import (
|
|
||||||
reject_suggestion as reject_suggestion_svc,
|
|
||||||
)
|
|
||||||
from sqlalchemy.orm import Session
|
|
||||||
|
|
||||||
router = APIRouter(prefix="/procedure-suggestions", tags=["procedure-suggestions"])
|
|
||||||
|
|
||||||
|
|
||||||
def _team_for_role(user: User) -> str | None:
|
|
||||||
"""Return the single team a non-admin lead is scoped to, or None for admin (both)."""
|
|
||||||
if user.role == "red_lead":
|
|
||||||
return "red"
|
|
||||||
if user.role == "blue_lead":
|
|
||||||
return "blue"
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def _to_out(suggestion, template_by_id: dict) -> ProcedureSuggestionOut:
|
|
||||||
out = ProcedureSuggestionOut.model_validate(suggestion)
|
|
||||||
template = template_by_id.get(suggestion.template_id)
|
|
||||||
if template is not None:
|
|
||||||
out.template_name = template.name
|
|
||||||
out.template_current_text = (
|
|
||||||
template.attack_procedure if suggestion.team == "red" else template.expected_detection
|
|
||||||
)
|
|
||||||
return out
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("", response_model=list[ProcedureSuggestionOut])
|
|
||||||
def list_suggestions(
|
|
||||||
db: Session = Depends(get_db),
|
|
||||||
current_user: User = Depends(require_any_role_strict("red_lead", "blue_lead", "admin")),
|
|
||||||
) -> list[ProcedureSuggestionOut]:
|
|
||||||
"""List pending procedure suggestions, scoped to the caller's team (admins see both)."""
|
|
||||||
team = _team_for_role(current_user)
|
|
||||||
suggestions = list_pending_suggestions(db, team=team)
|
|
||||||
template_ids = {s.template_id for s in suggestions}
|
|
||||||
templates = (
|
|
||||||
db.query(TestTemplate).filter(TestTemplate.id.in_(template_ids)).all() if template_ids else []
|
|
||||||
)
|
|
||||||
template_by_id = {t.id: t for t in templates}
|
|
||||||
return [_to_out(s, template_by_id) for s in suggestions]
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/for-test/{test_id}", response_model=list[ProcedureSuggestionOut])
|
|
||||||
def list_suggestions_for_test(
|
|
||||||
test_id: uuid.UUID,
|
|
||||||
db: Session = Depends(get_db),
|
|
||||||
current_user: User = Depends(require_any_role_strict("red_lead", "blue_lead", "admin")),
|
|
||||||
) -> list[ProcedureSuggestionOut]:
|
|
||||||
"""List pending suggestions tied to a specific test, scoped to the
|
|
||||||
caller's team (admins see both) — powers the blocking review popup a
|
|
||||||
lead sees when opening a test that has one awaiting them."""
|
|
||||||
team = _team_for_role(current_user)
|
|
||||||
suggestions = list_pending_suggestions(db, team=team, source_test_id=test_id)
|
|
||||||
template_ids = {s.template_id for s in suggestions}
|
|
||||||
templates = (
|
|
||||||
db.query(TestTemplate).filter(TestTemplate.id.in_(template_ids)).all() if template_ids else []
|
|
||||||
)
|
|
||||||
template_by_id = {t.id: t for t in templates}
|
|
||||||
return [_to_out(s, template_by_id) for s in suggestions]
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/{suggestion_id}/approve", response_model=ProcedureSuggestionOut)
|
|
||||||
def approve_suggestion(
|
|
||||||
suggestion_id: uuid.UUID,
|
|
||||||
db: Session = Depends(get_db),
|
|
||||||
current_user: User = Depends(require_any_role_strict("red_lead", "blue_lead", "admin")),
|
|
||||||
) -> ProcedureSuggestionOut:
|
|
||||||
"""Approve a suggestion, writing it into its template's suggested-procedure field."""
|
|
||||||
_check_team_permission(current_user, _team_or_404(db, suggestion_id))
|
|
||||||
try:
|
|
||||||
with UnitOfWork(db) as uow:
|
|
||||||
suggestion = approve_suggestion_svc(db, suggestion_id, current_user)
|
|
||||||
log_action(
|
|
||||||
db, user_id=current_user.id, action="approve_procedure_suggestion",
|
|
||||||
entity_type="procedure_suggestion", entity_id=suggestion.id,
|
|
||||||
details={"template_id": str(suggestion.template_id), "team": suggestion.team},
|
|
||||||
)
|
|
||||||
uow.commit()
|
|
||||||
except EntityNotFoundError as e:
|
|
||||||
raise HTTPException(status_code=404, detail=str(e)) from e
|
|
||||||
except InvalidOperationError as e:
|
|
||||||
raise HTTPException(status_code=400, detail=str(e)) from e
|
|
||||||
db.refresh(suggestion)
|
|
||||||
template = db.query(TestTemplate).filter(TestTemplate.id == suggestion.template_id).first()
|
|
||||||
return _to_out(suggestion, {template.id: template} if template else {})
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/{suggestion_id}/reject", response_model=ProcedureSuggestionOut)
|
|
||||||
def reject_suggestion(
|
|
||||||
suggestion_id: uuid.UUID,
|
|
||||||
db: Session = Depends(get_db),
|
|
||||||
current_user: User = Depends(require_any_role_strict("red_lead", "blue_lead", "admin")),
|
|
||||||
) -> ProcedureSuggestionOut:
|
|
||||||
"""Reject a suggestion without touching the template."""
|
|
||||||
_check_team_permission(current_user, _team_or_404(db, suggestion_id))
|
|
||||||
try:
|
|
||||||
with UnitOfWork(db) as uow:
|
|
||||||
suggestion = reject_suggestion_svc(db, suggestion_id, current_user)
|
|
||||||
log_action(
|
|
||||||
db, user_id=current_user.id, action="reject_procedure_suggestion",
|
|
||||||
entity_type="procedure_suggestion", entity_id=suggestion.id,
|
|
||||||
details={"template_id": str(suggestion.template_id), "team": suggestion.team},
|
|
||||||
)
|
|
||||||
uow.commit()
|
|
||||||
except EntityNotFoundError as e:
|
|
||||||
raise HTTPException(status_code=404, detail=str(e)) from e
|
|
||||||
except InvalidOperationError as e:
|
|
||||||
raise HTTPException(status_code=400, detail=str(e)) from e
|
|
||||||
db.refresh(suggestion)
|
|
||||||
return _to_out(suggestion, {})
|
|
||||||
|
|
||||||
|
|
||||||
def _team_or_404(db: Session, suggestion_id: uuid.UUID) -> str:
|
|
||||||
"""Look up just the team of a suggestion, for a permission check before mutating it."""
|
|
||||||
suggestion = db.query(ProcedureSuggestion).filter(ProcedureSuggestion.id == suggestion_id).first()
|
|
||||||
if suggestion is None:
|
|
||||||
raise HTTPException(status_code=404, detail="Procedure suggestion not found")
|
|
||||||
return suggestion.team
|
|
||||||
|
|
||||||
|
|
||||||
def _check_team_permission(user: User, team: str) -> None:
|
|
||||||
"""Raise 403 if a non-admin lead tries to act on the other team's suggestion."""
|
|
||||||
scoped_team = _team_for_role(user)
|
|
||||||
if scoped_team is not None and scoped_team != team:
|
|
||||||
raise HTTPException(status_code=403, detail=f"Not authorized to review {team} team suggestions")
|
|
||||||
@@ -2,10 +2,9 @@
|
|||||||
|
|
||||||
# Import UUID from uuid
|
# Import UUID from uuid
|
||||||
from uuid import UUID
|
from uuid import UUID
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
# Import APIRouter, Depends, HTTPException, Query, Request from fastapi
|
# Import APIRouter, Depends, Query, Request from fastapi
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Query, Request
|
from fastapi import APIRouter, Depends, Query, Request
|
||||||
|
|
||||||
# Import FileResponse from fastapi.responses
|
# Import FileResponse from fastapi.responses
|
||||||
from fastapi.responses import FileResponse
|
from fastapi.responses import FileResponse
|
||||||
@@ -22,36 +21,12 @@ from app.dependencies.auth import get_current_user, require_any_role
|
|||||||
# Import limiter from app.limiter
|
# Import limiter from app.limiter
|
||||||
from app.limiter import limiter
|
from app.limiter import limiter
|
||||||
|
|
||||||
# Import settings from app.config
|
|
||||||
from app.config import settings
|
|
||||||
|
|
||||||
# Import User from app.models.user
|
# Import User from app.models.user
|
||||||
from app.models.user import User
|
from app.models.user import User
|
||||||
|
|
||||||
# Import report_generation_service from app.services
|
# Import report_generation_service from app.services
|
||||||
from app.services import report_generation_service
|
from app.services import report_generation_service
|
||||||
|
|
||||||
|
|
||||||
def _assert_safe_report_path(filepath: str) -> str:
|
|
||||||
"""Raise 500 if the generated filepath escapes the configured report directory."""
|
|
||||||
output_dir = Path(settings.REPORT_OUTPUT_DIR).resolve()
|
|
||||||
resolved = Path(filepath).resolve()
|
|
||||||
if not resolved.is_relative_to(output_dir):
|
|
||||||
raise HTTPException(status_code=500, detail="Report generation path error")
|
|
||||||
return filepath
|
|
||||||
|
|
||||||
|
|
||||||
def _assert_reports_enabled() -> None:
|
|
||||||
"""Raise a clean 503 while report generation is unfinished/disabled.
|
|
||||||
|
|
||||||
The frontend hides the Reports UI entirely; without this guard, hitting
|
|
||||||
these endpoints directly falls through to whatever the underlying
|
|
||||||
render pipeline raises (e.g. missing weasyprint system deps) as a raw,
|
|
||||||
unhelpful 500.
|
|
||||||
"""
|
|
||||||
if not settings.REPORTS_ENABLED:
|
|
||||||
raise HTTPException(status_code=503, detail="Report generation is not yet available on this platform")
|
|
||||||
|
|
||||||
# Assign router = APIRouter(prefix="/reports/generate", tags=["professional-reports"])
|
# Assign router = APIRouter(prefix="/reports/generate", tags=["professional-reports"])
|
||||||
router = APIRouter(prefix="/reports/generate", tags=["professional-reports"])
|
router = APIRouter(prefix="/reports/generate", tags=["professional-reports"])
|
||||||
|
|
||||||
@@ -84,14 +59,13 @@ def generate_purple_report(
|
|||||||
user: User = Depends(require_any_role("red_lead", "blue_lead", "viewer")),
|
user: User = Depends(require_any_role("red_lead", "blue_lead", "viewer")),
|
||||||
) -> FileResponse:
|
) -> FileResponse:
|
||||||
"""Generate a Purple Team campaign assessment report."""
|
"""Generate a Purple Team campaign assessment report."""
|
||||||
_assert_reports_enabled()
|
|
||||||
# Assign filepath = report_generation_service.generate_purple_campaign_report(
|
# Assign filepath = report_generation_service.generate_purple_campaign_report(
|
||||||
filepath = report_generation_service.generate_purple_campaign_report(
|
filepath = report_generation_service.generate_purple_campaign_report(
|
||||||
db, str(campaign_id), output_format=format,
|
db, str(campaign_id), output_format=format,
|
||||||
)
|
)
|
||||||
# Return FileResponse(
|
# Return FileResponse(
|
||||||
return FileResponse(
|
return FileResponse(
|
||||||
_assert_safe_report_path(filepath),
|
filepath,
|
||||||
# Keyword argument: media_type
|
# Keyword argument: media_type
|
||||||
media_type=_MEDIA_TYPES[format],
|
media_type=_MEDIA_TYPES[format],
|
||||||
# Keyword argument: filename
|
# Keyword argument: filename
|
||||||
@@ -115,14 +89,13 @@ def generate_coverage_report(
|
|||||||
user: User = Depends(require_any_role("red_lead", "blue_lead", "viewer")),
|
user: User = Depends(require_any_role("red_lead", "blue_lead", "viewer")),
|
||||||
) -> FileResponse:
|
) -> FileResponse:
|
||||||
"""Generate an organization-wide MITRE ATT&CK coverage report."""
|
"""Generate an organization-wide MITRE ATT&CK coverage report."""
|
||||||
_assert_reports_enabled()
|
|
||||||
# Assign filepath = report_generation_service.generate_coverage_report(
|
# Assign filepath = report_generation_service.generate_coverage_report(
|
||||||
filepath = report_generation_service.generate_coverage_report(
|
filepath = report_generation_service.generate_coverage_report(
|
||||||
db, output_format=format,
|
db, output_format=format,
|
||||||
)
|
)
|
||||||
# Return FileResponse(
|
# Return FileResponse(
|
||||||
return FileResponse(
|
return FileResponse(
|
||||||
_assert_safe_report_path(filepath),
|
filepath,
|
||||||
# Keyword argument: media_type
|
# Keyword argument: media_type
|
||||||
media_type=_MEDIA_TYPES[format],
|
media_type=_MEDIA_TYPES[format],
|
||||||
# Keyword argument: filename
|
# Keyword argument: filename
|
||||||
@@ -146,14 +119,13 @@ def generate_executive_report(
|
|||||||
user: User = Depends(require_any_role("red_lead", "blue_lead", "viewer")),
|
user: User = Depends(require_any_role("red_lead", "blue_lead", "viewer")),
|
||||||
) -> FileResponse:
|
) -> FileResponse:
|
||||||
"""Generate an executive security summary report."""
|
"""Generate an executive security summary report."""
|
||||||
_assert_reports_enabled()
|
|
||||||
# Assign filepath = report_generation_service.generate_executive_summary(
|
# Assign filepath = report_generation_service.generate_executive_summary(
|
||||||
filepath = report_generation_service.generate_executive_summary(
|
filepath = report_generation_service.generate_executive_summary(
|
||||||
db, output_format=format,
|
db, output_format=format,
|
||||||
)
|
)
|
||||||
# Return FileResponse(
|
# Return FileResponse(
|
||||||
return FileResponse(
|
return FileResponse(
|
||||||
_assert_safe_report_path(filepath),
|
filepath,
|
||||||
# Keyword argument: media_type
|
# Keyword argument: media_type
|
||||||
media_type=_MEDIA_TYPES[format],
|
media_type=_MEDIA_TYPES[format],
|
||||||
# Keyword argument: filename
|
# Keyword argument: filename
|
||||||
@@ -177,14 +149,13 @@ def generate_quarterly_report(
|
|||||||
user: User = Depends(require_any_role("red_lead", "blue_lead", "viewer")),
|
user: User = Depends(require_any_role("red_lead", "blue_lead", "viewer")),
|
||||||
) -> FileResponse:
|
) -> FileResponse:
|
||||||
"""Generate a quarterly security summary report."""
|
"""Generate a quarterly security summary report."""
|
||||||
_assert_reports_enabled()
|
|
||||||
# Assign filepath = report_generation_service.generate_quarterly_summary(
|
# Assign filepath = report_generation_service.generate_quarterly_summary(
|
||||||
filepath = report_generation_service.generate_quarterly_summary(
|
filepath = report_generation_service.generate_quarterly_summary(
|
||||||
db, output_format=format,
|
db, output_format=format,
|
||||||
)
|
)
|
||||||
# Return FileResponse(
|
# Return FileResponse(
|
||||||
return FileResponse(
|
return FileResponse(
|
||||||
_assert_safe_report_path(filepath),
|
filepath,
|
||||||
# Keyword argument: media_type
|
# Keyword argument: media_type
|
||||||
media_type=_MEDIA_TYPES[format],
|
media_type=_MEDIA_TYPES[format],
|
||||||
# Keyword argument: filename
|
# Keyword argument: filename
|
||||||
@@ -210,14 +181,13 @@ def generate_technique_report(
|
|||||||
user: User = Depends(get_current_user),
|
user: User = Depends(get_current_user),
|
||||||
) -> FileResponse:
|
) -> FileResponse:
|
||||||
"""Generate a detailed report for one MITRE technique."""
|
"""Generate a detailed report for one MITRE technique."""
|
||||||
_assert_reports_enabled()
|
|
||||||
# Assign filepath = report_generation_service.generate_technique_detail_report(
|
# Assign filepath = report_generation_service.generate_technique_detail_report(
|
||||||
filepath = report_generation_service.generate_technique_detail_report(
|
filepath = report_generation_service.generate_technique_detail_report(
|
||||||
db, str(technique_id), output_format=format,
|
db, str(technique_id), output_format=format,
|
||||||
)
|
)
|
||||||
# Return FileResponse(
|
# Return FileResponse(
|
||||||
return FileResponse(
|
return FileResponse(
|
||||||
_assert_safe_report_path(filepath),
|
filepath,
|
||||||
# Keyword argument: media_type
|
# Keyword argument: media_type
|
||||||
media_type=_MEDIA_TYPES[format],
|
media_type=_MEDIA_TYPES[format],
|
||||||
# Keyword argument: filename
|
# Keyword argument: filename
|
||||||
|
|||||||
@@ -1,113 +0,0 @@
|
|||||||
"""Phase 12: Risk Intelligence router."""
|
|
||||||
|
|
||||||
from typing import List, Optional
|
|
||||||
from uuid import UUID
|
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, Query
|
|
||||||
from sqlalchemy.orm import Session
|
|
||||||
|
|
||||||
from app.database import get_db
|
|
||||||
from app.dependencies.auth import get_current_user, require_any_role
|
|
||||||
from app.schemas.risk_schema import (
|
|
||||||
TechniqueRiskProfileOut,
|
|
||||||
ComputeResult,
|
|
||||||
)
|
|
||||||
from app.services import risk_intelligence_service as svc
|
|
||||||
|
|
||||||
router = APIRouter(prefix="/risk", tags=["risk-intelligence"])
|
|
||||||
|
|
||||||
|
|
||||||
# ── Compute ──────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
@router.post("/compute", response_model=ComputeResult, status_code=202)
|
|
||||||
def compute_all(
|
|
||||||
db: Session = Depends(get_db),
|
|
||||||
user=Depends(require_any_role("admin", "red_lead", "blue_lead")),
|
|
||||||
):
|
|
||||||
"""Recompute risk scores for ALL techniques (admin / leads only)."""
|
|
||||||
result = svc.compute_all_risk_scores(db)
|
|
||||||
return result
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/profiles/{technique_id}/compute", response_model=TechniqueRiskProfileOut)
|
|
||||||
def compute_one(
|
|
||||||
technique_id: UUID,
|
|
||||||
db: Session = Depends(get_db),
|
|
||||||
user=Depends(get_current_user),
|
|
||||||
):
|
|
||||||
"""Compute (or refresh) the risk profile for a single technique."""
|
|
||||||
return svc.compute_technique_risk(db, technique_id)
|
|
||||||
|
|
||||||
|
|
||||||
# ── Read ─────────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
@router.get("/profiles", response_model=List[TechniqueRiskProfileOut])
|
|
||||||
def list_profiles(
|
|
||||||
risk_level: Optional[str] = None,
|
|
||||||
min_score: Optional[float] = None,
|
|
||||||
max_score: Optional[float] = None,
|
|
||||||
stale_only: bool = False,
|
|
||||||
limit: int = Query(100, ge=1, le=500),
|
|
||||||
offset: int = Query(0, ge=0),
|
|
||||||
db: Session = Depends(get_db),
|
|
||||||
user=Depends(get_current_user),
|
|
||||||
):
|
|
||||||
"""List risk profiles with optional filters."""
|
|
||||||
return svc.list_risk_profiles(
|
|
||||||
db,
|
|
||||||
risk_level=risk_level,
|
|
||||||
min_score=min_score,
|
|
||||||
max_score=max_score,
|
|
||||||
stale_only=stale_only,
|
|
||||||
limit=limit,
|
|
||||||
offset=offset,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/profiles/{technique_id}", response_model=TechniqueRiskProfileOut)
|
|
||||||
def get_profile(
|
|
||||||
technique_id: UUID,
|
|
||||||
db: Session = Depends(get_db),
|
|
||||||
user=Depends(get_current_user),
|
|
||||||
):
|
|
||||||
"""Get the current risk profile for a technique."""
|
|
||||||
return svc.get_risk_profile(db, technique_id)
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/matrix")
|
|
||||||
def risk_matrix(
|
|
||||||
db: Session = Depends(get_db),
|
|
||||||
user=Depends(get_current_user),
|
|
||||||
):
|
|
||||||
"""All profiled techniques with likelihood/impact coordinates for matrix view."""
|
|
||||||
return svc.get_risk_matrix(db)
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/summary")
|
|
||||||
def risk_summary(
|
|
||||||
db: Session = Depends(get_db),
|
|
||||||
user=Depends(get_current_user),
|
|
||||||
):
|
|
||||||
"""Aggregate risk statistics: counts by level, average score, top risks."""
|
|
||||||
return svc.get_risk_summary(db)
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/recommendations")
|
|
||||||
def recommendations(
|
|
||||||
limit: int = Query(20, ge=1, le=100),
|
|
||||||
db: Session = Depends(get_db),
|
|
||||||
user=Depends(get_current_user),
|
|
||||||
):
|
|
||||||
"""Prioritised list of techniques with actionable recommendations."""
|
|
||||||
return svc.get_recommendations(db, limit=limit)
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/top")
|
|
||||||
def top_risks(
|
|
||||||
limit: int = Query(10, ge=1, le=50),
|
|
||||||
db: Session = Depends(get_db),
|
|
||||||
user=Depends(get_current_user),
|
|
||||||
):
|
|
||||||
"""Top N highest-risk techniques (sorted by risk score desc)."""
|
|
||||||
profiles = svc.list_risk_profiles(db, limit=limit)
|
|
||||||
return profiles
|
|
||||||
@@ -165,7 +165,7 @@ def score_history(
|
|||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
# Entry: current_user
|
# Entry: current_user
|
||||||
current_user: User = Depends(get_current_user),
|
current_user: User = Depends(get_current_user),
|
||||||
) -> list:
|
) -> dict:
|
||||||
"""Get historical score data points (weekly).
|
"""Get historical score data points (weekly).
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
@@ -174,7 +174,7 @@ def score_history(
|
|||||||
current_user (User): Authenticated user making the request.
|
current_user (User): Authenticated user making the request.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
list: Weekly score data points for the requested period.
|
dict: Weekly score data points for the requested period.
|
||||||
"""
|
"""
|
||||||
# Return get_score_history(db, period)
|
# Return get_score_history(db, period)
|
||||||
return get_score_history(db, period)
|
return get_score_history(db, period)
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user