From 1eb65809ec497829086b3b3f637f59966000e051 Mon Sep 17 00:00:00 2001 From: duffyduck Date: Sat, 22 Aug 2026 19:31:46 +0200 Subject: [PATCH] Gegenbuch: Dienstkonto statt Token, .env mit gueltigen Werten Blocker behoben: Ich hatte ein dauerhaftes API-Token vorausgesetzt - das gibt es in OpenCRM nicht. Zugangstoken leben 15 Minuten, der Gegenbuch-Container waere nach dem ersten Durchlauf gestorben. Aufgefallen erst durch die Frage des Betreibers, woher er den Token nimmt. Loesung: Das Gegenbuch meldet sich bei jedem Lauf selbst an, mit einem eigenen Benutzerkonto, dessen Rolle ausschliesslich audit:read traegt. Damit kann es nur Pruefwerte lesen - keine Kundendaten, keine Aenderungen. CRM_TOKEN bleibt fuer Tests moeglich, ist aber nicht mehr der Normalweg. Fehlerfaelle (falsches Passwort, Anmelde-Bremse, CRM nicht erreichbar) werden unterschieden und im Klartext gemeldet. .env.example nennt jetzt zu jedem Schalter die gueltigen Werte - bisher liess sich nur raten, ob es prod oder production heisst. Einschliesslich des Falls "erst nur Staging testen, Prod spaeter dazunehmen". Beide READMEs um "Zugang einrichten" ergaenzt: Rolle mit nur audit:read, Benutzer damit, Zugangsdaten in die .env. Mit dem Hinweis, dass jede Anmeldung im Audit-Log erscheint - gewollt, denn so faellt auch auf, wenn das Gegenbuch aufhoert zu arbeiten. Verifiziert gegen eine Attrappe, die wie das echte CRM eine Anmeldung verlangt. Co-Authored-By: Claude Opus 5 --- README.md | 9 +++- docs/todo.md | 24 +++++++++++ tools/audit-notary/.env.example | 61 ++++++++++++++++++-------- tools/audit-notary/README.md | 28 +++++++++++- tools/audit-notary/docker-compose.yml | 6 ++- tools/audit-notary/entrypoint.sh | 3 +- tools/audit-notary/notary.mjs | 62 +++++++++++++++++++++++++-- 7 files changed, 167 insertions(+), 26 deletions(-) diff --git a/README.md b/README.md index 46da5340..dfe0bd4a 100644 --- a/README.md +++ b/README.md @@ -466,7 +466,7 @@ beim nächsten Durchlauf auf. Die Richtung ist dabei entscheidend: ``` -Gegenbuch ──holt lesend──> OpenCRM (HTTPS, Token nur mit audit:read) +Gegenbuch ──holt lesend──> OpenCRM (HTTPS, Konto nur mit audit:read) OpenCRM ─────────────────> (kennt das Gegenbuch nicht) ``` @@ -479,10 +479,15 @@ keine Geheimnisse enthält. ```bash git clone opencrm cd opencrm/tools/audit-notary -cp .env.example .env # CRM-Adresse und Token eintragen +cp .env.example .env # CRM-Adresse und Dienstkonto eintragen docker compose up -d ``` +**Vorher im CRM anlegen:** eine Rolle mit ausschließlich dem Recht +`audit:read` und einen Benutzer damit. Das Gegenbuch meldet sich mit diesem +Konto bei jedem Durchlauf selbst an – ein dauerhaftes Token gibt es nicht, weil +Zugangstoken nach 15 Minuten ablaufen. + Zwei Bücher auf einer Maschine – etwa für Produktion und Test – sind vorgesehen. Alles Weitere in [tools/audit-notary/README.md](tools/audit-notary/README.md). diff --git a/docs/todo.md b/docs/todo.md index 671d9692..f2e0e3d6 100644 --- a/docs/todo.md +++ b/docs/todo.md @@ -97,6 +97,30 @@ isolierte Instanz (keine Multi-Tenancy im Code), Provisioning + Abrechnung ## ✅ Erledigt +- [x] **🔑 Gegenbuch: Dienstkonto statt Token, .env mit gueltigen Werten** (2026-08-22) + - **Blocker gefunden und behoben:** Ich hatte ein dauerhaftes API-Token + vorausgesetzt – das gibt es in OpenCRM gar nicht. Zugangstoken leben + **15 Minuten**; der Gegenbuch-Container waere nach dem ersten Durchlauf + gestorben. Aufgefallen erst, als der Betreiber fragte, woher er den Token + nimmt. + Loesung: Das Gegenbuch meldet sich bei **jedem Lauf selbst an** – mit einem + eigenen Benutzerkonto, dessen Rolle ausschliesslich `audit:read` traegt. + Damit kann es nur Pruefwerte lesen: keine Kundendaten, keine Aenderungen. + `CRM_TOKEN` bleibt fuer Tests moeglich, ist aber nicht mehr der Normalweg. + - Fehlerfaelle sauber gemeldet: falsches Passwort → Klartext statt HTTP-Code, + Anmelde-Bremse (429) benannt, CRM nicht erreichbar unterschieden. + - **`.env.example` nennt jetzt die gueltigen Werte** – bisher liess sich nur + raten, ob es `prod` oder `production` heisst. Fuer jeden Schalter steht + dabei, was erlaubt ist, inkl. des Falls „erst nur Staging testen, Prod + spaeter dazunehmen“ (`COMPOSE_PROFILES=staging` → `prod,staging`). + - Beide READMEs um „Zugang einrichten“ ergaenzt: Rolle mit nur `audit:read`, + Benutzer damit, Zugangsdaten in die `.env`. Mit dem Hinweis, dass jede + Anmeldung im Audit-Log erscheint – gewollt, denn so sieht man auch, wenn + das Gegenbuch aufhoert zu arbeiten. + - Verifiziert gegen eine Attrappe, die wie das echte CRM eine Anmeldung + verlangt: Anmeldung + Lauf erfolgreich, falsches Passwort → verstaendliche + Meldung, exit 1. + - [x] **🐳 Gegenbuch als Docker-Setup, lokales Buch auf eigener Maschine** (2026-08-22) - Betreiber-Entscheidung: Das Gegenbuch laeuft auf einer **eigenen Maschine** fuer Prod und Staging; ein externes Git-Repository entfaellt, das Buch diff --git a/tools/audit-notary/.env.example b/tools/audit-notary/.env.example index f2cd8784..c75c7a4e 100644 --- a/tools/audit-notary/.env.example +++ b/tools/audit-notary/.env.example @@ -6,41 +6,68 @@ # Was hier passiert: Der Rechner holt regelmäßig einen kurzen Kontrollwert von # OpenCRM ab und schreibt ihn in ein Buch, das nur hier liegt. Wird später im # CRM etwas nachträglich verändert, widerspricht das dem Buch. -# -# Welche Bücher sollen laufen? -COMPOSE_PROFILES=prod,staging -# Nur Kosmetik – steht in den Einträgen des Buchs. + +# ============================================================ +# Welche Bücher sollen laufen? +# ============================================================ +# Gültige Werte: prod | staging | prod,staging | (leer = keins) +# Genau so geschrieben – nicht "production" oder "test". +# +# Nur Staging testen: COMPOSE_PROFILES=staging +# Später Prod dazunehmen: COMPOSE_PROFILES=prod,staging +# Danach: docker compose up -d (der laufende Dienst bleibt unberührt) +COMPOSE_PROFILES=staging + +# Steht in den Einträgen des Buchs. Beliebiger Text, nur Kosmetik. NOTAR_EMAIL=gegenbuch@example.de -# ---------------- Produktion ---------------- -# Von welcher OpenCRM-Instanz wird geholt? + +# ============================================================ +# Produktion +# ============================================================ +# Adresse der OpenCRM-Instanz, von der geholt wird. +# Gültig: vollständige URL mit https:// und OHNE Schrägstrich am Ende. PROD_CRM_URL=https://crm.example.de -# Token eines Benutzers mit dem Recht audit:read – MEHR NICHT. -# Damit lassen sich ausschließlich Prüfwerte lesen: keine Kundendaten, keine -# Änderungen. Selbst wenn es abhandenkommt, ist damit nichts anzufangen. -PROD_CRM_TOKEN= +# Zugang: ein eigenes Benutzerkonto im CRM, das NUR das Recht "audit:read" hat. +# Wie man es anlegt, steht in der README unter "Zugang einrichten". +# +# Das Gegenbuch meldet sich damit bei jedem Durchlauf selbst an. Ein fest +# hinterlegtes Token gibt es bewusst nicht – Zugangstoken laufen nach +# 15 Minuten ab und wären beim nächsten Durchlauf längst ungültig. +PROD_CRM_EMAIL=gegenbuch@deine-domain.de +PROD_CRM_PASSWORD= -# Wie oft geprüft wird (Sekunden). 3600 = stündlich. +# Wie oft geprüft wird, in Sekunden. +# Gültig: ganze Zahl > 0. Üblich: 3600 (stündlich), 900 (viertelstündlich) # Kürzer heißt: kleineres Zeitfenster, in dem eine Änderung unbemerkt bliebe. PROD_INTERVAL=3600 -# Beim ALLERERSTEN Start einmalig auf true setzen, danach wieder leeren. +# Beim ALLERERSTEN Start einmalig setzen, danach wieder leeren. +# Gültige Werte: true | (leer) # Grund: Die erste Eintragung legt fest, was als Ausgangszustand gilt – das # soll nicht versehentlich passieren. PROD_GENESIS_ACK= -# Normalerweise leer lassen. Nur nötig, wenn das Gedächtnis des Gegenbuchs -# verlorenging (z. B. Verzeichnis gelöscht) und du geklärt hast, warum. +# Normalerweise leer lassen. +# Gültige Werte: true | (leer) +# Nur nötig, wenn das Datenverzeichnis verlorenging (z. B. gelöscht) UND du +# geklärt hast, warum. Siehe README, Abschnitt "Wenn das Gedächtnis fehlt". PROD_ADOPT_ACK= -# Wo das Buch liegt. DIESES VERZEICHNIS GEHÖRT INS BACKUP. +# Wo das Buch liegt – relativ zu diesem Verzeichnis. +# DIESES VERZEICHNIS GEHÖRT INS BACKUP (enthält Buch und Signaturschlüssel). PROD_DIR=./data/prod -# ---------------- Test / Staging ---------------- + +# ============================================================ +# Test / Staging +# ============================================================ +# Gleiche Regeln wie oben, eigenes Konto und eigenes Verzeichnis. STAGING_CRM_URL=https://staging.example.de -STAGING_CRM_TOKEN= +STAGING_CRM_EMAIL=gegenbuch@deine-domain.de +STAGING_CRM_PASSWORD= STAGING_INTERVAL=3600 STAGING_GENESIS_ACK= STAGING_ADOPT_ACK= diff --git a/tools/audit-notary/README.md b/tools/audit-notary/README.md index 97d9ac98..eaf8d7f8 100644 --- a/tools/audit-notary/README.md +++ b/tools/audit-notary/README.md @@ -57,10 +57,36 @@ soll nicht versehentlich passieren. getrennte Dienste mit getrennten Verzeichnissen und getrennten Schlüsseln. Welche laufen, steuert `COMPOSE_PROFILES` in der `.env`. +### Zugang einrichten (das brauchst du vorher) + +Das Gegenbuch braucht ein **eigenes Benutzerkonto** im CRM – kein Token. Der +Grund: Zugangstoken laufen nach 15 Minuten ab und wären beim nächsten +stündlichen Durchlauf längst ungültig. Das Gegenbuch meldet sich deshalb bei +jedem Lauf selbst an. + +Im CRM, als Administrator: + +1. **Rolle anlegen**, z. B. `Gegenbuch` – und ihr **ausschließlich** das Recht + `audit:read` geben. Sonst nichts. +2. **Benutzer anlegen**, z. B. `gegenbuch@deine-domain.de`, mit dieser Rolle + und einem langen, zufälligen Passwort. +3. E-Mail und Passwort in die `.env` des Gegenbuchs eintragen + (`PROD_CRM_EMAIL` / `PROD_CRM_PASSWORD`). + +Mit `audit:read` allein kann dieses Konto **nur Prüfwerte lesen** – keine +Kundendaten, keine Verträge, nichts ändern. Selbst wenn die Zugangsdaten +abhandenkommen, ist damit nichts anzufangen. + +Für Produktion und Test jeweils ein eigenes Konto in der jeweiligen Instanz. + +> Jede Anmeldung erscheint im Audit-Log der jeweiligen Instanz. Das ist so +> gewollt: Man sieht, dass das Gegenbuch arbeitet – und wenn es aufhört, fällt +> auch das auf. + ### Wer redet mit wem ``` -Gegenbuch ──holt lesend──> OpenCRM (HTTPS, Token nur mit audit:read) +Gegenbuch ──holt lesend──> OpenCRM (HTTPS, Konto nur mit audit:read) OpenCRM ─────────────────> (kennt das Gegenbuch nicht) ``` diff --git a/tools/audit-notary/docker-compose.yml b/tools/audit-notary/docker-compose.yml index 2a2d4198..fc6880b9 100644 --- a/tools/audit-notary/docker-compose.yml +++ b/tools/audit-notary/docker-compose.yml @@ -26,7 +26,8 @@ services: environment: INSTANZ: prod CRM_URL: ${PROD_CRM_URL} - CRM_TOKEN: ${PROD_CRM_TOKEN} + CRM_EMAIL: ${PROD_CRM_EMAIL} + CRM_PASSWORD: ${PROD_CRM_PASSWORD} NOTARY_INTERVAL: ${PROD_INTERVAL:-3600} NOTAR_EMAIL: ${NOTAR_EMAIL:-gegenbuch@localhost} # Nur beim allerersten Lauf einmalig auf true, danach wieder leeren: @@ -46,7 +47,8 @@ services: environment: INSTANZ: staging CRM_URL: ${STAGING_CRM_URL} - CRM_TOKEN: ${STAGING_CRM_TOKEN} + CRM_EMAIL: ${STAGING_CRM_EMAIL} + CRM_PASSWORD: ${STAGING_CRM_PASSWORD} NOTARY_INTERVAL: ${STAGING_INTERVAL:-3600} NOTAR_EMAIL: ${NOTAR_EMAIL:-gegenbuch@localhost} NOTARY_GENESIS_ACK: ${STAGING_GENESIS_ACK:-} diff --git a/tools/audit-notary/entrypoint.sh b/tools/audit-notary/entrypoint.sh index a0d56534..d58a509b 100755 --- a/tools/audit-notary/entrypoint.sh +++ b/tools/audit-notary/entrypoint.sh @@ -4,7 +4,8 @@ set -uo pipefail : "${INSTANZ:?INSTANZ fehlt (z. B. prod oder staging)}" : "${CRM_URL:?CRM_URL fehlt – von welcher OpenCRM-Instanz soll geholt werden?}" -: "${CRM_TOKEN:?CRM_TOKEN fehlt – Token eines Benutzers mit audit:read}" +: "${CRM_EMAIL:?CRM_EMAIL fehlt – Dienstkonto im CRM mit dem Recht audit:read}" +: "${CRM_PASSWORD:?CRM_PASSWORD fehlt – Passwort dieses Dienstkontos}" INTERVALL="${NOTARY_INTERVAL:-3600}" BUCH=/gegenbuch/buch diff --git a/tools/audit-notary/notary.mjs b/tools/audit-notary/notary.mjs index 9e01b240..1cfe98ef 100755 --- a/tools/audit-notary/notary.mjs +++ b/tools/audit-notary/notary.mjs @@ -132,8 +132,20 @@ if (!SIGN && process.env.NOTARY_INSECURE_ACK !== 'mir-ist-klar-dass-das-ungeschu ); process.exit(1); } -if (!CRM_URL || !CRM_TOKEN) { - console.error('CRM_URL und CRM_TOKEN müssen gesetzt sein.'); +const CRM_EMAIL = process.env.CRM_EMAIL; +const CRM_PASSWORD = process.env.CRM_PASSWORD; + +if (!CRM_URL) { + console.error('CRM_URL muss gesetzt sein.'); + process.exit(1); +} +if (!CRM_TOKEN && !(CRM_EMAIL && CRM_PASSWORD)) { + console.error( + 'Es fehlt der Zugang zum CRM.\n' + + 'Entweder CRM_EMAIL und CRM_PASSWORD eines Dienstkontos setzen (empfohlen –\n' + + 'das Gegenbuch meldet sich dann bei jedem Lauf selbst an), oder ein bereits\n' + + 'vorhandenes CRM_TOKEN mitgeben (läuft nach 15 Minuten ab, nur für Tests).', + ); process.exit(1); } @@ -180,13 +192,56 @@ if (SIGN && !PIN) { } } +// Access-Tokens leben nur 15 Minuten – ein dauerhaft hinterlegtes Token waere +// beim naechsten stuendlichen Lauf laengst abgelaufen. Deshalb meldet sich das +// Gegenbuch mit einem eigenen Dienstkonto an und holt sich pro Lauf ein +// frisches. Das Konto braucht ausschliesslich die Berechtigung `audit:read`. +let zugangsToken = CRM_TOKEN || null; + +async function anmelden() { + let r; + try { + r = await fetch(`${CRM_URL}/api/auth/login`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ email: CRM_EMAIL, password: CRM_PASSWORD }), + }); + } catch (e) { + console.error( + `Das CRM ist nicht erreichbar (${CRM_URL}).\n` + + ` Grund: ${e instanceof Error ? e.message : String(e)}`, + ); + process.exit(1); + } + if (r.status === 401) { + console.error( + 'Anmeldung am CRM abgelehnt – E-Mail oder Passwort des Dienstkontos stimmen nicht.', + ); + process.exit(1); + } + if (r.status === 429) { + console.error('Das CRM hat die Anmeldung wegen zu vieler Versuche gebremst. Später erneut.'); + process.exit(1); + } + if (!r.ok) { + console.error(`Anmeldung am CRM fehlgeschlagen: HTTP ${r.status}`); + process.exit(1); + } + const j = await r.json(); + if (!j.success || !j.data?.token) { + console.error(`Anmeldung am CRM fehlgeschlagen: ${j.error || 'kein Token in der Antwort'}`); + process.exit(1); + } + zugangsToken = j.data.token; +} + async function hole(pfad) { // Fehler werden hier zu einer erklaerenden Zeile – frueher flog ein // Node-Stacktrace hoch, also genau die kryptische erste Zeile, die fuer // git-Meldungen schon abgestellt war. let r; try { - r = await fetch(`${CRM_URL}${pfad}`, { headers: { Authorization: `Bearer ${CRM_TOKEN}` } }); + r = await fetch(`${CRM_URL}${pfad}`, { headers: { Authorization: `Bearer ${zugangsToken}` } }); } catch (e) { console.error( `Das CRM ist nicht erreichbar (${CRM_URL}).\n` + @@ -672,6 +727,7 @@ if (Number.isFinite(MIN_SEQ) && bisher.length < MIN_SEQ) { // 4) Abgleich mit dem CRM, VOR dem Anhaengen. // --------------------------------------------------------------------------- const letzter = bisher[bisher.length - 1]; +if (!CRM_TOKEN) await anmelden(); const aktuell = await hole('/api/audit-logs/checkpoint'); if (letzter) {