bff for callbacks

This commit is contained in:
Stephan D
2026-03-01 02:04:15 +01:00
parent 709df51512
commit 86eab3bb70
44 changed files with 1563 additions and 25 deletions

View File

@@ -0,0 +1,15 @@
package callbacks
import (
"context"
"github.com/tech/sendico/pkg/auth"
"github.com/tech/sendico/pkg/model"
"go.mongodb.org/mongo-driver/v2/bson"
)
type DB interface {
auth.ProtectedDB[*model.Callback]
SetArchived(ctx context.Context, accountRef, organizationRef, callbackRef bson.ObjectID, archived, cascade bool) error
List(ctx context.Context, accountRef, organizationRef, _ bson.ObjectID, cursor *model.ViewCursor) ([]model.Callback, error)
}

View File

@@ -3,6 +3,7 @@ package db
import (
"github.com/tech/sendico/pkg/auth"
"github.com/tech/sendico/pkg/db/account"
"github.com/tech/sendico/pkg/db/callbacks"
"github.com/tech/sendico/pkg/db/chainassets"
"github.com/tech/sendico/pkg/db/chainwalletroutes"
mongoimpl "github.com/tech/sendico/pkg/db/internal/mongo"
@@ -29,6 +30,7 @@ type Factory interface {
NewOrganizationDB() (organization.DB, error)
NewInvitationsDB() (invitation.DB, error)
NewRecipientsDB() (recipient.DB, error)
NewCallbacksDB() (callbacks.DB, error)
NewVerificationsDB() (verification.DB, error)
NewRolesDB() (role.DB, error)

View File

@@ -0,0 +1,30 @@
package callbacksdb
import (
"context"
"github.com/tech/sendico/pkg/mutil/mzap"
"go.mongodb.org/mongo-driver/v2/bson"
"go.uber.org/zap"
)
func (db *CallbacksDB) SetArchived(
ctx context.Context,
accountRef,
organizationRef,
callbackRef bson.ObjectID,
isArchived,
cascade bool,
) error {
if err := db.ArchivableDB.SetArchived(ctx, accountRef, callbackRef, isArchived); err != nil {
db.DBImp.Logger.Warn("Failed to change callback archive status", zap.Error(err),
mzap.AccRef(accountRef),
mzap.ObjRef("organization_ref", organizationRef),
mzap.ObjRef("callback_ref", callbackRef),
zap.Bool("archived", isArchived),
zap.Bool("cascade", cascade),
)
return err
}
return nil
}

View File

@@ -0,0 +1,112 @@
package callbacksdb
import (
"context"
"errors"
"github.com/tech/sendico/pkg/auth"
"github.com/tech/sendico/pkg/db/callbacks"
"github.com/tech/sendico/pkg/db/policy"
ri "github.com/tech/sendico/pkg/db/repository/index"
"github.com/tech/sendico/pkg/db/storable"
"github.com/tech/sendico/pkg/merrors"
"github.com/tech/sendico/pkg/mlogger"
"github.com/tech/sendico/pkg/model"
"github.com/tech/sendico/pkg/mservice"
"go.mongodb.org/mongo-driver/v2/mongo"
"go.uber.org/zap"
)
type CallbacksDB struct {
auth.ProtectedDBImp[*model.Callback]
auth.ArchivableDB[*model.Callback]
}
func Create(
ctx context.Context,
logger mlogger.Logger,
enforcer auth.Enforcer,
pdb policy.DB,
db *mongo.Database,
) (*CallbacksDB, error) {
if err := ensureBuiltInPolicy(ctx, logger, pdb); err != nil {
return nil, err
}
p, err := auth.CreateDBImp[*model.Callback](ctx, logger, pdb, enforcer, mservice.Callbacks, db)
if err != nil {
return nil, err
}
for _, definition := range []*ri.Definition{
{
Name: "uq_callbacks_client_url",
Keys: []ri.Key{
{Field: storable.OrganizationRefField, Sort: ri.Asc},
{Field: "client_id", Sort: ri.Asc},
{Field: "url", Sort: ri.Asc},
},
Unique: true,
},
{
Name: "idx_callbacks_lookup",
Keys: []ri.Key{
{Field: storable.OrganizationRefField, Sort: ri.Asc},
{Field: "status", Sort: ri.Asc},
{Field: "event_types", Sort: ri.Asc},
},
},
} {
if err := p.DBImp.Repository.CreateIndex(definition); err != nil {
p.DBImp.Logger.Warn("Failed to create callbacks index", zap.String("index", definition.Name), zap.Error(err))
return nil, err
}
}
createEmpty := func() *model.Callback {
return &model.Callback{}
}
getArchivable := func(callback *model.Callback) model.Archivable {
return &callback.ArchivableBase
}
return &CallbacksDB{
ProtectedDBImp: *p,
ArchivableDB: auth.NewArchivableDB(
p.DBImp,
p.DBImp.Logger,
enforcer,
createEmpty,
getArchivable,
),
}, nil
}
func ensureBuiltInPolicy(ctx context.Context, logger mlogger.Logger, pdb policy.DB) error {
var existing model.PolicyDescription
if err := pdb.GetBuiltInPolicy(ctx, mservice.Callbacks, &existing); err == nil {
return nil
} else if !errors.Is(err, merrors.ErrNoData) {
return err
}
description := "Callbacks subscription management"
resourceTypes := []mservice.Type{mservice.Callbacks}
policyDescription := &model.PolicyDescription{
Describable: model.Describable{
Name: "Callbacks",
Description: &description,
},
ResourceTypes: &resourceTypes,
}
if err := pdb.Create(ctx, policyDescription); err != nil && !errors.Is(err, merrors.ErrDataConflict) {
if logger != nil {
logger.Warn("Failed to create built-in callbacks policy", zap.Error(err))
}
return err
}
return pdb.GetBuiltInPolicy(ctx, mservice.Callbacks, &existing)
}
var _ callbacks.DB = (*CallbacksDB)(nil)

View File

@@ -0,0 +1,36 @@
package callbacksdb
import (
"context"
"errors"
"github.com/tech/sendico/pkg/db/repository"
"github.com/tech/sendico/pkg/merrors"
"github.com/tech/sendico/pkg/model"
mauth "github.com/tech/sendico/pkg/mutil/db/auth"
"go.mongodb.org/mongo-driver/v2/bson"
)
func (db *CallbacksDB) List(
ctx context.Context,
accountRef,
organizationRef,
_ bson.ObjectID,
cursor *model.ViewCursor,
) ([]model.Callback, error) {
res, err := mauth.GetProtectedObjects[model.Callback](
ctx,
db.DBImp.Logger,
accountRef,
organizationRef,
model.ActionRead,
repository.OrgFilter(organizationRef),
cursor,
db.Enforcer,
db.DBImp.Repository,
)
if errors.Is(err, merrors.ErrNoData) {
return []model.Callback{}, nil
}
return res, err
}

View File

@@ -10,9 +10,11 @@ import (
"github.com/mitchellh/mapstructure"
"github.com/tech/sendico/pkg/auth"
"github.com/tech/sendico/pkg/db/account"
"github.com/tech/sendico/pkg/db/callbacks"
"github.com/tech/sendico/pkg/db/chainassets"
"github.com/tech/sendico/pkg/db/chainwalletroutes"
"github.com/tech/sendico/pkg/db/internal/mongo/accountdb"
"github.com/tech/sendico/pkg/db/internal/mongo/callbacksdb"
"github.com/tech/sendico/pkg/db/internal/mongo/chainassetsdb"
"github.com/tech/sendico/pkg/db/internal/mongo/chainwalletroutesdb"
"github.com/tech/sendico/pkg/db/internal/mongo/invitationdb"
@@ -218,6 +220,10 @@ func (db *DB) NewRecipientsDB() (recipient.DB, error) {
return newProtectedDB(db, create)
}
func (db *DB) NewCallbacksDB() (callbacks.DB, error) {
return newProtectedDB(db, callbacksdb.Create)
}
func (db *DB) NewRefreshTokensDB() (refreshtokens.DB, error) {
return refreshtokensdb.Create(db.logger, db.db())
}

41
api/pkg/model/callback.go Normal file
View File

@@ -0,0 +1,41 @@
package model
import "github.com/tech/sendico/pkg/mservice"
type CallbackStatus string
const (
CallbackStatusActive CallbackStatus = "active"
CallbackStatusDisabled CallbackStatus = "disabled"
)
type CallbackSigningMode string
const (
CallbackSigningModeNone CallbackSigningMode = "none"
CallbackSigningModeHMACSHA256 CallbackSigningMode = "hmac_sha256"
)
type CallbackRetryPolicy struct {
MinDelayMS int `bson:"min_ms" json:"minDelayMs"`
MaxDelayMS int `bson:"max_ms" json:"maxDelayMs"`
SigningMode CallbackSigningMode `bson:"signing_mode" json:"signingMode"`
SecretRef string `bson:"secret_ref,omitempty" json:"secretRef,omitempty"`
Headers map[string]string `bson:"headers,omitempty" json:"headers,omitempty"`
MaxAttempts int `bson:"max_attempts" json:"maxAttempts"`
RequestTimeoutMS int `bson:"request_timeout_ms" json:"requestTimeoutMs"`
}
type Callback struct {
PermissionBound `bson:",inline" json:",inline"`
Describable `bson:",inline" json:",inline"`
ClientID string `bson:"client_id" json:"clientId"`
Status CallbackStatus `bson:"status" json:"status"`
URL string `bson:"url" json:"url"`
EventTypes []string `bson:"event_types" json:"eventTypes"`
RetryPolicy CallbackRetryPolicy `bson:"retry_policy" json:"retryPolicy"`
}
func (*Callback) Collection() string {
return mservice.Callbacks
}

View File

@@ -35,6 +35,7 @@ const (
ChainWalletBalances Type = "chain_wallet_balances" // Represents managed chain wallet balances
ChainTransfers Type = "chain_transfers" // Represents chain transfers
ChainDeposits Type = "chain_deposits" // Represents chain deposits
Callbacks Type = "callbacks" // Represents webhook callback subscriptions
Notifications Type = "notifications" // Represents notifications sent to users
Organizations Type = "organizations" // Represents organizations in the system
Payments Type = "payments" // Represents payments service
@@ -58,7 +59,7 @@ const (
func StringToSType(s string) (Type, error) {
switch Type(s) {
case Accounts, Verification, Amplitude, Site, Changes, Clients, ChainGateway, ChainWallets, WalletRoutes, ChainWalletBalances,
ChainTransfers, ChainDeposits, MntxGateway, PaymentGateway, FXOracle, FeePlans, BillingDocuments, FilterProjects, Invitations, Invoices, Logo, Ledger,
ChainTransfers, ChainDeposits, Callbacks, MntxGateway, PaymentGateway, FXOracle, FeePlans, BillingDocuments, FilterProjects, Invitations, Invoices, Logo, Ledger,
LedgerAccounts, LedgerBalances, LedgerEntries, LedgerOutbox, LedgerParties, LedgerPlines, Notifications,
Organizations, Payments, PaymentRoutes, PaymentOrchestrator, PaymentMethods, Permissions, Policies, PolicyAssignements,
Recipients, RefreshTokens, Roles, Storage, Tenants, Workflows, Discovery:

View File

@@ -8,10 +8,12 @@ import (
// Config describes Vault KV v2 connection settings.
type Config struct {
Address string `mapstructure:"address" yaml:"address"`
TokenEnv string `mapstructure:"token_env" yaml:"token_env"`
Namespace string `mapstructure:"namespace" yaml:"namespace"`
MountPath string `mapstructure:"mount_path" yaml:"mount_path"`
Address string `mapstructure:"address" yaml:"address"`
TokenEnv string `mapstructure:"token_env" yaml:"token_env"`
TokenFileEnv string `mapstructure:"token_file_env" yaml:"token_file_env"`
TokenFile string `mapstructure:"token_file" yaml:"token_file"`
Namespace string `mapstructure:"namespace" yaml:"namespace"`
MountPath string `mapstructure:"mount_path" yaml:"mount_path"`
}
// Client defines KV operations used by services.

View File

@@ -36,16 +36,14 @@ func newService(opts Options) (Client, error) {
return nil, merrors.InvalidArgument(component + ": address is required")
}
tokenEnv := strings.TrimSpace(opts.Config.TokenEnv)
if tokenEnv == "" {
logger.Error("Vault token env missing")
return nil, merrors.InvalidArgument(component + ": token_env is required")
token, tokenSource, err := resolveToken(opts.Config)
if err != nil {
logger.Error("Vault token configuration is invalid", zap.Error(err))
return nil, err
}
token := strings.TrimSpace(os.Getenv(tokenEnv))
if token == "" {
logger.Error("Vault token missing; expected Vault Agent to export token", zap.String("env", tokenEnv))
return nil, merrors.InvalidArgument(component + ": token env " + tokenEnv + " is not set (expected Vault Agent sink to populate it)")
logger.Error("Vault token missing", zap.String("source", tokenSource))
return nil, merrors.InvalidArgument(component + ": vault token is empty")
}
mountPath := strings.Trim(strings.TrimSpace(opts.Config.MountPath), "/")
@@ -148,4 +146,36 @@ func normalizePath(secretPath string) (string, error) {
return normalizedPath, nil
}
func resolveToken(config Config) (string, string, error) {
tokenEnv := strings.TrimSpace(config.TokenEnv)
if tokenEnv != "" {
if token := strings.TrimSpace(os.Getenv(tokenEnv)); token != "" {
return token, "token_env:" + tokenEnv, nil
}
}
tokenFilePath := strings.TrimSpace(config.TokenFile)
if tokenFileEnv := strings.TrimSpace(config.TokenFileEnv); tokenFileEnv != "" {
if resolved := strings.TrimSpace(os.Getenv(tokenFileEnv)); resolved != "" {
tokenFilePath = resolved
}
}
if tokenFilePath != "" {
raw, err := os.ReadFile(tokenFilePath)
if err != nil {
return "", "", merrors.Internal("vault kv: failed to read token file " + tokenFilePath + ": " + err.Error())
}
return strings.TrimSpace(string(raw)), "token_file:" + tokenFilePath, nil
}
if tokenEnv != "" {
return "", "token_env:" + tokenEnv, merrors.InvalidArgument("vault kv: token env " + tokenEnv + " is empty")
}
if strings.TrimSpace(config.TokenFileEnv) != "" {
return "", "token_file_env:" + strings.TrimSpace(config.TokenFileEnv), merrors.InvalidArgument("vault kv: token file env is empty")
}
return "", "", merrors.InvalidArgument("vault kv: either token_env or token_file/token_file_env must be configured")
}
var _ Client = (*service)(nil)

View File

@@ -10,11 +10,13 @@ import (
// Config describes how to connect to Vault for managed wallet keys.
type Config struct {
Address string `mapstructure:"address" yaml:"address"`
TokenEnv string `mapstructure:"token_env" yaml:"token_env"`
Namespace string `mapstructure:"namespace" yaml:"namespace"`
MountPath string `mapstructure:"mount_path" yaml:"mount_path"`
KeyPrefix string `mapstructure:"key_prefix" yaml:"key_prefix"`
Address string `mapstructure:"address" yaml:"address"`
TokenEnv string `mapstructure:"token_env" yaml:"token_env"`
TokenFileEnv string `mapstructure:"token_file_env" yaml:"token_file_env"`
TokenFile string `mapstructure:"token_file" yaml:"token_file"`
Namespace string `mapstructure:"namespace" yaml:"namespace"`
MountPath string `mapstructure:"mount_path" yaml:"mount_path"`
KeyPrefix string `mapstructure:"key_prefix" yaml:"key_prefix"`
}
// ManagedWalletKey captures metadata returned after key provisioning.

View File

@@ -38,10 +38,12 @@ func newService(opts Options) (Service, error) {
store, err := kv.New(kv.Options{
Logger: logger,
Config: kv.Config{
Address: opts.Config.Address,
TokenEnv: opts.Config.TokenEnv,
Namespace: opts.Config.Namespace,
MountPath: opts.Config.MountPath,
Address: opts.Config.Address,
TokenEnv: opts.Config.TokenEnv,
TokenFileEnv: opts.Config.TokenFileEnv,
TokenFile: opts.Config.TokenFile,
Namespace: opts.Config.Namespace,
MountPath: opts.Config.MountPath,
},
Component: component,
})