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:
@@ -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).
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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=
|
||||
|
||||
@@ -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)
|
||||
```
|
||||
|
||||
|
||||
@@ -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:-}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user