seal/open (bzw. wrap/unwrap) in allen vier betroffenen Stellen binden ein Additional Authenticated Data an Chiffretext/DEK-Wrapper: ein Austausch zwischen zwei Kontexten faellt beim Entschluesseln auf, statt still zu funktionieren. - dms/internal/crypto/envelope.go + service.go: AAD = Tenant-Slug (bereits vorhandener Parameter in Service.Seal/Open, Signatur unveraendert -- keine der rund 30 externen Aufrufstellen musste angepasst werden) - archive/internal/backupcrypto/envelope.go + reposecret.go/file.go: gleiches Muster, AAD = Tenant-Slug - mail/internal/crypto/envelope.go + service.go + kekversions.go: gleiches Muster, AAD = Tenant-Slug (inkl. ARC-08 KEK-Versionsverwaltung) - internal/kek/master.go + store.go: wrap/unwrap sowie EncryptWithTenantKEK/DecryptWithTenantKEK binden AAD. Die drei echten externen Aufrufer angepasst: ldapsync (Tenant-ID + Zweck), webhook (Ziel-URL des Abonnements), account-api modulekeys (fester Zweck-Bezeichner -- SecretCipher-Interface aus internal/moduletrust reicht keine Datensatz-ID durch, eine Erweiterung wuerde in andere Module hineinwirken, bewusst nicht Teil dieser Kachel) Rueckwaertskompatibel: jedes open/unwrap versucht zuerst mit AAD, faellt bei Fehlschlag auf kein-AAD zurueck -- vor dieser Aenderung versiegelte Datensaetze bleiben lesbar. Bekannte, dokumentierte Grenze: ein Austausch zwischen zwei Datensaetzen DESSELBEN Mandanten/Kontexts bleibt unerkannt (identisches AAD) -- volle Bindung an den einzelnen Datensatz braeuchte eine Datensatz-ID an rund 30 Aufrufstellen, bewusst nicht Teil dieser kleinstmoeglichen Loesung. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
163 lines
5.9 KiB
Go
163 lines
5.9 KiB
Go
// Package webhook implementiert Core API-07: eine zentrale Webhook-Registry
|
|
// und Zustellungs-Engine fuer alle Fachmodule (DMS/Mail/Archive/Workflow/AI).
|
|
// Module reichen Ereignisse EINMAL zur Zustellung ein und implementieren
|
|
// selbst KEINE eigene Retry-/Signatur-Logik — das ist der zentrale Zweck
|
|
// dieser Kachel ("Bewusst vermeiden: jedes Modul baut seine eigene
|
|
// Webhook-Zustellungs-Engine"). Zustellung laeuft ueber dieselbe
|
|
// Postgres-Jobqueue-Konvention (SELECT ... FOR UPDATE SKIP LOCKED) wie
|
|
// internal/tenant.Lifecycle.ProcessDueDeletions (TEN-04) und
|
|
// internal/notify.Dispatcher (CFG-02) — kein Redis/AMQP.
|
|
package webhook
|
|
|
|
import (
|
|
"context"
|
|
"crypto/hmac"
|
|
"crypto/sha256"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"time"
|
|
|
|
"github.com/jackc/pgx/v5/pgxpool"
|
|
|
|
"gitea.perlbach24.de/scripte/nexarch/internal/kek"
|
|
)
|
|
|
|
const (
|
|
StatusPending = "pending"
|
|
StatusDelivered = "delivered"
|
|
StatusFailed = "failed"
|
|
)
|
|
|
|
// Subscription ist EIN externer Abonnent fuer einen Ereignistyp
|
|
// (Akzeptanzkriterium 1: Module registrieren Ereignistypen, externe
|
|
// Abonnenten registrieren Ziel-URLs — dieses Paket modelliert die
|
|
// Abonnenten-Seite; welche Ereignistypen ein Modul anbietet, ist bewusst
|
|
// NICHT Teil dieser Kachel).
|
|
type Subscription struct {
|
|
ID string
|
|
EventType string
|
|
TargetURL string
|
|
Secret string
|
|
CreatedAt time.Time
|
|
}
|
|
|
|
// ErrSecretEncryptionKeyRequired wird geliefert, wenn Store ohne
|
|
// Verschluesselungsschluessel fuer Abonnement-Geheimnisse erzeugt wird
|
|
// (QA-15 F-22: kein Klartext-Fallback, fail closed statt eines stillen
|
|
// unverschluesselten Betriebs).
|
|
var ErrSecretEncryptionKeyRequired = errors.New("webhook: verschluesselungsschluessel fuer geheimnisse fehlt")
|
|
|
|
// Store persistiert Abonnements und Zustellversuche. secretKey verschluesselt
|
|
// jedes Abonnement-Geheimnis (AES-256-GCM ueber internal/kek, derselbe
|
|
// Envelope-Mechanismus wie API-16/FDN-09) — das Geheimnis dient der
|
|
// Signaturpruefung durch den Empfaenger und ist ebenso schuetzenswert wie ein
|
|
// Passwort (QA-15 F-22).
|
|
type Store struct {
|
|
pool *pgxpool.Pool
|
|
secretKey []byte
|
|
}
|
|
|
|
// NewStore erzeugt einen Store. secretKey MUSS gesetzt sein (32 Byte,
|
|
// AES-256) — ohne ihn koennen weder neue Abonnements angelegt noch
|
|
// bestehende Geheimnisse fuer die Zustellung gelesen werden.
|
|
func NewStore(pool *pgxpool.Pool, secretKey []byte) (*Store, error) {
|
|
if len(secretKey) != kek.MasterKeySize {
|
|
return nil, ErrSecretEncryptionKeyRequired
|
|
}
|
|
return &Store{pool: pool, secretKey: secretKey}, nil
|
|
}
|
|
|
|
// Subscribe registriert einen Abonnenten fuer einen Ereignistyp. target_url
|
|
// wird gegen SSRF geprueft (QA-15 F-22, siehe TargetPolicy), secret wird vor
|
|
// dem Speichern verschluesselt und spaeter zur HMAC-Signierung jeder
|
|
// Zustellung an diesen Abonnenten verwendet (Akzeptanzkriterium 3).
|
|
func (s *Store) Subscribe(ctx context.Context, policy TargetPolicy, eventType, targetURL, secret string) (Subscription, error) {
|
|
if err := policy.ValidateTargetURL(ctx, targetURL); err != nil {
|
|
return Subscription{}, err
|
|
}
|
|
// AAD (F-21) bindet das verschluesselte Geheimnis an seine Ziel-URL:
|
|
// ein Austausch des Chiffretexts zwischen zwei Abonnements mit
|
|
// unterschiedlicher Ziel-URL faellt beim Entschluesseln auf.
|
|
ciphertext, err := kek.EncryptWithTenantKEK(s.secretKey, []byte(secret), []byte(targetURL))
|
|
if err != nil {
|
|
return Subscription{}, fmt.Errorf("geheimnis verschluesseln: %w", err)
|
|
}
|
|
var sub Subscription
|
|
sub.EventType, sub.TargetURL, sub.Secret = eventType, targetURL, secret
|
|
row := s.pool.QueryRow(ctx, `
|
|
INSERT INTO webhook_subscriptions (event_type, target_url, secret_ciphertext)
|
|
VALUES ($1, $2, $3)
|
|
RETURNING id, created_at
|
|
`, eventType, targetURL, ciphertext)
|
|
if err := row.Scan(&sub.ID, &sub.CreatedAt); err != nil {
|
|
return Subscription{}, fmt.Errorf("abonnement anlegen: %w", err)
|
|
}
|
|
return sub, nil
|
|
}
|
|
|
|
// Enqueue reicht EIN Ereignis zur Zustellung an ALLE Abonnenten des
|
|
// angegebenen Ereignistyps ein — dies ist die EINZIGE Schnittstelle, die
|
|
// ein Fachmodul braucht (Akzeptanzkriterium 1). Jeder Abonnent erhaelt
|
|
// einen eigenen, unabhaengigen Zustellversuch-Datensatz.
|
|
func (s *Store) Enqueue(ctx context.Context, eventType string, payload any) (int, error) {
|
|
payloadJSON, err := json.Marshal(payload)
|
|
if err != nil {
|
|
return 0, fmt.Errorf("payload serialisieren: %w", err)
|
|
}
|
|
|
|
rows, err := s.pool.Query(ctx, `
|
|
SELECT id FROM webhook_subscriptions WHERE event_type = $1
|
|
`, eventType)
|
|
if err != nil {
|
|
return 0, fmt.Errorf("abonnenten ermitteln: %w", err)
|
|
}
|
|
var subscriptionIDs []string
|
|
for rows.Next() {
|
|
var id string
|
|
if err := rows.Scan(&id); err != nil {
|
|
rows.Close()
|
|
return 0, fmt.Errorf("abonnent lesen: %w", err)
|
|
}
|
|
subscriptionIDs = append(subscriptionIDs, id)
|
|
}
|
|
rows.Close()
|
|
if err := rows.Err(); err != nil {
|
|
return 0, err
|
|
}
|
|
|
|
for _, subID := range subscriptionIDs {
|
|
if _, err := s.pool.Exec(ctx, `
|
|
INSERT INTO webhook_deliveries (subscription_id, event_type, payload, status, next_attempt_at)
|
|
VALUES ($1, $2, $3, $4, now())
|
|
`, subID, eventType, payloadJSON, StatusPending); err != nil {
|
|
return 0, fmt.Errorf("zustellung einreihen: %w", err)
|
|
}
|
|
}
|
|
return len(subscriptionIDs), nil
|
|
}
|
|
|
|
// delivery ist ein interner Datensatz fuer EINEN Zustellversuch, inklusive
|
|
// der zugehoerigen Abonnentendaten (per JOIN geladen).
|
|
type delivery struct {
|
|
ID string
|
|
TargetURL string
|
|
Secret string
|
|
Payload []byte
|
|
Attempt int
|
|
}
|
|
|
|
// Sign berechnet die HMAC-SHA256-Signatur des Payloads (Akzeptanzkriterium
|
|
// 3) — hex-kodiert, damit sie problemlos als HTTP-Header uebertragen werden
|
|
// kann.
|
|
func Sign(secret string, payload []byte) string {
|
|
mac := hmac.New(sha256.New, []byte(secret))
|
|
mac.Write(payload)
|
|
return hex.EncodeToString(mac.Sum(nil))
|
|
}
|
|
|
|
// SignatureHeader ist der HTTP-Header, unter dem die Signatur uebertragen
|
|
// wird — dokumentierter Vertrag fuer Empfaenger (Akzeptanzkriterium 3).
|
|
const SignatureHeader = "X-Nexarch-Signature-256"
|