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 <noreply@anthropic.com>
This commit is contained in:
2026-08-22 19:31:46 +02:00
co-authored by Claude Opus 5
parent 89d617ab70
commit 1eb65809ec
7 changed files with 167 additions and 26 deletions
+44 -17
View File
@@ -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=
+27 -1
View File
@@ -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)
```
+4 -2
View File
@@ -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:-}
+2 -1
View File
@@ -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
+59 -3
View File
@@ -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) {