Files
nexarch/internal/webhook/webhook.go
T
patrickandClaude Sonnet 5 74c052a3e4
CI / Web-Barrierefreiheitspruefung (axe, Tastatur, Reflow, Textabstaende) (push) Canceled after 0s
CI / Core-Modul (Build/Vet/Test) (push) Canceled after 0s
CI / DMS Security-Test-Gate (AC5, Merge-Gate) (push) Canceled after 0s
CI / DMS-Modul (Lint/Build/Test/Coverage/Artefakt) (push) Canceled after 0s
CI / DMS-Modul Supply-Chain-Scan (govulncheck) (push) Canceled after 0s
CI / Web (dms-browser) (push) Canceled after 0s
CI / Web (pdf-viewer) (push) Canceled after 0s
CI / Web (shl) (push) Canceled after 0s
Core-Schnittstellen-Vertragstests / contract-tests (push) Canceled after 0s
Supply-Chain-Scan (Go) / govulncheck (push) Canceled after 0s
OPS-10: F-25 Neuverpackung der Modul-Signaturschluessel bei Schluesselwechsel (RewrapAll, -rewrap-module-keys)
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-22 10:24:50 +02:00

160 lines
5.7 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
}
ciphertext, err := kek.EncryptWithTenantKEK(s.secretKey, []byte(secret))
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"