Files
sendico/api/edge/bff/internal/server/callbacksimp/rotate.go
2026-03-02 16:27:33 +01:00

286 lines
9.0 KiB
Go

package callbacksimp
import (
"context"
"errors"
"net/http"
"net/url"
"strings"
"github.com/tech/sendico/pkg/api/http/response"
"github.com/tech/sendico/pkg/merrors"
"github.com/tech/sendico/pkg/model"
"github.com/tech/sendico/server/interface/api/sresponse"
mutil "github.com/tech/sendico/server/internal/mutil/param"
"go.mongodb.org/mongo-driver/v2/bson"
"go.uber.org/zap"
)
type signingSecretMutation struct {
SetSecretRef string
Clear bool
Generated string
}
func (a *CallbacksAPI) rotateSecret(r *http.Request, account *model.Account, accessToken *sresponse.TokenData) http.HandlerFunc {
callbackRef, err := a.Cph.GetRef(r)
if err != nil {
a.Logger.Warn("Failed to parse callback reference", zap.Error(err), mutil.PLog(a.Cph, r))
return response.BadReference(a.Logger, a.Name(), a.Cph.Name(), a.Cph.GetID(r), err)
}
var callback model.Callback
if err := a.db.Get(r.Context(), *account.GetID(), callbackRef, &callback); err != nil {
a.Logger.Warn("Failed to fetch callback for secret rotation", zap.Error(err))
return response.Auto(a.Logger, a.Name(), err)
}
if callback.RetryPolicy.SigningMode != model.CallbackSigningModeHMACSHA256 {
return response.BadRequest(a.Logger, a.Name(), "invalid_signing_mode", "rotate-secret is available only for hmac_sha256 callbacks")
}
secretRef, generatedSecret, err := a.secrets.Provision(r.Context(), callback.OrganizationRef, callbackRef)
if err != nil {
a.Logger.Warn("Failed to rotate callback signing secret", zap.Error(err))
return response.Auto(a.Logger, a.Name(), err)
}
if err := a.db.SetSigningSecretRef(r.Context(), *account.GetID(), callbackRef, secretRef); err != nil {
a.Logger.Warn("Failed to persist rotated callback signing secret reference", zap.Error(err))
return response.Auto(a.Logger, a.Name(), err)
}
return a.callbackResponse(&callback, accessToken, generatedSecret, false)
}
func (a *CallbacksAPI) normalizeAndPrepare(
ctx context.Context,
callback *model.Callback,
organizationRef bson.ObjectID,
existingSecretRef string,
allowSecretGeneration bool,
) (signingSecretMutation, error) {
if callback == nil {
return signingSecretMutation{}, merrors.InvalidArgument("callback payload is required")
}
if organizationRef.IsZero() {
return signingSecretMutation{}, merrors.InvalidArgument("organization reference is required", "organizationRef")
}
callback.Name = strings.TrimSpace(callback.Name)
callback.Description = trimDescription(callback.Description)
callback.URL = strings.TrimSpace(callback.URL)
if callback.URL == "" {
return signingSecretMutation{}, merrors.InvalidArgument("url is required", "url")
}
if err := validateCallbackURL(callback.URL); err != nil {
return signingSecretMutation{}, err
}
if callback.Name == "" {
callback.Name = callback.URL
}
status, err := normalizeStatus(callback.Status, a.config.DefaultStatus)
if err != nil {
return signingSecretMutation{}, err
}
callback.Status = status
callback.EventTypes = normalizeEventTypes(callback.EventTypes, a.config.DefaultEventTypes)
callback.RetryPolicy.Backoff.MinDelayMS = defaultInt(callback.RetryPolicy.Backoff.MinDelayMS, defaultRetryMinDelayMS)
callback.RetryPolicy.Backoff.MaxDelayMS = defaultInt(callback.RetryPolicy.Backoff.MaxDelayMS, defaultRetryMaxDelayMS)
if callback.RetryPolicy.Backoff.MaxDelayMS < callback.RetryPolicy.Backoff.MinDelayMS {
callback.RetryPolicy.Backoff.MaxDelayMS = callback.RetryPolicy.Backoff.MinDelayMS
}
callback.RetryPolicy.MaxAttempts = defaultInt(callback.RetryPolicy.MaxAttempts, defaultRetryMaxAttempts)
callback.RetryPolicy.RequestTimeoutMS = defaultInt(callback.RetryPolicy.RequestTimeoutMS, defaultRetryRequestTimeoutMS)
callback.RetryPolicy.Headers = normalizeHeaders(callback.RetryPolicy.Headers)
mode, err := normalizeSigningMode(callback.RetryPolicy.SigningMode)
if err != nil {
return signingSecretMutation{}, err
}
callback.RetryPolicy.SigningMode = mode
existingSecretRef = strings.TrimSpace(existingSecretRef)
switch callback.RetryPolicy.SigningMode {
case model.CallbackSigningModeNone:
return signingSecretMutation{Clear: existingSecretRef != ""}, nil
case model.CallbackSigningModeHMACSHA256:
if existingSecretRef != "" {
return signingSecretMutation{SetSecretRef: existingSecretRef}, nil
}
if !allowSecretGeneration {
return signingSecretMutation{}, merrors.InvalidArgument("signing secret is required for hmac_sha256 callbacks", "retryPolicy.signingMode")
}
if callback.GetID().IsZero() {
callback.SetID(bson.NewObjectID())
}
secretRef, generatedSecret, err := a.secrets.Provision(ctx, organizationRef, *callback.GetID())
if err != nil {
return signingSecretMutation{}, err
}
return signingSecretMutation{SetSecretRef: secretRef, Generated: generatedSecret}, nil
default:
return signingSecretMutation{}, merrors.InvalidArgument("unsupported signing mode", "retryPolicy.signingMode")
}
}
func (a *CallbacksAPI) applySigningSecretMutation(
ctx context.Context,
accountRef,
callbackRef bson.ObjectID,
mutation signingSecretMutation,
) error {
if callbackRef.IsZero() {
return merrors.InvalidArgument("callback reference is required", "callbackRef")
}
if strings.TrimSpace(mutation.SetSecretRef) != "" {
return a.db.SetSigningSecretRef(ctx, accountRef, callbackRef, mutation.SetSecretRef)
}
if mutation.Clear {
err := a.db.ClearSigningSecretRef(ctx, accountRef, callbackRef)
if err != nil && !errors.Is(err, merrors.ErrNoData) {
return err
}
}
return nil
}
func (a *CallbacksAPI) callbackResponse(
callback *model.Callback,
accessToken *sresponse.TokenData,
generatedSecret string,
created bool,
) http.HandlerFunc {
if callback == nil || accessToken == nil {
return response.Internal(a.Logger, a.Name(), merrors.Internal("failed to build callback response"))
}
return sresponse.Callback(a.Logger, callback, accessToken, generatedSecret, created)
}
func normalizeStatus(raw, fallback model.CallbackStatus) (model.CallbackStatus, error) {
candidate := strings.ToLower(strings.TrimSpace(string(raw)))
if candidate == "" {
candidate = strings.ToLower(strings.TrimSpace(string(fallback)))
}
switch candidate {
case "", "active", "enabled":
return model.CallbackStatusActive, nil
case "disabled", "inactive":
return model.CallbackStatusDisabled, nil
default:
return "", merrors.InvalidArgument("unsupported callback status", "status")
}
}
func normalizeSigningMode(raw model.CallbackSigningMode) (model.CallbackSigningMode, error) {
mode := strings.ToLower(strings.TrimSpace(string(raw)))
switch mode {
case "", "none":
return model.CallbackSigningModeNone, nil
case "hmac_sha256", "hmac-sha256", "hmac":
return model.CallbackSigningModeHMACSHA256, nil
default:
return "", merrors.InvalidArgument("unsupported callback signing mode", "retryPolicy.signingMode")
}
}
func normalizeEventTypes(eventTypes []string, defaults []string) []string {
if len(eventTypes) == 0 {
return normalizeEventTypes(defaults, nil)
}
seen := make(map[string]struct{}, len(eventTypes))
out := make([]string, 0, len(eventTypes))
for _, eventType := range eventTypes {
value := strings.TrimSpace(eventType)
if value == "" {
continue
}
if _, exists := seen[value]; exists {
continue
}
seen[value] = struct{}{}
out = append(out, value)
}
if len(out) == 0 {
if len(defaults) > 0 {
return normalizeEventTypes(defaults, nil)
}
return []string{model.PaymentStatusUpdatedType}
}
return out
}
func normalizeHeaders(headers map[string]string) map[string]string {
if len(headers) == 0 {
return nil
}
out := make(map[string]string, len(headers))
for key, value := range headers {
k := strings.TrimSpace(key)
if k == "" {
continue
}
out[k] = strings.TrimSpace(value)
}
if len(out) == 0 {
return nil
}
return out
}
func mergeCallbackMutable(dst, src *model.Callback) {
dst.OrganizationRef = src.OrganizationRef
dst.Describable = src.Describable
dst.Status = src.Status
dst.URL = src.URL
dst.EventTypes = append([]string(nil), src.EventTypes...)
dst.RetryPolicy = model.CallbackRetryPolicy{
Backoff: model.CallbackBackoff{
MinDelayMS: src.RetryPolicy.Backoff.MinDelayMS,
MaxDelayMS: src.RetryPolicy.Backoff.MaxDelayMS,
},
SigningMode: src.RetryPolicy.SigningMode,
Headers: normalizeHeaders(src.RetryPolicy.Headers),
MaxAttempts: src.RetryPolicy.MaxAttempts,
RequestTimeoutMS: src.RetryPolicy.RequestTimeoutMS,
}
}
func defaultInt(value, fallback int) int {
if value > 0 {
return value
}
return fallback
}
func trimDescription(in *string) *string {
if in == nil {
return nil
}
value := strings.TrimSpace(*in)
if value == "" {
return nil
}
return &value
}
func validateCallbackURL(raw string) error {
parsed, err := url.ParseRequestURI(raw)
if err != nil {
return merrors.InvalidArgument("url is invalid", "url")
}
switch strings.ToLower(strings.TrimSpace(parsed.Scheme)) {
case "https", "http":
default:
return merrors.InvalidArgument("url scheme must be http or https", "url")
}
if strings.TrimSpace(parsed.Host) == "" {
return merrors.InvalidArgument("url host is required", "url")
}
return nil
}