feat(settings): Settings page with email, webhooks, notifications, profile [FASE-8]
Some checks failed
Aegis CI / lint-and-test (push) Has been cancelled

- SystemConfig model + migration b033 for runtime key-value config
- GET/PATCH /system/email-config + POST /system/email-test (admin only)
- email_service reads SMTP config from DB (overrides .env)
- Webhooks now accessible to red_lead/blue_lead + admin
- GET /users/me already existed; /users/me/preferences already working
- SettingsPage with 4 role-aware tabs:
  * Profile & Jira: jira_account_id, user info
  * Notifications: role-specific email/in-app toggles (12 prefs)
  * Webhooks: full CRUD + test ping (leads + admin)
  * Email/SMTP: enable toggle, server config, test email (admin only)
- Added /settings route (all authenticated users)
- Settings link added to Sidebar

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
kitos
2026-05-19 15:10:31 +02:00
parent 93ebcf2b86
commit 0e1b8e2b39
10 changed files with 1493 additions and 24 deletions

View File

@@ -21,6 +21,8 @@ from app.models.worklog import Worklog
from app.models.osint_item import OsintItem
from app.models.scoring_config import ScoringConfig
from app.models.enums import TechniqueStatus, TestState, TestResult, TeamSide
from app.models.webhook_config import WebhookConfig
from app.models.system_config import SystemConfig
__all__ = [
"User", "Technique", "Test", "TestTemplate", "Evidence",
@@ -34,4 +36,5 @@ __all__ = [
"JiraLink", "JiraLinkEntityType", "JiraSyncDirection",
"Worklog", "OsintItem", "ScoringConfig",
"TechniqueStatus", "TestState", "TestResult", "TeamSide",
"WebhookConfig", "SystemConfig",
]

View File

@@ -0,0 +1,26 @@
"""SystemConfig model — runtime key-value configuration store."""
import uuid
from sqlalchemy import Column, String, Text, DateTime, func
from sqlalchemy.dialects.postgresql import UUID
from app.database import Base
class SystemConfig(Base):
"""Generic key-value store for runtime system configuration.
Currently used for:
- SMTP email settings (overrides .env values when present)
Keys are namespaced by convention: ``smtp.host``, ``smtp.port``, etc.
"""
__tablename__ = "system_configs"
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
key = Column(String(200), unique=True, nullable=False, index=True)
value = Column(Text, nullable=True)
description = Column(String(500), nullable=True)
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())

View File

