Merge PR #128: harden notification settings
Harden notification settings for Milestone 6.
This commit is contained in:
@@ -108,7 +108,8 @@ Then visit [http://localhost:8080](http://localhost:8080)
|
|||||||
|
|
||||||
DMARQ sends notifications through Apprise target URLs configured in
|
DMARQ sends notifications through Apprise target URLs configured in
|
||||||
**Settings** > **Notifications**. Add one target URL per line, enable
|
**Settings** > **Notifications**. Add one target URL per line, enable
|
||||||
notifications, and use **Send Test** to verify delivery. Apprise supports email,
|
notifications, and use **Send Test** to verify delivery. Target URLs are
|
||||||
|
encrypted in the database and redacted in API responses. Apprise supports email,
|
||||||
Slack, Teams, Discord, generic webhooks, and many other targets.
|
Slack, Teams, Discord, generic webhooks, and many other targets.
|
||||||
|
|
||||||
Notification settings include alert-rule toggles and thresholds for:
|
Notification settings include alert-rule toggles and thresholds for:
|
||||||
@@ -120,11 +121,15 @@ Notification settings include alert-rule toggles and thresholds for:
|
|||||||
|
|
||||||
DMARQ can also send daily and weekly summaries. Use **Preview Summary** to see
|
DMARQ can also send daily and weekly summaries. Use **Preview Summary** to see
|
||||||
the current summary payload, **Send Summary Now** for an immediate message, and
|
the current summary payload, **Send Summary Now** for an immediate message, and
|
||||||
the daily/weekly toggles to enable scheduled delivery.
|
the daily/weekly toggles to enable scheduled delivery. Outbound messages are
|
||||||
|
rate-limited by the configured cooldown and email addresses are redacted by
|
||||||
|
default before delivery.
|
||||||
|
|
||||||
Alert history is available in **Settings** > **Notifications** after alerts have
|
Alert history is available in **Settings** > **Notifications** after alerts have
|
||||||
been evaluated or sent. History rows track active/resolved status, first seen,
|
been evaluated or sent. History rows track active/resolved status, first seen,
|
||||||
last seen, observed count, and alert metadata.
|
last seen, observed count, and alert metadata. Notification and alert-rule
|
||||||
|
configuration changes are recorded in the configuration audit trail without
|
||||||
|
storing raw notification secrets.
|
||||||
|
|
||||||
See [Settings](docs/user_guide/settings.md) and
|
See [Settings](docs/user_guide/settings.md) and
|
||||||
[Configuration](docs/deployment/configuration.md) for examples and available
|
[Configuration](docs/deployment/configuration.md) for examples and available
|
||||||
|
|||||||
@@ -0,0 +1,82 @@
|
|||||||
|
"""add alert configuration audit
|
||||||
|
|
||||||
|
Revision ID: b9c0d1e2f3a4
|
||||||
|
Revises: b8c9d0e1f2a3
|
||||||
|
Create Date: 2026-05-23 00:00:00.000000
|
||||||
|
|
||||||
|
"""
|
||||||
|
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = "b9c0d1e2f3a4"
|
||||||
|
down_revision: Union[str, Sequence[str], None] = "b8c9d0e1f2a3"
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
"""Create alert configuration audit trail rows."""
|
||||||
|
op.create_table(
|
||||||
|
"alert_configuration_audit",
|
||||||
|
sa.Column("id", sa.Integer(), nullable=False),
|
||||||
|
sa.Column("key", sa.String(length=100), nullable=False),
|
||||||
|
sa.Column("old_value", sa.Text(), nullable=True),
|
||||||
|
sa.Column("new_value", sa.Text(), nullable=True),
|
||||||
|
sa.Column("changed_by", sa.String(length=100), nullable=True),
|
||||||
|
sa.Column("auth_type", sa.String(length=50), nullable=True),
|
||||||
|
sa.Column("changed_at", sa.DateTime(), nullable=False),
|
||||||
|
sa.PrimaryKeyConstraint("id"),
|
||||||
|
)
|
||||||
|
op.create_index(
|
||||||
|
op.f("ix_alert_configuration_audit_id"),
|
||||||
|
"alert_configuration_audit",
|
||||||
|
["id"],
|
||||||
|
unique=False,
|
||||||
|
)
|
||||||
|
op.create_index(
|
||||||
|
op.f("ix_alert_configuration_audit_key"),
|
||||||
|
"alert_configuration_audit",
|
||||||
|
["key"],
|
||||||
|
unique=False,
|
||||||
|
)
|
||||||
|
op.create_index(
|
||||||
|
op.f("ix_alert_configuration_audit_changed_by"),
|
||||||
|
"alert_configuration_audit",
|
||||||
|
["changed_by"],
|
||||||
|
unique=False,
|
||||||
|
)
|
||||||
|
op.create_index(
|
||||||
|
op.f("ix_alert_configuration_audit_changed_at"),
|
||||||
|
"alert_configuration_audit",
|
||||||
|
["changed_at"],
|
||||||
|
unique=False,
|
||||||
|
)
|
||||||
|
op.create_index(
|
||||||
|
"ix_alert_configuration_audit_key_changed_at",
|
||||||
|
"alert_configuration_audit",
|
||||||
|
["key", "changed_at"],
|
||||||
|
unique=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
"""Drop alert configuration audit trail rows."""
|
||||||
|
op.drop_index(
|
||||||
|
"ix_alert_configuration_audit_key_changed_at",
|
||||||
|
table_name="alert_configuration_audit",
|
||||||
|
)
|
||||||
|
op.drop_index(
|
||||||
|
op.f("ix_alert_configuration_audit_changed_at"),
|
||||||
|
table_name="alert_configuration_audit",
|
||||||
|
)
|
||||||
|
op.drop_index(
|
||||||
|
op.f("ix_alert_configuration_audit_changed_by"),
|
||||||
|
table_name="alert_configuration_audit",
|
||||||
|
)
|
||||||
|
op.drop_index(op.f("ix_alert_configuration_audit_key"), table_name="alert_configuration_audit")
|
||||||
|
op.drop_index(op.f("ix_alert_configuration_audit_id"), table_name="alert_configuration_audit")
|
||||||
|
op.drop_table("alert_configuration_audit")
|
||||||
@@ -18,10 +18,16 @@ from fastapi import APIRouter, Depends, HTTPException, status
|
|||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.core.credential_encryption import decrypt_secret, encrypt_secret, is_encrypted_secret
|
||||||
from app.core.database import get_db
|
from app.core.database import get_db
|
||||||
from app.core.security import require_admin_auth
|
from app.core.security import require_admin_auth
|
||||||
from app.models.setting import Setting
|
from app.models.setting import Setting
|
||||||
from app.services.alert_history import list_alert_history, record_alert_evaluation
|
from app.services.alert_history import (
|
||||||
|
list_alert_config_audit,
|
||||||
|
list_alert_history,
|
||||||
|
record_alert_config_change,
|
||||||
|
record_alert_evaluation,
|
||||||
|
)
|
||||||
from app.services.alert_rules import evaluate_alert_rules, send_current_alerts
|
from app.services.alert_rules import evaluate_alert_rules, send_current_alerts
|
||||||
from app.services.notifications import send_notification
|
from app.services.notifications import send_notification
|
||||||
from app.services.summary_notifications import build_summary, send_summary_notification
|
from app.services.summary_notifications import build_summary, send_summary_notification
|
||||||
@@ -130,6 +136,27 @@ SETTING_DEFAULTS: List[Dict[str, Any]] = [
|
|||||||
"value_type": "string",
|
"value_type": "string",
|
||||||
"category": "notifications",
|
"category": "notifications",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"key": "notifications.min_send_interval_minutes",
|
||||||
|
"value": "15",
|
||||||
|
"description": "Minimum minutes between outbound notification deliveries",
|
||||||
|
"value_type": "integer",
|
||||||
|
"category": "notifications",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "notifications.redact_pii_enabled",
|
||||||
|
"value": "true",
|
||||||
|
"description": "Redact email addresses from outbound notification titles and bodies",
|
||||||
|
"value_type": "boolean",
|
||||||
|
"category": "notifications",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "notifications.last_sent_at",
|
||||||
|
"value": "",
|
||||||
|
"description": "Internal timestamp for outbound notification rate limiting",
|
||||||
|
"value_type": "string",
|
||||||
|
"category": "notifications",
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"key": "notifications.alert_new_sources_enabled",
|
"key": "notifications.alert_new_sources_enabled",
|
||||||
"value": "true",
|
"value": "true",
|
||||||
@@ -250,6 +277,7 @@ def _seed_defaults(db: Session) -> None:
|
|||||||
category=defaults["category"],
|
category=defaults["category"],
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
_migrate_plaintext_secret_settings(db)
|
||||||
db.commit()
|
db.commit()
|
||||||
|
|
||||||
|
|
||||||
@@ -257,6 +285,55 @@ def _get_setting(key: str, db: Session) -> Optional[Setting]:
|
|||||||
return db.query(Setting).filter(Setting.key == key).first()
|
return db.query(Setting).filter(Setting.key == key).first()
|
||||||
|
|
||||||
|
|
||||||
|
def _migrate_plaintext_secret_settings(db: Session) -> None:
|
||||||
|
"""Encrypt legacy plaintext secret settings opportunistically."""
|
||||||
|
rows = db.query(Setting).filter(Setting.key.in_(_SECRET_KEYS)).all()
|
||||||
|
for row in rows:
|
||||||
|
if row.value and not is_encrypted_secret(row.value):
|
||||||
|
row.value = encrypt_secret(row.value)
|
||||||
|
|
||||||
|
|
||||||
|
def _stored_value_for_setting(key: str, value: Optional[str]) -> Optional[str]:
|
||||||
|
if key in _SECRET_KEYS:
|
||||||
|
return encrypt_secret(value)
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def _plain_value_for_setting(key: str, value: Optional[str]) -> Optional[str]:
|
||||||
|
if key not in _SECRET_KEYS:
|
||||||
|
return value
|
||||||
|
return decrypt_secret(value)
|
||||||
|
|
||||||
|
|
||||||
|
def _audit_value_for_setting(key: str, value: Optional[str]) -> Optional[str]:
|
||||||
|
if key in _SECRET_KEYS:
|
||||||
|
return "[redacted]" if value else ""
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def _should_audit_setting(key: str) -> bool:
|
||||||
|
return key.startswith("notifications.")
|
||||||
|
|
||||||
|
|
||||||
|
def _audit_setting_change(
|
||||||
|
db: Session,
|
||||||
|
*,
|
||||||
|
key: str,
|
||||||
|
old_plain: Optional[str],
|
||||||
|
new_plain: Optional[str],
|
||||||
|
auth_context: Optional[Dict[str, Any]],
|
||||||
|
) -> None:
|
||||||
|
if not _should_audit_setting(key) or old_plain == new_plain:
|
||||||
|
return
|
||||||
|
record_alert_config_change(
|
||||||
|
db,
|
||||||
|
key=key,
|
||||||
|
old_value=_audit_value_for_setting(key, old_plain),
|
||||||
|
new_value=_audit_value_for_setting(key, new_plain),
|
||||||
|
auth_context=auth_context,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _row_to_dict(row: Setting, redact_secrets: bool = True) -> Dict[str, Any]:
|
def _row_to_dict(row: Setting, redact_secrets: bool = True) -> Dict[str, Any]:
|
||||||
value = row.value
|
value = row.value
|
||||||
if redact_secrets and row.key in _SECRET_KEYS and value:
|
if redact_secrets and row.key in _SECRET_KEYS and value:
|
||||||
@@ -307,6 +384,7 @@ class NotificationTestResponse(BaseModel):
|
|||||||
configured_targets: int = 0
|
configured_targets: int = 0
|
||||||
invalid_targets: int = 0
|
invalid_targets: int = 0
|
||||||
skipped: bool = False
|
skipped: bool = False
|
||||||
|
rate_limited: bool = False
|
||||||
error: Optional[str] = None
|
error: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
@@ -329,6 +407,12 @@ class AlertHistoryResponse(BaseModel):
|
|||||||
history: List[Dict[str, Any]]
|
history: List[Dict[str, Any]]
|
||||||
|
|
||||||
|
|
||||||
|
class AlertConfigurationAuditResponse(BaseModel):
|
||||||
|
"""Persisted alert configuration audit response."""
|
||||||
|
|
||||||
|
audit: List[Dict[str, Any]]
|
||||||
|
|
||||||
|
|
||||||
class SummaryResponse(BaseModel):
|
class SummaryResponse(BaseModel):
|
||||||
"""Current DMARC summary notification preview."""
|
"""Current DMARC summary notification preview."""
|
||||||
|
|
||||||
@@ -427,6 +511,16 @@ async def get_notification_alert_history(
|
|||||||
return {"history": list_alert_history(db, active=active, limit=max(1, min(limit, 200)))}
|
return {"history": list_alert_history(db, active=active, limit=max(1, min(limit, 200)))}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/notifications/config-audit", response_model=AlertConfigurationAuditResponse)
|
||||||
|
async def get_notification_config_audit(
|
||||||
|
limit: int = 50,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
_auth: dict = Depends(require_admin_auth),
|
||||||
|
) -> AlertConfigurationAuditResponse:
|
||||||
|
"""Return recent notification and alert-rule configuration changes."""
|
||||||
|
return {"audit": list_alert_config_audit(db, limit=max(1, min(limit, 200)))}
|
||||||
|
|
||||||
|
|
||||||
@router.get("/notifications/summary", response_model=SummaryResponse)
|
@router.get("/notifications/summary", response_model=SummaryResponse)
|
||||||
async def preview_notification_summary(
|
async def preview_notification_summary(
|
||||||
period: str = "daily",
|
period: str = "daily",
|
||||||
@@ -495,23 +589,41 @@ async def update_setting(
|
|||||||
) -> SettingResponse:
|
) -> SettingResponse:
|
||||||
"""Update or create a single setting."""
|
"""Update or create a single setting."""
|
||||||
row = _get_setting(key, db)
|
row = _get_setting(key, db)
|
||||||
|
new_value = payload.value
|
||||||
if row is None:
|
if row is None:
|
||||||
# Find matching default metadata
|
# Find matching default metadata
|
||||||
default_meta = next((d for d in SETTING_DEFAULTS if d["key"] == key), None)
|
default_meta = next((d for d in SETTING_DEFAULTS if d["key"] == key), None)
|
||||||
|
new_plain = _plain_value_for_setting(key, new_value)
|
||||||
row = Setting(
|
row = Setting(
|
||||||
key=key,
|
key=key,
|
||||||
value=payload.value,
|
value=_stored_value_for_setting(key, new_value),
|
||||||
description=default_meta["description"] if default_meta else None,
|
description=default_meta["description"] if default_meta else None,
|
||||||
value_type=default_meta["value_type"] if default_meta else "string",
|
value_type=default_meta["value_type"] if default_meta else "string",
|
||||||
category=default_meta["category"] if default_meta else "general",
|
category=default_meta["category"] if default_meta else "general",
|
||||||
)
|
)
|
||||||
db.add(row)
|
db.add(row)
|
||||||
|
_audit_setting_change(
|
||||||
|
db,
|
||||||
|
key=key,
|
||||||
|
old_plain=None,
|
||||||
|
new_plain=new_plain,
|
||||||
|
auth_context=_auth,
|
||||||
|
)
|
||||||
else:
|
else:
|
||||||
# For secret keys, only update if not the redacted placeholder
|
# For secret keys, only update if not the redacted placeholder
|
||||||
if key in _SECRET_KEYS and payload.value == "**redacted**":
|
if key in _SECRET_KEYS and payload.value == "**redacted**":
|
||||||
db.refresh(row)
|
db.refresh(row)
|
||||||
return _row_to_dict(row)
|
return _row_to_dict(row)
|
||||||
row.value = payload.value
|
old_plain = _plain_value_for_setting(key, row.value)
|
||||||
|
new_plain = _plain_value_for_setting(key, new_value)
|
||||||
|
row.value = _stored_value_for_setting(key, new_value)
|
||||||
|
_audit_setting_change(
|
||||||
|
db,
|
||||||
|
key=key,
|
||||||
|
old_plain=old_plain,
|
||||||
|
new_plain=new_plain,
|
||||||
|
auth_context=_auth,
|
||||||
|
)
|
||||||
db.commit()
|
db.commit()
|
||||||
db.refresh(row)
|
db.refresh(row)
|
||||||
return _row_to_dict(row)
|
return _row_to_dict(row)
|
||||||
@@ -533,20 +645,37 @@ async def bulk_update_settings(
|
|||||||
row = _get_setting(key, db)
|
row = _get_setting(key, db)
|
||||||
if row is None:
|
if row is None:
|
||||||
default_meta = next((d for d in SETTING_DEFAULTS if d["key"] == key), None)
|
default_meta = next((d for d in SETTING_DEFAULTS if d["key"] == key), None)
|
||||||
|
new_plain = _plain_value_for_setting(key, value)
|
||||||
row = Setting(
|
row = Setting(
|
||||||
key=key,
|
key=key,
|
||||||
value=value,
|
value=_stored_value_for_setting(key, value),
|
||||||
description=default_meta["description"] if default_meta else None,
|
description=default_meta["description"] if default_meta else None,
|
||||||
value_type=default_meta["value_type"] if default_meta else "string",
|
value_type=default_meta["value_type"] if default_meta else "string",
|
||||||
category=default_meta["category"] if default_meta else "general",
|
category=default_meta["category"] if default_meta else "general",
|
||||||
)
|
)
|
||||||
db.add(row)
|
db.add(row)
|
||||||
|
_audit_setting_change(
|
||||||
|
db,
|
||||||
|
key=key,
|
||||||
|
old_plain=None,
|
||||||
|
new_plain=new_plain,
|
||||||
|
auth_context=_auth,
|
||||||
|
)
|
||||||
else:
|
else:
|
||||||
# Skip secret placeholder updates
|
# Skip secret placeholder updates
|
||||||
if key in _SECRET_KEYS and value == "**redacted**":
|
if key in _SECRET_KEYS and value == "**redacted**":
|
||||||
results.append(_row_to_dict(row))
|
results.append(_row_to_dict(row))
|
||||||
continue
|
continue
|
||||||
row.value = value
|
old_plain = _plain_value_for_setting(key, row.value)
|
||||||
|
new_plain = _plain_value_for_setting(key, value)
|
||||||
|
row.value = _stored_value_for_setting(key, value)
|
||||||
|
_audit_setting_change(
|
||||||
|
db,
|
||||||
|
key=key,
|
||||||
|
old_plain=old_plain,
|
||||||
|
new_plain=new_plain,
|
||||||
|
auth_context=_auth,
|
||||||
|
)
|
||||||
results.append(_row_to_dict(row))
|
results.append(_row_to_dict(row))
|
||||||
db.commit()
|
db.commit()
|
||||||
# Re-read rows to get updated_at timestamps
|
# Re-read rows to get updated_at timestamps
|
||||||
|
|||||||
@@ -31,3 +31,22 @@ class AlertHistory(Base):
|
|||||||
|
|
||||||
def __repr__(self):
|
def __repr__(self):
|
||||||
return f"<AlertHistory {self.rule} active={self.is_active}>"
|
return f"<AlertHistory {self.rule} active={self.is_active}>"
|
||||||
|
|
||||||
|
|
||||||
|
class AlertConfigurationAudit(Base):
|
||||||
|
"""Audit trail for notification and alert-rule configuration changes."""
|
||||||
|
|
||||||
|
__tablename__ = "alert_configuration_audit"
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
|
key = Column(String(100), nullable=False, index=True)
|
||||||
|
old_value = Column(Text, nullable=True)
|
||||||
|
new_value = Column(Text, nullable=True)
|
||||||
|
changed_by = Column(String(100), nullable=True, index=True)
|
||||||
|
auth_type = Column(String(50), nullable=True)
|
||||||
|
changed_at = Column(DateTime, default=datetime.utcnow, nullable=False, index=True)
|
||||||
|
|
||||||
|
__table_args__ = (Index("ix_alert_configuration_audit_key_changed_at", "key", "changed_at"),)
|
||||||
|
|
||||||
|
def __repr__(self):
|
||||||
|
return f"<AlertConfigurationAudit {self.key}>"
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ from typing import Any, Dict, List, Optional
|
|||||||
|
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app.models.alert import AlertHistory
|
from app.models.alert import AlertConfigurationAudit, AlertHistory
|
||||||
|
|
||||||
|
|
||||||
def _json_dumps(value: Dict[str, Any]) -> str:
|
def _json_dumps(value: Dict[str, Any]) -> str:
|
||||||
@@ -125,3 +125,64 @@ def list_alert_history(
|
|||||||
query.order_by(AlertHistory.last_seen_at.desc(), AlertHistory.id.desc()).limit(limit).all()
|
query.order_by(AlertHistory.last_seen_at.desc(), AlertHistory.id.desc()).limit(limit).all()
|
||||||
)
|
)
|
||||||
return [_row_to_dict(row) for row in rows]
|
return [_row_to_dict(row) for row in rows]
|
||||||
|
|
||||||
|
|
||||||
|
def _actor_from_auth(auth_context: Optional[Dict[str, Any]]) -> Dict[str, Optional[str]]:
|
||||||
|
auth_context = auth_context or {}
|
||||||
|
user_id = auth_context.get("user_id")
|
||||||
|
if user_id is not None:
|
||||||
|
changed_by = str(user_id)
|
||||||
|
elif auth_context.get("payload", {}).get("sub"):
|
||||||
|
changed_by = str(auth_context["payload"]["sub"])
|
||||||
|
else:
|
||||||
|
changed_by = str(auth_context.get("auth_type") or "unknown")
|
||||||
|
return {
|
||||||
|
"changed_by": changed_by,
|
||||||
|
"auth_type": str(auth_context.get("auth_type") or "unknown"),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def record_alert_config_change(
|
||||||
|
db: Session,
|
||||||
|
*,
|
||||||
|
key: str,
|
||||||
|
old_value: Optional[str],
|
||||||
|
new_value: Optional[str],
|
||||||
|
auth_context: Optional[Dict[str, Any]] = None,
|
||||||
|
changed_at: Optional[datetime] = None,
|
||||||
|
) -> None:
|
||||||
|
"""Record one sanitized alert/notification setting change."""
|
||||||
|
actor = _actor_from_auth(auth_context)
|
||||||
|
db.add(
|
||||||
|
AlertConfigurationAudit(
|
||||||
|
key=key,
|
||||||
|
old_value=old_value,
|
||||||
|
new_value=new_value,
|
||||||
|
changed_by=actor["changed_by"],
|
||||||
|
auth_type=actor["auth_type"],
|
||||||
|
changed_at=changed_at or datetime.utcnow(),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _config_audit_row_to_dict(row: AlertConfigurationAudit) -> Dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"id": row.id,
|
||||||
|
"key": row.key,
|
||||||
|
"old_value": row.old_value,
|
||||||
|
"new_value": row.new_value,
|
||||||
|
"changed_by": row.changed_by,
|
||||||
|
"auth_type": row.auth_type,
|
||||||
|
"changed_at": row.changed_at.isoformat() if row.changed_at else None,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def list_alert_config_audit(db: Session, *, limit: int = 50) -> List[Dict[str, Any]]:
|
||||||
|
"""Return recent alert/notification configuration changes."""
|
||||||
|
rows = (
|
||||||
|
db.query(AlertConfigurationAudit)
|
||||||
|
.order_by(AlertConfigurationAudit.changed_at.desc(), AlertConfigurationAudit.id.desc())
|
||||||
|
.limit(limit)
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
return [_config_audit_row_to_dict(row) for row in rows]
|
||||||
|
|||||||
@@ -4,11 +4,13 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import logging
|
import logging
|
||||||
from dataclasses import asdict, dataclass
|
from dataclasses import asdict, dataclass
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
from typing import Dict, List, Optional, Tuple
|
from typing import Dict, List, Optional, Tuple
|
||||||
|
|
||||||
import apprise
|
import apprise
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.core.credential_encryption import decrypt_secret
|
||||||
from app.models.setting import Setting
|
from app.models.setting import Setting
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -23,14 +25,17 @@ class NotificationResult:
|
|||||||
configured_targets: int = 0
|
configured_targets: int = 0
|
||||||
invalid_targets: int = 0
|
invalid_targets: int = 0
|
||||||
skipped: bool = False
|
skipped: bool = False
|
||||||
|
rate_limited: bool = False
|
||||||
error: Optional[str] = None
|
error: Optional[str] = None
|
||||||
|
|
||||||
def to_dict(self) -> Dict[str, object]:
|
def to_dict(self) -> Dict[str, object]:
|
||||||
return asdict(self)
|
return asdict(self)
|
||||||
|
|
||||||
|
|
||||||
def _truthy(value: Optional[str]) -> bool:
|
def _truthy(value: Optional[str], default: bool = False) -> bool:
|
||||||
return str(value or "").strip().lower() in {"1", "true", "yes", "on"}
|
if value in (None, ""):
|
||||||
|
return default
|
||||||
|
return str(value).strip().lower() in {"1", "true", "yes", "on"}
|
||||||
|
|
||||||
|
|
||||||
def _split_apprise_urls(value: Optional[str]) -> List[str]:
|
def _split_apprise_urls(value: Optional[str]) -> List[str]:
|
||||||
@@ -48,6 +53,130 @@ def _notification_settings(db: Session) -> Dict[str, Optional[str]]:
|
|||||||
return {row.key: row.value for row in rows}
|
return {row.key: row.value for row in rows}
|
||||||
|
|
||||||
|
|
||||||
|
def _decrypted_setting(settings: Dict[str, Optional[str]], key: str) -> Optional[str]:
|
||||||
|
try:
|
||||||
|
return decrypt_secret(settings.get(key))
|
||||||
|
except ValueError:
|
||||||
|
logger.exception("Encrypted notification setting could not be decrypted: %s", key)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _int_setting(value: Optional[str], default: int) -> int:
|
||||||
|
try:
|
||||||
|
return int(value or default)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return default
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_timestamp(value: Optional[str]) -> Optional[datetime]:
|
||||||
|
if not value:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
parsed = datetime.fromisoformat(value.replace("Z", "+00:00"))
|
||||||
|
except ValueError:
|
||||||
|
return None
|
||||||
|
if parsed.tzinfo is None:
|
||||||
|
parsed = parsed.replace(tzinfo=timezone.utc)
|
||||||
|
return parsed.astimezone(timezone.utc)
|
||||||
|
|
||||||
|
|
||||||
|
def _set_notification_setting(db: Session, key: str, value: str) -> None:
|
||||||
|
row = db.query(Setting).filter(Setting.key == key).first()
|
||||||
|
if row is None:
|
||||||
|
row = Setting(
|
||||||
|
key=key,
|
||||||
|
value=value,
|
||||||
|
value_type="string",
|
||||||
|
category="notifications",
|
||||||
|
)
|
||||||
|
db.add(row)
|
||||||
|
else:
|
||||||
|
row.value = value
|
||||||
|
|
||||||
|
|
||||||
|
def _rate_limit_result(settings: Dict[str, Optional[str]]) -> Optional[NotificationResult]:
|
||||||
|
interval_minutes = max(
|
||||||
|
0, _int_setting(settings.get("notifications.min_send_interval_minutes"), 15)
|
||||||
|
)
|
||||||
|
if interval_minutes <= 0:
|
||||||
|
return None
|
||||||
|
|
||||||
|
last_sent_at = _parse_timestamp(settings.get("notifications.last_sent_at"))
|
||||||
|
if last_sent_at is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
next_allowed = last_sent_at + timedelta(minutes=interval_minutes)
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
if now >= next_allowed:
|
||||||
|
return None
|
||||||
|
|
||||||
|
retry_after = max(1, int((next_allowed - now).total_seconds() // 60) + 1)
|
||||||
|
return NotificationResult(
|
||||||
|
success=False,
|
||||||
|
skipped=True,
|
||||||
|
rate_limited=True,
|
||||||
|
message=f"Notification rate limit active. Try again in about {retry_after} minute(s).",
|
||||||
|
error="rate_limited",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
_EMAIL_LOCAL_CHARS = frozenset(
|
||||||
|
"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789._%+-"
|
||||||
|
)
|
||||||
|
_EMAIL_DOMAIN_CHARS = frozenset("ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789.-")
|
||||||
|
_EMAIL_LEADING_PUNCTUATION = frozenset("\"'(<[{")
|
||||||
|
_EMAIL_TRAILING_PUNCTUATION = frozenset("\"'.,;:!?)]}>")
|
||||||
|
|
||||||
|
|
||||||
|
def _redact_email_token(token: str) -> str:
|
||||||
|
leading = ""
|
||||||
|
trailing = ""
|
||||||
|
core = token
|
||||||
|
|
||||||
|
while core and core[0] in _EMAIL_LEADING_PUNCTUATION:
|
||||||
|
leading += core[0]
|
||||||
|
core = core[1:]
|
||||||
|
while core and core[-1] in _EMAIL_TRAILING_PUNCTUATION:
|
||||||
|
trailing = core[-1] + trailing
|
||||||
|
core = core[:-1]
|
||||||
|
|
||||||
|
if core.count("@") != 1:
|
||||||
|
return token
|
||||||
|
|
||||||
|
local_part, domain_part = core.split("@", 1)
|
||||||
|
domain_labels = domain_part.split(".")
|
||||||
|
if (
|
||||||
|
not local_part
|
||||||
|
or not domain_part
|
||||||
|
or len(domain_labels) < 2
|
||||||
|
or len(domain_labels[-1]) < 2
|
||||||
|
or not domain_labels[-1].isalpha()
|
||||||
|
or any(not label for label in domain_labels)
|
||||||
|
or any(char not in _EMAIL_LOCAL_CHARS for char in local_part)
|
||||||
|
or any(char not in _EMAIL_DOMAIN_CHARS for char in domain_part)
|
||||||
|
):
|
||||||
|
return token
|
||||||
|
|
||||||
|
return f"{leading}[redacted-email]@{domain_part}{trailing}"
|
||||||
|
|
||||||
|
|
||||||
|
def redact_notification_text(value: str) -> str:
|
||||||
|
"""Remove common PII from outbound notification text."""
|
||||||
|
redacted = []
|
||||||
|
token = []
|
||||||
|
for char in value:
|
||||||
|
if char.isspace():
|
||||||
|
if token:
|
||||||
|
redacted.append(_redact_email_token("".join(token)))
|
||||||
|
token = []
|
||||||
|
redacted.append(char)
|
||||||
|
else:
|
||||||
|
token.append(char)
|
||||||
|
if token:
|
||||||
|
redacted.append(_redact_email_token("".join(token)))
|
||||||
|
return "".join(redacted)
|
||||||
|
|
||||||
|
|
||||||
def _add_apprise_targets(notifier: apprise.Apprise, urls: List[str]) -> Tuple[int, int]:
|
def _add_apprise_targets(notifier: apprise.Apprise, urls: List[str]) -> Tuple[int, int]:
|
||||||
configured_targets = 0
|
configured_targets = 0
|
||||||
invalid_targets = 0
|
invalid_targets = 0
|
||||||
@@ -81,7 +210,11 @@ def send_notification(
|
|||||||
message="Notifications are disabled.",
|
message="Notifications are disabled.",
|
||||||
)
|
)
|
||||||
|
|
||||||
urls = _split_apprise_urls(settings.get("notifications.apprise_urls"))
|
rate_limit = None if force else _rate_limit_result(settings)
|
||||||
|
if rate_limit:
|
||||||
|
return rate_limit
|
||||||
|
|
||||||
|
urls = _split_apprise_urls(_decrypted_setting(settings, "notifications.apprise_urls"))
|
||||||
if not urls:
|
if not urls:
|
||||||
return NotificationResult(
|
return NotificationResult(
|
||||||
success=False,
|
success=False,
|
||||||
@@ -99,6 +232,9 @@ def send_notification(
|
|||||||
)
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
if _truthy(settings.get("notifications.redact_pii_enabled"), default=True):
|
||||||
|
title = redact_notification_text(title)
|
||||||
|
body = redact_notification_text(body)
|
||||||
success = bool(notifier.notify(title=title, body=body))
|
success = bool(notifier.notify(title=title, body=body))
|
||||||
except Exception: # pylint: disable=broad-exception-caught
|
except Exception: # pylint: disable=broad-exception-caught
|
||||||
logger.exception("Apprise notification delivery failed.")
|
logger.exception("Apprise notification delivery failed.")
|
||||||
@@ -119,6 +255,13 @@ def send_notification(
|
|||||||
error="not_delivered",
|
error="not_delivered",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
_set_notification_setting(
|
||||||
|
db,
|
||||||
|
"notifications.last_sent_at",
|
||||||
|
datetime.now(timezone.utc).isoformat(),
|
||||||
|
)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
return NotificationResult(
|
return NotificationResult(
|
||||||
success=True,
|
success=True,
|
||||||
message="Notification sent.",
|
message="Notification sent.",
|
||||||
|
|||||||
@@ -229,12 +229,29 @@
|
|||||||
<textarea x-model="s['notifications.apprise_urls']"
|
<textarea x-model="s['notifications.apprise_urls']"
|
||||||
class="textarea textarea-bordered w-full min-h-32 font-mono text-sm"
|
class="textarea textarea-bordered w-full min-h-32 font-mono text-sm"
|
||||||
placeholder="mailto://user:password@example.com slack://token-a/token-b/token-c/channel"></textarea>
|
placeholder="mailto://user:password@example.com slack://token-a/token-b/token-c/channel"></textarea>
|
||||||
<label class="label"><span class="label-text-alt text-muted-foreground">One target per line. Values are redacted after saving.</span></label>
|
<label class="label"><span class="label-text-alt text-muted-foreground">One target per line. Values are encrypted at rest and redacted after saving.</span></label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<div class="form-control w-full">
|
||||||
|
<label class="label"><span class="label-text font-medium">Minimum Send Interval</span></label>
|
||||||
|
<input type="number" x-model.number="s['notifications.min_send_interval_minutes']"
|
||||||
|
class="input input-bordered w-full" min="0" />
|
||||||
|
</div>
|
||||||
|
<div class="form-control">
|
||||||
|
<label class="label cursor-pointer justify-start gap-3">
|
||||||
|
<input type="checkbox"
|
||||||
|
:checked="s['notifications.redact_pii_enabled'] === 'true'"
|
||||||
|
@change="s['notifications.redact_pii_enabled'] = $event.target.checked ? 'true' : 'false'"
|
||||||
|
class="checkbox checkbox-primary" />
|
||||||
|
<span class="label-text font-medium">Redact email addresses in messages</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="border-t border-border pt-4 space-y-4">
|
<div class="border-t border-border pt-4 space-y-4">
|
||||||
<div>
|
<div>
|
||||||
<h3 class="text-sm font-semibold">Alert Rules</h3>
|
<h3 class="text-sm font-semibold">Alert Rules</h3>
|
||||||
@@ -409,6 +426,44 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="border-t border-border pt-4 space-y-4">
|
||||||
|
<div class="flex items-center justify-between gap-3">
|
||||||
|
<h3 class="text-sm font-semibold">Configuration Audit</h3>
|
||||||
|
<button type="button" class="btn btn-outline btn-sm" :disabled="loadingConfigAudit" @click="loadConfigAudit()">
|
||||||
|
<template x-if="!loadingConfigAudit">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none"
|
||||||
|
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="mr-2">
|
||||||
|
<path d="M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8"></path>
|
||||||
|
<path d="M3 3v5h5"></path>
|
||||||
|
<path d="M12 7v5l4 2"></path>
|
||||||
|
</svg>
|
||||||
|
</template>
|
||||||
|
<template x-if="loadingConfigAudit"><span class="loading loading-spinner loading-xs mr-2"></span></template>
|
||||||
|
Refresh
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<template x-if="configAudit.length === 0">
|
||||||
|
<div class="alert alert-info">
|
||||||
|
<span>No configuration changes recorded yet.</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<div class="space-y-2" x-show="configAudit.length > 0">
|
||||||
|
<template x-for="item in configAudit" :key="item.id">
|
||||||
|
<div class="rounded-md border border-border p-3">
|
||||||
|
<div class="flex flex-col gap-1 sm:flex-row sm:items-center sm:justify-between">
|
||||||
|
<div>
|
||||||
|
<div class="text-sm font-semibold" x-text="item.key"></div>
|
||||||
|
<div class="text-sm text-muted-foreground" x-text="`${item.old_value || '(empty)'} -> ${item.new_value || '(empty)'}`"></div>
|
||||||
|
</div>
|
||||||
|
<div class="text-xs text-muted-foreground" x-text="new Date(item.changed_at).toLocaleString()"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="flex flex-col sm:flex-row justify-end gap-2">
|
<div class="flex flex-col sm:flex-row justify-end gap-2">
|
||||||
<button type="button" class="btn btn-outline btn-md" :disabled="saving || previewingSummary" @click="previewSummary()">
|
<button type="button" class="btn btn-outline btn-md" :disabled="saving || previewingSummary" @click="previewSummary()">
|
||||||
<template x-if="!previewingSummary">
|
<template x-if="!previewingSummary">
|
||||||
@@ -520,6 +575,8 @@ function settingsApp() {
|
|||||||
summaryPreview: null,
|
summaryPreview: null,
|
||||||
loadingAlertHistory: false,
|
loadingAlertHistory: false,
|
||||||
alertHistory: [],
|
alertHistory: [],
|
||||||
|
loadingConfigAudit: false,
|
||||||
|
configAudit: [],
|
||||||
showCfToken: false,
|
showCfToken: false,
|
||||||
|
|
||||||
// Session cookie is sent automatically by the browser (httpOnly, same-origin).
|
// Session cookie is sent automatically by the browser (httpOnly, same-origin).
|
||||||
@@ -544,6 +601,7 @@ function settingsApp() {
|
|||||||
rows.forEach(r => { map[r.key] = r.value ?? ''; });
|
rows.forEach(r => { map[r.key] = r.value ?? ''; });
|
||||||
this.s = map;
|
this.s = map;
|
||||||
await this.loadAlertHistory(false);
|
await this.loadAlertHistory(false);
|
||||||
|
await this.loadConfigAudit(false);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
this.showFlash('Error loading settings: ' + err.message, false);
|
this.showFlash('Error loading settings: ' + err.message, false);
|
||||||
}
|
}
|
||||||
@@ -567,6 +625,9 @@ function settingsApp() {
|
|||||||
} else {
|
} else {
|
||||||
const rows = await res.json();
|
const rows = await res.json();
|
||||||
rows.forEach(r => { this.s[r.key] = r.value ?? ''; });
|
rows.forEach(r => { this.s[r.key] = r.value ?? ''; });
|
||||||
|
if (category === 'notifications') {
|
||||||
|
await this.loadConfigAudit(false);
|
||||||
|
}
|
||||||
this.showFlash('Settings saved successfully.', true);
|
this.showFlash('Settings saved successfully.', true);
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -717,6 +778,32 @@ function settingsApp() {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
async loadConfigAudit(showMessage = true) {
|
||||||
|
this.loadingConfigAudit = true;
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/v1/settings/notifications/config-audit?limit=10', {
|
||||||
|
headers: this.apiHeaders(),
|
||||||
|
});
|
||||||
|
const data = await res.json().catch(() => ({}));
|
||||||
|
if (!res.ok) {
|
||||||
|
if (showMessage) {
|
||||||
|
this.showFlash('Configuration audit failed: ' + (data.detail || res.statusText), false);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
this.configAudit = data.audit || [];
|
||||||
|
if (showMessage) {
|
||||||
|
this.showFlash('Configuration audit refreshed.', true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
if (showMessage) {
|
||||||
|
this.showFlash('Error loading configuration audit: ' + err.message, false);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
this.loadingConfigAudit = false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
showFlash(msg, ok) {
|
showFlash(msg, ok) {
|
||||||
this.flashMsg = msg;
|
this.flashMsg = msg;
|
||||||
this.flashOk = ok;
|
this.flashOk = ok;
|
||||||
|
|||||||
@@ -7,11 +7,13 @@ from datetime import datetime, timedelta, timezone
|
|||||||
from fastapi.testclient import TestClient
|
from fastapi.testclient import TestClient
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app.models.alert import AlertHistory
|
from app.core.credential_encryption import decrypt_secret, is_encrypted_secret
|
||||||
|
from app.models.alert import AlertConfigurationAudit, AlertHistory
|
||||||
from app.models.domain import Domain
|
from app.models.domain import Domain
|
||||||
from app.models.report import DMARCReport, ReportRecord
|
from app.models.report import DMARCReport, ReportRecord
|
||||||
from app.models.setting import Setting
|
from app.models.setting import Setting
|
||||||
from app.services.notifications import NotificationResult
|
from app.services.alert_history import list_alert_config_audit, record_alert_config_change
|
||||||
|
from app.services.notifications import NotificationResult, send_notification
|
||||||
from app.services.summary_notifications import send_due_scheduled_summaries
|
from app.services.summary_notifications import send_due_scheduled_summaries
|
||||||
|
|
||||||
|
|
||||||
@@ -88,6 +90,13 @@ class TestSettingModel:
|
|||||||
assert "dns.resolver" in repr(row)
|
assert "dns.resolver" in repr(row)
|
||||||
assert "dns" in repr(row)
|
assert "dns" in repr(row)
|
||||||
|
|
||||||
|
def test_alert_reprs(self):
|
||||||
|
alert = AlertHistory(rule="missing_reports", is_active=True)
|
||||||
|
audit = AlertConfigurationAudit(key="notifications.apprise_enabled")
|
||||||
|
|
||||||
|
assert "missing_reports" in repr(alert)
|
||||||
|
assert "notifications.apprise_enabled" in repr(audit)
|
||||||
|
|
||||||
|
|
||||||
class TestSettingsAPI:
|
class TestSettingsAPI:
|
||||||
"""Integration tests for /api/v1/settings endpoints."""
|
"""Integration tests for /api/v1/settings endpoints."""
|
||||||
@@ -112,6 +121,8 @@ class TestSettingsAPI:
|
|||||||
assert "notifications.summary_weekly_enabled" in keys
|
assert "notifications.summary_weekly_enabled" in keys
|
||||||
assert "notifications.summary_send_hour_utc" in keys
|
assert "notifications.summary_send_hour_utc" in keys
|
||||||
assert "notifications.summary_weekday_utc" in keys
|
assert "notifications.summary_weekday_utc" in keys
|
||||||
|
assert "notifications.min_send_interval_minutes" in keys
|
||||||
|
assert "notifications.redact_pii_enabled" in keys
|
||||||
|
|
||||||
def test_list_settings_filter_by_category(self, authed_client: TestClient):
|
def test_list_settings_filter_by_category(self, authed_client: TestClient):
|
||||||
"""GET /api/v1/settings?category=dmarc returns only dmarc settings."""
|
"""GET /api/v1/settings?category=dmarc returns only dmarc settings."""
|
||||||
@@ -199,6 +210,48 @@ class TestSettingsAPI:
|
|||||||
assert res.status_code == 200
|
assert res.status_code == 200
|
||||||
assert res.json()["value"] == "**redacted**"
|
assert res.json()["value"] == "**redacted**"
|
||||||
|
|
||||||
|
def test_apprise_urls_are_encrypted_at_rest(
|
||||||
|
self,
|
||||||
|
authed_client: TestClient,
|
||||||
|
db_session: Session,
|
||||||
|
):
|
||||||
|
"""Apprise target URLs are encrypted in the settings table."""
|
||||||
|
target = "mailto://user:password@example.com"
|
||||||
|
authed_client.get("/api/v1/settings")
|
||||||
|
|
||||||
|
res = authed_client.put(
|
||||||
|
"/api/v1/settings/notifications.apprise_urls",
|
||||||
|
json={"value": target},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert res.status_code == 200
|
||||||
|
row = db_session.query(Setting).filter(Setting.key == "notifications.apprise_urls").first()
|
||||||
|
assert row is not None
|
||||||
|
assert row.value != target
|
||||||
|
assert is_encrypted_secret(row.value)
|
||||||
|
assert decrypt_secret(row.value) == target
|
||||||
|
|
||||||
|
def test_legacy_plaintext_secret_setting_is_migrated_on_read(
|
||||||
|
self,
|
||||||
|
authed_client: TestClient,
|
||||||
|
db_session: Session,
|
||||||
|
):
|
||||||
|
"""Plaintext secret settings are encrypted the next time defaults are seeded."""
|
||||||
|
db_session.add(
|
||||||
|
Setting(
|
||||||
|
key="notifications.apprise_urls",
|
||||||
|
value="mailto://user:password@example.com",
|
||||||
|
category="notifications",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
db_session.commit()
|
||||||
|
|
||||||
|
res = authed_client.get("/api/v1/settings")
|
||||||
|
|
||||||
|
assert res.status_code == 200
|
||||||
|
row = db_session.query(Setting).filter(Setting.key == "notifications.apprise_urls").first()
|
||||||
|
assert is_encrypted_secret(row.value)
|
||||||
|
|
||||||
def test_redacted_placeholder_does_not_overwrite(self, authed_client: TestClient):
|
def test_redacted_placeholder_does_not_overwrite(self, authed_client: TestClient):
|
||||||
"""Sending **redacted** back to PUT should not overwrite the stored value."""
|
"""Sending **redacted** back to PUT should not overwrite the stored value."""
|
||||||
authed_client.get("/api/v1/settings")
|
authed_client.get("/api/v1/settings")
|
||||||
@@ -264,6 +317,204 @@ class TestSettingsAPI:
|
|||||||
assert "password" not in str(data)
|
assert "password" not in str(data)
|
||||||
assert FakeApprise.instances[0].messages[0]["title"] == "DMARQ test notification"
|
assert FakeApprise.instances[0].messages[0]["title"] == "DMARQ test notification"
|
||||||
|
|
||||||
|
def test_notification_delivery_is_rate_limited(
|
||||||
|
self,
|
||||||
|
db_session: Session,
|
||||||
|
monkeypatch,
|
||||||
|
):
|
||||||
|
"""Non-forced notification sends respect the configured cooldown."""
|
||||||
|
|
||||||
|
class FakeApprise:
|
||||||
|
instances = []
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self.messages = []
|
||||||
|
FakeApprise.instances.append(self)
|
||||||
|
|
||||||
|
def add(self, url):
|
||||||
|
return True
|
||||||
|
|
||||||
|
def notify(self, *, title, body):
|
||||||
|
self.messages.append({"title": title, "body": body})
|
||||||
|
return True
|
||||||
|
|
||||||
|
monkeypatch.setattr("app.services.notifications.apprise.Apprise", FakeApprise)
|
||||||
|
db_session.add_all(
|
||||||
|
[
|
||||||
|
Setting(
|
||||||
|
key="notifications.apprise_enabled",
|
||||||
|
value="true",
|
||||||
|
category="notifications",
|
||||||
|
),
|
||||||
|
Setting(
|
||||||
|
key="notifications.apprise_urls",
|
||||||
|
value="mailto://user:password@example.com",
|
||||||
|
category="notifications",
|
||||||
|
),
|
||||||
|
Setting(
|
||||||
|
key="notifications.min_send_interval_minutes",
|
||||||
|
value="15",
|
||||||
|
category="notifications",
|
||||||
|
),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
db_session.commit()
|
||||||
|
|
||||||
|
first = send_notification(db_session, title="First", body="First body")
|
||||||
|
second = send_notification(db_session, title="Second", body="Second body")
|
||||||
|
|
||||||
|
assert first.success is True
|
||||||
|
assert second.success is False
|
||||||
|
assert second.rate_limited is True
|
||||||
|
assert second.error == "rate_limited"
|
||||||
|
assert sum(len(instance.messages) for instance in FakeApprise.instances) == 1
|
||||||
|
|
||||||
|
def test_notification_delivery_edges_are_sanitized(
|
||||||
|
self,
|
||||||
|
db_session: Session,
|
||||||
|
monkeypatch,
|
||||||
|
):
|
||||||
|
"""Notification delivery handles disabled, invalid, and failed target paths."""
|
||||||
|
|
||||||
|
class FalseApprise:
|
||||||
|
def add(self, url):
|
||||||
|
return False
|
||||||
|
|
||||||
|
class RaisingApprise:
|
||||||
|
def add(self, url):
|
||||||
|
return True
|
||||||
|
|
||||||
|
def notify(self, *, title, body):
|
||||||
|
raise RuntimeError("delivery failed")
|
||||||
|
|
||||||
|
class NotDeliveredApprise:
|
||||||
|
def add(self, url):
|
||||||
|
return True
|
||||||
|
|
||||||
|
def notify(self, *, title, body):
|
||||||
|
return False
|
||||||
|
|
||||||
|
assert send_notification(db_session, title="Off", body="Body").skipped is True
|
||||||
|
|
||||||
|
db_session.add_all(
|
||||||
|
[
|
||||||
|
Setting(
|
||||||
|
key="notifications.apprise_enabled",
|
||||||
|
value="true",
|
||||||
|
category="notifications",
|
||||||
|
),
|
||||||
|
Setting(
|
||||||
|
key="notifications.apprise_urls",
|
||||||
|
value="mailto://user:password@example.com",
|
||||||
|
category="notifications",
|
||||||
|
),
|
||||||
|
Setting(
|
||||||
|
key="notifications.min_send_interval_minutes",
|
||||||
|
value="not-an-integer",
|
||||||
|
category="notifications",
|
||||||
|
),
|
||||||
|
Setting(
|
||||||
|
key="notifications.last_sent_at",
|
||||||
|
value="not-a-date",
|
||||||
|
category="notifications",
|
||||||
|
),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
db_session.commit()
|
||||||
|
|
||||||
|
monkeypatch.setattr("app.services.notifications.apprise.Apprise", FalseApprise)
|
||||||
|
invalid = send_notification(db_session, title="Invalid", body="Body")
|
||||||
|
assert invalid.success is False
|
||||||
|
assert invalid.invalid_targets == 1
|
||||||
|
|
||||||
|
monkeypatch.setattr("app.services.notifications.apprise.Apprise", RaisingApprise)
|
||||||
|
failed = send_notification(db_session, title="Raises", body="Body")
|
||||||
|
assert failed.error == "delivery_failed"
|
||||||
|
|
||||||
|
monkeypatch.setattr("app.services.notifications.apprise.Apprise", NotDeliveredApprise)
|
||||||
|
not_delivered = send_notification(db_session, title="No", body="Body")
|
||||||
|
assert not_delivered.error == "not_delivered"
|
||||||
|
|
||||||
|
def test_notification_decrypt_error_returns_no_targets(
|
||||||
|
self,
|
||||||
|
db_session: Session,
|
||||||
|
monkeypatch,
|
||||||
|
):
|
||||||
|
"""Unreadable encrypted target settings fail closed without exposing secrets."""
|
||||||
|
db_session.add_all(
|
||||||
|
[
|
||||||
|
Setting(
|
||||||
|
key="notifications.apprise_enabled",
|
||||||
|
value="true",
|
||||||
|
category="notifications",
|
||||||
|
),
|
||||||
|
Setting(
|
||||||
|
key="notifications.apprise_urls",
|
||||||
|
value="enc:v1:bad-token",
|
||||||
|
category="notifications",
|
||||||
|
),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
db_session.commit()
|
||||||
|
|
||||||
|
def raise_value_error(value): # pylint: disable=unused-argument
|
||||||
|
raise ValueError("bad token")
|
||||||
|
|
||||||
|
monkeypatch.setattr("app.services.notifications.decrypt_secret", raise_value_error)
|
||||||
|
|
||||||
|
result = send_notification(db_session, title="Bad secret", body="Body")
|
||||||
|
|
||||||
|
assert result.success is False
|
||||||
|
assert result.message == "No notification targets are configured."
|
||||||
|
|
||||||
|
def test_notification_delivery_redacts_email_addresses(
|
||||||
|
self,
|
||||||
|
db_session: Session,
|
||||||
|
monkeypatch,
|
||||||
|
):
|
||||||
|
"""Outbound notification text redacts email addresses by default."""
|
||||||
|
|
||||||
|
class FakeApprise:
|
||||||
|
messages = []
|
||||||
|
|
||||||
|
def add(self, url):
|
||||||
|
return True
|
||||||
|
|
||||||
|
def notify(self, *, title, body):
|
||||||
|
self.messages.append({"title": title, "body": body})
|
||||||
|
return True
|
||||||
|
|
||||||
|
monkeypatch.setattr("app.services.notifications.apprise.Apprise", FakeApprise)
|
||||||
|
db_session.add_all(
|
||||||
|
[
|
||||||
|
Setting(
|
||||||
|
key="notifications.apprise_enabled",
|
||||||
|
value="true",
|
||||||
|
category="notifications",
|
||||||
|
),
|
||||||
|
Setting(
|
||||||
|
key="notifications.apprise_urls",
|
||||||
|
value="mailto://user:password@example.com",
|
||||||
|
category="notifications",
|
||||||
|
),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
db_session.commit()
|
||||||
|
|
||||||
|
result = send_notification(
|
||||||
|
db_session,
|
||||||
|
title="Failure for admin@example.com",
|
||||||
|
body="Sample from alice@example.com failed DMARC.",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result.success is True
|
||||||
|
assert "admin@example.com" not in FakeApprise.messages[0]["title"]
|
||||||
|
assert "alice@example.com" not in FakeApprise.messages[0]["body"]
|
||||||
|
assert "[redacted-email]@example.com" in FakeApprise.messages[0]["body"]
|
||||||
|
redact_text = send_notification.__globals__["redact_notification_text"]
|
||||||
|
assert redact_text('"admin@example.com!"').strip('"') == "[redacted-email]@example.com!"
|
||||||
|
assert redact_text("not-an-email") == "not-an-email"
|
||||||
|
|
||||||
def test_test_notification_without_targets_returns_400(self, authed_client: TestClient):
|
def test_test_notification_without_targets_returns_400(self, authed_client: TestClient):
|
||||||
"""Test notification returns a useful error when no target is configured."""
|
"""Test notification returns a useful error when no target is configured."""
|
||||||
authed_client.get("/api/v1/settings")
|
authed_client.get("/api/v1/settings")
|
||||||
@@ -469,6 +720,128 @@ class TestSettingsAPI:
|
|||||||
assert resolved
|
assert resolved
|
||||||
assert all(item["is_active"] is False for item in resolved)
|
assert all(item["is_active"] is False for item in resolved)
|
||||||
|
|
||||||
|
def test_notification_alert_send_failure_returns_400(
|
||||||
|
self,
|
||||||
|
authed_client: TestClient,
|
||||||
|
monkeypatch,
|
||||||
|
):
|
||||||
|
"""Alert send endpoint surfaces notification delivery failures."""
|
||||||
|
|
||||||
|
def fake_send_current_alerts(db): # pylint: disable=unused-argument
|
||||||
|
return {
|
||||||
|
"alerts": [{"title": "Alert", "detail": "Detail"}],
|
||||||
|
"notification": {
|
||||||
|
"success": False,
|
||||||
|
"message": "No valid notification targets are configured.",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"app.api.api_v1.endpoints.settings.send_current_alerts",
|
||||||
|
fake_send_current_alerts,
|
||||||
|
)
|
||||||
|
|
||||||
|
res = authed_client.post("/api/v1/settings/notifications/alerts/send")
|
||||||
|
|
||||||
|
assert res.status_code == 400
|
||||||
|
assert res.json()["detail"]["notification"]["success"] is False
|
||||||
|
|
||||||
|
def test_notification_config_audit_records_sanitized_changes(
|
||||||
|
self,
|
||||||
|
authed_client: TestClient,
|
||||||
|
):
|
||||||
|
"""Notification setting changes create an audit trail without secret values."""
|
||||||
|
authed_client.get("/api/v1/settings")
|
||||||
|
|
||||||
|
res = authed_client.post(
|
||||||
|
"/api/v1/settings/bulk",
|
||||||
|
json={
|
||||||
|
"settings": {
|
||||||
|
"notifications.apprise_enabled": "true",
|
||||||
|
"notifications.apprise_urls": "mailto://user:password@example.com",
|
||||||
|
"notifications.alert_failure_threshold_count": "250",
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert res.status_code == 200
|
||||||
|
audit_res = authed_client.get("/api/v1/settings/notifications/config-audit")
|
||||||
|
assert audit_res.status_code == 200
|
||||||
|
audit = audit_res.json()["audit"]
|
||||||
|
keys = {item["key"] for item in audit}
|
||||||
|
assert "notifications.apprise_enabled" in keys
|
||||||
|
assert "notifications.apprise_urls" in keys
|
||||||
|
assert "notifications.alert_failure_threshold_count" in keys
|
||||||
|
secret_row = next(item for item in audit if item["key"] == "notifications.apprise_urls")
|
||||||
|
assert secret_row["new_value"] == "[redacted]"
|
||||||
|
assert "password" not in str(audit)
|
||||||
|
|
||||||
|
def test_notification_config_audit_actor_variants(
|
||||||
|
self,
|
||||||
|
db_session: Session,
|
||||||
|
):
|
||||||
|
"""Config audit actor detection covers session and JWT auth contexts."""
|
||||||
|
record_alert_config_change(
|
||||||
|
db_session,
|
||||||
|
key="notifications.apprise_enabled",
|
||||||
|
old_value="false",
|
||||||
|
new_value="true",
|
||||||
|
auth_context={"auth_type": "session", "user_id": 123},
|
||||||
|
)
|
||||||
|
record_alert_config_change(
|
||||||
|
db_session,
|
||||||
|
key="notifications.apprise_enabled",
|
||||||
|
old_value="true",
|
||||||
|
new_value="false",
|
||||||
|
auth_context={"auth_type": "jwt", "payload": {"sub": "admin@example.com"}},
|
||||||
|
)
|
||||||
|
db_session.commit()
|
||||||
|
|
||||||
|
audit = list_alert_config_audit(db_session, limit=2)
|
||||||
|
|
||||||
|
assert {row["changed_by"] for row in audit} == {"123", "admin@example.com"}
|
||||||
|
|
||||||
|
def test_bulk_update_upserts_and_preserves_redacted_secret(
|
||||||
|
self,
|
||||||
|
authed_client: TestClient,
|
||||||
|
db_session: Session,
|
||||||
|
):
|
||||||
|
"""Bulk settings handles new rows and redacted secret placeholders."""
|
||||||
|
authed_client.get("/api/v1/settings")
|
||||||
|
authed_client.put(
|
||||||
|
"/api/v1/settings/notifications.apprise_urls",
|
||||||
|
json={"value": "mailto://user:password@example.com"},
|
||||||
|
)
|
||||||
|
before = (
|
||||||
|
db_session.query(Setting)
|
||||||
|
.filter(Setting.key == "notifications.apprise_urls")
|
||||||
|
.first()
|
||||||
|
.value
|
||||||
|
)
|
||||||
|
|
||||||
|
res = authed_client.post(
|
||||||
|
"/api/v1/settings/bulk",
|
||||||
|
json={
|
||||||
|
"settings": {
|
||||||
|
"notifications.custom_notice": "enabled",
|
||||||
|
"notifications.apprise_urls": "**redacted**",
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert res.status_code == 200
|
||||||
|
after = (
|
||||||
|
db_session.query(Setting)
|
||||||
|
.filter(Setting.key == "notifications.apprise_urls")
|
||||||
|
.first()
|
||||||
|
.value
|
||||||
|
)
|
||||||
|
custom = (
|
||||||
|
db_session.query(Setting).filter(Setting.key == "notifications.custom_notice").first()
|
||||||
|
)
|
||||||
|
assert after == before
|
||||||
|
assert custom.value == "enabled"
|
||||||
|
|
||||||
def test_notification_summary_preview_returns_recent_activity(
|
def test_notification_summary_preview_returns_recent_activity(
|
||||||
self,
|
self,
|
||||||
authed_client: TestClient,
|
authed_client: TestClient,
|
||||||
@@ -547,6 +920,33 @@ class TestSettingsAPI:
|
|||||||
assert sent_messages[0]["title"].startswith("DMARQ weekly summary")
|
assert sent_messages[0]["title"].startswith("DMARQ weekly summary")
|
||||||
assert "Weekly DMARC summary" in sent_messages[0]["body"]
|
assert "Weekly DMARC summary" in sent_messages[0]["body"]
|
||||||
|
|
||||||
|
def test_notification_summary_invalid_period_and_failure_paths(
|
||||||
|
self,
|
||||||
|
authed_client: TestClient,
|
||||||
|
monkeypatch,
|
||||||
|
):
|
||||||
|
"""Summary endpoints return useful 400 responses for invalid or failed sends."""
|
||||||
|
preview_res = authed_client.get("/api/v1/settings/notifications/summary?period=monthly")
|
||||||
|
assert preview_res.status_code == 400
|
||||||
|
|
||||||
|
send_res = authed_client.post("/api/v1/settings/notifications/summary/send?period=monthly")
|
||||||
|
assert send_res.status_code == 400
|
||||||
|
|
||||||
|
def fake_send_summary_notification(db, period): # pylint: disable=unused-argument
|
||||||
|
return {
|
||||||
|
"summary": {"period": period},
|
||||||
|
"notification": {"success": False, "message": "Not delivered."},
|
||||||
|
}
|
||||||
|
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"app.api.api_v1.endpoints.settings.send_summary_notification",
|
||||||
|
fake_send_summary_notification,
|
||||||
|
)
|
||||||
|
|
||||||
|
failed_res = authed_client.post("/api/v1/settings/notifications/summary/send?period=daily")
|
||||||
|
assert failed_res.status_code == 400
|
||||||
|
assert failed_res.json()["detail"]["notification"]["success"] is False
|
||||||
|
|
||||||
def test_due_scheduled_summaries_send_once_per_period(
|
def test_due_scheduled_summaries_send_once_per_period(
|
||||||
self,
|
self,
|
||||||
db_session: Session,
|
db_session: Session,
|
||||||
|
|||||||
@@ -80,12 +80,14 @@ For database operations, use the [Database Backup and Restore](backups.md) guide
|
|||||||
DMARQ stores notification targets in the web settings table. Configure them under
|
DMARQ stores notification targets in the web settings table. Configure them under
|
||||||
**Settings** > **Notifications** and use newline-separated Apprise URLs, such as
|
**Settings** > **Notifications** and use newline-separated Apprise URLs, such as
|
||||||
email, Slack, Teams, Discord, or webhook targets. Saved target URLs are redacted
|
email, Slack, Teams, Discord, or webhook targets. Saved target URLs are redacted
|
||||||
from API responses.
|
from API responses and encrypted at rest with the application `SECRET_KEY`.
|
||||||
|
|
||||||
| Setting | Description | Default | Example |
|
| Setting | Description | Default | Example |
|
||||||
|---------|-------------|---------|---------|
|
|---------|-------------|---------|---------|
|
||||||
| `notifications.apprise_enabled` | Enable Apprise notification delivery | `false` | `true` |
|
| `notifications.apprise_enabled` | Enable Apprise notification delivery | `false` | `true` |
|
||||||
| `notifications.apprise_urls` | Newline-separated Apprise target URLs | - | `mailto://user:pass@example.com` |
|
| `notifications.apprise_urls` | Newline-separated Apprise target URLs | - | `mailto://user:pass@example.com` |
|
||||||
|
| `notifications.min_send_interval_minutes` | Minimum minutes between outbound notification deliveries | `15` | `30` |
|
||||||
|
| `notifications.redact_pii_enabled` | Redact email addresses from outbound notification text | `true` | `true` |
|
||||||
| `notifications.alert_new_sources_enabled` | Alert on newly observed sending sources | `true` | `true` |
|
| `notifications.alert_new_sources_enabled` | Alert on newly observed sending sources | `true` | `true` |
|
||||||
| `notifications.alert_compliance_drop_enabled` | Alert on recent compliance-rate drops | `true` | `true` |
|
| `notifications.alert_compliance_drop_enabled` | Alert on recent compliance-rate drops | `true` | `true` |
|
||||||
| `notifications.alert_compliance_drop_points` | Minimum compliance-rate drop in percentage points | `10` | `15` |
|
| `notifications.alert_compliance_drop_points` | Minimum compliance-rate drop in percentage points | `10` | `15` |
|
||||||
@@ -98,9 +100,11 @@ from API responses.
|
|||||||
| `notifications.summary_send_hour_utc` | UTC hour for scheduled summaries | `8` | `7` |
|
| `notifications.summary_send_hour_utc` | UTC hour for scheduled summaries | `8` | `7` |
|
||||||
| `notifications.summary_weekday_utc` | UTC weekday for weekly summaries, where 0 is Monday | `0` | `4` |
|
| `notifications.summary_weekday_utc` | UTC weekday for weekly summaries, where 0 is Monday | `0` | `4` |
|
||||||
|
|
||||||
Alert history is stored in the database-backed `alert_history` table. Current
|
Alert history is stored in the database-backed `alert_history` table.
|
||||||
retention is indefinite; prune old resolved rows according to your operational
|
Notification and alert-rule configuration changes are stored in
|
||||||
policy if long-term storage size matters.
|
`alert_configuration_audit` with secret values sanitized. Current retention is
|
||||||
|
indefinite; prune old resolved history and audit rows according to your
|
||||||
|
operational policy if long-term storage size matters.
|
||||||
|
|
||||||
### Cloudflare Integration
|
### Cloudflare Integration
|
||||||
|
|
||||||
|
|||||||
+2
-1
@@ -113,11 +113,12 @@ Goal: notify administrators when action is needed.
|
|||||||
|
|
||||||
Delivered:
|
Delivered:
|
||||||
- Apprise notification integration for newline-separated notification target URLs.
|
- Apprise notification integration for newline-separated notification target URLs.
|
||||||
- Notification settings UI can save Apprise targets, keeps target URLs redacted after save, and can send a test notification.
|
- Notification settings UI can save Apprise targets, stores target URLs encrypted, keeps target URLs redacted after save, and can send a test notification.
|
||||||
- Alert rules for new sender source, compliance drop, DMARC failures above threshold, and missing reports.
|
- Alert rules for new sender source, compliance drop, DMARC failures above threshold, and missing reports.
|
||||||
- Notification settings UI can evaluate active alerts and send the current alert summary on demand.
|
- Notification settings UI can evaluate active alerts and send the current alert summary on demand.
|
||||||
- Daily and weekly DMARC summary notifications, including scheduled delivery and manual preview/send controls.
|
- Daily and weekly DMARC summary notifications, including scheduled delivery and manual preview/send controls.
|
||||||
- Alert history records active and resolved alerts with first-seen, last-seen, observed-count, and payload metadata.
|
- Alert history records active and resolved alerts with first-seen, last-seen, observed-count, and payload metadata.
|
||||||
|
- Outbound notifications are rate-limited, email addresses are redacted by default, and notification configuration changes are audited without raw secrets.
|
||||||
|
|
||||||
Exit criteria:
|
Exit criteria:
|
||||||
- A user can receive meaningful alerts without opening the dashboard daily.
|
- A user can receive meaningful alerts without opening the dashboard daily.
|
||||||
|
|||||||
@@ -39,8 +39,13 @@ Configure where DMARQ sends notifications:
|
|||||||
4. Add one Apprise target URL per line.
|
4. Add one Apprise target URL per line.
|
||||||
5. Save and use **Send Test** to verify delivery.
|
5. Save and use **Send Test** to verify delivery.
|
||||||
|
|
||||||
Target URLs are redacted after saving so credentials are not exposed through the
|
Target URLs are encrypted in the database and redacted after saving so
|
||||||
settings API or page reloads.
|
credentials are not exposed through the settings API or page reloads.
|
||||||
|
|
||||||
|
The notification page also includes a minimum send interval. This cooldown
|
||||||
|
limits repeated outbound notifications if several alert checks run close
|
||||||
|
together. Email addresses in notification titles and bodies are redacted by
|
||||||
|
default before messages are sent.
|
||||||
|
|
||||||
### Alert Thresholds
|
### Alert Thresholds
|
||||||
|
|
||||||
@@ -69,6 +74,12 @@ Alert history appears in **Settings** > **Notifications** after alerts have been
|
|||||||
evaluated or sent. Each row shows whether the alert is active or resolved, how
|
evaluated or sent. Each row shows whether the alert is active or resolved, how
|
||||||
many times it has been observed, and the latest alert detail.
|
many times it has been observed, and the latest alert detail.
|
||||||
|
|
||||||
|
### Configuration Audit
|
||||||
|
|
||||||
|
Notification and alert-rule setting changes appear in **Settings** >
|
||||||
|
**Notifications**. Secret values are shown only as redacted markers in this
|
||||||
|
audit trail.
|
||||||
|
|
||||||
### Integration Notifications
|
### Integration Notifications
|
||||||
|
|
||||||
Apprise supports email, Slack, Teams, Discord, generic webhooks, and many other
|
Apprise supports email, Slack, Teams, Discord, generic webhooks, and many other
|
||||||
|
|||||||
Reference in New Issue
Block a user