diff --git a/backend/alembic/versions/b033_system_configs.py b/backend/alembic/versions/b033_system_configs.py new file mode 100644 index 0000000..75db08d --- /dev/null +++ b/backend/alembic/versions/b033_system_configs.py @@ -0,0 +1,43 @@ +"""Phase 8: system_configs table for runtime configuration. + +Revision ID: b033syscfg +Revises: b032phase7 +""" +from typing import Sequence, Union +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql +from alembic import op + +revision: str = "b033syscfg" +down_revision: Union[str, None] = "b032phase7" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def _table_exists(name: str) -> bool: + bind = op.get_bind() + insp = sa.inspect(bind) + return name in insp.get_table_names() + + +def upgrade() -> None: + if not _table_exists("system_configs"): + op.create_table( + "system_configs", + sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True), + sa.Column("key", sa.String(200), unique=True, nullable=False), + sa.Column("value", sa.Text, nullable=True), + sa.Column("description", sa.String(500), nullable=True), + sa.Column( + "updated_at", + sa.DateTime(timezone=True), + server_default=sa.text("now()"), + ), + ) + op.create_index("ix_system_configs_key", "system_configs", ["key"]) + + +def downgrade() -> None: + if _table_exists("system_configs"): + op.drop_index("ix_system_configs_key", table_name="system_configs") + op.drop_table("system_configs") diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py index 2780d6d..af6a30d 100644 --- a/backend/app/models/__init__.py +++ b/backend/app/models/__init__.py @@ -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", ] diff --git a/backend/app/models/system_config.py b/backend/app/models/system_config.py new file mode 100644 index 0000000..5e0416b --- /dev/null +++ b/backend/app/models/system_config.py @@ -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()) diff --git a/backend/app/routers/system.py b/backend/app/routers/system.py index 96ee96d..f61b73d 100644 --- a/backend/app/routers/system.py +++ b/backend/app/routers/system.py @@ -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}"} diff --git a/backend/app/routers/webhooks.py b/backend/app/routers/webhooks.py index 5a9e39c..11ce303 100644 --- a/backend/app/routers/webhooks.py +++ b/backend/app/routers/webhooks.py @@ -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 diff --git a/backend/app/services/email_service.py b/backend/app/services/email_service.py index 44c68f8..a9e1e6f 100644 --- a/backend/app/services/email_service.py +++ b/backend/app/services/email_service.py @@ -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
Aegis ATT&CK Coverage Platform