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
+7 -2
View File
@@ -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 <dieses Repository> 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).
+24
View File
@@ -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
+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) {