feat(settings): Settings page with email, webhooks, notifications, profile [FASE-8]
Some checks failed
Aegis CI / lint-and-test (push) Has been cancelled
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:
43
backend/alembic/versions/b033_system_configs.py
Normal file
43
backend/alembic/versions/b033_system_configs.py
Normal file
@@ -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")
|
||||||
@@ -21,6 +21,8 @@ from app.models.worklog import Worklog
|
|||||||
from app.models.osint_item import OsintItem
|
from app.models.osint_item import OsintItem
|
||||||
from app.models.scoring_config import ScoringConfig
|
from app.models.scoring_config import ScoringConfig
|
||||||
from app.models.enums import TechniqueStatus, TestState, TestResult, TeamSide
|
from app.models.enums import TechniqueStatus, TestState, TestResult, TeamSide
|
||||||
|
from app.models.webhook_config import WebhookConfig
|
||||||
|
from app.models.system_config import SystemConfig
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"User", "Technique", "Test", "TestTemplate", "Evidence",
|
"User", "Technique", "Test", "TestTemplate", "Evidence",
|
||||||
@@ -34,4 +36,5 @@ __all__ = [
|
|||||||
"JiraLink", "JiraLinkEntityType", "JiraSyncDirection",
|
"JiraLink", "JiraLinkEntityType", "JiraSyncDirection",
|
||||||
"Worklog", "OsintItem", "ScoringConfig",
|
"Worklog", "OsintItem", "ScoringConfig",
|
||||||
"TechniqueStatus", "TestState", "TestResult", "TeamSide",
|
"TechniqueStatus", "TestState", "TestResult", "TeamSide",
|
||||||
|
"WebhookConfig", "SystemConfig",
|
||||||
]
|
]
|
||||||
|
|||||||
26
backend/app/models/system_config.py
Normal file
26
backend/app/models/system_config.py
Normal 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())
|
||||||
@@ -3,11 +3,16 @@
|
|||||||
Provides manual triggers for background operations such as the MITRE
|
Provides manual triggers for background operations such as the MITRE
|
||||||
ATT&CK synchronisation, intel scanning, Atomic Red Team import, and
|
ATT&CK synchronisation, intel scanning, Atomic Red Team import, and
|
||||||
scheduler health introspection.
|
scheduler health introspection.
|
||||||
|
|
||||||
|
Also exposes email configuration CRUD (admin only) that writes to the
|
||||||
|
system_configs table so settings survive container restarts.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import logging
|
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 sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app.database import SessionLocal, get_db
|
from app.database import SessionLocal, get_db
|
||||||
@@ -24,6 +29,68 @@ logger = logging.getLogger(__name__)
|
|||||||
router = APIRouter(prefix="/system", tags=["system"])
|
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:
|
def _bg_mitre_sync() -> None:
|
||||||
"""Run MITRE sync in a background task with its own DB session."""
|
"""Run MITRE sync in a background task with its own DB session."""
|
||||||
logger.info("Background MITRE sync task starting...")
|
logger.info("Background MITRE sync task starting...")
|
||||||
@@ -132,3 +199,89 @@ def scheduler_status(
|
|||||||
for job in jobs
|
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}"}
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ from fastapi import APIRouter, Depends, status
|
|||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app.database import get_db
|
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.domain.unit_of_work import UnitOfWork
|
||||||
from app.models.user import User
|
from app.models.user import User
|
||||||
from app.schemas.webhook import WebhookConfigCreate, WebhookConfigOut, WebhookConfigUpdate
|
from app.schemas.webhook import WebhookConfigCreate, WebhookConfigOut, WebhookConfigUpdate
|
||||||
@@ -52,7 +52,7 @@ def list_webhooks_route(
|
|||||||
offset: int = 0,
|
offset: int = 0,
|
||||||
limit: int = 50,
|
limit: int = 50,
|
||||||
db: Session = Depends(get_db),
|
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.**"""
|
"""Return all webhook configurations. **Requires admin role.**"""
|
||||||
webhooks = list_webhooks(db, offset=offset, limit=limit)
|
webhooks = list_webhooks(db, offset=offset, limit=limit)
|
||||||
@@ -68,7 +68,7 @@ def list_webhooks_route(
|
|||||||
def create_webhook_route(
|
def create_webhook_route(
|
||||||
payload: WebhookConfigCreate,
|
payload: WebhookConfigCreate,
|
||||||
db: Session = Depends(get_db),
|
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.**"""
|
"""Create a new webhook configuration. **Requires admin role.**"""
|
||||||
with UnitOfWork(db) as uow:
|
with UnitOfWork(db) as uow:
|
||||||
@@ -87,7 +87,7 @@ def create_webhook_route(
|
|||||||
def get_webhook_route(
|
def get_webhook_route(
|
||||||
webhook_id: uuid.UUID,
|
webhook_id: uuid.UUID,
|
||||||
db: Session = Depends(get_db),
|
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.**"""
|
"""Return a single webhook configuration. **Requires admin role.**"""
|
||||||
wh = get_webhook_or_raise(db, webhook_id)
|
wh = get_webhook_or_raise(db, webhook_id)
|
||||||
@@ -104,7 +104,7 @@ def update_webhook_route(
|
|||||||
webhook_id: uuid.UUID,
|
webhook_id: uuid.UUID,
|
||||||
payload: WebhookConfigUpdate,
|
payload: WebhookConfigUpdate,
|
||||||
db: Session = Depends(get_db),
|
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.**"""
|
"""Update one or more fields of a webhook configuration. **Requires admin role.**"""
|
||||||
with UnitOfWork(db) as uow:
|
with UnitOfWork(db) as uow:
|
||||||
@@ -123,7 +123,7 @@ def update_webhook_route(
|
|||||||
def delete_webhook_route(
|
def delete_webhook_route(
|
||||||
webhook_id: uuid.UUID,
|
webhook_id: uuid.UUID,
|
||||||
db: Session = Depends(get_db),
|
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.**"""
|
"""Hard-delete a webhook configuration. **Requires admin role.**"""
|
||||||
with UnitOfWork(db) as uow:
|
with UnitOfWork(db) as uow:
|
||||||
@@ -140,7 +140,7 @@ def delete_webhook_route(
|
|||||||
def test_webhook_route(
|
def test_webhook_route(
|
||||||
webhook_id: uuid.UUID,
|
webhook_id: uuid.UUID,
|
||||||
db: Session = Depends(get_db),
|
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.**"""
|
"""Send a test ping to the webhook endpoint. **Requires admin role.**"""
|
||||||
# Verify the webhook exists before dispatching
|
# Verify the webhook exists before dispatching
|
||||||
|
|||||||
@@ -1,21 +1,94 @@
|
|||||||
"""Email notification service using SMTP.
|
"""Email notification service using SMTP.
|
||||||
|
|
||||||
Sending is silently skipped when SMTP_ENABLED=False (default).
|
Sending is silently skipped when SMTP_ENABLED=False (default) and no
|
||||||
All errors are caught and logged — email failures never crash the caller.
|
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 logging
|
||||||
import smtplib
|
import smtplib
|
||||||
from email.mime.multipart import MIMEMultipart
|
from email.mime.multipart import MIMEMultipart
|
||||||
from email.mime.text import MIMEText
|
from email.mime.text import MIMEText
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
from app.config import settings
|
from app.config import settings
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
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."""
|
# Helpers — read effective SMTP config (DB first, env fallback)
|
||||||
if not settings.SMTP_ENABLED:
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
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)
|
logger.debug("SMTP disabled — skipping email to %s: %s", to, subject)
|
||||||
return False
|
return False
|
||||||
if not to:
|
if not to:
|
||||||
@@ -23,14 +96,14 @@ def send_email(to: str, subject: str, html_body: str) -> bool:
|
|||||||
try:
|
try:
|
||||||
msg = MIMEMultipart("alternative")
|
msg = MIMEMultipart("alternative")
|
||||||
msg["Subject"] = f"[Aegis] {subject}"
|
msg["Subject"] = f"[Aegis] {subject}"
|
||||||
msg["From"] = settings.SMTP_FROM_EMAIL
|
msg["From"] = cfg["from_email"]
|
||||||
msg["To"] = to
|
msg["To"] = to
|
||||||
msg.attach(MIMEText(html_body, "html"))
|
msg.attach(MIMEText(html_body, "html"))
|
||||||
with smtplib.SMTP(settings.SMTP_HOST, settings.SMTP_PORT, timeout=10) as server:
|
with smtplib.SMTP(cfg["host"], cfg["port"], timeout=10) as server:
|
||||||
if settings.SMTP_USE_TLS:
|
if cfg["use_tls"]:
|
||||||
server.starttls()
|
server.starttls()
|
||||||
if settings.SMTP_USERNAME:
|
if cfg["username"]:
|
||||||
server.login(settings.SMTP_USERNAME, settings.SMTP_PASSWORD)
|
server.login(cfg["username"], cfg["password"])
|
||||||
server.send_message(msg)
|
server.send_message(msg)
|
||||||
logger.info("Email sent to %s: %s", to, subject)
|
logger.info("Email sent to %s: %s", to, subject)
|
||||||
return True
|
return True
|
||||||
@@ -39,7 +112,12 @@ def send_email(to: str, subject: str, html_body: str) -> bool:
|
|||||||
return False
|
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."""
|
"""Notify that a test was validated."""
|
||||||
url = f"{settings.PLATFORM_URL}/tests/{test_id}"
|
url = f"{settings.PLATFORM_URL}/tests/{test_id}"
|
||||||
html = f"""
|
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><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>
|
<p style="color:#666;font-size:12px">Aegis ATT&CK Coverage Platform</p>
|
||||||
</body></html>"""
|
</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."""
|
"""Notify that a campaign was completed."""
|
||||||
url = f"{settings.PLATFORM_URL}/campaigns/{campaign_id}"
|
url = f"{settings.PLATFORM_URL}/campaigns/{campaign_id}"
|
||||||
html = f"""
|
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><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>
|
<p style="color:#666;font-size:12px">Aegis ATT&CK Coverage Platform</p>
|
||||||
</body></html>"""
|
</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."""
|
"""Notify of new MITRE techniques after sync."""
|
||||||
if created == 0:
|
if created == 0:
|
||||||
return False
|
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><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>
|
<p style="color:#666;font-size:12px">Aegis ATT&CK Coverage Platform</p>
|
||||||
</body></html>"""
|
</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">✅ 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)
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ const ThreatActorDetailPage = React.lazy(() => import("./pages/ThreatActorDetail
|
|||||||
const CampaignsPage = React.lazy(() => import("./pages/CampaignsPage"));
|
const CampaignsPage = React.lazy(() => import("./pages/CampaignsPage"));
|
||||||
const CampaignDetailPage = React.lazy(() => import("./pages/CampaignDetailPage"));
|
const CampaignDetailPage = React.lazy(() => import("./pages/CampaignDetailPage"));
|
||||||
const ComparisonPage = React.lazy(() => import("./pages/ComparisonPage"));
|
const ComparisonPage = React.lazy(() => import("./pages/ComparisonPage"));
|
||||||
|
const SettingsPage = React.lazy(() => import("./pages/SettingsPage"));
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
return (
|
return (
|
||||||
@@ -92,6 +93,9 @@ export default function App() {
|
|||||||
{/* ── Reports ──────────────────────────────────────────── */}
|
{/* ── Reports ──────────────────────────────────────────── */}
|
||||||
<Route path="/reports" element={<Suspense fallback={<LoadingSpinner text="Loading…" />}><ReportsPage /></Suspense>} />
|
<Route path="/reports" element={<Suspense fallback={<LoadingSpinner text="Loading…" />}><ReportsPage /></Suspense>} />
|
||||||
|
|
||||||
|
{/* ── Settings (all authenticated users) ───────────────── */}
|
||||||
|
<Route path="/settings" element={<Suspense fallback={<LoadingSpinner text="Loading…" />}><SettingsPage /></Suspense>} />
|
||||||
|
|
||||||
{/* ── System (admin only) ──────────────────────────────── */}
|
{/* ── System (admin only) ──────────────────────────────── */}
|
||||||
<Route
|
<Route
|
||||||
path="/system"
|
path="/system"
|
||||||
|
|||||||
147
frontend/src/api/settings.ts
Normal file
147
frontend/src/api/settings.ts
Normal file
@@ -0,0 +1,147 @@
|
|||||||
|
import client from "./client";
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Email / SMTP config (admin only)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export interface EmailConfigOut {
|
||||||
|
enabled: boolean;
|
||||||
|
host: string;
|
||||||
|
port: number;
|
||||||
|
username: string;
|
||||||
|
from_email: string;
|
||||||
|
use_tls: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EmailConfigUpdate {
|
||||||
|
enabled?: boolean;
|
||||||
|
host?: string;
|
||||||
|
port?: number;
|
||||||
|
username?: string;
|
||||||
|
password?: string;
|
||||||
|
from_email?: string;
|
||||||
|
use_tls?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getEmailConfig(): Promise<EmailConfigOut> {
|
||||||
|
const { data } = await client.get<EmailConfigOut>("/system/email-config");
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateEmailConfig(payload: EmailConfigUpdate): Promise<EmailConfigOut> {
|
||||||
|
const { data } = await client.patch<EmailConfigOut>("/system/email-config", payload);
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function sendTestEmail(to: string): Promise<{ detail: string }> {
|
||||||
|
const { data } = await client.post<{ detail: string }>("/system/email-test", { to });
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Webhook config (admin + leads)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export interface WebhookOut {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
url: string;
|
||||||
|
secret: string | null;
|
||||||
|
events: string[];
|
||||||
|
is_active: boolean;
|
||||||
|
created_at: string | null;
|
||||||
|
last_triggered_at: string | null;
|
||||||
|
failure_count: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WebhookCreate {
|
||||||
|
name: string;
|
||||||
|
url: string;
|
||||||
|
secret?: string;
|
||||||
|
events: string[];
|
||||||
|
is_active?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WebhookUpdate {
|
||||||
|
name?: string;
|
||||||
|
url?: string;
|
||||||
|
secret?: string;
|
||||||
|
events?: string[];
|
||||||
|
is_active?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getWebhooks(): Promise<WebhookOut[]> {
|
||||||
|
const { data } = await client.get<WebhookOut[]>("/webhooks");
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createWebhook(payload: WebhookCreate): Promise<WebhookOut> {
|
||||||
|
const { data } = await client.post<WebhookOut>("/webhooks", payload);
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateWebhook(id: string, payload: WebhookUpdate): Promise<WebhookOut> {
|
||||||
|
const { data } = await client.patch<WebhookOut>(`/webhooks/${id}`, payload);
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteWebhook(id: string): Promise<void> {
|
||||||
|
await client.delete(`/webhooks/${id}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function testWebhook(id: string): Promise<{ detail: string }> {
|
||||||
|
const { data } = await client.post<{ detail: string }>(`/webhooks/${id}/test`);
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// User preferences (all users)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export interface NotificationPreferences {
|
||||||
|
// Universal
|
||||||
|
email_on_test_validated: boolean;
|
||||||
|
email_on_test_rejected: boolean;
|
||||||
|
email_on_campaign_completed: boolean;
|
||||||
|
email_on_new_mitre_techniques: boolean;
|
||||||
|
email_on_stale_coverage: boolean;
|
||||||
|
in_app_all: boolean;
|
||||||
|
// Tech + leads
|
||||||
|
email_on_assigned_to_campaign?: boolean;
|
||||||
|
email_on_test_state_change?: boolean;
|
||||||
|
// Leads only
|
||||||
|
email_on_all_team_validations?: boolean;
|
||||||
|
email_on_webhook_failures?: boolean;
|
||||||
|
// Admin only
|
||||||
|
email_on_new_users?: boolean;
|
||||||
|
email_on_system_errors?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UserPreferencesUpdate {
|
||||||
|
notification_preferences?: Partial<NotificationPreferences>;
|
||||||
|
jira_account_id?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UserMeOut {
|
||||||
|
id: string;
|
||||||
|
username: string;
|
||||||
|
email: string | null;
|
||||||
|
role: string;
|
||||||
|
is_active: boolean;
|
||||||
|
must_change_password: boolean;
|
||||||
|
created_at: string | null;
|
||||||
|
last_login: string | null;
|
||||||
|
notification_preferences: NotificationPreferences | null;
|
||||||
|
jira_account_id: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getMe(): Promise<UserMeOut> {
|
||||||
|
const { data } = await client.get<UserMeOut>("/users/me");
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateMyPreferences(payload: UserPreferencesUpdate): Promise<UserMeOut> {
|
||||||
|
const { data } = await client.patch<UserMeOut>("/users/me/preferences", payload);
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
@@ -50,6 +50,7 @@ const mainLinks: NavItem[] = [
|
|||||||
{ to: "/compliance", label: "Compliance", icon: ShieldCheck },
|
{ to: "/compliance", label: "Compliance", icon: ShieldCheck },
|
||||||
{ to: "/comparison", label: "Comparison", icon: GitCompareArrows, roles: ["admin", "red_lead", "blue_lead", "viewer"] },
|
{ to: "/comparison", label: "Comparison", icon: GitCompareArrows, roles: ["admin", "red_lead", "blue_lead", "viewer"] },
|
||||||
{ to: "/reports", label: "Reports", icon: BarChart3 },
|
{ to: "/reports", label: "Reports", icon: BarChart3 },
|
||||||
|
{ to: "/settings", label: "Settings", icon: Settings },
|
||||||
];
|
];
|
||||||
|
|
||||||
const systemLinks: NavItem[] = [
|
const systemLinks: NavItem[] = [
|
||||||
|
|||||||
1003
frontend/src/pages/SettingsPage.tsx
Normal file
1003
frontend/src/pages/SettingsPage.tsx
Normal file
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user