package alerting import ( "context" "encoding/json" "errors" "fmt" "github.com/google/uuid" "github.com/jackc/pgx/v5" "github.com/syncova/syncova/packages/platform/crypto" ) // channelColumnList sind die Spalten eines Kanals in fester Reihenfolge. // // Das verschluesselte Geheimnis fehlt bewusst: Es verlaesst die Datenbank nur // ueber LoadChannelSecret, und dieser Weg ist eine einzelne Funktion, die sich // pruefen laesst. const channelColumnList = ` id, name, channel_type, enabled, minimum_severity, configuration, last_delivery_at, last_delivery_error, consecutive_failures, created_at` // scanChannel liest eine Kanalzeile. func scanChannel(scanner rowScanner) (*Channel, error) { var ( loadedChannel Channel channelTypeText string severityText string configurationJSON []byte deliveryError *string ) scanError := scanner.Scan( &loadedChannel.ID, &loadedChannel.Name, &channelTypeText, &loadedChannel.Enabled, &severityText, &configurationJSON, &loadedChannel.LastDeliveryAt, &deliveryError, &loadedChannel.ConsecutiveFailures, &loadedChannel.CreatedAt) if scanError != nil { return nil, scanError } loadedChannel.ChannelType = ChannelType(channelTypeText) loadedChannel.MinimumSeverity = Severity(severityText) if deliveryError != nil { loadedChannel.LastDeliveryError = *deliveryError } if len(configurationJSON) > 0 { _ = json.Unmarshal(configurationJSON, &loadedChannel.Configuration) } return &loadedChannel, nil } // CreateChannelRequest beschreibt einen neuen Kanal. type CreateChannelRequest struct { // Name ist die sprechende Bezeichnung. Name string // ChannelType ist die Art der Zustellung. ChannelType ChannelType // MinimumSeverity ist der niedrigste zugestellte Schweregrad. MinimumSeverity Severity // Configuration traegt die Zustelldaten ohne Geheimnisse. Configuration map[string]any // Secret ist das Zugangsgeheimnis; leer, wenn keines noetig ist. Secret string // CreatedBy benennt den Anlegenden. CreatedBy *uuid.UUID } // CreateChannel legt einen Benachrichtigungskanal an. // // Das Geheimnis wird mit demselben Secret Store verschluesselt, der die // MFA-Geheimnisse und die Datenschluessel der Repositories schuetzt. Ein // zweiter Schluesselsatz braechte keinen Sicherheitsgewinn, aber eine zweite // Stelle, an der er verloren gehen kann. func (store *Store) CreateChannel(createContext context.Context, request CreateChannelRequest, secretStore crypto.SecretStore) (*Channel, error) { if request.Name == "" { return nil, errors.New("der kanal braucht einen namen") } if request.ChannelType != ChannelEmail && request.ChannelType != ChannelWebhook { return nil, fmt.Errorf("die kanalart %q ist unbekannt", request.ChannelType) } if request.MinimumSeverity == "" { request.MinimumSeverity = SeverityHigh } configurationJSON, encodeError := json.Marshal(request.Configuration) if encodeError != nil { return nil, fmt.Errorf("die konfiguration konnte nicht abgelegt werden: %w", encodeError) } var ( encryptedSecret []byte keyVersion *string ) if request.Secret != "" { if secretStore == nil { return nil, errors.New("ohne verschluesselung laesst sich kein zugangsgeheimnis ablegen") } sealedSecret, sealedKeyVersion, sealError := secretStore.Encrypt([]byte(request.Secret)) if sealError != nil { return nil, fmt.Errorf("das zugangsgeheimnis konnte nicht gesichert werden: %w", sealError) } encryptedSecret = sealedSecret keyVersion = &sealedKeyVersion } const insertStatement = ` INSERT INTO notification_channels (name, channel_type, minimum_severity, configuration, credentials_ciphertext, credentials_key_version, created_by) VALUES ($1, $2, $3, $4, $5, $6, $7) RETURNING ` + channelColumnList createdChannel, scanError := scanChannel(store.connectionPool.QueryRow(createContext, insertStatement, request.Name, string(request.ChannelType), string(request.MinimumSeverity), configurationJSON, encryptedSecret, keyVersion, request.CreatedBy)) if scanError != nil { return nil, fmt.Errorf("der kanal konnte nicht angelegt werden: %w", scanError) } return createdChannel, nil } // ListChannels liefert alle Benachrichtigungskanaele. func (store *Store) ListChannels(listContext context.Context) ([]Channel, error) { const selectStatement = `SELECT ` + channelColumnList + ` FROM notification_channels ORDER BY name` channelRows, queryError := store.connectionPool.Query(listContext, selectStatement) if queryError != nil { return nil, fmt.Errorf("die kanaele konnten nicht gelesen werden: %w", queryError) } defer channelRows.Close() loadedChannels := make([]Channel, 0, 4) for channelRows.Next() { loadedChannel, scanError := scanChannel(channelRows) if scanError != nil { return nil, fmt.Errorf("ein kanal konnte nicht gelesen werden: %w", scanError) } loadedChannels = append(loadedChannels, *loadedChannel) } return loadedChannels, channelRows.Err() } // GetChannel liest einen Kanal. func (store *Store) GetChannel(readContext context.Context, channelIdentifier uuid.UUID) (*Channel, error) { const selectStatement = `SELECT ` + channelColumnList + ` FROM notification_channels WHERE id = $1` loadedChannel, scanError := scanChannel(store.connectionPool.QueryRow(readContext, selectStatement, channelIdentifier)) if errors.Is(scanError, pgx.ErrNoRows) { return nil, fmt.Errorf("%w: %s", ErrChannelNotFound, channelIdentifier) } if scanError != nil { return nil, fmt.Errorf("der kanal konnte nicht gelesen werden: %w", scanError) } return loadedChannel, nil } // DeleteChannel entfernt einen Kanal. func (store *Store) DeleteChannel(deleteContext context.Context, channelIdentifier uuid.UUID) error { commandTag, execError := store.connectionPool.Exec(deleteContext, `DELETE FROM notification_channels WHERE id = $1`, channelIdentifier) if execError != nil { return fmt.Errorf("der kanal konnte nicht geloescht werden: %w", execError) } if commandTag.RowsAffected() == 0 { return fmt.Errorf("%w: %s", ErrChannelNotFound, channelIdentifier) } return nil } // LoadChannelSecret entschluesselt das Zugangsgeheimnis eines Kanals. // // Der einzige Weg, auf dem ein Geheimnis die Datenbank verlaesst. Es geht // niemals in eine API-Antwort, ein Protokoll oder eine Fehlermeldung // (PROMPT.md §140). func (store *Store) LoadChannelSecret(loadContext context.Context, channelIdentifier uuid.UUID, secretStore crypto.SecretStore) (string, error) { const selectStatement = ` SELECT credentials_ciphertext, credentials_key_version FROM notification_channels WHERE id = $1` var ( encryptedSecret []byte keyVersion *string ) scanError := store.connectionPool.QueryRow(loadContext, selectStatement, channelIdentifier). Scan(&encryptedSecret, &keyVersion) if errors.Is(scanError, pgx.ErrNoRows) { return "", fmt.Errorf("%w: %s", ErrChannelNotFound, channelIdentifier) } if scanError != nil { return "", fmt.Errorf("das zugangsgeheimnis konnte nicht gelesen werden: %w", scanError) } if len(encryptedSecret) == 0 || keyVersion == nil { return "", nil } if secretStore == nil { return "", errors.New("ohne verschluesselung laesst sich das zugangsgeheimnis nicht lesen") } plaintextSecret, decryptError := secretStore.Decrypt(encryptedSecret, *keyVersion) if decryptError != nil { return "", fmt.Errorf("das zugangsgeheimnis liess sich nicht entschluesseln: %w", decryptError) } return string(plaintextSecret), nil } // RecordDelivery haelt eine Zustellung fest. // // Der Eindeutigkeitsindex verhindert eine zweite erfolgreiche Zustellung // derselben Meldung ueber denselben Kanal. Ein Verstoss dagegen ist deshalb kein // Fehler, sondern die Auskunft „wurde bereits zugestellt". func (store *Store) RecordDelivery(recordContext context.Context, alertIdentifier uuid.UUID, channelIdentifier uuid.UUID, deliveryError error) error { deliveryStatus := "sent" var errorMessage *string if deliveryError != nil { deliveryStatus = "failed" errorText := deliveryError.Error() errorMessage = &errorText } const insertStatement = ` INSERT INTO notification_deliveries (alert_id, channel_id, status, error_message) VALUES ($1, $2, $3, $4) ON CONFLICT DO NOTHING` if _, execError := store.connectionPool.Exec(recordContext, insertStatement, alertIdentifier, channelIdentifier, deliveryStatus, errorMessage); execError != nil { return fmt.Errorf("die zustellung konnte nicht vermerkt werden: %w", execError) } // Der Zustand des Kanals wird mitgefuehrt: Ein Kanal, der seit Wochen nichts // zustellt, ist genauso schlimm wie eine fehlende Meldung. if deliveryError != nil { const failureStatement = ` UPDATE notification_channels SET consecutive_failures = consecutive_failures + 1, last_delivery_error = $2, updated_at = now() WHERE id = $1` _, updateError := store.connectionPool.Exec(recordContext, failureStatement, channelIdentifier, deliveryError.Error()) return updateError } const successStatement = ` UPDATE notification_channels SET consecutive_failures = 0, last_delivery_error = NULL, last_delivery_at = now(), updated_at = now() WHERE id = $1` _, updateError := store.connectionPool.Exec(recordContext, successStatement, channelIdentifier) return updateError } // WasDelivered meldet, ob eine Meldung ueber einen Kanal bereits zugestellt wurde. func (store *Store) WasDelivered(checkContext context.Context, alertIdentifier uuid.UUID, channelIdentifier uuid.UUID) (bool, error) { const checkStatement = ` SELECT EXISTS ( SELECT 1 FROM notification_deliveries WHERE alert_id = $1 AND channel_id = $2 AND status = 'sent' )` var wasDelivered bool if scanError := store.connectionPool.QueryRow(checkContext, checkStatement, alertIdentifier, channelIdentifier).Scan(&wasDelivered); scanError != nil { return false, fmt.Errorf("die zustellung konnte nicht geprueft werden: %w", scanError) } return wasDelivered, nil } // EnabledChannelsFor liefert die Kanaele, die einen Schweregrad zustellen. func (store *Store) EnabledChannelsFor(listContext context.Context, severity Severity) ([]Channel, error) { allChannels, listError := store.ListChannels(listContext) if listError != nil { return nil, listError } matchingChannels := make([]Channel, 0, len(allChannels)) for _, channel := range allChannels { if !channel.Enabled { continue } if !severity.AtLeast(channel.MinimumSeverity) { continue } matchingChannels = append(matchingChannels, channel) } return matchingChannels, nil }