feat(phases): implement webhooks (6.1), email (7.1), user preferences (7.2)
Some checks failed
Aegis CI / lint-and-test (push) Has been cancelled
Some checks failed
Aegis CI / lint-and-test (push) Has been cancelled
- Phase 6.1: WebhookConfig model, CRUD router (/api/v1/webhooks, admin-only), dispatch_webhook() with HMAC signing; integrated into test validation, campaign completion, and MITRE sync job - Phase 7.1: SMTP email service with send_test_validated_email, send_campaign_completed_email, send_new_mitre_techniques_email; notify_role_with_email() added to notification_service - Phase 7.2: notification_preferences and jira_account_id on User model; PATCH /users/me/preferences endpoint; Alembic migrations b031phase6 and b032phase7 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
32
backend/alembic/versions/b031_phase6_webhooks.py
Normal file
32
backend/alembic/versions/b031_phase6_webhooks.py
Normal file
@@ -0,0 +1,32 @@
|
||||
"""Phase 6.1: webhook_configs table.
|
||||
|
||||
Revision ID: b031phase6
|
||||
Revises: b030phase5
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.dialects import postgresql
|
||||
from alembic import op
|
||||
|
||||
revision: str = "b031phase6"
|
||||
down_revision: Union[str, None] = "b030phase5"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"webhook_configs",
|
||||
sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True),
|
||||
sa.Column("name", sa.String(200), nullable=False),
|
||||
sa.Column("url", sa.Text, nullable=False),
|
||||
sa.Column("secret", sa.String(256), nullable=True),
|
||||
sa.Column("events", postgresql.JSONB, nullable=False, server_default="[]"),
|
||||
sa.Column("is_active", sa.Boolean, nullable=False, server_default="true"),
|
||||
sa.Column("created_by", postgresql.UUID(as_uuid=True), sa.ForeignKey("users.id", ondelete="SET NULL"), nullable=True),
|
||||
sa.Column("last_triggered_at", sa.DateTime, nullable=True),
|
||||
sa.Column("failure_count", sa.Integer, nullable=False, server_default="0"),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
|
||||
)
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_table("webhook_configs")
|
||||
41
backend/alembic/versions/b032_phase7_user_prefs.py
Normal file
41
backend/alembic/versions/b032_phase7_user_prefs.py
Normal file
@@ -0,0 +1,41 @@
|
||||
"""Phase 7.2: user notification_preferences and jira_account_id columns.
|
||||
|
||||
Revision ID: b032phase7
|
||||
Revises: b031phase6
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.dialects import postgresql
|
||||
from alembic import op
|
||||
|
||||
revision: str = "b032phase7"
|
||||
down_revision: Union[str, None] = "b031phase6"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
_DEFAULT_PREFS = '{"email_on_test_validated": true, "email_on_campaign_completed": true, "email_on_new_mitre_techniques": false, "in_app_all": true}'
|
||||
|
||||
def _column_names(table: str) -> set[str]:
|
||||
bind = op.get_bind()
|
||||
insp = sa.inspect(bind)
|
||||
return {c["name"] for c in insp.get_columns(table)}
|
||||
|
||||
def upgrade() -> None:
|
||||
user_cols = _column_names("users")
|
||||
if "notification_preferences" not in user_cols:
|
||||
op.add_column(
|
||||
"users",
|
||||
sa.Column("notification_preferences", postgresql.JSONB, nullable=True, server_default=_DEFAULT_PREFS),
|
||||
)
|
||||
if "jira_account_id" not in user_cols:
|
||||
op.add_column(
|
||||
"users",
|
||||
sa.Column("jira_account_id", sa.String(100), nullable=True),
|
||||
)
|
||||
|
||||
def downgrade() -> None:
|
||||
user_cols = _column_names("users")
|
||||
if "jira_account_id" in user_cols:
|
||||
op.drop_column("users", "jira_account_id")
|
||||
if "notification_preferences" in user_cols:
|
||||
op.drop_column("users", "notification_preferences")
|
||||
@@ -70,6 +70,16 @@ class Settings(BaseSettings):
|
||||
COMPANY_NAME: str = "Organization"
|
||||
COMPANY_LOGO_PATH: str = "app/templates/reports/assets/logo.png"
|
||||
|
||||
# ── Email / SMTP ──────────────────────────────────────────────────
|
||||
SMTP_ENABLED: bool = False
|
||||
SMTP_HOST: str = ""
|
||||
SMTP_PORT: int = 587
|
||||
SMTP_USERNAME: str = ""
|
||||
SMTP_PASSWORD: str = ""
|
||||
SMTP_FROM_EMAIL: str = "aegis@company.com"
|
||||
SMTP_USE_TLS: bool = True
|
||||
PLATFORM_URL: str = "http://localhost:5173" # base URL for links in emails
|
||||
|
||||
# ── Scoring weights (must sum to 100) ────────────────────────────
|
||||
SCORING_WEIGHT_TESTS: int = 40
|
||||
SCORING_WEIGHT_DETECTION_RULES: int = 25
|
||||
|
||||
@@ -42,11 +42,13 @@ scheduler = BackgroundScheduler()
|
||||
|
||||
def _run_mitre_sync() -> None:
|
||||
"""Execute a MITRE sync inside its own DB session."""
|
||||
from app.services.webhook_service import dispatch_webhook
|
||||
logger.info("Scheduled MITRE sync job starting...")
|
||||
db = SessionLocal()
|
||||
try:
|
||||
summary = sync_mitre(db)
|
||||
logger.info("Scheduled MITRE sync job finished — %s", summary)
|
||||
dispatch_webhook("mitre.synced", {"created": summary.get("created", 0), "updated": summary.get("updated", 0)})
|
||||
except Exception:
|
||||
logger.exception("Scheduled MITRE sync job failed")
|
||||
finally:
|
||||
|
||||
@@ -37,6 +37,7 @@ 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.domain.errors import DomainError
|
||||
from app.middleware.error_handler import domain_exception_handler
|
||||
from app.middleware.request_context import RequestContextMiddleware
|
||||
@@ -123,6 +124,7 @@ app.include_router(professional_reports_router.router, prefix="/api/v1")
|
||||
app.include_router(analytics_router.router, prefix="/api/v1")
|
||||
app.include_router(advanced_metrics_router.router, prefix="/api/v1")
|
||||
app.include_router(osint_router.router, prefix="/api/v1")
|
||||
app.include_router(webhooks_router.router, prefix="/api/v1")
|
||||
|
||||
|
||||
@app.get("/health", include_in_schema=False)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import uuid
|
||||
from sqlalchemy import Column, String, Boolean, DateTime, func
|
||||
from sqlalchemy.dialects.postgresql import UUID
|
||||
from sqlalchemy.dialects.postgresql import UUID, JSONB
|
||||
|
||||
from app.database import Base
|
||||
|
||||
@@ -28,3 +28,5 @@ class User(Base):
|
||||
must_change_password = Column(Boolean, default=True)
|
||||
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||
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)
|
||||
|
||||
19
backend/app/models/webhook_config.py
Normal file
19
backend/app/models/webhook_config.py
Normal file
@@ -0,0 +1,19 @@
|
||||
"""WebhookConfig model — outbound HTTP notification endpoints."""
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
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())
|
||||
@@ -33,6 +33,7 @@ from app.services.campaign_crud_service import (
|
||||
from app.domain.unit_of_work import UnitOfWork
|
||||
from app.services.audit_service import log_action
|
||||
from app.services.notification_service import notify_role
|
||||
from app.services.webhook_service import dispatch_webhook
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -284,6 +285,7 @@ def complete_campaign(
|
||||
)
|
||||
uow.commit()
|
||||
db.refresh(campaign)
|
||||
dispatch_webhook("campaign.completed", {"campaign_id": str(campaign.id), "name": campaign.name})
|
||||
|
||||
return serialize_campaign(db, campaign)
|
||||
|
||||
|
||||
@@ -45,6 +45,7 @@ from app.schemas.test_template import TestTemplateInstantiate
|
||||
from app.domain.unit_of_work import UnitOfWork
|
||||
from app.services.audit_service import log_action
|
||||
from app.services.status_service import recalculate_technique_status
|
||||
from app.services.webhook_service import dispatch_webhook
|
||||
from app.services.test_crud_service import (
|
||||
create_test as crud_create_test,
|
||||
create_test_from_template as crud_create_from_template,
|
||||
@@ -462,6 +463,10 @@ def validate_red(
|
||||
recalculate_technique_status(db, test.technique)
|
||||
uow.commit()
|
||||
db.refresh(test)
|
||||
if test.state == TestState.validated:
|
||||
dispatch_webhook("test.validated", {"test_id": str(test.id), "technique_id": str(test.technique_id), "result": test.result.value if test.result else None})
|
||||
elif test.state == TestState.rejected:
|
||||
dispatch_webhook("test.rejected", {"test_id": str(test.id), "technique_id": str(test.technique_id)})
|
||||
return test
|
||||
|
||||
|
||||
@@ -489,6 +494,10 @@ def validate_blue(
|
||||
recalculate_technique_status(db, test.technique)
|
||||
uow.commit()
|
||||
db.refresh(test)
|
||||
if test.state == TestState.validated:
|
||||
dispatch_webhook("test.validated", {"test_id": str(test.id), "technique_id": str(test.technique_id), "result": test.result.value if test.result else None})
|
||||
elif test.state == TestState.rejected:
|
||||
dispatch_webhook("test.rejected", {"test_id": str(test.id), "technique_id": str(test.technique_id)})
|
||||
return test
|
||||
|
||||
|
||||
|
||||
@@ -9,7 +9,8 @@ from app.database import get_db
|
||||
from app.dependencies.auth import require_role
|
||||
from app.domain.unit_of_work import UnitOfWork
|
||||
from app.models.user import User
|
||||
from app.schemas.user import UserCreate, UserUpdate, UserOut
|
||||
from app.dependencies.auth import get_current_user
|
||||
from app.schemas.user import UserCreate, UserUpdate, UserOut, UserPreferencesUpdate
|
||||
from app.services.audit_service import log_action
|
||||
from app.services.user_service import (
|
||||
create_user,
|
||||
@@ -21,6 +22,26 @@ from app.services.user_service import (
|
||||
router = APIRouter(prefix="/users", tags=["users"])
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# PATCH /users/me/preferences — update current user preferences
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@router.patch("/me/preferences", response_model=UserOut)
|
||||
def update_my_preferences(
|
||||
payload: UserPreferencesUpdate,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""Update the current user's notification preferences and Jira account ID."""
|
||||
update_data = payload.model_dump(exclude_unset=True)
|
||||
for field, value in update_data.items():
|
||||
setattr(current_user, field, value)
|
||||
db.commit()
|
||||
db.refresh(current_user)
|
||||
return current_user
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# GET /users — list all users
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
149
backend/app/routers/webhooks.py
Normal file
149
backend/app/routers/webhooks.py
Normal file
@@ -0,0 +1,149 @@
|
||||
"""Webhook configuration CRUD router — admin only.
|
||||
|
||||
Endpoints
|
||||
---------
|
||||
GET /webhooks — list all webhook configs
|
||||
POST /webhooks — create a new webhook config
|
||||
GET /webhooks/{id} — get a single webhook config
|
||||
PATCH /webhooks/{id} — update a webhook config
|
||||
DELETE /webhooks/{id} — hard-delete a webhook config
|
||||
POST /webhooks/{id}/test — send a test ping
|
||||
"""
|
||||
|
||||
import uuid
|
||||
|
||||
from fastapi import APIRouter, Depends, status
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.database import get_db
|
||||
from app.dependencies.auth import require_role
|
||||
from app.domain.unit_of_work import UnitOfWork
|
||||
from app.models.user import User
|
||||
from app.schemas.webhook import WebhookConfigCreate, WebhookConfigOut, WebhookConfigUpdate
|
||||
from app.services.webhook_service import (
|
||||
create_webhook,
|
||||
delete_webhook,
|
||||
dispatch_webhook,
|
||||
get_webhook_or_raise,
|
||||
list_webhooks,
|
||||
update_webhook,
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/webhooks", tags=["webhooks"])
|
||||
|
||||
|
||||
def _mask_secret(wh) -> WebhookConfigOut:
|
||||
"""Return a WebhookConfigOut with the secret masked."""
|
||||
out = WebhookConfigOut.model_validate(wh)
|
||||
if wh.secret:
|
||||
out.secret = "***"
|
||||
else:
|
||||
out.secret = None
|
||||
return out
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# GET /webhooks
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@router.get("", response_model=list[WebhookConfigOut])
|
||||
def list_webhooks_route(
|
||||
offset: int = 0,
|
||||
limit: int = 50,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(require_role("admin")),
|
||||
):
|
||||
"""Return all webhook configurations. **Requires admin role.**"""
|
||||
webhooks = list_webhooks(db, offset=offset, limit=limit)
|
||||
return [_mask_secret(wh) for wh in webhooks]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# POST /webhooks
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@router.post("", response_model=WebhookConfigOut, status_code=status.HTTP_201_CREATED)
|
||||
def create_webhook_route(
|
||||
payload: WebhookConfigCreate,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(require_role("admin")),
|
||||
):
|
||||
"""Create a new webhook configuration. **Requires admin role.**"""
|
||||
with UnitOfWork(db) as uow:
|
||||
wh = create_webhook(db, created_by=current_user.id, payload=payload)
|
||||
uow.commit()
|
||||
db.refresh(wh)
|
||||
return _mask_secret(wh)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# GET /webhooks/{id}
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@router.get("/{webhook_id}", response_model=WebhookConfigOut)
|
||||
def get_webhook_route(
|
||||
webhook_id: uuid.UUID,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(require_role("admin")),
|
||||
):
|
||||
"""Return a single webhook configuration. **Requires admin role.**"""
|
||||
wh = get_webhook_or_raise(db, webhook_id)
|
||||
return _mask_secret(wh)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# PATCH /webhooks/{id}
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@router.patch("/{webhook_id}", response_model=WebhookConfigOut)
|
||||
def update_webhook_route(
|
||||
webhook_id: uuid.UUID,
|
||||
payload: WebhookConfigUpdate,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(require_role("admin")),
|
||||
):
|
||||
"""Update one or more fields of a webhook configuration. **Requires admin role.**"""
|
||||
with UnitOfWork(db) as uow:
|
||||
wh = update_webhook(db, webhook_id, payload)
|
||||
uow.commit()
|
||||
db.refresh(wh)
|
||||
return _mask_secret(wh)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# DELETE /webhooks/{id}
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@router.delete("/{webhook_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
def delete_webhook_route(
|
||||
webhook_id: uuid.UUID,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(require_role("admin")),
|
||||
):
|
||||
"""Hard-delete a webhook configuration. **Requires admin role.**"""
|
||||
with UnitOfWork(db) as uow:
|
||||
delete_webhook(db, webhook_id)
|
||||
uow.commit()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# POST /webhooks/{id}/test
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@router.post("/{webhook_id}/test", status_code=status.HTTP_202_ACCEPTED)
|
||||
def test_webhook_route(
|
||||
webhook_id: uuid.UUID,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(require_role("admin")),
|
||||
):
|
||||
"""Send a test ping to the webhook endpoint. **Requires admin role.**"""
|
||||
# Verify the webhook exists before dispatching
|
||||
get_webhook_or_raise(db, webhook_id)
|
||||
dispatch_webhook("webhook.test", {"webhook_id": str(webhook_id), "message": "Test ping from Aegis"})
|
||||
return {"detail": "Test ping dispatched"}
|
||||
@@ -121,6 +121,13 @@ class PasswordChange(BaseModel):
|
||||
return _validate_password_strength(v)
|
||||
|
||||
|
||||
class UserPreferencesUpdate(BaseModel):
|
||||
"""Payload for updating current user's notification preferences and Jira account."""
|
||||
|
||||
notification_preferences: dict | None = None
|
||||
jira_account_id: str | None = None
|
||||
|
||||
|
||||
class UserOut(BaseModel):
|
||||
"""Complete representation returned by the API."""
|
||||
|
||||
@@ -132,5 +139,7 @@ class UserOut(BaseModel):
|
||||
must_change_password: bool = True
|
||||
created_at: datetime | None = None
|
||||
last_login: datetime | None = None
|
||||
notification_preferences: dict | None = None
|
||||
jira_account_id: str | None = None
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
32
backend/app/schemas/webhook.py
Normal file
32
backend/app/schemas/webhook.py
Normal file
@@ -0,0 +1,32 @@
|
||||
"""Pydantic schemas for Webhook endpoints."""
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
from pydantic import BaseModel, HttpUrl, ConfigDict
|
||||
|
||||
class WebhookConfigCreate(BaseModel):
|
||||
name: str
|
||||
url: str
|
||||
secret: str | None = None
|
||||
events: list[str] = []
|
||||
is_active: bool = True
|
||||
|
||||
class WebhookConfigUpdate(BaseModel):
|
||||
name: str | None = None
|
||||
url: str | None = None
|
||||
secret: str | None = None
|
||||
events: list[str] | None = None
|
||||
is_active: bool | None = None
|
||||
|
||||
class WebhookConfigOut(BaseModel):
|
||||
id: uuid.UUID
|
||||
name: str
|
||||
url: str
|
||||
secret: str | None = None # masked on read
|
||||
events: list[str]
|
||||
is_active: bool
|
||||
created_by: uuid.UUID | None = None
|
||||
last_triggered_at: datetime | None = None
|
||||
failure_count: int
|
||||
created_at: datetime | None = None
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
79
backend/app/services/email_service.py
Normal file
79
backend/app/services/email_service.py
Normal file
@@ -0,0 +1,79 @@
|
||||
"""Email notification service using SMTP.
|
||||
|
||||
Sending is silently skipped when SMTP_ENABLED=False (default).
|
||||
All errors are caught and logged — email failures never crash the caller.
|
||||
"""
|
||||
import logging
|
||||
import smtplib
|
||||
from email.mime.multipart import MIMEMultipart
|
||||
from email.mime.text import MIMEText
|
||||
|
||||
from app.config import settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def send_email(to: str, subject: str, html_body: str) -> bool:
|
||||
"""Send an HTML email. Returns True on success, False on skip/error."""
|
||||
if not settings.SMTP_ENABLED:
|
||||
logger.debug("SMTP disabled — skipping email to %s: %s", to, subject)
|
||||
return False
|
||||
if not to:
|
||||
return False
|
||||
try:
|
||||
msg = MIMEMultipart("alternative")
|
||||
msg["Subject"] = f"[Aegis] {subject}"
|
||||
msg["From"] = settings.SMTP_FROM_EMAIL
|
||||
msg["To"] = to
|
||||
msg.attach(MIMEText(html_body, "html"))
|
||||
with smtplib.SMTP(settings.SMTP_HOST, settings.SMTP_PORT, timeout=10) as server:
|
||||
if settings.SMTP_USE_TLS:
|
||||
server.starttls()
|
||||
if settings.SMTP_USERNAME:
|
||||
server.login(settings.SMTP_USERNAME, settings.SMTP_PASSWORD)
|
||||
server.send_message(msg)
|
||||
logger.info("Email sent to %s: %s", to, subject)
|
||||
return True
|
||||
except Exception:
|
||||
logger.exception("Failed to send email to %s: %s", to, subject)
|
||||
return False
|
||||
|
||||
|
||||
def send_test_validated_email(to: str, test_name: str, technique_id: str, test_id: str) -> bool:
|
||||
"""Notify that a test was validated."""
|
||||
url = f"{settings.PLATFORM_URL}/tests/{test_id}"
|
||||
html = f"""
|
||||
<html><body style="font-family:sans-serif;color:#1a1a2e">
|
||||
<h2 style="color:#22d3ee">✅ Test Validated</h2>
|
||||
<p>Test <strong>{test_name}</strong> for technique <code>{technique_id}</code> has been validated.</p>
|
||||
<p><a href="{url}" style="background:#22d3ee;color:#000;padding:8px 16px;border-radius:4px;text-decoration:none">View Test</a></p>
|
||||
<p style="color:#666;font-size:12px">Aegis ATT&CK Coverage Platform</p>
|
||||
</body></html>"""
|
||||
return send_email(to, f"Test Validated: {test_name}", html)
|
||||
|
||||
|
||||
def send_campaign_completed_email(to: str, campaign_name: str, campaign_id: str) -> bool:
|
||||
"""Notify that a campaign was completed."""
|
||||
url = f"{settings.PLATFORM_URL}/campaigns/{campaign_id}"
|
||||
html = f"""
|
||||
<html><body style="font-family:sans-serif;color:#1a1a2e">
|
||||
<h2 style="color:#22d3ee">🎯 Campaign Completed</h2>
|
||||
<p>Campaign <strong>{campaign_name}</strong> has been completed.</p>
|
||||
<p><a href="{url}" style="background:#22d3ee;color:#000;padding:8px 16px;border-radius:4px;text-decoration:none">View Campaign</a></p>
|
||||
<p style="color:#666;font-size:12px">Aegis ATT&CK Coverage Platform</p>
|
||||
</body></html>"""
|
||||
return send_email(to, f"Campaign Completed: {campaign_name}", html)
|
||||
|
||||
|
||||
def send_new_mitre_techniques_email(to: str, created: int, updated: int) -> bool:
|
||||
"""Notify of new MITRE techniques after sync."""
|
||||
if created == 0:
|
||||
return False
|
||||
html = f"""
|
||||
<html><body style="font-family:sans-serif;color:#1a1a2e">
|
||||
<h2 style="color:#22d3ee">🔄 MITRE ATT&CK Updated</h2>
|
||||
<p><strong>{created}</strong> new techniques added, <strong>{updated}</strong> updated.</p>
|
||||
<p><a href="{settings.PLATFORM_URL}/techniques" style="background:#22d3ee;color:#000;padding:8px 16px;border-radius:4px;text-decoration:none">View Techniques</a></p>
|
||||
<p style="color:#666;font-size:12px">Aegis ATT&CK Coverage Platform</p>
|
||||
</body></html>"""
|
||||
return send_email(to, f"MITRE ATT&CK Updated: {created} new techniques", html)
|
||||
@@ -158,6 +158,40 @@ def cleanup_old_notifications(db: Session, days: int = 90) -> int:
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def notify_role_with_email(
|
||||
db: Session,
|
||||
*,
|
||||
role: str,
|
||||
type: str,
|
||||
title: str,
|
||||
message: str,
|
||||
entity_type: str,
|
||||
entity_id: uuid.UUID,
|
||||
email_fn=None, # callable(user_email) -> bool, optional
|
||||
) -> None:
|
||||
"""Send in-app notifications + optional email to all active users with a role."""
|
||||
users = (
|
||||
db.query(User)
|
||||
.filter(User.role == role, User.is_active == True) # noqa: E712
|
||||
.all()
|
||||
)
|
||||
for user in users:
|
||||
create_notification(
|
||||
db,
|
||||
user_id=user.id,
|
||||
type=type,
|
||||
title=title,
|
||||
message=message,
|
||||
entity_type=entity_type,
|
||||
entity_id=entity_id,
|
||||
)
|
||||
if email_fn and user.email:
|
||||
try:
|
||||
email_fn(user.email)
|
||||
except Exception:
|
||||
pass # email failures never crash notification flow
|
||||
|
||||
|
||||
def notify_test_state_change(db: Session, test, new_state: str) -> None:
|
||||
"""Dispatch notifications based on a test's new state.
|
||||
|
||||
|
||||
145
backend/app/services/webhook_service.py
Normal file
145
backend/app/services/webhook_service.py
Normal file
@@ -0,0 +1,145 @@
|
||||
"""Webhook dispatch service — outbound HTTP notifications.
|
||||
|
||||
Supported event types:
|
||||
- test.validated — fired when a test reaches "validated" state
|
||||
- test.rejected — fired when a test reaches "rejected" state
|
||||
- campaign.completed — fired when a campaign is completed
|
||||
- campaign.started — fired when a campaign is activated
|
||||
- mitre.synced — fired after MITRE ATT&CK sync completes
|
||||
- technique.status_changed — fired when a technique's status changes
|
||||
- webhook.test — manual test ping from the admin UI
|
||||
"""
|
||||
|
||||
import hashlib
|
||||
import hmac
|
||||
import logging
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
|
||||
import requests
|
||||
|
||||
from app.database import SessionLocal
|
||||
from app.models.webhook_config import WebhookConfig
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Dispatch
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def dispatch_webhook(event_type: str, payload: dict) -> None:
|
||||
"""Send an outbound webhook to all active subscribers for *event_type*.
|
||||
|
||||
Opens its own DB session so this can be called outside request context
|
||||
(e.g. from background jobs). All exceptions are caught; webhook failures
|
||||
never crash the caller.
|
||||
"""
|
||||
db = SessionLocal()
|
||||
try:
|
||||
webhooks = (
|
||||
db.query(WebhookConfig)
|
||||
.filter(WebhookConfig.is_active == True) # noqa: E712
|
||||
.all()
|
||||
)
|
||||
for wh in webhooks:
|
||||
# Filter by subscribed events — empty list means "all events"
|
||||
subscribed: list = wh.events or []
|
||||
if subscribed and event_type not in subscribed:
|
||||
continue
|
||||
_send_webhook(db, wh, event_type, payload)
|
||||
except Exception:
|
||||
logger.exception("dispatch_webhook: unexpected error for event_type=%s", event_type)
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
def _send_webhook(db, wh: WebhookConfig, event_type: str, payload: dict) -> None:
|
||||
"""Send a single webhook POST and update its metadata."""
|
||||
body = {
|
||||
"event": event_type,
|
||||
"data": payload,
|
||||
"timestamp": datetime.utcnow().isoformat() + "Z",
|
||||
}
|
||||
headers = {"Content-Type": "application/json"}
|
||||
if wh.secret:
|
||||
import json
|
||||
raw = json.dumps(body, separators=(",", ":"), sort_keys=True).encode()
|
||||
sig = hmac.new(wh.secret.encode(), raw, hashlib.sha256).hexdigest()
|
||||
headers["X-Aegis-Signature"] = f"sha256={sig}"
|
||||
try:
|
||||
resp = requests.post(wh.url, json=body, headers=headers, timeout=10)
|
||||
resp.raise_for_status()
|
||||
wh.last_triggered_at = datetime.utcnow()
|
||||
wh.failure_count = 0
|
||||
db.commit()
|
||||
logger.info("Webhook '%s' (%s) dispatched OK for event=%s", wh.name, wh.url, event_type)
|
||||
except Exception as exc:
|
||||
wh.failure_count = (wh.failure_count or 0) + 1
|
||||
wh.last_triggered_at = datetime.utcnow()
|
||||
try:
|
||||
db.commit()
|
||||
except Exception:
|
||||
db.rollback()
|
||||
logger.warning(
|
||||
"Webhook '%s' (%s) failed for event=%s: %s (failure_count=%d)",
|
||||
wh.name, wh.url, event_type, exc, wh.failure_count,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# CRUD
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def list_webhooks(db, *, offset: int = 0, limit: int = 50) -> list[WebhookConfig]:
|
||||
"""Return paginated webhook configs."""
|
||||
return (
|
||||
db.query(WebhookConfig)
|
||||
.order_by(WebhookConfig.created_at.desc())
|
||||
.offset(offset)
|
||||
.limit(limit)
|
||||
.all()
|
||||
)
|
||||
|
||||
|
||||
def get_webhook_or_raise(db, webhook_id: uuid.UUID) -> WebhookConfig:
|
||||
"""Fetch a webhook by ID or raise 404."""
|
||||
from app.domain.errors import EntityNotFoundError
|
||||
wh = db.query(WebhookConfig).filter(WebhookConfig.id == webhook_id).first()
|
||||
if wh is None:
|
||||
raise EntityNotFoundError("WebhookConfig", str(webhook_id))
|
||||
return wh
|
||||
|
||||
|
||||
def create_webhook(db, created_by: uuid.UUID, payload) -> WebhookConfig:
|
||||
"""Create and persist a new WebhookConfig."""
|
||||
wh = WebhookConfig(
|
||||
name=payload.name,
|
||||
url=payload.url,
|
||||
secret=payload.secret,
|
||||
events=payload.events,
|
||||
is_active=payload.is_active,
|
||||
created_by=created_by,
|
||||
)
|
||||
db.add(wh)
|
||||
db.flush()
|
||||
return wh
|
||||
|
||||
|
||||
def update_webhook(db, webhook_id: uuid.UUID, payload) -> WebhookConfig:
|
||||
"""Apply a partial update to an existing WebhookConfig."""
|
||||
wh = get_webhook_or_raise(db, webhook_id)
|
||||
update_data = payload.model_dump(exclude_unset=True)
|
||||
for field, value in update_data.items():
|
||||
setattr(wh, field, value)
|
||||
db.flush()
|
||||
return wh
|
||||
|
||||
|
||||
def delete_webhook(db, webhook_id: uuid.UUID) -> None:
|
||||
"""Hard-delete a WebhookConfig."""
|
||||
wh = get_webhook_or_raise(db, webhook_id)
|
||||
db.delete(wh)
|
||||
db.flush()
|
||||
Reference in New Issue
Block a user