Manage weekly report recipients centrally
Some checks failed
OfficeCom Sentinel Client / validate-client (push) Successful in 23s
OfficeCom Sentinel Client / build-client-windows (push) Failing after 19s

This commit is contained in:
OfficeCom Codex
2026-07-26 11:09:26 +02:00
parent 854c99e3b2
commit dfdae7a532
7 changed files with 156 additions and 9 deletions

View File

@@ -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

View File

@@ -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:

View File

@@ -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; } }

View File

@@ -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>

View 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 %}

View File

@@ -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,

View 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;