1051 lines
44 KiB
Python
1051 lines
44 KiB
Python
from __future__ import annotations
|
|
|
|
from datetime import datetime, timedelta
|
|
import json
|
|
import re
|
|
from threading import RLock
|
|
from typing import Any
|
|
from uuid import uuid4
|
|
|
|
from sqlalchemy import case, func, or_, update
|
|
from sqlalchemy.exc import IntegrityError
|
|
from sqlmodel import Session, select
|
|
|
|
from ..core.redaction import redact_message
|
|
from ..core.time import utc_now
|
|
from ..db.models import App, AuditEvent, Connector, DiscoveryRun, NotificationChannel, NotificationEvent, NotificationRule
|
|
from . import media_pipeline_service, safety_center_service
|
|
from .audit_logger import log_event
|
|
from .notification_channels import external, home_assistant
|
|
from .secret_store import set_secret
|
|
|
|
SEVERITY_RANK = {'info': 0, 'low': 1, 'medium': 2, 'high': 3, 'critical': 4}
|
|
SECRET_KEYS = {'token', 'password', 'secret', 'api_key', 'apikey', 'authorization', 'bearer'}
|
|
_EVALUATION_LOCK = RLock()
|
|
|
|
|
|
def _now() -> datetime:
|
|
return utc_now()
|
|
|
|
|
|
def _redact(value: Any) -> Any:
|
|
if isinstance(value, str):
|
|
redacted = redact_message(value)
|
|
if re.search(r'(api[_-]?key|token|password|bearer)', redacted, flags=re.IGNORECASE):
|
|
return '[REDACTED]'
|
|
return redacted
|
|
if isinstance(value, list):
|
|
return [_redact(item) for item in value]
|
|
if isinstance(value, dict):
|
|
result: dict[str, Any] = {}
|
|
for key, item in value.items():
|
|
lowered = str(key).lower()
|
|
if any(secret_key in lowered for secret_key in SECRET_KEYS):
|
|
continue
|
|
result[str(key)] = _redact(item)
|
|
return result
|
|
return value
|
|
|
|
|
|
def _rule_to_dict(rule: NotificationRule) -> dict[str, Any]:
|
|
return {
|
|
'id': rule.id,
|
|
'name': rule.name,
|
|
'enabled': rule.enabled,
|
|
'source': rule.source,
|
|
'eventType': rule.event_type,
|
|
'severityMin': rule.severity_min,
|
|
'categories': rule.categories_json,
|
|
'targets': rule.targets_json,
|
|
'channels': rule.channels_json,
|
|
'cooldownSeconds': rule.cooldown_seconds,
|
|
'quietHours': rule.quiet_hours_json,
|
|
'conditions': rule.conditions_json,
|
|
'dedupeKeyTemplate': rule.dedupe_key_template,
|
|
'createdAt': rule.created_at.isoformat(),
|
|
'updatedAt': rule.updated_at.isoformat(),
|
|
}
|
|
|
|
|
|
def _event_to_dict(event: NotificationEvent) -> dict[str, Any]:
|
|
payload = _redact(event.payload_json)
|
|
channel_results = payload.get('channelResults', {}) if isinstance(payload, dict) else {}
|
|
return {
|
|
'id': event.id,
|
|
'ruleId': event.rule_id,
|
|
'source': event.source,
|
|
'eventType': event.event_type,
|
|
'severity': event.severity,
|
|
'title': event.title,
|
|
'message': event.message,
|
|
'category': event.category,
|
|
'target': event.target,
|
|
'dedupeKey': event.dedupe_key,
|
|
'channels': event.channels_json,
|
|
'status': event.status,
|
|
'reason': event.reason,
|
|
'payload': payload,
|
|
'channelResults': channel_results,
|
|
'read': event.read,
|
|
'createdAt': event.created_at.isoformat(),
|
|
'sentAt': event.sent_at.isoformat() if event.sent_at else None,
|
|
'suppressedUntil': event.suppressed_until.isoformat() if event.suppressed_until else None,
|
|
}
|
|
|
|
|
|
def _channel_to_dict(channel: NotificationChannel, session: Session | None = None) -> dict[str, Any]:
|
|
safe_config = {
|
|
key: value
|
|
for key, value in channel.config_json.items()
|
|
if not any(secret_key in str(key).lower() for secret_key in SECRET_KEYS)
|
|
}
|
|
status = 'connected' if channel.enabled and channel.type == 'dashboard' else ('configured' if channel.enabled else 'disabled')
|
|
has_secret = bool(channel.secret_refs_json)
|
|
missing: list[str] = []
|
|
safe_config_alias = _redact(safe_config)
|
|
if channel.type == 'home_assistant' and session is not None:
|
|
ha_status = home_assistant.config_status(session, channel)
|
|
status = ha_status['status']
|
|
has_secret = ha_status['hasSecret']
|
|
missing = ha_status['missing']
|
|
safe_config_alias = _redact(ha_status['safeConfig'])
|
|
elif channel.type in external.SECRET_REFS and session is not None:
|
|
external_status = external.config_status(session, channel)
|
|
status = external_status['status']
|
|
has_secret = external_status['hasSecret']
|
|
missing = external_status['missing']
|
|
safe_config_alias = _redact(external_status['safeConfig'])
|
|
return {
|
|
'id': channel.id,
|
|
'type': channel.type,
|
|
'name': channel.name,
|
|
'enabled': channel.enabled,
|
|
'config': safe_config_alias,
|
|
'safeConfig': safe_config_alias,
|
|
'secretRefs': channel.secret_refs_json,
|
|
'status': status,
|
|
'hasSecret': has_secret,
|
|
'missing': missing,
|
|
'lastError': safe_config.get('lastError'),
|
|
'lastSentAt': safe_config.get('lastSentAt'),
|
|
'lastTestAt': channel.last_test_at.isoformat() if channel.last_test_at else None,
|
|
'createdAt': channel.created_at.isoformat(),
|
|
'updatedAt': channel.updated_at.isoformat(),
|
|
}
|
|
|
|
|
|
def list_rules(session: Session) -> list[dict[str, Any]]:
|
|
return [_rule_to_dict(rule) for rule in session.exec(select(NotificationRule).order_by(NotificationRule.name)).all()]
|
|
|
|
|
|
def create_rule(session: Session, payload: dict[str, Any]) -> dict[str, Any]:
|
|
rule = NotificationRule(
|
|
id=str(payload.get('id') or f"notify-{uuid4().hex[:12]}"),
|
|
name=str(payload.get('name') or 'Notification Rule'),
|
|
enabled=bool(payload.get('enabled', True)),
|
|
source=str(payload.get('source') or 'safety'),
|
|
event_type=str(payload.get('eventType') or payload.get('event_type') or 'safety.finding'),
|
|
severity_min=str(payload.get('severityMin') or payload.get('severity_min') or 'medium'),
|
|
categories_json=list(payload.get('categories') or payload.get('categories_json') or []),
|
|
targets_json=list(payload.get('targets') or payload.get('targets_json') or []),
|
|
channels_json=list(payload.get('channels') or payload.get('channels_json') or ['dashboard']),
|
|
cooldown_seconds=int(payload.get('cooldownSeconds') or payload.get('cooldown_seconds') or 3600),
|
|
quiet_hours_json=dict(payload.get('quietHours') or payload.get('quiet_hours_json') or {}),
|
|
conditions_json=dict(payload.get('conditions') or payload.get('conditions_json') or {}),
|
|
dedupe_key_template=str(payload.get('dedupeKeyTemplate') or payload.get('dedupe_key_template') or '{source}:{event_type}:{target}:{category}'),
|
|
)
|
|
session.add(rule)
|
|
session.commit()
|
|
session.refresh(rule)
|
|
safety_center_service.invalidate_cache()
|
|
return _rule_to_dict(rule)
|
|
|
|
|
|
def update_rule(session: Session, rule_id: str, payload: dict[str, Any]) -> dict[str, Any]:
|
|
rule = session.get(NotificationRule, rule_id)
|
|
if not rule:
|
|
return {'success': False, 'status': 'not_found', 'id': rule_id}
|
|
mapping = {
|
|
'name': 'name',
|
|
'enabled': 'enabled',
|
|
'source': 'source',
|
|
'eventType': 'event_type',
|
|
'severityMin': 'severity_min',
|
|
'cooldownSeconds': 'cooldown_seconds',
|
|
'dedupeKeyTemplate': 'dedupe_key_template',
|
|
}
|
|
for input_key, attr in mapping.items():
|
|
if input_key in payload:
|
|
setattr(rule, attr, payload[input_key])
|
|
if 'categories' in payload:
|
|
rule.categories_json = list(payload['categories'])
|
|
if 'targets' in payload:
|
|
rule.targets_json = list(payload['targets'])
|
|
if 'channels' in payload:
|
|
rule.channels_json = list(payload['channels'])
|
|
if 'quietHours' in payload:
|
|
rule.quiet_hours_json = dict(payload['quietHours'])
|
|
if 'conditions' in payload:
|
|
rule.conditions_json = dict(payload['conditions'])
|
|
rule.updated_at = _now()
|
|
session.add(rule)
|
|
session.commit()
|
|
session.refresh(rule)
|
|
safety_center_service.invalidate_cache()
|
|
return _rule_to_dict(rule)
|
|
|
|
|
|
def delete_rule(session: Session, rule_id: str) -> dict[str, Any]:
|
|
rule = session.get(NotificationRule, rule_id)
|
|
if not rule:
|
|
return {'success': False, 'status': 'not_found', 'id': rule_id}
|
|
session.delete(rule)
|
|
session.commit()
|
|
safety_center_service.invalidate_cache()
|
|
return {'success': True, 'status': 'deleted', 'id': rule_id}
|
|
|
|
|
|
def list_channels(session: Session) -> list[dict[str, Any]]:
|
|
return [_channel_to_dict(channel, session) for channel in session.exec(select(NotificationChannel).order_by(NotificationChannel.name)).all()]
|
|
|
|
|
|
def create_channel(session: Session, payload: dict[str, Any]) -> dict[str, Any]:
|
|
channel_type = str(payload.get('type') or 'dashboard')
|
|
config = dict(payload.get('config') or payload.get('config_json') or {})
|
|
token = str(payload.get('token') or config.pop('token', '') or '').strip()
|
|
if channel_type == 'home_assistant':
|
|
config = home_assistant.safe_config(config)
|
|
elif channel_type in external.SECRET_REFS:
|
|
config = external.safe_config(channel_type, config)
|
|
channel = NotificationChannel(
|
|
id=str(payload.get('id') or f"channel-{uuid4().hex[:12]}"),
|
|
type=channel_type,
|
|
name=str(payload.get('name') or payload.get('type') or 'Dashboard'),
|
|
enabled=bool(payload.get('enabled', True)),
|
|
config_json=_redact(config),
|
|
secret_refs_json=list(payload.get('secretRefs') or payload.get('secret_refs_json') or ([home_assistant.SECRET_REF] if channel_type == 'home_assistant' else ([external.SECRET_REFS[channel_type]] if channel_type in external.SECRET_REFS else []))),
|
|
)
|
|
if channel_type == 'home_assistant' and token:
|
|
set_secret(session, home_assistant.SECRET_REF, token, used_by=['notification_channel:home_assistant'], commit=False)
|
|
elif channel_type in external.SECRET_REFS and token:
|
|
set_secret(session, external.SECRET_REFS[channel_type], token, used_by=[f'notification_channel:{channel_type}'], commit=False)
|
|
session.add(channel)
|
|
session.commit()
|
|
session.refresh(channel)
|
|
safety_center_service.invalidate_cache()
|
|
return _channel_to_dict(channel, session)
|
|
|
|
|
|
def update_channel(session: Session, channel_id: str, payload: dict[str, Any]) -> dict[str, Any]:
|
|
channel = session.get(NotificationChannel, channel_id)
|
|
if not channel:
|
|
return {'success': False, 'status': 'not_found', 'id': channel_id}
|
|
if 'name' in payload:
|
|
channel.name = str(payload['name'])
|
|
if 'enabled' in payload:
|
|
channel.enabled = bool(payload['enabled'])
|
|
if 'type' in payload:
|
|
channel.type = str(payload['type'])
|
|
if 'config' in payload:
|
|
config = dict(payload['config'])
|
|
token = str(payload.get('token') or config.pop('token', '') or '').strip()
|
|
if channel.type == 'home_assistant':
|
|
channel.config_json = _redact(home_assistant.safe_config(config))
|
|
if token:
|
|
set_secret(session, home_assistant.SECRET_REF, token, used_by=['notification_channel:home_assistant'], commit=False)
|
|
channel.secret_refs_json = [home_assistant.SECRET_REF]
|
|
elif channel.type in external.SECRET_REFS:
|
|
channel.config_json = _redact(external.safe_config(channel.type, config))
|
|
if token:
|
|
secret_ref = external.SECRET_REFS[channel.type]
|
|
set_secret(session, secret_ref, token, used_by=[f'notification_channel:{channel.type}'], commit=False)
|
|
channel.secret_refs_json = [secret_ref]
|
|
else:
|
|
channel.config_json = _redact(config)
|
|
elif channel.type == 'home_assistant' and payload.get('token'):
|
|
set_secret(session, home_assistant.SECRET_REF, str(payload['token']), used_by=['notification_channel:home_assistant'], commit=False)
|
|
channel.secret_refs_json = [home_assistant.SECRET_REF]
|
|
elif channel.type in external.SECRET_REFS and payload.get('token'):
|
|
secret_ref = external.SECRET_REFS[channel.type]
|
|
set_secret(session, secret_ref, str(payload['token']), used_by=[f'notification_channel:{channel.type}'], commit=False)
|
|
channel.secret_refs_json = [secret_ref]
|
|
if 'secretRefs' in payload:
|
|
channel.secret_refs_json = list(payload['secretRefs'])
|
|
channel.updated_at = _now()
|
|
session.add(channel)
|
|
session.commit()
|
|
session.refresh(channel)
|
|
safety_center_service.invalidate_cache()
|
|
return _channel_to_dict(channel, session)
|
|
|
|
|
|
def test_channel(session: Session, channel_id: str) -> dict[str, Any]:
|
|
channel = session.get(NotificationChannel, channel_id)
|
|
if not channel:
|
|
return {'success': False, 'status': 'not_found', 'id': channel_id}
|
|
channel.last_test_at = _now()
|
|
channel.updated_at = channel.last_test_at
|
|
session.add(channel)
|
|
session.commit()
|
|
session.refresh(channel)
|
|
if channel.type == 'home_assistant':
|
|
result = home_assistant.send_test_notification(session, channel)
|
|
channel.config_json = {
|
|
**(channel.config_json or {}),
|
|
'lastError': result.get('reason') if not result.get('success') else None,
|
|
'lastSentAt': channel.last_test_at.isoformat() if result.get('success') else channel.config_json.get('lastSentAt'),
|
|
}
|
|
session.add(channel)
|
|
session.commit()
|
|
session.refresh(channel)
|
|
log_event(
|
|
session,
|
|
source='notification',
|
|
identity='system',
|
|
action_id='notification.channel.test',
|
|
target=channel.id,
|
|
target_type='notification_channel',
|
|
risk='low',
|
|
policy_decision='allowed',
|
|
reason=str(result.get('reason') or result.get('status') or 'test_notification'),
|
|
result='success' if result.get('success') else 'failed',
|
|
redacted_metadata={'status': result.get('status'), 'missing': result.get('missing')},
|
|
)
|
|
result['channel'] = _channel_to_dict(channel, session)
|
|
return result
|
|
if channel.type in external.SECRET_REFS:
|
|
result = external.send_notification(session, channel, {"title": "AppOps test", "message": "Dit is een gecontroleerde testmelding vanuit AppOps."})
|
|
channel.config_json = {
|
|
**(channel.config_json or {}),
|
|
'lastError': result.get('reason') if not result.get('success') else None,
|
|
'lastSentAt': channel.last_test_at.isoformat() if result.get('success') else channel.config_json.get('lastSentAt'),
|
|
}
|
|
session.add(channel)
|
|
session.commit()
|
|
result['channel'] = _channel_to_dict(channel, session)
|
|
return result
|
|
safe = _channel_to_dict(channel, session)
|
|
if not channel.enabled:
|
|
return {'success': True, 'status': 'disabled', 'channel': safe}
|
|
if channel.type == 'dashboard':
|
|
return {'success': True, 'status': 'sent', 'channel': safe}
|
|
if channel.type in {'home_assistant', 'webhook'}:
|
|
configured = bool(channel.config_json.get('url') or channel.secret_refs_json)
|
|
return {'success': True, 'status': 'prepared' if configured else 'missing_config', 'channel': safe}
|
|
return {'success': True, 'status': 'prepared', 'channel': safe}
|
|
|
|
|
|
def test_channel_connection(session: Session, channel_id: str) -> dict[str, Any]:
|
|
channel = session.get(NotificationChannel, channel_id)
|
|
if not channel:
|
|
return {'success': False, 'status': 'not_found', 'id': channel_id}
|
|
channel.last_test_at = _now()
|
|
channel.updated_at = channel.last_test_at
|
|
session.add(channel)
|
|
session.commit()
|
|
session.refresh(channel)
|
|
if channel.type == 'home_assistant':
|
|
result = home_assistant.test_connection(session, channel)
|
|
channel.config_json = {
|
|
**(channel.config_json or {}),
|
|
'lastError': result.get('status') if not result.get('success') else None,
|
|
}
|
|
session.add(channel)
|
|
session.commit()
|
|
session.refresh(channel)
|
|
result['channel'] = _channel_to_dict(channel, session)
|
|
return result
|
|
return test_channel(session, channel_id)
|
|
|
|
|
|
def list_events(
|
|
session: Session,
|
|
include_dismissed: bool = True,
|
|
limit: int = 250,
|
|
offset: int = 0,
|
|
) -> list[dict[str, Any]]:
|
|
bounded_limit = max(1, min(limit, 500))
|
|
bounded_offset = max(0, offset)
|
|
statement = select(NotificationEvent)
|
|
if not include_dismissed:
|
|
statement = statement.where(NotificationEvent.status != 'dismissed')
|
|
inbox_priority = case(
|
|
(
|
|
(NotificationEvent.read == False) # noqa: E712
|
|
& NotificationEvent.status.not_in(['dismissed', 'suppressed']),
|
|
0,
|
|
),
|
|
(NotificationEvent.status.not_in(['dismissed', 'suppressed']), 1),
|
|
(NotificationEvent.status == 'suppressed', 2),
|
|
else_=3,
|
|
)
|
|
statement = (
|
|
statement
|
|
.order_by(inbox_priority.asc(), NotificationEvent.created_at.desc(), NotificationEvent.id.desc())
|
|
.offset(bounded_offset)
|
|
.limit(bounded_limit)
|
|
)
|
|
events = session.exec(statement).all()
|
|
return [_event_to_dict(event) for event in events]
|
|
|
|
|
|
def unread_events(session: Session, limit: int = 250, offset: int = 0) -> list[dict[str, Any]]:
|
|
bounded_limit = max(1, min(limit, 500))
|
|
bounded_offset = max(0, offset)
|
|
events = session.exec(
|
|
select(NotificationEvent)
|
|
.where(NotificationEvent.read == False) # noqa: E712
|
|
.where(NotificationEvent.status != 'dismissed')
|
|
.where(NotificationEvent.status != 'suppressed')
|
|
.order_by(NotificationEvent.created_at.desc(), NotificationEvent.id.desc())
|
|
.offset(bounded_offset)
|
|
.limit(bounded_limit)
|
|
).all()
|
|
return [_event_to_dict(event) for event in events]
|
|
|
|
|
|
def mark_read(session: Session, event_id: str) -> dict[str, Any]:
|
|
event = session.get(NotificationEvent, event_id)
|
|
if not event:
|
|
return {'success': False, 'status': 'not_found', 'id': event_id}
|
|
event.read = True
|
|
if event.status == 'queued':
|
|
event.status = 'sent'
|
|
session.add(event)
|
|
session.commit()
|
|
session.refresh(event)
|
|
result = _event_to_dict(event)
|
|
result['success'] = True
|
|
return result
|
|
|
|
|
|
def dismiss_event(session: Session, event_id: str) -> dict[str, Any]:
|
|
event = session.get(NotificationEvent, event_id)
|
|
if not event:
|
|
return {'success': False, 'status': 'not_found', 'id': event_id}
|
|
event.status = 'dismissed'
|
|
event.read = True
|
|
session.add(event)
|
|
session.commit()
|
|
session.refresh(event)
|
|
result = _event_to_dict(event)
|
|
result['success'] = True
|
|
return result
|
|
|
|
|
|
def mark_all_read(session: Session) -> dict[str, Any]:
|
|
events = session.exec(select(NotificationEvent).where(NotificationEvent.read == False)).all() # noqa: E712
|
|
for event in events:
|
|
event.read = True
|
|
if event.status == 'queued':
|
|
event.status = 'sent'
|
|
session.add(event)
|
|
session.commit()
|
|
return {'success': True, 'updated': len(events)}
|
|
|
|
|
|
def _severity_matches(rule: NotificationRule, event: dict[str, Any]) -> bool:
|
|
return SEVERITY_RANK.get(str(event.get('severity', 'info')), 0) >= SEVERITY_RANK.get(rule.severity_min, 0)
|
|
|
|
|
|
def _condition_matches(rule: NotificationRule, event: dict[str, Any]) -> bool:
|
|
if rule.source != event.get('source') or rule.event_type != event.get('event_type'):
|
|
return False
|
|
if not _severity_matches(rule, event):
|
|
return False
|
|
category = str(event.get('category') or '')
|
|
target = str(event.get('target') or '')
|
|
if rule.categories_json and category not in rule.categories_json:
|
|
return False
|
|
if rule.targets_json and target not in rule.targets_json:
|
|
return False
|
|
title_contains = rule.conditions_json.get('titleContains') if isinstance(rule.conditions_json, dict) else None
|
|
if title_contains:
|
|
title = str(event.get('title') or '').lower()
|
|
if not any(str(item).lower() in title for item in title_contains):
|
|
return False
|
|
return True
|
|
|
|
|
|
def _rule_specificity(rule: NotificationRule) -> int:
|
|
conditions = rule.conditions_json if isinstance(rule.conditions_json, dict) else {}
|
|
constrained_fields = sum((
|
|
bool(rule.categories_json),
|
|
bool(rule.targets_json),
|
|
bool(conditions),
|
|
))
|
|
return constrained_fields * 100 + SEVERITY_RANK.get(rule.severity_min, 0)
|
|
|
|
|
|
def _dedupe_key(rule: NotificationRule, event: dict[str, Any]) -> str:
|
|
fallback = str(event.get('dedupe_key') or f"{event.get('source')}:{event.get('event_type')}:{event.get('target')}:{event.get('category')}")
|
|
try:
|
|
return rule.dedupe_key_template.format(
|
|
source=event.get('source') or '',
|
|
event_type=event.get('event_type') or '',
|
|
severity=event.get('severity') or '',
|
|
category=event.get('category') or '',
|
|
target=event.get('target') or '',
|
|
id=event.get('id') or event.get('findingId') or '',
|
|
) or fallback
|
|
except Exception:
|
|
return fallback
|
|
|
|
|
|
def _cooldown_event(session: Session, rule: NotificationRule, dedupe_key: str) -> NotificationEvent | None:
|
|
cutoff = _now() - timedelta(seconds=rule.cooldown_seconds)
|
|
statement = (
|
|
select(NotificationEvent)
|
|
.where(NotificationEvent.rule_id == rule.id)
|
|
.where(NotificationEvent.dedupe_key == dedupe_key)
|
|
.where(or_(
|
|
NotificationEvent.reason.is_(None),
|
|
NotificationEvent.reason.not_in(['cooldown', 'quiet_hours']),
|
|
))
|
|
.where(NotificationEvent.created_at >= cutoff)
|
|
.order_by(NotificationEvent.created_at.desc())
|
|
)
|
|
return session.exec(statement).first()
|
|
|
|
|
|
def _quiet_hours_until(
|
|
rule: NotificationRule,
|
|
event: dict[str, Any],
|
|
now: datetime | None = None,
|
|
) -> datetime | None:
|
|
quiet = rule.quiet_hours_json or {}
|
|
if not quiet.get('enabled'):
|
|
return None
|
|
if event.get('severity') == 'critical' and quiet.get('allowCriticalOverride', True):
|
|
return None
|
|
try:
|
|
start = int(quiet.get('startHour', 22)) % 24
|
|
end = int(quiet.get('endHour', 7)) % 24
|
|
except (TypeError, ValueError):
|
|
start, end = 22, 7
|
|
current = now or _now()
|
|
hour = current.hour
|
|
suppressed = hour >= start or hour < end if start > end else start <= hour < end
|
|
if not suppressed:
|
|
return None
|
|
until = current.replace(hour=end, minute=0, second=0, microsecond=0)
|
|
if until <= current:
|
|
until += timedelta(days=1)
|
|
return until
|
|
|
|
|
|
def _quiet_hours_suppressed(rule: NotificationRule, event: dict[str, Any]) -> bool:
|
|
return _quiet_hours_until(rule, event) is not None
|
|
|
|
|
|
def _persist_event(session: Session, rule: NotificationRule, event: dict[str, Any], status: str, reason: str | None = None, suppressed_until: datetime | None = None) -> NotificationEvent:
|
|
row = NotificationEvent(
|
|
id=f"notif-{uuid4().hex[:12]}",
|
|
rule_id=rule.id,
|
|
source=str(event.get('source') or rule.source),
|
|
event_type=str(event.get('event_type') or rule.event_type),
|
|
severity=str(event.get('severity') or rule.severity_min),
|
|
title=_redact(str(event.get('title') or 'Notification')),
|
|
message=_redact(str(event.get('message') or event.get('title') or 'Notification')),
|
|
category=str(event.get('category') or '') or None,
|
|
target=str(event.get('target') or '') or None,
|
|
dedupe_key=_dedupe_key(rule, event),
|
|
channels_json=list(rule.channels_json or ['dashboard']),
|
|
status=status,
|
|
reason=reason,
|
|
payload_json=_redact(dict(event.get('payload') or {})),
|
|
read=status == 'suppressed',
|
|
sent_at=_now() if status == 'sent' else None,
|
|
suppressed_until=suppressed_until,
|
|
)
|
|
session.add(row)
|
|
session.commit()
|
|
session.refresh(row)
|
|
return row
|
|
|
|
|
|
def _suppressed_channel_results(channels: list[str], reason: str) -> dict[str, dict[str, Any]]:
|
|
return {channel_id: {'status': 'suppressed', 'reason': reason} for channel_id in channels}
|
|
|
|
|
|
def _record_suppression(
|
|
session: Session,
|
|
rule: NotificationRule,
|
|
event: dict[str, Any],
|
|
reason: str,
|
|
suppressed_until: datetime,
|
|
) -> NotificationEvent:
|
|
"""Atomically aggregate one read-only evidence row per routine suppression identity."""
|
|
dedupe_key = _dedupe_key(rule, event)
|
|
now = _now()
|
|
source_payload = event.get('payload') if isinstance(event.get('payload'), dict) else {}
|
|
channels = list(rule.channels_json or ['dashboard'])
|
|
payload_patch = _redact({
|
|
**source_payload,
|
|
'channelResults': _suppressed_channel_results(channels, reason),
|
|
})
|
|
values = {
|
|
'severity': str(event.get('severity') or rule.severity_min),
|
|
'title': _redact(str(event.get('title') or 'Notification')),
|
|
'message': _redact(str(event.get('message') or event.get('title') or 'Notification')),
|
|
'category': str(event.get('category') or '') or None,
|
|
'target': str(event.get('target') or '') or None,
|
|
'channels_json': channels,
|
|
'read': True,
|
|
'suppressed_until': suppressed_until,
|
|
}
|
|
|
|
def update_existing() -> bool:
|
|
payload = func.json_set(
|
|
func.json_patch(NotificationEvent.payload_json, json.dumps(payload_patch)),
|
|
'$.suppressionCount',
|
|
func.coalesce(func.json_extract(NotificationEvent.payload_json, '$.suppressionCount'), 0) + 1,
|
|
'$.lastSuppressedAt',
|
|
now.isoformat(),
|
|
)
|
|
result = session.execute(
|
|
update(NotificationEvent)
|
|
.where(NotificationEvent.rule_id == rule.id)
|
|
.where(NotificationEvent.dedupe_key == dedupe_key)
|
|
.where(NotificationEvent.status == 'suppressed')
|
|
.where(NotificationEvent.reason == reason)
|
|
.values(**values, payload_json=payload)
|
|
)
|
|
return bool(result.rowcount)
|
|
|
|
if not update_existing():
|
|
row = NotificationEvent(
|
|
id=f'notif-{uuid4()}',
|
|
rule_id=rule.id,
|
|
source=str(event.get('source') or rule.source),
|
|
event_type=str(event.get('event_type') or rule.event_type),
|
|
dedupe_key=dedupe_key,
|
|
status='suppressed',
|
|
reason=reason,
|
|
created_at=now,
|
|
payload_json={
|
|
**payload_patch,
|
|
'suppressionCount': 1,
|
|
'firstSuppressedAt': now.isoformat(),
|
|
'lastSuppressedAt': now.isoformat(),
|
|
},
|
|
**values,
|
|
)
|
|
session.add(row)
|
|
try:
|
|
session.commit()
|
|
except IntegrityError:
|
|
session.rollback()
|
|
if not update_existing():
|
|
raise
|
|
session.commit()
|
|
row = session.exec(
|
|
select(NotificationEvent)
|
|
.where(NotificationEvent.rule_id == rule.id)
|
|
.where(NotificationEvent.dedupe_key == dedupe_key)
|
|
.where(NotificationEvent.status == 'suppressed')
|
|
.where(NotificationEvent.reason == reason)
|
|
).one()
|
|
return row
|
|
|
|
|
|
def _event_payload(event: NotificationEvent) -> dict[str, Any]:
|
|
return {
|
|
'id': event.id,
|
|
'ruleId': event.rule_id,
|
|
'source': event.source,
|
|
'event_type': event.event_type,
|
|
'severity': event.severity,
|
|
'title': event.title,
|
|
'message': event.message,
|
|
'category': event.category,
|
|
'target': event.target,
|
|
'dedupe_key': event.dedupe_key,
|
|
}
|
|
|
|
|
|
def _deliver_event(session: Session, row: NotificationEvent) -> NotificationEvent:
|
|
results: dict[str, dict[str, Any]] = {}
|
|
for channel_id in row.channels_json or ['dashboard']:
|
|
if channel_id == 'dashboard':
|
|
results[channel_id] = {'status': 'sent'}
|
|
continue
|
|
channel = session.get(NotificationChannel, channel_id)
|
|
if not channel:
|
|
results[channel_id] = {'status': 'failed', 'reason': 'channel_not_found'}
|
|
continue
|
|
if channel.type == 'home_assistant':
|
|
result = home_assistant.send_notification(session, channel, _event_payload(row))
|
|
results[channel_id] = _redact(result)
|
|
if result.get('status') == 'sent':
|
|
channel.config_json = {**(channel.config_json or {}), 'lastError': None, 'lastSentAt': _now().isoformat()}
|
|
elif result.get('status') == 'failed':
|
|
channel.config_json = {**(channel.config_json or {}), 'lastError': result.get('reason') or 'error'}
|
|
channel.updated_at = _now()
|
|
session.add(channel)
|
|
continue
|
|
if channel.type in external.SECRET_REFS:
|
|
result = external.send_notification(session, channel, _event_payload(row))
|
|
results[channel_id] = _redact(result)
|
|
channel.config_json = {
|
|
**(channel.config_json or {}),
|
|
'lastError': result.get('reason') if not result.get('success') else None,
|
|
'lastSentAt': _now().isoformat() if result.get('success') else channel.config_json.get('lastSentAt'),
|
|
}
|
|
channel.updated_at = _now()
|
|
session.add(channel)
|
|
continue
|
|
results[channel_id] = {'status': 'suppressed', 'reason': 'unsupported_channel'}
|
|
previous_retry_count = int((row.payload_json or {}).get('deliveryRetryCount') or 0)
|
|
retry_count = previous_retry_count + 1
|
|
failed_channels = [channel_id for channel_id, result in results.items() if result.get('status') in {'failed', 'missing_config', 'auth_failed', 'unreachable'}]
|
|
delivered_channels = [channel_id for channel_id, result in results.items() if result.get('status') == 'sent']
|
|
dead_letter = bool(failed_channels and retry_count >= 3)
|
|
row.status = 'sent' if delivered_channels else ('failed' if failed_channels else 'suppressed')
|
|
row.read = row.read or row.status == 'suppressed'
|
|
row.reason = 'delivery_dead_letter' if dead_letter else 'partial_delivery_failed' if failed_channels and delivered_channels else 'delivery_failed' if failed_channels else None
|
|
row.sent_at = _now() if delivered_channels else None
|
|
row.payload_json = _redact({
|
|
**(row.payload_json or {}),
|
|
'channelResults': results,
|
|
'deliveryRetryCount': retry_count,
|
|
'deliveryPending': bool(failed_channels and not dead_letter),
|
|
'deadLetter': dead_letter,
|
|
})
|
|
session.add(row)
|
|
session.commit()
|
|
session.refresh(row)
|
|
return row
|
|
|
|
|
|
def retry_failed_deliveries(session: Session, limit: int = 20) -> dict[str, Any]:
|
|
bounded_limit = max(1, min(limit, 100))
|
|
rows = session.exec(
|
|
select(NotificationEvent)
|
|
.where(NotificationEvent.status != 'dismissed')
|
|
.where(NotificationEvent.payload_json['deliveryPending'].as_boolean() == True) # noqa: E712
|
|
.order_by(NotificationEvent.created_at.asc())
|
|
.limit(bounded_limit)
|
|
).all()
|
|
retried = 0
|
|
recovered = 0
|
|
dead_lettered = 0
|
|
for row in rows:
|
|
payload = row.payload_json or {}
|
|
if bool(payload.get('deadLetter')) or int(payload.get('deliveryRetryCount') or 0) >= 3:
|
|
dead_lettered += 1
|
|
continue
|
|
retried += 1
|
|
_deliver_event(session, row)
|
|
if not bool((row.payload_json or {}).get('deliveryPending')) and not bool((row.payload_json or {}).get('deadLetter')):
|
|
recovered += 1
|
|
elif bool((row.payload_json or {}).get('deadLetter')):
|
|
dead_lettered += 1
|
|
return {'retried': retried, 'recovered': recovered, 'deadLettered': dead_lettered}
|
|
|
|
|
|
def evaluate_rules(session: Session, events: list[dict[str, Any]]) -> dict[str, Any]:
|
|
rules = session.exec(select(NotificationRule).where(NotificationRule.enabled == True)).all() # noqa: E712
|
|
created: list[dict[str, Any]] = []
|
|
suppressed: list[dict[str, Any]] = []
|
|
failed: list[dict[str, Any]] = []
|
|
for event in events:
|
|
if event.get('event_type') == 'safety.score':
|
|
continue
|
|
matching_rules = [rule for rule in rules if _condition_matches(rule, event)]
|
|
if not matching_rules:
|
|
continue
|
|
highest_specificity = max(_rule_specificity(rule) for rule in matching_rules)
|
|
for rule in matching_rules:
|
|
if _rule_specificity(rule) != highest_specificity:
|
|
continue
|
|
dedupe = _dedupe_key(rule, event)
|
|
previous = _cooldown_event(session, rule, dedupe)
|
|
if previous:
|
|
until = previous.created_at + timedelta(seconds=rule.cooldown_seconds)
|
|
row = _record_suppression(session, rule, event, 'cooldown', until)
|
|
suppressed.append(_event_to_dict(row))
|
|
continue
|
|
quiet_hours_until = _quiet_hours_until(rule, event)
|
|
if quiet_hours_until:
|
|
row = _record_suppression(session, rule, event, 'quiet_hours', quiet_hours_until)
|
|
suppressed.append(_event_to_dict(row))
|
|
continue
|
|
try:
|
|
row = _persist_event(session, rule, event, 'sent')
|
|
row = _deliver_event(session, row)
|
|
created.append(_event_to_dict(row))
|
|
except Exception as exc:
|
|
failed.append({'ruleId': rule.id, 'reason': _redact(str(exc))})
|
|
return {'created': len(created), 'suppressed': len(suppressed), 'failed': len(failed), 'events': created, 'suppressedEvents': suppressed, 'failedEvents': failed}
|
|
|
|
|
|
def _auto_resolve_stale_connector_events(session: Session, current_events: list[dict[str, Any]]) -> int:
|
|
current_targets = {
|
|
str(event.get('target'))
|
|
for event in current_events
|
|
if event.get('source') == 'connector' and event.get('event_type') in {'connector.missing_config', 'connector.failed'}
|
|
}
|
|
rows = session.exec(
|
|
select(NotificationEvent)
|
|
.where(NotificationEvent.source == 'connector')
|
|
.where(NotificationEvent.event_type.in_(['connector.missing_config', 'connector.failed']))
|
|
.where(NotificationEvent.status != 'dismissed')
|
|
).all()
|
|
resolved = 0
|
|
for row in rows:
|
|
if str(row.target) in current_targets:
|
|
continue
|
|
row.status = 'dismissed'
|
|
row.reason = 'auto_resolved'
|
|
row.read = True
|
|
session.add(row)
|
|
resolved += 1
|
|
if resolved:
|
|
session.commit()
|
|
return resolved
|
|
|
|
|
|
def _auto_resolve_stale_safety_events(session: Session, current_events: list[dict[str, Any]]) -> int:
|
|
def event_identity(event: dict[str, Any]) -> str:
|
|
payload = event.get('payload') if isinstance(event.get('payload'), dict) else {}
|
|
return str(payload.get('findingId') or event.get('dedupe_key') or '')
|
|
|
|
current_ids = {
|
|
event_identity(event)
|
|
for event in current_events
|
|
if event.get('source') == 'safety' and event.get('event_type') == 'safety.finding'
|
|
}
|
|
rows = session.exec(
|
|
select(NotificationEvent)
|
|
.where(NotificationEvent.source == 'safety')
|
|
.where(NotificationEvent.event_type == 'safety.finding')
|
|
.where(NotificationEvent.status != 'dismissed')
|
|
).all()
|
|
resolved = 0
|
|
for row in rows:
|
|
payload = row.payload_json if isinstance(row.payload_json, dict) else {}
|
|
row_identity = str(payload.get('findingId') or row.dedupe_key)
|
|
if row_identity in current_ids:
|
|
continue
|
|
row.status = 'dismissed'
|
|
row.reason = 'auto_resolved'
|
|
row.read = True
|
|
session.add(row)
|
|
resolved += 1
|
|
if resolved:
|
|
session.commit()
|
|
return resolved
|
|
|
|
|
|
def _auto_resolve_stale_media_events(session: Session, current_events: list[dict[str, Any]]) -> int:
|
|
current_keys = {
|
|
str(event.get('dedupe_key') or '')
|
|
for event in current_events
|
|
if event.get('source') == 'media_pipeline' and event.get('event_type') == 'media.issue'
|
|
}
|
|
rows = session.exec(
|
|
select(NotificationEvent)
|
|
.where(NotificationEvent.source == 'media_pipeline')
|
|
.where(NotificationEvent.event_type == 'media.issue')
|
|
.where(NotificationEvent.status != 'dismissed')
|
|
).all()
|
|
resolved = 0
|
|
for row in rows:
|
|
if str(row.dedupe_key or '') in current_keys:
|
|
continue
|
|
row.status = 'dismissed'
|
|
row.reason = 'auto_resolved'
|
|
row.read = True
|
|
session.add(row)
|
|
resolved += 1
|
|
if resolved:
|
|
session.commit()
|
|
return resolved
|
|
|
|
|
|
def _reconcile_current_state(session: Session) -> int:
|
|
current_events = [*_connector_events(session), *_safety_events(session), *_media_events(session)]
|
|
return (
|
|
_auto_resolve_stale_connector_events(session, current_events)
|
|
+ _auto_resolve_stale_safety_events(session, current_events)
|
|
+ _auto_resolve_stale_media_events(session, current_events)
|
|
)
|
|
|
|
|
|
def _safety_events(session: Session) -> list[dict[str, Any]]:
|
|
dashboard = safety_center_service.get_dashboard(session)
|
|
events: list[dict[str, Any]] = []
|
|
for finding in dashboard.get('findings', []):
|
|
if finding.get('status') != 'open':
|
|
continue
|
|
events.append({
|
|
'source': 'safety',
|
|
'event_type': 'safety.finding',
|
|
'severity': finding.get('severity', 'medium'),
|
|
'title': finding.get('title', 'Safety finding'),
|
|
'message': finding.get('description') or finding.get('title') or 'Safety finding',
|
|
'category': finding.get('category') or 'safety',
|
|
'target': ','.join(finding.get('affectedResources') or [])[:160] or 'safety-center',
|
|
'dedupe_key': f"safety.finding:{finding.get('id')}",
|
|
'payload': {'findingId': finding.get('id'), 'source': finding.get('source'), 'category': finding.get('category')},
|
|
})
|
|
return events
|
|
|
|
|
|
def _connector_events(session: Session) -> list[dict[str, Any]]:
|
|
result = []
|
|
for connector in session.exec(select(Connector)).all():
|
|
status = str(connector.status or 'unknown')
|
|
if status in {'missing_config', 'disconnected', 'error'}:
|
|
event_type = 'connector.failed' if status == 'error' else 'connector.missing_config'
|
|
result.append({
|
|
'source': 'connector',
|
|
'event_type': event_type,
|
|
'severity': 'medium' if event_type == 'connector.missing_config' else 'high',
|
|
'title': f'{connector.name} connector vraagt aandacht',
|
|
'message': f'{connector.name} connector status is {status}.',
|
|
'category': 'connector_config',
|
|
'target': connector.id,
|
|
'dedupe_key': f'{event_type}:{connector.id}',
|
|
'payload': {'connectorId': connector.id, 'status': status, 'secretRef': connector.secret_ref},
|
|
})
|
|
return result
|
|
|
|
|
|
def _media_events(session: Session) -> list[dict[str, Any]]:
|
|
summary = media_pipeline_service.summary(session)
|
|
result: list[dict[str, Any]] = []
|
|
counts = summary.get('counts', {})
|
|
for reason in summary.get('risk', {}).get('reasons', []):
|
|
normalized_reason = str(reason).lower()
|
|
if counts.get('stalledDownloads', 0) > 0 and 'deluge' in normalized_reason and 'stalled' in normalized_reason:
|
|
continue
|
|
if counts.get('failedWidefrogJobs', 0) > 0 and 'widefrog' in normalized_reason and 'fail' in normalized_reason:
|
|
continue
|
|
result.append({
|
|
'source': 'media_pipeline',
|
|
'event_type': 'media.issue',
|
|
'severity': 'medium',
|
|
'title': 'Media Pipeline issue',
|
|
'message': str(reason),
|
|
'category': 'media',
|
|
'target': 'media-pipeline',
|
|
'dedupe_key': f"media.issue:{str(reason).lower()}",
|
|
'payload': {'reason': reason},
|
|
})
|
|
if counts.get('stalledDownloads', 0) > 0:
|
|
result.append({'source': 'media_pipeline', 'event_type': 'media.issue', 'severity': 'medium', 'title': 'Deluge stalled downloads', 'message': f"{counts['stalledDownloads']} stalled download(s).", 'category': 'downloads', 'target': 'deluge', 'dedupe_key': 'media.issue:deluge:stalled', 'payload': {'count': counts['stalledDownloads']}})
|
|
if counts.get('failedWidefrogJobs', 0) > 0:
|
|
result.append({'source': 'media_pipeline', 'event_type': 'media.issue', 'severity': 'medium', 'title': 'Widefrog failed jobs', 'message': f"{counts['failedWidefrogJobs']} failed Widefrog job(s).", 'category': 'widefrog', 'target': 'widefrog', 'dedupe_key': 'media.issue:widefrog:failed', 'payload': {'count': counts['failedWidefrogJobs']}})
|
|
return result
|
|
|
|
|
|
def _audit_events(session: Session) -> list[dict[str, Any]]:
|
|
rows = session.exec(select(AuditEvent).order_by(AuditEvent.timestamp.desc())).all()
|
|
result = []
|
|
for row in rows[:50]:
|
|
if row.policy_decision == 'blocked' or row.result == 'blocked':
|
|
event_type = 'audit.blocked_action' if row.risk in {'high', 'critical'} else ('audit.voice_denied' if row.source == 'voice' else 'audit.blocked_action')
|
|
result.append({
|
|
'source': 'audit',
|
|
'event_type': event_type,
|
|
'severity': 'high' if row.risk in {'high', 'critical'} else 'medium',
|
|
'title': 'Blocked dangerous action attempt' if event_type == 'audit.blocked_action' else 'Voice action denied',
|
|
'message': f'{row.action_id} on {row.target} was blocked.',
|
|
'category': 'policy',
|
|
'target': row.target,
|
|
'dedupe_key': f'{event_type}:{row.action_id}:{row.target}',
|
|
'payload': {'auditId': row.id, 'action': row.action_id, 'risk': row.risk},
|
|
})
|
|
return result
|
|
|
|
|
|
def _inventory_events(session: Session) -> list[dict[str, Any]]:
|
|
needs_review = session.exec(select(App).where(App.needs_review == True)).all() # noqa: E712
|
|
if not needs_review:
|
|
return []
|
|
return [{
|
|
'source': 'inventory',
|
|
'event_type': 'inventory.needs_review',
|
|
'severity': 'medium',
|
|
'title': 'Inventory review nodig',
|
|
'message': f'{len(needs_review)} app(s) vragen review.',
|
|
'category': 'inventory',
|
|
'target': 'inventory',
|
|
'dedupe_key': 'inventory.needs_review',
|
|
'payload': {'count': len(needs_review)},
|
|
}]
|
|
|
|
|
|
def _setup_events(session: Session) -> list[dict[str, Any]]:
|
|
latest = session.exec(select(DiscoveryRun).order_by(DiscoveryRun.started_at.desc())).first()
|
|
if latest:
|
|
return []
|
|
return [{
|
|
'source': 'setup',
|
|
'event_type': 'setup.incomplete',
|
|
'severity': 'medium',
|
|
'title': 'Setup incomplete',
|
|
'message': 'Discovery is nog niet uitgevoerd.',
|
|
'category': 'setup',
|
|
'target': 'setup',
|
|
'dedupe_key': 'setup.incomplete',
|
|
'payload': {'missing': 'discovery'},
|
|
}]
|
|
|
|
|
|
def evaluate_source(session: Session, source: str = 'all') -> dict[str, Any]:
|
|
with _EVALUATION_LOCK:
|
|
return _evaluate_source_locked(session, source)
|
|
|
|
|
|
def _evaluate_source_locked(session: Session, source: str = 'all') -> dict[str, Any]:
|
|
source_map = {
|
|
'safety': _safety_events,
|
|
'connectors': _connector_events,
|
|
'connector': _connector_events,
|
|
'media': _media_events,
|
|
'media_pipeline': _media_events,
|
|
'audit': _audit_events,
|
|
'inventory': _inventory_events,
|
|
'setup': _setup_events,
|
|
}
|
|
if source == 'all':
|
|
events: list[dict[str, Any]] = []
|
|
for key in ['safety', 'connectors', 'media', 'audit', 'inventory', 'setup']:
|
|
events.extend(source_map[key](session))
|
|
else:
|
|
events = source_map.get(source, lambda _: [])(session)
|
|
resolved = 0
|
|
if source in {'all', 'connectors', 'connector'}:
|
|
resolved += _auto_resolve_stale_connector_events(session, events)
|
|
if source in {'all', 'safety'}:
|
|
resolved += _auto_resolve_stale_safety_events(session, events)
|
|
if source in {'all', 'media', 'media_pipeline'}:
|
|
resolved += _auto_resolve_stale_media_events(session, events)
|
|
result = evaluate_rules(session, events)
|
|
result['resolved'] = resolved
|
|
result['evaluated'] = len(events)
|
|
result['source'] = source
|
|
return result
|
|
|
|
|
|
def summary(session: Session) -> dict[str, Any]:
|
|
events = session.exec(select(NotificationEvent)).all()
|
|
rules = session.exec(select(NotificationRule)).all()
|
|
channels = session.exec(select(NotificationChannel)).all()
|
|
unread = [event for event in events if not event.read and event.status not in {'dismissed', 'suppressed'}]
|
|
suppressed = [event for event in events if event.status == 'suppressed']
|
|
failed = [event for event in events if event.status == 'failed']
|
|
delivered = [event for event in events if event.status not in {'dismissed', 'suppressed'}]
|
|
last = max(delivered, key=lambda event: event.created_at) if delivered else None
|
|
return {
|
|
'unreadCount': len(unread),
|
|
'activeRulesCount': len([rule for rule in rules if rule.enabled]),
|
|
'suppressedCount': len(suppressed),
|
|
'failedCount': len(failed),
|
|
'lastNotification': _event_to_dict(last) if last else None,
|
|
'channels': [_channel_to_dict(channel, session) for channel in channels],
|
|
}
|