@@ -3,11 +3,16 @@
Provides manual triggers for background operations such as the MITRE
ATT&CK synchronisation, intel scanning, Atomic Red Team import, and
scheduler health introspection.
Also exposes email configuration CRUD (admin only) that writes to the
system_configs table so settings survive container restarts.
"""
import logging
from typing import Optional
from fastapi import APIRouter, BackgroundTasks, Depends, Request
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, Request, status
from pydantic import BaseModel
from sqlalchemy.orm import Session
from app.database import SessionLocal, get_db
@@ -24,6 +29,68 @@ logger = logging.getLogger(__name__)
router = APIRouter(prefix="/system", tags=["system"])
# ---------------------------------------------------------------------------
# Pydantic schemas for email config
# ---------------------------------------------------------------------------
class EmailConfigOut(BaseModel):
enabled: bool
host: str
port: int
username: str
from_email: str
use_tls: bool
# password is never returned
class EmailConfigUpdate(BaseModel):
enabled: Optional[bool] = None
host: Optional[str] = None
port: Optional[int] = None
username: Optional[str] = None
password: Optional[str] = None
from_email: Optional[str] = None
use_tls: Optional[bool] = None
class EmailTestRequest(BaseModel):
to: str
# ---------------------------------------------------------------------------
# Helpers for system_configs CRUD
# ---------------------------------------------------------------------------
_SMTP_KEYS = {
"enabled": "smtp.enabled",
"host": "smtp.host",
"port": "smtp.port",
"username": "smtp.username",
"password": "smtp.password",
"from_email": "smtp.from_email",
"use_tls": "smtp.use_tls",
}
def _upsert_config(db: Session, key: str, value: str) -> None:
from app.models.system_config import SystemConfig # lazy import avoids circular
row = db.query(SystemConfig).filter(SystemConfig.key == key).first()
if row:
row.value = value
else:
row = SystemConfig(key=key, value=value)
db.add(row)
def _read_email_config_from_db(db: Session) -> dict:
"""Return a dict with resolved email settings (DB overrides env)."""
from app.services.email_service import _get_smtp_config
return _get_smtp_config(db)
def _bg_mitre_sync() -> None:
"""Run MITRE sync in a background task with its own DB session."""
logger.info("Background MITRE sync task starting...")
@@ -132,3 +199,89 @@ def scheduler_status(
for job in jobs
],
}
# ---------------------------------------------------------------------------
# GET /system/email-config
# ---------------------------------------------------------------------------
@router.get("/email-config", response_model=EmailConfigOut)
def get_email_config(
db: Session = Depends(get_db),
current_user: User = Depends(require_role("admin")),
):
"""Return current SMTP email configuration (merged DB + env).
**Requires** the ``admin`` role. Password is never returned.
"""
cfg = _read_email_config_from_db(db)
return EmailConfigOut(
enabled=cfg["enabled"],
host=cfg["host"],
port=cfg["port"],
username=cfg["username"],
from_email=cfg["from_email"],
use_tls=cfg["use_tls"],
)
# ---------------------------------------------------------------------------
# PATCH /system/email-config
# ---------------------------------------------------------------------------
@router.patch("/email-config", response_model=EmailConfigOut)
def update_email_config(
payload: EmailConfigUpdate,
db: Session = Depends(get_db),
current_user: User = Depends(require_role("admin")),
):
"""Update SMTP email configuration and persist to DB.
**Requires** the ``admin`` role.
Only provided fields are updated (partial update).
"""
update_data = payload.model_dump(exclude_unset=True)
for field, val in update_data.items():
db_key = _SMTP_KEYS.get(field)
if db_key:
_upsert_config(db, db_key, str(val))
db.commit()
cfg = _read_email_config_from_db(db)
return EmailConfigOut(
enabled=cfg["enabled"],
host=cfg["host"],
port=cfg["port"],
username=cfg["username"],
from_email=cfg["from_email"],
use_tls=cfg["use_tls"],
)
# ---------------------------------------------------------------------------
# POST /system/email-test
# ---------------------------------------------------------------------------
@router.post("/email-test")
def send_test_email(
payload: EmailTestRequest,
db: Session = Depends(get_db),
current_user: User = Depends(require_role("admin")),
):
"""Send a test email to verify SMTP configuration.
**Requires** the ``admin`` role.
Returns 200 on success, 502 if sending fails.
"""
from app.services.email_service import send_test_email as _send_test
ok = _send_test(payload.to, db=db)
if not ok:
raise HTTPException(
status_code=status.HTTP_502_BAD_GATEWAY,
detail="Failed to send test email. Check SMTP configuration and server logs.",
)
return {"detail": f"Test email sent to {payload.to}"}

View File

@@ -16,7 +16,7 @@ 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.dependencies.auth import require_any_role
from app.domain.unit_of_work import UnitOfWork
from app.models.user import User
from app.schemas.webhook import WebhookConfigCreate, WebhookConfigOut, WebhookConfigUpdate
@@ -52,7 +52,7 @@ def list_webhooks_route(
offset: int = 0,
limit: int = 50,
db: Session = Depends(get_db),
current_user: User = Depends(require_role("admin")),
current_user: User = Depends(require_any_role("red_lead", "blue_lead")),
):
"""Return all webhook configurations. **Requires admin role.**"""
webhooks = list_webhooks(db, offset=offset, limit=limit)
@@ -68,7 +68,7 @@ def list_webhooks_route(
def create_webhook_route(
payload: WebhookConfigCreate,
db: Session = Depends(get_db),
current_user: User = Depends(require_role("admin")),
current_user: User = Depends(require_any_role("red_lead", "blue_lead")),
):
"""Create a new webhook configuration. **Requires admin role.**"""
with UnitOfWork(db) as uow:
@@ -87,7 +87,7 @@ def create_webhook_route(
def get_webhook_route(
webhook_id: uuid.UUID,
db: Session = Depends(get_db),
current_user: User = Depends(require_role("admin")),
current_user: User = Depends(require_any_role("red_lead", "blue_lead")),
):
"""Return a single webhook configuration. **Requires admin role.**"""
wh = get_webhook_or_raise(db, webhook_id)
@@ -104,7 +104,7 @@ def update_webhook_route(
webhook_id: uuid.UUID,
payload: WebhookConfigUpdate,
db: Session = Depends(get_db),
current_user: User = Depends(require_role("admin")),
current_user: User = Depends(require_any_role("red_lead", "blue_lead")),
):
"""Update one or more fields of a webhook configuration. **Requires admin role.**"""
with UnitOfWork(db) as uow:
@@ -123,7 +123,7 @@ def update_webhook_route(
def delete_webhook_route(
webhook_id: uuid.UUID,
db: Session = Depends(get_db),
current_user: User = Depends(require_role("admin")),
current_user: User = Depends(require_any_role("red_lead", "blue_lead")),
):
"""Hard-delete a webhook configuration. **Requires admin role.**"""
with UnitOfWork(db) as uow:
@@ -140,7 +140,7 @@ def delete_webhook_route(
def test_webhook_route(
webhook_id: uuid.UUID,
db: Session = Depends(get_db),
current_user: User = Depends(require_role("admin")),
current_user: User = Depends(require_any_role("red_lead", "blue_lead")),
):
"""Send a test ping to the webhook endpoint. **Requires admin role.**"""
# Verify the webhook exists before dispatching

View File

@@ -1,21 +1,94 @@
"""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.
Sending is silently skipped when SMTP_ENABLED=False (default) and no
DB config overrides it. All errors are caught and logged — email
failures never crash the caller.
Config priority:
1. system_configs table (key ``smtp.*``) — managed via the Settings UI
2. .env / environment variables (app.config.settings)
"""
import logging
import smtplib
from email.mime.multipart import MIMEMultipart
from email.mime.text import MIMEText
from typing import Optional
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:
# ---------------------------------------------------------------------------
# Helpers — read effective SMTP config (DB first, env fallback)
# ---------------------------------------------------------------------------
def _get_smtp_config(db=None) -> dict:
"""Return a dict with resolved SMTP settings.
When *db* is provided the function looks up ``system_configs`` rows
whose key starts with ``smtp.`` and overrides the .env values.
"""
cfg = {
"enabled": settings.SMTP_ENABLED,
"host": settings.SMTP_HOST,
"port": settings.SMTP_PORT,
"username": settings.SMTP_USERNAME,
"password": settings.SMTP_PASSWORD,
"from_email": settings.SMTP_FROM_EMAIL,
"use_tls": settings.SMTP_USE_TLS,
}
if db is not None:
try:
from app.models.system_config import SystemConfig # avoid circular
rows = db.query(SystemConfig).filter(
SystemConfig.key.like("smtp.%")
).all()
for row in rows:
k = row.key # e.g. "smtp.host"
v = row.value
if v is None:
continue
short = k[len("smtp."):] # "host"
if short == "enabled":
cfg["enabled"] = v.lower() in ("true", "1", "yes")
elif short == "host":
cfg["host"] = v
elif short == "port":
try:
cfg["port"] = int(v)
except ValueError:
pass
elif short == "username":
cfg["username"] = v
elif short == "password":
cfg["password"] = v
elif short == "from_email":
cfg["from_email"] = v
elif short == "use_tls":
cfg["use_tls"] = v.lower() in ("true", "1", "yes")
except Exception:
logger.exception("Failed to read SMTP config from DB — falling back to env")
return cfg
# ---------------------------------------------------------------------------
# Core send
# ---------------------------------------------------------------------------
def send_email(to: str, subject: str, html_body: str, db=None) -> bool:
"""Send an HTML email. Returns True on success, False on skip/error.
Pass *db* to allow runtime config override from system_configs table.
"""
cfg = _get_smtp_config(db)
if not cfg["enabled"]:
logger.debug("SMTP disabled — skipping email to %s: %s", to, subject)
return False
if not to:
@@ -23,14 +96,14 @@ def send_email(to: str, subject: str, html_body: str) -> bool:
try:
msg = MIMEMultipart("alternative")
msg["Subject"] = f"[Aegis] {subject}"
msg["From"] = settings.SMTP_FROM_EMAIL
msg["From"] = cfg["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:
with smtplib.SMTP(cfg["host"], cfg["port"], timeout=10) as server:
if cfg["use_tls"]:
server.starttls()
if settings.SMTP_USERNAME:
server.login(settings.SMTP_USERNAME, settings.SMTP_PASSWORD)
if cfg["username"]:
server.login(cfg["username"], cfg["password"])
server.send_message(msg)
logger.info("Email sent to %s: %s", to, subject)
return True
@@ -39,7 +112,12 @@ def send_email(to: str, subject: str, html_body: str) -> bool:
return False
def send_test_validated_email(to: str, test_name: str, technique_id: str, test_id: str) -> bool:
# ---------------------------------------------------------------------------
# Typed senders
# ---------------------------------------------------------------------------
def send_test_validated_email(to: str, test_name: str, technique_id: str, test_id: str, db=None) -> bool:
"""Notify that a test was validated."""
url = f"{settings.PLATFORM_URL}/tests/{test_id}"
html = f"""
@@ -49,10 +127,10 @@ def send_test_validated_email(to: str, test_name: str, technique_id: str, test_i
<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)
return send_email(to, f"Test Validated: {test_name}", html, db=db)
def send_campaign_completed_email(to: str, campaign_name: str, campaign_id: str) -> bool:
def send_campaign_completed_email(to: str, campaign_name: str, campaign_id: str, db=None) -> bool:
"""Notify that a campaign was completed."""
url = f"{settings.PLATFORM_URL}/campaigns/{campaign_id}"
html = f"""
@@ -62,10 +140,10 @@ def send_campaign_completed_email(to: str, campaign_name: str, campaign_id: str)
<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)
return send_email(to, f"Campaign Completed: {campaign_name}", html, db=db)
def send_new_mitre_techniques_email(to: str, created: int, updated: int) -> bool:
def send_new_mitre_techniques_email(to: str, created: int, updated: int, db=None) -> bool:
"""Notify of new MITRE techniques after sync."""
if created == 0:
return False
@@ -76,4 +154,15 @@ def send_new_mitre_techniques_email(to: str, created: int, updated: int) -> bool
<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)
return send_email(to, f"MITRE ATT&CK Updated: {created} new techniques", html, db=db)
def send_test_email(to: str, db=None) -> bool:
"""Send a test/ping email to verify SMTP config."""
html = """
<html><body style="font-family:sans-serif;color:#1a1a2e">
<h2 style="color:#22d3ee">&#x2705; Email Configuration Test</h2>
<p>This is a test email from Aegis. If you received this, your SMTP configuration is working correctly.</p>
<p style="color:#666;font-size:12px">Aegis ATT&CK Coverage Platform</p>
</body></html>"""
return send_email(to, "Email Configuration Test", html, db=db)