diff --git a/infra/debug-dashboard/.env.example b/infra/debug-dashboard/.env.example index da405ab..df441f1 100644 --- a/infra/debug-dashboard/.env.example +++ b/infra/debug-dashboard/.env.example @@ -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 diff --git a/infra/debug-dashboard/app.py b/infra/debug-dashboard/app.py index 2614b27..9d16e52 100644 --- a/infra/debug-dashboard/app.py +++ b/infra/debug-dashboard/app.py @@ -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//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//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: diff --git a/infra/debug-dashboard/static/app.css b/infra/debug-dashboard/static/app.css index 7be7326..fa62d30 100644 --- a/infra/debug-dashboard/static/app.css +++ b/infra/debug-dashboard/static/app.css @@ -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; } } diff --git a/infra/debug-dashboard/templates/base.html b/infra/debug-dashboard/templates/base.html index d88be31..19ce49d 100644 --- a/infra/debug-dashboard/templates/base.html +++ b/infra/debug-dashboard/templates/base.html @@ -9,7 +9,7 @@
OC Sentinel - +
{% block content %}{% endblock %}
diff --git a/infra/debug-dashboard/templates/recipients.html b/infra/debug-dashboard/templates/recipients.html new file mode 100644 index 0000000..5bc18f5 --- /dev/null +++ b/infra/debug-dashboard/templates/recipients.html @@ -0,0 +1,19 @@ +{% extends "base.html" %} +{% block title %}Berichtsempfaenger - OCSentinel{% endblock %} +{% block content %} +

Wochenberichte

Berichtsempfaenger

Diese Regeln bestimmen, wer den Wochenbericht einer Organisation per E-Mail erhaelt.

+ +

Neue Regel

Empfaenger hinzufuegen

+
+ + + + + +
+ +

Aktive Regeln

E-Mail-Verteiler

"Alle Organisationen" wird zu jedem organisationsspezifischen Verteiler hinzugefuegt.

+
+{% for rule in rules %}{% else %}{% endfor %} +
OrganisationE-Mail-AdresseStatusAktion
{{ rule[2] }}{{ rule[3] }}{{ 'aktiv' if rule[4] else 'pausiert' }}
Noch keine Empfaengerregeln angelegt.
+{% endblock %} diff --git a/infra/n8n/ocsentinel-weekly-organization-reports.json b/infra/n8n/ocsentinel-weekly-organization-reports.json index 159babd..e745cdf 100644 --- a/infra/n8n/ocsentinel-weekly-organization-reports.json +++ b/infra/n8n/ocsentinel-weekly-organization-reports.json @@ -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, diff --git a/infra/postgres/002_organization_report_recipients.sql b/infra/postgres/002_organization_report_recipients.sql new file mode 100644 index 0000000..0bf5a66 --- /dev/null +++ b/infra/postgres/002_organization_report_recipients.sql @@ -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;