Manage weekly report recipients centrally
This commit is contained in:
@@ -5,3 +5,4 @@ DB_USER=ocsentinel_debug
|
||||
DB_PASSWORD=replace-with-server-generated-password
|
||||
DASHBOARD_USER=ocsentinel-debug
|
||||
DASHBOARD_PASSWORD=replace-with-server-generated-password
|
||||
DASHBOARD_CSRF_SECRET=replace-with-server-generated-secret
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
import os
|
||||
from functools import wraps
|
||||
|
||||
import psycopg
|
||||
from flask import Flask, Response, abort, render_template
|
||||
from flask import Flask, Response, abort, redirect, render_template, request, url_for
|
||||
|
||||
|
||||
app = Flask(__name__)
|
||||
@@ -35,6 +36,17 @@ def requires_auth(view):
|
||||
return wrapped
|
||||
|
||||
|
||||
def csrf_token():
|
||||
secret = os.environ["DASHBOARD_CSRF_SECRET"].encode("utf-8")
|
||||
return hmac.new(secret, b"recipient-rules", hashlib.sha256).hexdigest()
|
||||
|
||||
|
||||
def require_csrf():
|
||||
supplied = request.form.get("csrf_token", "")
|
||||
if not hmac.compare_digest(supplied, csrf_token()):
|
||||
abort(400)
|
||||
|
||||
|
||||
@app.get("/")
|
||||
@requires_auth
|
||||
def overview():
|
||||
@@ -134,6 +146,80 @@ def weekly_report(report_id):
|
||||
return render_template("weekly_report.html", report=report)
|
||||
|
||||
|
||||
@app.get("/recipients")
|
||||
@requires_auth
|
||||
def recipients():
|
||||
with db_connection() as connection, connection.cursor() as cursor:
|
||||
cursor.execute(
|
||||
"""
|
||||
SELECT id, organization_id, organization_name, recipient_email, enabled
|
||||
FROM ocsentinel.organization_report_recipient
|
||||
ORDER BY organization_id = '*', organization_name, recipient_email
|
||||
"""
|
||||
)
|
||||
rules = cursor.fetchall()
|
||||
cursor.execute(
|
||||
"""
|
||||
SELECT DISTINCT payload #>> '{NinjaOne,OrganizationId}',
|
||||
payload #>> '{NinjaOne,OrganizationName}'
|
||||
FROM ocsentinel.current_device_status
|
||||
WHERE coalesce(payload #>> '{NinjaOne,OrganizationId}', '') <> ''
|
||||
ORDER BY 2
|
||||
"""
|
||||
)
|
||||
organizations = cursor.fetchall()
|
||||
|
||||
return render_template("recipients.html", rules=rules, organizations=organizations, csrf_token=csrf_token())
|
||||
|
||||
|
||||
@app.post("/recipients")
|
||||
@requires_auth
|
||||
def add_recipient():
|
||||
require_csrf()
|
||||
organization_id = request.form.get("organization_id", "").strip()
|
||||
organization_name = request.form.get("organization_name", "").strip()
|
||||
recipient_email = request.form.get("recipient_email", "").strip().lower()
|
||||
if not organization_id or not organization_name or "@" not in recipient_email:
|
||||
abort(400)
|
||||
|
||||
with db_connection() as connection, connection.cursor() as cursor:
|
||||
cursor.execute(
|
||||
"""
|
||||
INSERT INTO ocsentinel.organization_report_recipient
|
||||
(organization_id, organization_name, recipient_email)
|
||||
VALUES (%s, %s, %s)
|
||||
ON CONFLICT (organization_id, recipient_email) DO NOTHING
|
||||
""",
|
||||
(organization_id, organization_name, recipient_email),
|
||||
)
|
||||
connection.commit()
|
||||
|
||||
return redirect(url_for("recipients"))
|
||||
|
||||
|
||||
@app.post("/recipients/<int:rule_id>/toggle")
|
||||
@requires_auth
|
||||
def toggle_recipient(rule_id):
|
||||
require_csrf()
|
||||
with db_connection() as connection, connection.cursor() as cursor:
|
||||
cursor.execute(
|
||||
"UPDATE ocsentinel.organization_report_recipient SET enabled = NOT enabled WHERE id = %s",
|
||||
(rule_id,),
|
||||
)
|
||||
connection.commit()
|
||||
return redirect(url_for("recipients"))
|
||||
|
||||
|
||||
@app.post("/recipients/<int:rule_id>/delete")
|
||||
@requires_auth
|
||||
def delete_recipient(rule_id):
|
||||
require_csrf()
|
||||
with db_connection() as connection, connection.cursor() as cursor:
|
||||
cursor.execute("DELETE FROM ocsentinel.organization_report_recipient WHERE id = %s", (rule_id,))
|
||||
connection.commit()
|
||||
return redirect(url_for("recipients"))
|
||||
|
||||
|
||||
@app.get("/healthz")
|
||||
def healthz():
|
||||
try:
|
||||
|
||||
@@ -10,4 +10,6 @@ main { max-width:1280px; margin:auto; padding:58px 6vw 80px; }.hero { max-width:
|
||||
table { width:100%; border-collapse:collapse; font-family:Arial,sans-serif; font-size:13px; } th { text-align:left; color:var(--muted); font-size:10px; letter-spacing:.1em; text-transform:uppercase; } th,td { padding:13px 8px; border-bottom:1px solid var(--line); } td a { color:var(--green); font-weight:700; text-decoration:none; }.state { display:inline-block; padding:4px 7px; border-radius:12px; background:#e2efe6; color:var(--green); font:700 10px Arial,sans-serif; text-transform:uppercase; }.state.warning { background:#fff0d7; color:var(--amber); }.state.critical { background:#ffe0db; color:var(--red); } pre { margin:0; padding:18px; overflow:auto; color:#dce7da; background:#13221b; border-radius:4px; font:12px/1.5 'Cascadia Code',Consolas,monospace; }.table-wrap { overflow:auto; }
|
||||
.report-frame { background:#fff; border:1px solid var(--line); box-shadow:0 12px 40px rgba(20,35,27,.1); }
|
||||
.panel-heading p:last-child { max-width:720px; margin:-13px 0 20px; color:var(--muted); font-size:14px; }.calm-panel { border-color:#b9d8c2; background:#f4fbf5; }
|
||||
.recipient-form { display:grid; grid-template-columns:minmax(220px,1fr) minmax(260px,1fr) auto; gap:14px; align-items:end; }.recipient-form label { display:grid; gap:6px; color:var(--muted); font:700 10px Arial,sans-serif; letter-spacing:.08em; text-transform:uppercase; }.recipient-form input,.recipient-form select { min-height:40px; padding:9px 10px; border:1px solid var(--line); border-radius:4px; background:#fff; color:var(--ink); font:14px Arial,sans-serif; }.recipient-form button,.rule-actions button { min-height:40px; padding:9px 13px; border:1px solid var(--green); border-radius:4px; background:var(--green); color:#fff; cursor:pointer; font:700 12px Arial,sans-serif; }.rule-actions { display:flex; gap:8px; }.rule-actions form { margin:0; }.rule-actions .button-secondary { border-color:#d8d4c6; background:#fffdf7; color:var(--ink); }.rule-actions .button-danger { border-color:#e3afa7; background:#fff0ed; color:#8a2a20; }
|
||||
@media (max-width:850px) { .recipient-form { grid-template-columns:1fr; }.rule-actions { min-width:220px; } }
|
||||
@media (max-width:850px) { .metrics { grid-template-columns:repeat(2,1fr); }.metrics article:last-child { grid-column:span 2; }.masthead { height:auto; min-height:70px; padding:14px 5vw; align-items:flex-start; }.header-links { justify-content:flex-end; flex-wrap:wrap; }.badge { display:none; } main { padding:38px 5vw; }.situation { align-items:flex-start; flex-direction:column; } }
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
<body class="app-shell">
|
||||
<header class="masthead">
|
||||
<a href="/" class="brand"><span>OC</span> Sentinel</a>
|
||||
<div class="header-links"><a class="{{ 'active' if request.endpoint == 'overview' else '' }}" href="/">Sicherheitslage</a><a class="{{ 'active' if request.endpoint in ('reports', 'weekly_report') else '' }}" href="{{ url_for('reports') }}">Wochenberichte</a><div class="badge">interner Sicherheitsbereich</div></div>
|
||||
<div class="header-links"><a class="{{ 'active' if request.endpoint == 'overview' else '' }}" href="/">Sicherheitslage</a><a class="{{ 'active' if request.endpoint in ('reports', 'weekly_report') else '' }}" href="{{ url_for('reports') }}">Wochenberichte</a><a class="{{ 'active' if request.endpoint in ('recipients', 'add_recipient', 'toggle_recipient', 'delete_recipient') else '' }}" href="{{ url_for('recipients') }}">Empfaenger</a><div class="badge">interner Sicherheitsbereich</div></div>
|
||||
</header>
|
||||
<main>{% block content %}{% endblock %}</main>
|
||||
</body>
|
||||
|
||||
19
infra/debug-dashboard/templates/recipients.html
Normal file
19
infra/debug-dashboard/templates/recipients.html
Normal file
@@ -0,0 +1,19 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Berichtsempfaenger - OCSentinel{% endblock %}
|
||||
{% block content %}
|
||||
<section class="hero compact"><p class="eyebrow">Wochenberichte</p><h1>Berichtsempfaenger</h1><p>Diese Regeln bestimmen, wer den Wochenbericht einer Organisation per E-Mail erhaelt.</p></section>
|
||||
|
||||
<section class="panel"><div class="panel-heading"><p class="eyebrow">Neue Regel</p><h2>Empfaenger hinzufuegen</h2></div>
|
||||
<form class="recipient-form" method="post" action="{{ url_for('add_recipient') }}">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<label>Organisation<select name="organization_id" id="organization_id" required onchange="document.getElementById('organization_name').value=this.options[this.selectedIndex].dataset.name"><option value="*" data-name="Alle Organisationen">Alle Organisationen</option>{% for organization in organizations %}<option value="{{ organization[0] }}" data-name="{{ organization[1] }}">{{ organization[1] }}</option>{% endfor %}</select></label>
|
||||
<input type="hidden" name="organization_name" id="organization_name" value="Alle Organisationen">
|
||||
<label>E-Mail-Adresse<input type="email" name="recipient_email" placeholder="name@officecom.it" required></label>
|
||||
<button type="submit">Empfaenger speichern</button>
|
||||
</form></section>
|
||||
|
||||
<section class="panel"><div class="panel-heading"><p class="eyebrow">Aktive Regeln</p><h2>E-Mail-Verteiler</h2><p>"Alle Organisationen" wird zu jedem organisationsspezifischen Verteiler hinzugefuegt.</p></div>
|
||||
<div class="table-wrap"><table><thead><tr><th>Organisation</th><th>E-Mail-Adresse</th><th>Status</th><th>Aktion</th></tr></thead><tbody>
|
||||
{% for rule in rules %}<tr><td>{{ rule[2] }}</td><td>{{ rule[3] }}</td><td><span class="state {{ 'ok' if rule[4] else 'warning' }}">{{ 'aktiv' if rule[4] else 'pausiert' }}</span></td><td class="rule-actions"><form method="post" action="{{ url_for('toggle_recipient', rule_id=rule[0]) }}"><input type="hidden" name="csrf_token" value="{{ csrf_token }}"><button class="button-secondary" type="submit">{{ 'Pausieren' if rule[4] else 'Aktivieren' }}</button></form><form method="post" action="{{ url_for('delete_recipient', rule_id=rule[0]) }}"><input type="hidden" name="csrf_token" value="{{ csrf_token }}"><button class="button-danger" type="submit">Loeschen</button></form></td></tr>{% else %}<tr><td colspan="4">Noch keine Empfaengerregeln angelegt.</td></tr>{% endfor %}
|
||||
</tbody></table></div></section>
|
||||
{% endblock %}
|
||||
@@ -37,13 +37,26 @@
|
||||
},
|
||||
{
|
||||
"parameters": {
|
||||
"jsCode": "const report = $('Build Organization HTML Reports').item.json;\nif ($json.emailEnabled !== true) return [];\nconst organizationName = String(report.organizationName || 'Unbekannte Organisation');\nconst recipients = ['lg@officecom.it', 'rk@officecom.biz'];\nconst davidOrganizations = [/^Mildenberger Verlag$/i, /^Kirsch\\b/i];\nif (davidOrganizations.some((pattern) => pattern.test(organizationName))) recipients.push('dd@officecom.it');\nreturn [{ json: { ...report, recipients } }];"
|
||||
"operation": "executeQuery",
|
||||
"query": "SELECT coalesce(array_agg(recipient_email ORDER BY recipient_email), ARRAY[]::text[]) AS recipients\nFROM ocsentinel.organization_report_recipient\nWHERE enabled = TRUE AND (organization_id = '*' OR organization_id = $1);",
|
||||
"options": { "queryReplacement": "={{ [ $('Build Organization HTML Reports').item.json.organizationId ] }}" }
|
||||
},
|
||||
"id": "assign-report-recipients",
|
||||
"name": "Empfaenger je Organisation festlegen",
|
||||
"id": "load-report-recipients",
|
||||
"name": "Empfaenger aus zentraler Zuordnung laden",
|
||||
"type": "n8n-nodes-base.postgres",
|
||||
"typeVersion": 2.5,
|
||||
"position": [1560, 300]
|
||||
,"credentials": { "postgres": { "id": "WkjY0kIF3kHvREys", "name": "OCSentinel PostgreSQL" } }
|
||||
},
|
||||
{
|
||||
"parameters": {
|
||||
"jsCode": "const report = $('Build Organization HTML Reports').item.json;\nconst emailEnabled = $('E-Mail-Versand aktiv').item.json.emailEnabled === true;\nif (!emailEnabled) return [];\nconst recipients = Array.from(new Set($json.recipients || []));\nif (recipients.length === 0) throw new Error(`No weekly report recipients configured for ${report.organizationName}.`);\nreturn [{ json: { ...report, recipients } }];"
|
||||
},
|
||||
"id": "prepare-report-email",
|
||||
"name": "E-Mail vorbereiten",
|
||||
"type": "n8n-nodes-base.code",
|
||||
"typeVersion": 2,
|
||||
"position": [1560, 300]
|
||||
"position": [1800, 300]
|
||||
},
|
||||
{
|
||||
"parameters": {
|
||||
@@ -57,7 +70,7 @@
|
||||
"name": "Send Weekly Organization Report",
|
||||
"type": "n8n-nodes-base.emailSend",
|
||||
"typeVersion": 2.1,
|
||||
"position": [1800, 300],
|
||||
"position": [2040, 300],
|
||||
"credentials": { "smtp": { "id": "vafGYgYzM9SbxQbW", "name": "SMTP account" } }
|
||||
}
|
||||
],
|
||||
@@ -66,8 +79,9 @@
|
||||
"Load Latest Device Reports": { "main": [[{ "node": "Build Organization HTML Reports", "type": "main", "index": 0 }]] },
|
||||
"Build Organization HTML Reports": { "main": [[{ "node": "Store Weekly Organization Reports", "type": "main", "index": 0 }]] },
|
||||
"Store Weekly Organization Reports": { "main": [[{ "node": "E-Mail-Versand aktiv", "type": "main", "index": 0 }]] },
|
||||
"E-Mail-Versand aktiv": { "main": [[{ "node": "Empfaenger je Organisation festlegen", "type": "main", "index": 0 }]] },
|
||||
"Empfaenger je Organisation festlegen": { "main": [[{ "node": "Send Weekly Organization Report", "type": "main", "index": 0 }]] }
|
||||
"E-Mail-Versand aktiv": { "main": [[{ "node": "Empfaenger aus zentraler Zuordnung laden", "type": "main", "index": 0 }]] },
|
||||
"Empfaenger aus zentraler Zuordnung laden": { "main": [[{ "node": "E-Mail vorbereiten", "type": "main", "index": 0 }]] },
|
||||
"E-Mail vorbereiten": { "main": [[{ "node": "Send Weekly Organization Report", "type": "main", "index": 0 }]] }
|
||||
},
|
||||
"settings": { "executionOrder": "v1", "timezone": "Europe/Berlin" },
|
||||
"active": true,
|
||||
|
||||
25
infra/postgres/002_organization_report_recipients.sql
Normal file
25
infra/postgres/002_organization_report_recipients.sql
Normal file
@@ -0,0 +1,25 @@
|
||||
-- Central recipient rules for OCSentinel weekly organization reports.
|
||||
-- The '*' organization ID applies to every organization.
|
||||
|
||||
BEGIN;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS ocsentinel.organization_report_recipient (
|
||||
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
||||
organization_id TEXT NOT NULL,
|
||||
organization_name TEXT NOT NULL,
|
||||
recipient_email TEXT NOT NULL,
|
||||
enabled BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
UNIQUE (organization_id, recipient_email)
|
||||
);
|
||||
|
||||
INSERT INTO ocsentinel.organization_report_recipient
|
||||
(organization_id, organization_name, recipient_email)
|
||||
VALUES
|
||||
('*', 'Alle Organisationen', 'lg@officecom.it'),
|
||||
('*', 'Alle Organisationen', 'rk@officecom.biz'),
|
||||
('9', 'Mildenberger Verlag', 'dd@officecom.it'),
|
||||
('16', 'Kirsch GmbH', 'dd@officecom.it')
|
||||
ON CONFLICT (organization_id, recipient_email) DO NOTHING;
|
||||
|
||||
COMMIT;
|
||||
Reference in New Issue
Block a user