feat(phase-34): resolve blocking tech debt — Redis, domain exceptions, indexes, CI
Some checks failed
Aegis CI / lint-and-test (push) Has been cancelled

Foundational changes required before any new feature work can begin.

- 0.1 Redis infrastructure: add redis:7-alpine to docker-compose dev and prod,
  REDIS_URL config, singleton client in app/infrastructure/redis_client.py
- 0.2 Token blacklist on Redis SEC-001: replace in-memory dict with Redis SETEX
  keyed by jti, auto-expiring TTL derived from token exp
- 0.3 Database indexes SR-006: Alembic migration b019 with 5 composite indexes
  for scoring, MTTD/MTTR, remediation, and notification queries
- 0.4 Domain exceptions TD-003: app/domain/exceptions.py with typed errors,
  error_handler middleware mapping them to HTTP, services decoupled from FastAPI
- 0.5 Fix silenced exceptions TD-007: replace 4 bare except-pass blocks in
  test_workflow_service with logger.warning with exc_info
- 0.6 CI pipeline TD-009: GitHub Actions workflow with Postgres and Redis
  service containers, ruff lint, pytest; ruff.toml for baseline config
This commit is contained in:
2026-02-17 15:43:05 +01:00
parent 6a327f6b51
commit 6d18a5417d
21 changed files with 464 additions and 124 deletions

View File

@@ -8,9 +8,9 @@ import logging
import uuid
from datetime import datetime
from fastapi import HTTPException
from sqlalchemy.orm import Session
from app.domain.exceptions import EntityNotFoundError, InvalidOperationError
from app.models.campaign import Campaign, CampaignTest, KILL_CHAIN_PHASES
from app.models.test import Test
from app.models.test_template import TestTemplate
@@ -49,7 +49,7 @@ def validate_no_circular_dependency(
) -> None:
"""Walk the depends_on chain and verify no cycle is formed.
Raises HTTPException(400) if a circular dependency is detected.
Raises :class:`InvalidOperationError` if a circular dependency is detected.
"""
if depends_on_id is None:
return
@@ -59,9 +59,8 @@ def validate_no_circular_dependency(
while current is not None:
if current in visited or current == test_id:
raise HTTPException(
status_code=400,
detail="Circular dependency detected in campaign test chain",
raise InvalidOperationError(
"Circular dependency detected in campaign test chain"
)
visited.add(current)
parent = db.query(CampaignTest).filter_by(id=current).first()
@@ -119,7 +118,7 @@ def generate_campaign_from_threat_actor(
"""
actor = db.query(ThreatActor).filter(ThreatActor.id == actor_id).first()
if not actor:
raise HTTPException(status_code=404, detail="Threat actor not found")
raise EntityNotFoundError("ThreatActor", str(actor_id))
# Get unvalidated techniques for this actor
gap_techniques = (
@@ -132,9 +131,8 @@ def generate_campaign_from_threat_actor(
)
if not gap_techniques:
raise HTTPException(
status_code=400,
detail=f"No uncovered techniques found for {actor.name}",
raise InvalidOperationError(
f"No uncovered techniques found for {actor.name}"
)
# Create the campaign

View File

@@ -11,18 +11,21 @@ Every public function validates the transition, mutates the test, writes an
audit-log entry, and commits the session.
"""
import logging
from datetime import datetime
from fastapi import HTTPException, status
from sqlalchemy.orm import Session
from app.config import settings
from app.domain.exceptions import InvalidOperationError, InvalidTransitionError
from app.models.enums import TestState
from app.models.test import Test
from app.models.user import User
from app.services.audit_service import log_action
from app.services.notification_service import notify_test_state_change, create_notification
logger = logging.getLogger(__name__)
# ---------------------------------------------------------------------------
# Valid transition map
# ---------------------------------------------------------------------------
@@ -59,23 +62,15 @@ def transition_state(
) -> Test:
"""Validate and perform a state transition, log it, and commit.
Raises :class:`~fastapi.HTTPException` 400 when the transition is invalid.
Raises :class:`InvalidTransitionError` when the transition is invalid.
"""
if not can_transition(test, target_state):
current = test.state if isinstance(test.state, TestState) else TestState(test.state)
valid = [s.value for s in VALID_TRANSITIONS.get(current, [])]
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail={
"message": (
f"Cannot transition from '{current.value}' to '{target_state.value}'. "
f"Valid transitions: {valid}"
),
"code": "INVALID_TRANSITION",
"current_state": current.value,
"target_state": target_state.value,
"valid_transitions": valid,
},
raise InvalidTransitionError(
current_state=current.value,
target_state=target_state.value,
valid_transitions=valid,
)
previous_state = test.state.value if isinstance(test.state, TestState) else test.state
@@ -103,8 +98,8 @@ def transition_state(
# Dispatch in-app notifications for the new state
try:
notify_test_state_change(db, test, target_state.value)
except Exception:
pass # Notifications are best-effort — don't block the workflow
except Exception as e:
logger.warning("Notification failed for test %s: %s", test.id, e, exc_info=True)
return test
@@ -169,22 +164,13 @@ def validate_as_red_lead(
"""
current = test.state.value if isinstance(test.state, TestState) else test.state
if test.state not in (TestState.in_review,):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail={
"message": f"Cannot validate red side while test is in '{current}' state (must be in_review)",
"code": "INVALID_STATE",
"current_state": current,
},
raise InvalidOperationError(
f"Cannot validate red side while test is in '{current}' state (must be in_review)"
)
if validation_status not in ("approved", "rejected"):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail={
"message": "validation_status must be 'approved' or 'rejected'",
"code": "INVALID_VALIDATION_STATUS",
},
raise InvalidOperationError(
"validation_status must be 'approved' or 'rejected'"
)
now = datetime.utcnow()
@@ -225,22 +211,13 @@ def validate_as_blue_lead(
"""
current = test.state.value if isinstance(test.state, TestState) else test.state
if test.state not in (TestState.in_review,):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail={
"message": f"Cannot validate blue side while test is in '{current}' state (must be in_review)",
"code": "INVALID_STATE",
"current_state": current,
},
raise InvalidOperationError(
f"Cannot validate blue side while test is in '{current}' state (must be in_review)"
)
if validation_status not in ("approved", "rejected"):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail={
"message": "validation_status must be 'approved' or 'rejected'",
"code": "INVALID_VALIDATION_STATUS",
},
raise InvalidOperationError(
"validation_status must be 'approved' or 'rejected'"
)
now = datetime.utcnow()
@@ -283,8 +260,8 @@ def check_dual_validation(db: Session, test: Test) -> Test:
db.commit()
try:
notify_test_state_change(db, test, "rejected")
except Exception:
pass
except Exception as e:
logger.warning("Notification failed for test %s (rejected): %s", test.id, e, exc_info=True)
elif red_status == "approved" and blue_status == "approved":
test.state = TestState.validated
db.commit()
@@ -292,12 +269,12 @@ def check_dual_validation(db: Session, test: Test) -> Test:
try:
from app.services.score_cache import invalidate
invalidate()
except Exception:
pass
except Exception as e:
logger.warning("Score cache invalidation failed: %s", e, exc_info=True)
try:
notify_test_state_change(db, test, "validated")
except Exception:
pass
except Exception as e:
logger.warning("Notification failed for test %s (validated): %s", test.id, e, exc_info=True)
else:
# One side hasn't voted yet — stay in_review, just flush
db.commit()