Files
nexarch/connect/internal/webhooksub/webhooksub.go
patrickandClaude Sonnet 5 0fef62b2a3
CI / Web-Barrierefreiheitspruefung (axe, Tastatur, Reflow, Textabstaende) (push) Canceled after 0s
CI / Web-Barrierefreiheitspruefung (axe, Tastatur, Reflow, Textabstaende) (pull_request) 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 / Connect Security-Test-Gate (QA-01 AC4, Merge-Gate) (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
Supply-Chain-Scan (Go) / govulncheck (push) Canceled after 0s
CI / Core-Modul (Build/Vet/Test) (pull_request) Canceled after 0s
CI / DMS Security-Test-Gate (AC5, Merge-Gate) (pull_request) Canceled after 0s
CI / Connect Security-Test-Gate (QA-01 AC4, Merge-Gate) (pull_request) Canceled after 0s
CI / DMS-Modul Supply-Chain-Scan (govulncheck) (pull_request) Canceled after 0s
CI / Web (dms-browser) (pull_request) Canceled after 0s
CI / Web (pdf-viewer) (pull_request) Canceled after 0s
CI / Web (shl) (pull_request) Canceled after 0s
Core-Schnittstellen-Vertragstests / contract-tests (pull_request) Canceled after 0s
Supply-Chain-Scan (Go) / govulncheck (pull_request) Canceled after 0s
Supply-Chain-Scan (npm) / npm-audit (pull_request) Canceled after 0s
Core-Pflichttest-Gate / pflichttest-gate (pull_request) Canceled after 0s
RBAC-Rollenhierarchie-Pflichtgate / rbac-policy-invariant (pull_request) Canceled after 0s
CI / DMS-Modul (Lint/Build/Test/Coverage/Artefakt) (push) Canceled after 0s
CI / DMS-Modul (Lint/Build/Test/Coverage/Artefakt) (pull_request) Canceled after 0s
API-03: 03 externe webhook abonnements nutzt core api 07
Connect-seitige Webhook-Abonnements ueber Core API-07 (zentrale
Webhook-Registry): connect/internal/webhookclient als schlanker
HTTP-Client gegen die Registry (Subscribe/Unsubscribe), connect/internal/
webhooksub mit lokaler, verschluesselter Abonnementverwaltung und
HTTP-Zustellempfaenger inkl. HMAC-Signaturpruefung. Core API-07 exponiert
bislang keinen HTTP-Wrapper fuer physisch getrennte Module (anders als
z.B. AUD-08) -- Tests laufen deshalb gegen einen den dokumentierten
Core-Vertrag nachbildenden Stub, echte Postgres-Testdatenbank, echte
HTTP-Verbindungen.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-10-16 13:18:10 +00:00

158 lines
5.6 KiB
Go

// Package webhooksub laesst Connect-Connectoren Ereignisse ueber Core
// API-07 (zentrale Webhook-Registry) abonnieren (API-03). Connect
// registriert lediglich Abonnements und empfaengt Zustellungen -- es
// betreibt selbst keine Zustellungs-Engine (Produkt-DNA dieser Kachel).
package webhooksub
import (
"context"
"crypto/rand"
"encoding/hex"
"errors"
"fmt"
"strings"
"sync"
"time"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
"gitea.perlbach24.de/scripte/nexarch/connect/internal/crypto"
"gitea.perlbach24.de/scripte/nexarch/connect/internal/webhookclient"
)
var (
ErrActionNotRegistered = errors.New("webhooksub: keine connector-aktion fuer dieses ereignis registriert")
ErrNotFound = errors.New("webhooksub: abonnement nicht gefunden")
)
// Action verarbeitet ein zugestelltes Ereignis fuer genau einen Connector
// und Ereignistyp (Akzeptanzkriterium 2).
type Action func(ctx context.Context, payload []byte) error
// Registration ist Connects lokale Sicht auf ein bei Core registriertes
// Abonnement.
type Registration struct {
ID string
TenantSlug string
ConnectorName string
EventType string
CreatedAt time.Time
}
// Manager verwaltet Connect-seitige Webhook-Abonnements: er registriert sie
// bei Core API-07, haelt die Zuordnung Ereignistyp -> Connector-Aktion im
// Prozessspeicher (wie registry.Registry) und verifiziert/verteilt
// zugestellte Ereignisse.
type Manager struct {
pool *pgxpool.Pool
crypto *crypto.Service
core *webhookclient.Client
callbackBaseURL string
mu sync.RWMutex
actions map[string]Action
}
func NewManager(pool *pgxpool.Pool, cryptoSvc *crypto.Service, core *webhookclient.Client, callbackBaseURL string) *Manager {
return &Manager{
pool: pool, crypto: cryptoSvc, core: core,
callbackBaseURL: strings.TrimRight(callbackBaseURL, "/"),
actions: make(map[string]Action),
}
}
func actionKey(connectorName, eventType string) string { return connectorName + "\x00" + eventType }
// RegisterAction verdrahtet die Aktion eines Connectors fuer eventType, vor
// jedem Subscribe/jeder Zustellung dafuer -- prozesslokal, wie
// registry.Registry, bei jedem Prozessstart neu aufgebaut.
func (m *Manager) RegisterAction(connectorName, eventType string, action Action) {
m.mu.Lock()
defer m.mu.Unlock()
m.actions[actionKey(connectorName, eventType)] = action
}
func (m *Manager) action(connectorName, eventType string) (Action, bool) {
m.mu.RLock()
defer m.mu.RUnlock()
a, ok := m.actions[actionKey(connectorName, eventType)]
return a, ok
}
// Subscribe registriert connectorName fuer eventType bei Core API-07
// (Akzeptanzkriterium 1). Es verlangt eine vorab per RegisterAction
// hinterlegte Aktion -- ein Abonnement ohne Ziel waere nie zustellbar.
// Schlaegt die Core-Registrierung fehl, wird nichts lokal gespeichert.
func (m *Manager) Subscribe(ctx context.Context, tenantSlug, connectorName, eventType string) (Registration, error) {
if _, ok := m.action(connectorName, eventType); !ok {
return Registration{}, fmt.Errorf("%w: %s/%s", ErrActionNotRegistered, connectorName, eventType)
}
id, err := newToken(16)
if err != nil {
return Registration{}, fmt.Errorf("webhooksub: id generieren: %w", err)
}
secret, err := newToken(32)
if err != nil {
return Registration{}, fmt.Errorf("webhooksub: geheimnis generieren: %w", err)
}
targetURL := m.callbackBaseURL + "/" + id
coreSub, err := m.core.Subscribe(ctx, tenantSlug, eventType, targetURL, secret)
if err != nil {
return Registration{}, fmt.Errorf("webhooksub: core-registrierung: %w", err)
}
sealed, err := m.crypto.Seal(ctx, tenantSlug, []byte(secret))
if err != nil {
return Registration{}, fmt.Errorf("webhooksub: geheimnis versiegeln: %w", err)
}
var createdAt time.Time
err = m.pool.QueryRow(ctx, `
INSERT INTO connector_webhook_subscriptions
(id, tenant_slug, connector_name, event_type, core_subscription_id, secret_ciphertext, secret_wrapped_dek)
VALUES ($1, $2, $3, $4, $5, $6, $7)
RETURNING created_at
`, id, tenantSlug, connectorName, eventType, coreSub.ID, sealed.Ciphertext, sealed.WrappedDEK).Scan(&createdAt)
if err != nil {
return Registration{}, fmt.Errorf("webhooksub: abonnement speichern: %w", err)
}
return Registration{ID: id, TenantSlug: tenantSlug, ConnectorName: connectorName, EventType: eventType, CreatedAt: createdAt}, nil
}
// Unsubscribe entfernt connectorNames eventType-Abonnement vollstaendig
// (Akzeptanzkriterium 3): erst bei Core, dann lokal. Schlaegt die
// Core-Abbestellung fehl, bleibt die lokale Zeile fuer einen Wiederholungs-
// versuch bestehen statt ein verwaistes Core-Abonnement zu hinterlassen.
func (m *Manager) Unsubscribe(ctx context.Context, tenantSlug, connectorName, eventType string) error {
var localID, coreSubscriptionID string
err := m.pool.QueryRow(ctx, `
SELECT id, core_subscription_id FROM connector_webhook_subscriptions
WHERE tenant_slug = $1 AND connector_name = $2 AND event_type = $3
`, tenantSlug, connectorName, eventType).Scan(&localID, &coreSubscriptionID)
if errors.Is(err, pgx.ErrNoRows) {
return ErrNotFound
}
if err != nil {
return fmt.Errorf("webhooksub: abonnement lesen: %w", err)
}
if err := m.core.Unsubscribe(ctx, tenantSlug, coreSubscriptionID); err != nil {
return fmt.Errorf("webhooksub: core-abbestellung: %w", err)
}
if _, err := m.pool.Exec(ctx, `DELETE FROM connector_webhook_subscriptions WHERE id = $1`, localID); err != nil {
return fmt.Errorf("webhooksub: abonnement loeschen: %w", err)
}
return nil
}
func newToken(numBytes int) (string, error) {
b := make([]byte, numBytes)
if _, err := rand.Read(b); err != nil {
return "", err
}
return hex.EncodeToString(b), nil
}