harden notification settings
This commit is contained in:
@@ -9,7 +9,7 @@ from typing import Any, Dict, List, Optional
|
||||
|
||||
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:
|
||||
@@ -125,3 +125,64 @@ def list_alert_history(
|
||||
query.order_by(AlertHistory.last_seen_at.desc(), AlertHistory.id.desc()).limit(limit).all()
|
||||
)
|
||||
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
|
||||
from dataclasses import asdict, dataclass
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Dict, List, Optional, Tuple
|
||||
|
||||
import apprise
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.credential_encryption import decrypt_secret
|
||||
from app.models.setting import Setting
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -23,14 +25,17 @@ class NotificationResult:
|
||||
configured_targets: int = 0
|
||||
invalid_targets: int = 0
|
||||
skipped: bool = False
|
||||
rate_limited: bool = False
|
||||
error: Optional[str] = None
|
||||
|
||||
def to_dict(self) -> Dict[str, object]:
|
||||
return asdict(self)
|
||||
|
||||
|
||||
def _truthy(value: Optional[str]) -> bool:
|
||||
return str(value or "").strip().lower() in {"1", "true", "yes", "on"}
|
||||
def _truthy(value: Optional[str], default: bool = False) -> bool:
|
||||
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]:
|
||||
@@ -48,6 +53,130 @@ def _notification_settings(db: Session) -> Dict[str, Optional[str]]:
|
||||
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]:
|
||||
configured_targets = 0
|
||||
invalid_targets = 0
|
||||
@@ -81,7 +210,11 @@ def send_notification(
|
||||
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:
|
||||
return NotificationResult(
|
||||
success=False,
|
||||
@@ -99,6 +232,9 @@ def send_notification(
|
||||
)
|
||||
|
||||
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))
|
||||
except Exception: # pylint: disable=broad-exception-caught
|
||||
logger.exception("Apprise notification delivery failed.")
|
||||
@@ -119,6 +255,13 @@ def send_notification(
|
||||
error="not_delivered",
|
||||
)
|
||||
|
||||
_set_notification_setting(
|
||||
db,
|
||||
"notifications.last_sent_at",
|
||||
datetime.now(timezone.utc).isoformat(),
|
||||
)
|
||||
db.commit()
|
||||
|
||||
return NotificationResult(
|
||||
success=True,
|
||||
message="Notification sent.",
|
||||
|
||||
Reference in New Issue
Block a user