bff for callbacks
This commit is contained in:
337
api/edge/bff/internal/server/callbacksimp/handlers.go
Normal file
337
api/edge/bff/internal/server/callbacksimp/handlers.go
Normal file
@@ -0,0 +1,337 @@
|
||||
package callbacksimp
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"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 callbackWriteResponse struct {
|
||||
AccessToken sresponse.TokenData `json:"accessToken"`
|
||||
Callbacks []model.Callback `json:"callbacks"`
|
||||
GeneratedSigningSecret string `json:"generatedSigningSecret,omitempty"`
|
||||
}
|
||||
|
||||
func (a *CallbacksAPI) create(r *http.Request, account *model.Account, accessToken *sresponse.TokenData) http.HandlerFunc {
|
||||
organizationRef, err := a.Oph.GetRef(r)
|
||||
if err != nil {
|
||||
a.Logger.Warn("Failed to parse organization reference", zap.Error(err), mutil.PLog(a.Oph, r))
|
||||
return response.BadReference(a.Logger, a.Name(), a.Oph.Name(), a.Oph.GetID(r), err)
|
||||
}
|
||||
|
||||
var callback model.Callback
|
||||
if err := json.NewDecoder(r.Body).Decode(&callback); err != nil {
|
||||
a.Logger.Warn("Failed to decode callback payload", zap.Error(err))
|
||||
return response.BadPayload(a.Logger, a.Name(), err)
|
||||
}
|
||||
|
||||
generatedSecret, err := a.normalizeAndPrepare(r.Context(), &callback, organizationRef, true)
|
||||
if err != nil {
|
||||
return response.Auto(a.Logger, a.Name(), err)
|
||||
}
|
||||
|
||||
if err := a.DB.Create(r.Context(), *account.GetID(), organizationRef, &callback); err != nil {
|
||||
a.Logger.Warn("Failed to create callback", zap.Error(err))
|
||||
return response.Auto(a.Logger, a.Name(), err)
|
||||
}
|
||||
|
||||
return a.callbackResponse(&callback, accessToken, generatedSecret, true)
|
||||
}
|
||||
|
||||
func (a *CallbacksAPI) update(r *http.Request, account *model.Account, accessToken *sresponse.TokenData) http.HandlerFunc {
|
||||
var input model.Callback
|
||||
if err := json.NewDecoder(r.Body).Decode(&input); err != nil {
|
||||
a.Logger.Warn("Failed to decode callback payload", zap.Error(err))
|
||||
return response.BadPayload(a.Logger, a.Name(), err)
|
||||
}
|
||||
|
||||
callbackRef := *input.GetID()
|
||||
if callbackRef.IsZero() {
|
||||
return response.Auto(a.Logger, a.Name(), merrors.InvalidArgument("callback id is required", "id"))
|
||||
}
|
||||
|
||||
var existing model.Callback
|
||||
if err := a.db.Get(r.Context(), *account.GetID(), callbackRef, &existing); err != nil {
|
||||
a.Logger.Warn("Failed to fetch callback before update", zap.Error(err))
|
||||
return response.Auto(a.Logger, a.Name(), err)
|
||||
}
|
||||
|
||||
mergeCallbackMutable(&existing, &input)
|
||||
generatedSecret, err := a.normalizeAndPrepare(r.Context(), &existing, existing.OrganizationRef, true)
|
||||
if err != nil {
|
||||
return response.Auto(a.Logger, a.Name(), err)
|
||||
}
|
||||
|
||||
if err := a.DB.Update(r.Context(), *account.GetID(), &existing); err != nil {
|
||||
a.Logger.Warn("Failed to update callback", zap.Error(err))
|
||||
return response.Auto(a.Logger, a.Name(), err)
|
||||
}
|
||||
|
||||
return a.callbackResponse(&existing, accessToken, generatedSecret, false)
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
callback.RetryPolicy.SecretRef = secretRef
|
||||
|
||||
if err := a.DB.Update(r.Context(), *account.GetID(), &callback); err != nil {
|
||||
a.Logger.Warn("Failed to persist rotated callback 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,
|
||||
allowSecretGeneration bool,
|
||||
) (string, error) {
|
||||
if callback == nil {
|
||||
return "", merrors.InvalidArgument("callback payload is required")
|
||||
}
|
||||
if organizationRef.IsZero() {
|
||||
return "", merrors.InvalidArgument("organization reference is required", "organizationRef")
|
||||
}
|
||||
|
||||
callback.SetOrganizationRef(organizationRef)
|
||||
callback.Name = strings.TrimSpace(callback.Name)
|
||||
callback.Description = trimDescription(callback.Description)
|
||||
callback.ClientID = strings.TrimSpace(callback.ClientID)
|
||||
if callback.ClientID == "" {
|
||||
callback.ClientID = organizationRef.Hex()
|
||||
}
|
||||
|
||||
callback.URL = strings.TrimSpace(callback.URL)
|
||||
if callback.URL == "" {
|
||||
return "", merrors.InvalidArgument("url is required", "url")
|
||||
}
|
||||
if err := validateCallbackURL(callback.URL); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if callback.Name == "" {
|
||||
callback.Name = callback.URL
|
||||
}
|
||||
|
||||
status, err := normalizeStatus(callback.Status, a.config.DefaultStatus)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
callback.Status = status
|
||||
callback.EventTypes = normalizeEventTypes(callback.EventTypes, a.config.DefaultEventTypes)
|
||||
|
||||
callback.RetryPolicy.MinDelayMS = defaultInt(callback.RetryPolicy.MinDelayMS, defaultRetryMinDelayMS)
|
||||
callback.RetryPolicy.MaxDelayMS = defaultInt(callback.RetryPolicy.MaxDelayMS, defaultRetryMaxDelayMS)
|
||||
if callback.RetryPolicy.MaxDelayMS < callback.RetryPolicy.MinDelayMS {
|
||||
callback.RetryPolicy.MaxDelayMS = callback.RetryPolicy.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 "", err
|
||||
}
|
||||
callback.RetryPolicy.SigningMode = mode
|
||||
|
||||
callback.RetryPolicy.SecretRef = strings.TrimSpace(callback.RetryPolicy.SecretRef)
|
||||
switch callback.RetryPolicy.SigningMode {
|
||||
case model.CallbackSigningModeNone:
|
||||
callback.RetryPolicy.SecretRef = ""
|
||||
return "", nil
|
||||
case model.CallbackSigningModeHMACSHA256:
|
||||
if callback.RetryPolicy.SecretRef != "" {
|
||||
return "", nil
|
||||
}
|
||||
if !allowSecretGeneration {
|
||||
return "", merrors.InvalidArgument("secretRef is required for hmac_sha256 callbacks", "retryPolicy.secretRef")
|
||||
}
|
||||
if callback.GetID().IsZero() {
|
||||
callback.SetID(bson.NewObjectID())
|
||||
}
|
||||
secretRef, generatedSecret, err := a.secrets.Provision(ctx, organizationRef, *callback.GetID())
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
callback.RetryPolicy.SecretRef = secretRef
|
||||
return generatedSecret, nil
|
||||
default:
|
||||
return "", merrors.InvalidArgument("unsupported signing mode", "retryPolicy.signingMode")
|
||||
}
|
||||
}
|
||||
|
||||
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"))
|
||||
}
|
||||
|
||||
resp := callbackWriteResponse{
|
||||
AccessToken: *accessToken,
|
||||
Callbacks: []model.Callback{*callback},
|
||||
GeneratedSigningSecret: generatedSecret,
|
||||
}
|
||||
if created {
|
||||
return response.Created(a.Logger, resp)
|
||||
}
|
||||
return response.Ok(a.Logger, resp)
|
||||
}
|
||||
|
||||
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.Describable = src.Describable
|
||||
dst.ClientID = src.ClientID
|
||||
dst.Status = src.Status
|
||||
dst.URL = src.URL
|
||||
dst.EventTypes = append([]string(nil), src.EventTypes...)
|
||||
dst.RetryPolicy = model.CallbackRetryPolicy{
|
||||
MinDelayMS: src.RetryPolicy.MinDelayMS,
|
||||
MaxDelayMS: src.RetryPolicy.MaxDelayMS,
|
||||
SigningMode: src.RetryPolicy.SigningMode,
|
||||
SecretRef: src.RetryPolicy.SecretRef,
|
||||
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
|
||||
}
|
||||
178
api/edge/bff/internal/server/callbacksimp/secrets.go
Normal file
178
api/edge/bff/internal/server/callbacksimp/secrets.go
Normal file
@@ -0,0 +1,178 @@
|
||||
package callbacksimp
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"path"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/prometheus/client_golang/prometheus"
|
||||
"github.com/prometheus/client_golang/prometheus/promauto"
|
||||
"github.com/tech/sendico/pkg/merrors"
|
||||
"github.com/tech/sendico/pkg/mlogger"
|
||||
"github.com/tech/sendico/pkg/vault/kv"
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
type signingSecretManager interface {
|
||||
Provision(ctx context.Context, organizationRef, callbackRef bson.ObjectID) (secretRef string, generatedSecret string, err error)
|
||||
}
|
||||
|
||||
type vaultSigningSecretManager struct {
|
||||
logger mlogger.Logger
|
||||
store kv.Client
|
||||
pathPrefix string
|
||||
field string
|
||||
secretLength int
|
||||
}
|
||||
|
||||
const (
|
||||
metricsResultSuccess = "success"
|
||||
metricsResultError = "error"
|
||||
)
|
||||
|
||||
var (
|
||||
signingSecretMetricsOnce sync.Once
|
||||
signingSecretStatus *prometheus.CounterVec
|
||||
signingSecretLatency *prometheus.HistogramVec
|
||||
)
|
||||
|
||||
func ensureSigningSecretMetrics() {
|
||||
signingSecretMetricsOnce.Do(func() {
|
||||
signingSecretStatus = promauto.NewCounterVec(prometheus.CounterOpts{
|
||||
Namespace: "sendico",
|
||||
Subsystem: "bff_callbacks",
|
||||
Name: "signing_secret_provision_total",
|
||||
Help: "Total callback signing secret provisioning attempts.",
|
||||
}, []string{"result"})
|
||||
signingSecretLatency = promauto.NewHistogramVec(prometheus.HistogramOpts{
|
||||
Namespace: "sendico",
|
||||
Subsystem: "bff_callbacks",
|
||||
Name: "signing_secret_provision_duration_seconds",
|
||||
Help: "Duration of callback signing secret provisioning attempts.",
|
||||
Buckets: prometheus.DefBuckets,
|
||||
}, []string{"result"})
|
||||
})
|
||||
}
|
||||
|
||||
func newSigningSecretManager(logger mlogger.Logger, cfg callbacksConfig) (signingSecretManager, error) {
|
||||
if err := cfg.validate(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if logger == nil {
|
||||
logger = zap.NewNop()
|
||||
}
|
||||
|
||||
manager := &vaultSigningSecretManager{
|
||||
logger: logger.Named("callbacks_secrets"),
|
||||
pathPrefix: strings.Trim(strings.TrimSpace(cfg.SecretPathPrefix), "/"),
|
||||
field: strings.TrimSpace(cfg.SecretField),
|
||||
secretLength: cfg.SecretLengthBytes,
|
||||
}
|
||||
if manager.pathPrefix == "" {
|
||||
manager.pathPrefix = defaultSigningSecretPathPrefix
|
||||
}
|
||||
if manager.field == "" {
|
||||
manager.field = defaultSigningSecretField
|
||||
}
|
||||
|
||||
if isVaultConfigEmpty(cfg.Vault) {
|
||||
manager.logger.Warn("Callbacks Vault config is not set; secret generation requires explicit secretRef in payloads")
|
||||
ensureSigningSecretMetrics()
|
||||
return manager, nil
|
||||
}
|
||||
|
||||
store, err := kv.New(kv.Options{
|
||||
Logger: manager.logger,
|
||||
Config: kv.Config{
|
||||
Address: strings.TrimSpace(cfg.Vault.Address),
|
||||
TokenEnv: strings.TrimSpace(cfg.Vault.TokenEnv),
|
||||
TokenFileEnv: strings.TrimSpace(cfg.Vault.TokenFileEnv),
|
||||
TokenFile: strings.TrimSpace(cfg.Vault.TokenFile),
|
||||
Namespace: strings.TrimSpace(cfg.Vault.Namespace),
|
||||
MountPath: strings.TrimSpace(cfg.Vault.MountPath),
|
||||
},
|
||||
Component: "bff callbacks signing secret manager",
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
manager.store = store
|
||||
ensureSigningSecretMetrics()
|
||||
|
||||
return manager, nil
|
||||
}
|
||||
|
||||
func (m *vaultSigningSecretManager) Provision(
|
||||
ctx context.Context,
|
||||
organizationRef,
|
||||
callbackRef bson.ObjectID,
|
||||
) (string, string, error) {
|
||||
start := time.Now()
|
||||
result := metricsResultSuccess
|
||||
defer func() {
|
||||
signingSecretStatus.WithLabelValues(result).Inc()
|
||||
signingSecretLatency.WithLabelValues(result).Observe(time.Since(start).Seconds())
|
||||
}()
|
||||
|
||||
if organizationRef.IsZero() {
|
||||
result = metricsResultError
|
||||
return "", "", merrors.InvalidArgument("organization reference is required", "organizationRef")
|
||||
}
|
||||
if callbackRef.IsZero() {
|
||||
result = metricsResultError
|
||||
return "", "", merrors.InvalidArgument("callback reference is required", "callbackRef")
|
||||
}
|
||||
if m.store == nil {
|
||||
result = metricsResultError
|
||||
return "", "", merrors.InvalidArgument("callbacks vault config is required to generate signing secrets", "api.callbacks.vault")
|
||||
}
|
||||
|
||||
secret, err := generateSigningSecret(m.secretLength)
|
||||
if err != nil {
|
||||
result = metricsResultError
|
||||
return "", "", err
|
||||
}
|
||||
|
||||
secretPath := path.Join(m.pathPrefix, organizationRef.Hex(), callbackRef.Hex())
|
||||
payload := map[string]interface{}{
|
||||
m.field: secret,
|
||||
"organization_ref": organizationRef.Hex(),
|
||||
"callback_ref": callbackRef.Hex(),
|
||||
"updated_at": time.Now().UTC().Format(time.RFC3339Nano),
|
||||
}
|
||||
if err := m.store.Put(ctx, secretPath, payload); err != nil {
|
||||
result = metricsResultError
|
||||
m.logger.Warn("Failed to store callback signing secret", zap.String("path", secretPath), zap.Error(err))
|
||||
return "", "", err
|
||||
}
|
||||
|
||||
secretRef := "vault:" + secretPath + "#" + m.field
|
||||
m.logger.Info("Callback signing secret stored", zap.String("secret_ref", secretRef), zap.String("callback_ref", callbackRef.Hex()))
|
||||
|
||||
return secretRef, secret, nil
|
||||
}
|
||||
|
||||
func isVaultConfigEmpty(cfg VaultConfig) bool {
|
||||
return strings.TrimSpace(cfg.Address) == "" &&
|
||||
strings.TrimSpace(cfg.TokenEnv) == "" &&
|
||||
strings.TrimSpace(cfg.TokenFileEnv) == "" &&
|
||||
strings.TrimSpace(cfg.TokenFile) == "" &&
|
||||
strings.TrimSpace(cfg.MountPath) == "" &&
|
||||
strings.TrimSpace(cfg.Namespace) == ""
|
||||
}
|
||||
|
||||
func generateSigningSecret(length int) (string, error) {
|
||||
if length <= 0 {
|
||||
return "", merrors.InvalidArgument("secret length must be greater than zero", "secret_length")
|
||||
}
|
||||
raw := make([]byte, length)
|
||||
if _, err := rand.Read(raw); err != nil {
|
||||
return "", merrors.Internal("failed to generate signing secret: " + err.Error())
|
||||
}
|
||||
return base64.RawURLEncoding.EncodeToString(raw), nil
|
||||
}
|
||||
139
api/edge/bff/internal/server/callbacksimp/service.go
Normal file
139
api/edge/bff/internal/server/callbacksimp/service.go
Normal file
@@ -0,0 +1,139 @@
|
||||
package callbacksimp
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
api "github.com/tech/sendico/pkg/api/http"
|
||||
"github.com/tech/sendico/pkg/db/callbacks"
|
||||
"github.com/tech/sendico/pkg/merrors"
|
||||
"github.com/tech/sendico/pkg/model"
|
||||
"github.com/tech/sendico/pkg/mservice"
|
||||
eapi "github.com/tech/sendico/server/interface/api"
|
||||
"github.com/tech/sendico/server/internal/server/papitemplate"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
type CallbacksAPI struct {
|
||||
papitemplate.ProtectedAPI[model.Callback]
|
||||
db callbacks.DB
|
||||
secrets signingSecretManager
|
||||
config callbacksConfig
|
||||
}
|
||||
|
||||
func (a *CallbacksAPI) Name() mservice.Type {
|
||||
return mservice.Callbacks
|
||||
}
|
||||
|
||||
func (a *CallbacksAPI) Finish(_ context.Context) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func CreateAPI(apiCtx eapi.API) (*CallbacksAPI, error) {
|
||||
dbFactory := func() (papitemplate.ProtectedDB[model.Callback], error) {
|
||||
return apiCtx.DBFactory().NewCallbacksDB()
|
||||
}
|
||||
|
||||
res := &CallbacksAPI{
|
||||
config: newCallbacksConfig(apiCtx.Config().Callbacks),
|
||||
}
|
||||
|
||||
p, err := papitemplate.CreateAPI(apiCtx, dbFactory, mservice.Organizations, mservice.Callbacks)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
res.ProtectedAPI = *p.
|
||||
WithNoCreateNotification().
|
||||
WithNoUpdateNotification().
|
||||
WithNoDeleteNotification().
|
||||
WithCreateHandler(res.create).
|
||||
WithUpdateHandler(res.update).
|
||||
Build()
|
||||
|
||||
if res.db, err = apiCtx.DBFactory().NewCallbacksDB(); err != nil {
|
||||
res.Logger.Warn("Failed to create callbacks database", zap.Error(err))
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if res.secrets, err = newSigningSecretManager(res.Logger, res.config); err != nil {
|
||||
res.Logger.Warn("Failed to initialize callbacks signing secret manager", zap.Error(err))
|
||||
return nil, err
|
||||
}
|
||||
|
||||
apiCtx.Register().AccountHandler(res.Name(), res.Cph.AddRef("/rotate-secret"), api.Post, res.rotateSecret)
|
||||
|
||||
return res, nil
|
||||
}
|
||||
|
||||
const (
|
||||
defaultCallbackStatus = model.CallbackStatusActive
|
||||
defaultRetryMaxAttempts = 8
|
||||
defaultRetryMinDelayMS = 1000
|
||||
defaultRetryMaxDelayMS = 300000
|
||||
defaultRetryRequestTimeoutMS = 10000
|
||||
defaultSigningSecretLengthBytes = 32
|
||||
defaultSigningSecretField = "value"
|
||||
defaultSigningSecretPathPrefix = "sendico/callbacks"
|
||||
)
|
||||
|
||||
type callbacksConfig struct {
|
||||
DefaultEventTypes []string
|
||||
DefaultStatus model.CallbackStatus
|
||||
SecretPathPrefix string
|
||||
SecretField string
|
||||
SecretLengthBytes int
|
||||
Vault VaultConfig
|
||||
}
|
||||
|
||||
type VaultConfig struct {
|
||||
Address string
|
||||
TokenEnv string
|
||||
TokenFileEnv string
|
||||
TokenFile string
|
||||
Namespace string
|
||||
MountPath string
|
||||
}
|
||||
|
||||
func newCallbacksConfig(source *eapi.CallbacksConfig) callbacksConfig {
|
||||
cfg := callbacksConfig{
|
||||
DefaultEventTypes: []string{model.PaymentStatusUpdatedType},
|
||||
DefaultStatus: defaultCallbackStatus,
|
||||
SecretPathPrefix: defaultSigningSecretPathPrefix,
|
||||
SecretField: defaultSigningSecretField,
|
||||
SecretLengthBytes: defaultSigningSecretLengthBytes,
|
||||
}
|
||||
if source == nil {
|
||||
return cfg
|
||||
}
|
||||
|
||||
if source.SecretPathPrefix != "" {
|
||||
cfg.SecretPathPrefix = source.SecretPathPrefix
|
||||
}
|
||||
if source.SecretField != "" {
|
||||
cfg.SecretField = source.SecretField
|
||||
}
|
||||
if source.SecretLengthBytes > 0 {
|
||||
cfg.SecretLengthBytes = source.SecretLengthBytes
|
||||
}
|
||||
if len(source.DefaultEventTypes) > 0 {
|
||||
cfg.DefaultEventTypes = source.DefaultEventTypes
|
||||
}
|
||||
if source.DefaultStatus != "" {
|
||||
cfg.DefaultStatus = model.CallbackStatus(source.DefaultStatus)
|
||||
}
|
||||
cfg.Vault = VaultConfig{
|
||||
Address: source.Vault.Address,
|
||||
TokenEnv: source.Vault.TokenEnv,
|
||||
TokenFileEnv: source.Vault.TokenFileEnv,
|
||||
TokenFile: source.Vault.TokenFile,
|
||||
Namespace: source.Vault.Namespace,
|
||||
MountPath: source.Vault.MountPath,
|
||||
}
|
||||
return cfg
|
||||
}
|
||||
|
||||
func (c callbacksConfig) validate() error {
|
||||
if c.SecretLengthBytes <= 0 {
|
||||
return merrors.InvalidArgument("callbacks signing secret length must be greater than zero", "api.callbacks.secret_length_bytes")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
Reference in New Issue
Block a user