From 86eab3bb70f43101963118d0c1bd3033260dcee5 Mon Sep 17 00:00:00 2001 From: Stephan D Date: Sun, 1 Mar 2026 02:04:15 +0100 Subject: [PATCH] bff for callbacks --- .woodpecker/bff.yml | 1 + api/edge/bff/config.dev.yml | 13 + api/edge/bff/config.yml | 13 + api/edge/bff/go.mod | 16 +- api/edge/bff/go.sum | 33 ++ api/edge/bff/interface/api/config.go | 11 + .../interface/services/callbacks/callbacks.go | 11 + api/edge/bff/internal/api/api.go | 2 + .../internal/server/callbacksimp/handlers.go | 337 ++++++++++++++++++ .../internal/server/callbacksimp/secrets.go | 178 +++++++++ .../internal/server/callbacksimp/service.go | 139 ++++++++ .../callbacks/internal/storage/service.go | 3 +- api/pkg/db/callbacks/callbacks.go | 15 + api/pkg/db/factory.go | 2 + .../db/internal/mongo/callbacksdb/archived.go | 30 ++ api/pkg/db/internal/mongo/callbacksdb/db.go | 112 ++++++ api/pkg/db/internal/mongo/callbacksdb/list.go | 36 ++ api/pkg/db/internal/mongo/db.go | 6 + api/pkg/model/callback.go | 41 +++ api/pkg/mservice/services.go | 3 +- api/pkg/vault/kv/module.go | 10 +- api/pkg/vault/kv/service.go | 46 ++- api/pkg/vault/managedkey/module.go | 12 +- api/pkg/vault/managedkey/service.go | 10 +- ci/dev/README.md | 2 +- ci/dev/vault-agent/bff.hcl | 21 ++ ci/prod/.env.runtime | 1 + ci/prod/compose/bff.yml | 44 +++ ci/prod/compose/vault-agent/bff.hcl | 21 ++ ci/prod/scripts/deploy/bff.sh | 9 + ci/scripts/bff/deploy.sh | 7 + docker-compose.dev.yml | 43 +++ interface/api.yaml | 15 + interface/api/callbacks/archive.yaml | 38 ++ interface/api/callbacks/bodies/callback.yaml | 9 + interface/api/callbacks/create.yaml | 34 ++ interface/api/callbacks/list.yaml | 34 ++ interface/api/callbacks/object.yaml | 65 ++++ interface/api/callbacks/request/callback.yaml | 5 + .../api/callbacks/response/callback.yaml | 19 + interface/api/callbacks/rotate_secret.yaml | 32 ++ interface/api/callbacks/update.yaml | 32 ++ interface/api/parameters/callbacks_ref.yaml | 10 + interface/models/callback/callback.yaml | 67 ++++ 44 files changed, 1563 insertions(+), 25 deletions(-) create mode 100644 api/edge/bff/interface/services/callbacks/callbacks.go create mode 100644 api/edge/bff/internal/server/callbacksimp/handlers.go create mode 100644 api/edge/bff/internal/server/callbacksimp/secrets.go create mode 100644 api/edge/bff/internal/server/callbacksimp/service.go create mode 100644 api/pkg/db/callbacks/callbacks.go create mode 100644 api/pkg/db/internal/mongo/callbacksdb/archived.go create mode 100644 api/pkg/db/internal/mongo/callbacksdb/db.go create mode 100644 api/pkg/db/internal/mongo/callbacksdb/list.go create mode 100644 api/pkg/model/callback.go create mode 100644 ci/dev/vault-agent/bff.hcl create mode 100644 ci/prod/compose/vault-agent/bff.hcl create mode 100644 interface/api/callbacks/archive.yaml create mode 100644 interface/api/callbacks/bodies/callback.yaml create mode 100644 interface/api/callbacks/create.yaml create mode 100644 interface/api/callbacks/list.yaml create mode 100644 interface/api/callbacks/object.yaml create mode 100644 interface/api/callbacks/request/callback.yaml create mode 100644 interface/api/callbacks/response/callback.yaml create mode 100644 interface/api/callbacks/rotate_secret.yaml create mode 100644 interface/api/callbacks/update.yaml create mode 100644 interface/api/parameters/callbacks_ref.yaml create mode 100644 interface/models/callback/callback.yaml diff --git a/.woodpecker/bff.yml b/.woodpecker/bff.yml index 37767dff..7f4038e3 100644 --- a/.woodpecker/bff.yml +++ b/.woodpecker/bff.yml @@ -4,6 +4,7 @@ matrix: BFF_DOCKERFILE: ci/prod/compose/bff.dockerfile BFF_MONGO_SECRET_PATH: sendico/db BFF_API_SECRET_PATH: sendico/api/endpoint + BFF_VAULT_SECRET_PATH: sendico/edge/bff/vault BFF_ENV: prod when: diff --git a/api/edge/bff/config.dev.yml b/api/edge/bff/config.dev.yml index bae06ea4..771745e6 100755 --- a/api/edge/bff/config.dev.yml +++ b/api/edge/bff/config.dev.yml @@ -109,6 +109,19 @@ api: dial_timeout_seconds: 5 call_timeout_seconds: 5 insecure: true + callbacks: + default_event_types: + - payment.status.updated + default_status: active + secret_path_prefix: sendico/callbacks + secret_field: value + secret_length_bytes: 32 + vault: + address: "http://dev-vault:8200" + token_env: VAULT_TOKEN + token_file_env: VAULT_TOKEN_FILE + namespace: "" + mount_path: kv app: diff --git a/api/edge/bff/config.yml b/api/edge/bff/config.yml index e94ccb5d..10a27045 100755 --- a/api/edge/bff/config.yml +++ b/api/edge/bff/config.yml @@ -111,6 +111,19 @@ api: dial_timeout_seconds: 5 call_timeout_seconds: 5 insecure: true + callbacks: + default_event_types: + - payment.status.updated + default_status: active + secret_path_prefix: sendico/callbacks + secret_field: value + secret_length_bytes: 32 + vault: + address: "https://vault.sendico.io" + token_env: VAULT_TOKEN + token_file_env: VAULT_TOKEN_FILE + namespace: "" + mount_path: kv app: diff --git a/api/edge/bff/go.mod b/api/edge/bff/go.mod index 6ab832be..a5ce8d7d 100644 --- a/api/edge/bff/go.mod +++ b/api/edge/bff/go.mod @@ -25,6 +25,7 @@ require ( github.com/go-chi/metrics v0.1.1 github.com/google/uuid v1.6.0 github.com/mitchellh/mapstructure v1.5.0 + github.com/prometheus/client_golang v1.23.2 github.com/shopspring/decimal v1.4.0 github.com/stretchr/testify v1.11.1 github.com/tech/sendico/gateway/tron v0.0.0-00010101000000-000000000000 @@ -83,11 +84,22 @@ require ( github.com/docker/go-units v0.5.0 // indirect github.com/felixge/httpsnoop v1.0.4 // indirect github.com/go-chi/chi v1.5.5 // indirect + github.com/go-jose/go-jose/v4 v4.1.3 // indirect github.com/go-logr/logr v1.4.3 // indirect github.com/go-logr/stdr v1.2.2 // indirect github.com/go-ole/go-ole v1.3.0 // indirect github.com/goccy/go-json v0.10.5 // indirect github.com/gogo/protobuf v1.3.2 // indirect + github.com/hashicorp/errwrap v1.1.0 // indirect + github.com/hashicorp/go-cleanhttp v0.5.2 // indirect + github.com/hashicorp/go-multierror v1.1.1 // indirect + github.com/hashicorp/go-retryablehttp v0.7.8 // indirect + github.com/hashicorp/go-rootcerts v1.0.2 // indirect + github.com/hashicorp/go-secure-stdlib/parseutil v0.2.0 // indirect + github.com/hashicorp/go-secure-stdlib/strutil v0.1.2 // indirect + github.com/hashicorp/go-sockaddr v1.0.7 // indirect + github.com/hashicorp/hcl v1.0.1-vault-7 // indirect + github.com/hashicorp/vault/api v1.22.0 // indirect github.com/klauspost/compress v1.18.4 // indirect github.com/lestrrat-go/blackmagic v1.0.4 // indirect github.com/lestrrat-go/dsig v1.0.0 // indirect @@ -100,6 +112,7 @@ require ( github.com/magiconair/properties v1.8.7 // indirect github.com/mattn/go-colorable v0.1.14 // indirect github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mitchellh/go-homedir v1.1.0 // indirect github.com/moby/docker-image-spec v1.3.1 // indirect github.com/moby/patternmatcher v0.6.0 // indirect github.com/moby/sys/sequential v0.6.0 // indirect @@ -116,10 +129,10 @@ require ( github.com/pkg/errors v0.9.1 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect - github.com/prometheus/client_golang v1.23.2 // indirect github.com/prometheus/client_model v0.6.2 // indirect github.com/prometheus/common v0.67.5 // indirect github.com/prometheus/procfs v0.20.1 // indirect + github.com/ryanuber/go-glob v1.0.0 // indirect github.com/segmentio/asm v1.2.1 // indirect github.com/shirou/gopsutil/v3 v3.24.5 // indirect github.com/shoenig/go-m1cpu v0.1.6 // indirect @@ -145,5 +158,6 @@ require ( golang.org/x/sync v0.19.0 // indirect golang.org/x/sys v0.41.0 // indirect golang.org/x/text v0.34.0 // indirect + golang.org/x/time v0.14.0 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171 // indirect ) diff --git a/api/edge/bff/go.sum b/api/edge/bff/go.sum index 1ae4cd27..891e1ad8 100644 --- a/api/edge/bff/go.sum +++ b/api/edge/bff/go.sum @@ -85,6 +85,8 @@ github.com/docker/go-connections v0.5.0 h1:USnMq7hx7gwdVZq1L49hLXaFtUdTADjXGp+uj github.com/docker/go-connections v0.5.0/go.mod h1:ov60Kzw0kKElRwhNs9UlUHAE/F9Fe6GLaXnqyDdmEXc= github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4= github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk= +github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM= +github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU= github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= github.com/go-chi/chi v1.5.4/go.mod h1:uaf8YgoFazUOkPBG7fxPftUylNumIev9awIWOENIuEg= @@ -98,6 +100,8 @@ github.com/go-chi/jwtauth/v5 v5.4.0 h1:Ieh0xMJsFvqylqJ02/mQHKzbbKO9DYNBh4DPKCwTw github.com/go-chi/jwtauth/v5 v5.4.0/go.mod h1:w6yjqUUXz1b8+oiJel64Sz1KJwduQM6qUA5QNzO5+bQ= github.com/go-chi/metrics v0.1.1 h1:CXhbnkAVVjb0k73EBRQ6Z2YdWFnbXZgNtg1Mboguibk= github.com/go-chi/metrics v0.1.1/go.mod h1:mcGTM1pPalP7WCtb+akNYFO/lwNwBBLCuedepqjoPn4= +github.com/go-jose/go-jose/v4 v4.1.3 h1:CVLmWDhDVRa6Mi/IgCgaopNosCaHz7zrMeF9MlZRkrs= +github.com/go-jose/go-jose/v4 v4.1.3/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08= github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= @@ -106,6 +110,8 @@ github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0= github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE= github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78= +github.com/go-test/deep v1.1.1 h1:0r/53hagsehfO4bzD2Pgr/+RgHqhmf+k1Bpse2cTu1U= +github.com/go-test/deep v1.1.1/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE= github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4= github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= @@ -123,6 +129,29 @@ github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.7 h1:X+2YciYSxvMQK0UZ7sg45ZVabVZBeBuvMkmuI2V3Fak= github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.7/go.mod h1:lW34nIZuQ8UDPdkon5fmfp2l3+ZkQ2me/+oecHYLOII= +github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= +github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY2I= +github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= +github.com/hashicorp/go-cleanhttp v0.5.2 h1:035FKYIWjmULyFRBKPs8TBQoi0x6d9G4xc9neXJWAZQ= +github.com/hashicorp/go-cleanhttp v0.5.2/go.mod h1:kO/YDlP8L1346E6Sodw+PrpBSV4/SoxCXGY6BqNFT48= +github.com/hashicorp/go-hclog v1.6.3 h1:Qr2kF+eVWjTiYmU7Y31tYlP1h0q/X3Nl3tPGdaB11/k= +github.com/hashicorp/go-hclog v1.6.3/go.mod h1:W4Qnvbt70Wk/zYJryRzDRU/4r0kIg0PVHBcfoyhpF5M= +github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo= +github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM= +github.com/hashicorp/go-retryablehttp v0.7.8 h1:ylXZWnqa7Lhqpk0L1P1LzDtGcCR0rPVUrx/c8Unxc48= +github.com/hashicorp/go-retryablehttp v0.7.8/go.mod h1:rjiScheydd+CxvumBsIrFKlx3iS0jrZ7LvzFGFmuKbw= +github.com/hashicorp/go-rootcerts v1.0.2 h1:jzhAVGtqPKbwpyCPELlgNWhE1znq+qwJtW5Oi2viEzc= +github.com/hashicorp/go-rootcerts v1.0.2/go.mod h1:pqUvnprVnM5bf7AOirdbb01K4ccR319Vf4pU3K5EGc8= +github.com/hashicorp/go-secure-stdlib/parseutil v0.2.0 h1:U+kC2dOhMFQctRfhK0gRctKAPTloZdMU5ZJxaesJ/VM= +github.com/hashicorp/go-secure-stdlib/parseutil v0.2.0/go.mod h1:Ll013mhdmsVDuoIXVfBtvgGJsXDYkTw1kooNcoCXuE0= +github.com/hashicorp/go-secure-stdlib/strutil v0.1.2 h1:kes8mmyCpxJsI7FTwtzRqEy9CdjCtrXrXGuOpxEA7Ts= +github.com/hashicorp/go-secure-stdlib/strutil v0.1.2/go.mod h1:Gou2R9+il93BqX25LAKCLuM+y9U2T4hlwvT1yprcna4= +github.com/hashicorp/go-sockaddr v1.0.7 h1:G+pTkSO01HpR5qCxg7lxfsFEZaG+C0VssTy/9dbT+Fw= +github.com/hashicorp/go-sockaddr v1.0.7/go.mod h1:FZQbEYa1pxkQ7WLpyXJ6cbjpT8q0YgQaK/JakXqGyWw= +github.com/hashicorp/hcl v1.0.1-vault-7 h1:ag5OxFVy3QYTFTJODRzTKVZ6xvdfLLCA1cy/Y6xGI0I= +github.com/hashicorp/hcl v1.0.1-vault-7/go.mod h1:XYhtn6ijBSAj6n4YqAaf7RBPS4I06AItNorpy+MoQNM= +github.com/hashicorp/vault/api v1.22.0 h1:+HYFquE35/B74fHoIeXlZIP2YADVboaPjaSicHEZiH0= +github.com/hashicorp/vault/api v1.22.0/go.mod h1:IUZA2cDvr4Ok3+NtK2Oq/r+lJeXkeCrHRmqdyWfpmGM= github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= github.com/klauspost/compress v1.18.4 h1:RPhnKRAQ4Fh8zU2FY/6ZFDwTVTxgJ/EMydqSTzE9a2c= @@ -159,6 +188,8 @@ github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHP github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG+4E0Y= +github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0= github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY= github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0= @@ -208,6 +239,8 @@ github.com/prometheus/procfs v0.20.1 h1:XwbrGOIplXW/AU3YhIhLODXMJYyC1isLFfYCsTEy github.com/prometheus/procfs v0.20.1/go.mod h1:o9EMBZGRyvDrSPH1RqdxhojkuXstoe4UlK79eF5TGGo= github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= +github.com/ryanuber/go-glob v1.0.0 h1:iQh3xXAumdQ+4Ufa5b25cRpC5TYKlno6hsv6Cb3pkBk= +github.com/ryanuber/go-glob v1.0.0/go.mod h1:807d1WSdnB0XRJzKNil9Om6lcp/3a0v4qIHxIXzX/Yc= github.com/segmentio/asm v1.2.1 h1:DTNbBqs57ioxAD4PrArqftgypG4/qNpXoJx8TVXxPR0= github.com/segmentio/asm v1.2.1/go.mod h1:BqMnlJP91P8d+4ibuonYZw9mfnzI9HfxselHZr5aAcs= github.com/sergi/go-diff v1.0.0/go.mod h1:0CfEIISq7TuYL3j771MWULgwwjU+GofnZX9QAmXWZgo= diff --git a/api/edge/bff/interface/api/config.go b/api/edge/bff/interface/api/config.go index 0f7898b7..0e50f14f 100644 --- a/api/edge/bff/interface/api/config.go +++ b/api/edge/bff/interface/api/config.go @@ -1,6 +1,7 @@ package api import ( + "github.com/tech/sendico/pkg/vault/kv" mwa "github.com/tech/sendico/server/interface/middleware" fsc "github.com/tech/sendico/server/interface/services/fileservice/config" ) @@ -13,6 +14,7 @@ type Config struct { PaymentOrchestrator *PaymentOrchestratorConfig `yaml:"payment_orchestrator"` PaymentQuotation *PaymentOrchestratorConfig `yaml:"payment_quotation"` PaymentMethods *PaymentOrchestratorConfig `yaml:"payment_methods"` + Callbacks *CallbacksConfig `yaml:"callbacks"` } type ChainGatewayConfig struct { @@ -45,3 +47,12 @@ type PaymentOrchestratorConfig struct { CallTimeoutSeconds int `yaml:"call_timeout_seconds"` Insecure bool `yaml:"insecure"` } + +type CallbacksConfig struct { + DefaultEventTypes []string `yaml:"default_event_types"` + DefaultStatus string `yaml:"default_status"` + SecretPathPrefix string `yaml:"secret_path_prefix"` + SecretField string `yaml:"secret_field"` + SecretLengthBytes int `yaml:"secret_length_bytes"` + Vault kv.Config `yaml:"vault"` +} diff --git a/api/edge/bff/interface/services/callbacks/callbacks.go b/api/edge/bff/interface/services/callbacks/callbacks.go new file mode 100644 index 00000000..c9686225 --- /dev/null +++ b/api/edge/bff/interface/services/callbacks/callbacks.go @@ -0,0 +1,11 @@ +package callbacks + +import ( + "github.com/tech/sendico/pkg/mservice" + "github.com/tech/sendico/server/interface/api" + "github.com/tech/sendico/server/internal/server/callbacksimp" +) + +func Create(a api.API) (mservice.MicroService, error) { + return callbacksimp.CreateAPI(a) +} diff --git a/api/edge/bff/internal/api/api.go b/api/edge/bff/internal/api/api.go index 702429f4..e21c454d 100644 --- a/api/edge/bff/internal/api/api.go +++ b/api/edge/bff/internal/api/api.go @@ -12,6 +12,7 @@ import ( "github.com/tech/sendico/pkg/mservice" "github.com/tech/sendico/server/interface/api" "github.com/tech/sendico/server/interface/services/account" + "github.com/tech/sendico/server/interface/services/callbacks" "github.com/tech/sendico/server/interface/services/invitation" "github.com/tech/sendico/server/interface/services/ledger" "github.com/tech/sendico/server/interface/services/logo" @@ -91,6 +92,7 @@ func (a *APIImp) installServices() error { srvf = append(srvf, wallet.Create) srvf = append(srvf, ledger.Create) srvf = append(srvf, recipient.Create) + srvf = append(srvf, callbacks.Create) srvf = append(srvf, paymethod.Create) srvf = append(srvf, payment.Create) diff --git a/api/edge/bff/internal/server/callbacksimp/handlers.go b/api/edge/bff/internal/server/callbacksimp/handlers.go new file mode 100644 index 00000000..20e7a111 --- /dev/null +++ b/api/edge/bff/internal/server/callbacksimp/handlers.go @@ -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 +} diff --git a/api/edge/bff/internal/server/callbacksimp/secrets.go b/api/edge/bff/internal/server/callbacksimp/secrets.go new file mode 100644 index 00000000..1467cad2 --- /dev/null +++ b/api/edge/bff/internal/server/callbacksimp/secrets.go @@ -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 +} diff --git a/api/edge/bff/internal/server/callbacksimp/service.go b/api/edge/bff/internal/server/callbacksimp/service.go new file mode 100644 index 00000000..b6d00baf --- /dev/null +++ b/api/edge/bff/internal/server/callbacksimp/service.go @@ -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 +} diff --git a/api/edge/callbacks/internal/storage/service.go b/api/edge/callbacks/internal/storage/service.go index 67962399..549eb948 100644 --- a/api/edge/callbacks/internal/storage/service.go +++ b/api/edge/callbacks/internal/storage/service.go @@ -13,6 +13,7 @@ import ( "github.com/tech/sendico/pkg/db/storable" "github.com/tech/sendico/pkg/merrors" "github.com/tech/sendico/pkg/mlogger" + "github.com/tech/sendico/pkg/mservice" mutil "github.com/tech/sendico/pkg/mutil/db" "go.mongodb.org/mongo-driver/v2/bson" "go.mongodb.org/mongo-driver/v2/mongo" @@ -22,7 +23,7 @@ import ( const ( inboxCollection string = "inbox" tasksCollection string = "tasks" - endpointsCollection string = "endpoints" + endpointsCollection string = mservice.Callbacks ) type mongoRepository struct { diff --git a/api/pkg/db/callbacks/callbacks.go b/api/pkg/db/callbacks/callbacks.go new file mode 100644 index 00000000..6b013853 --- /dev/null +++ b/api/pkg/db/callbacks/callbacks.go @@ -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) +} diff --git a/api/pkg/db/factory.go b/api/pkg/db/factory.go index db45addb..3ad90f0c 100644 --- a/api/pkg/db/factory.go +++ b/api/pkg/db/factory.go @@ -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) diff --git a/api/pkg/db/internal/mongo/callbacksdb/archived.go b/api/pkg/db/internal/mongo/callbacksdb/archived.go new file mode 100644 index 00000000..eece1ba0 --- /dev/null +++ b/api/pkg/db/internal/mongo/callbacksdb/archived.go @@ -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 +} diff --git a/api/pkg/db/internal/mongo/callbacksdb/db.go b/api/pkg/db/internal/mongo/callbacksdb/db.go new file mode 100644 index 00000000..0e4231b1 --- /dev/null +++ b/api/pkg/db/internal/mongo/callbacksdb/db.go @@ -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) diff --git a/api/pkg/db/internal/mongo/callbacksdb/list.go b/api/pkg/db/internal/mongo/callbacksdb/list.go new file mode 100644 index 00000000..96769e65 --- /dev/null +++ b/api/pkg/db/internal/mongo/callbacksdb/list.go @@ -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 +} diff --git a/api/pkg/db/internal/mongo/db.go b/api/pkg/db/internal/mongo/db.go index 414dfa1b..5441163a 100755 --- a/api/pkg/db/internal/mongo/db.go +++ b/api/pkg/db/internal/mongo/db.go @@ -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()) } diff --git a/api/pkg/model/callback.go b/api/pkg/model/callback.go new file mode 100644 index 00000000..ec7f8e55 --- /dev/null +++ b/api/pkg/model/callback.go @@ -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 +} diff --git a/api/pkg/mservice/services.go b/api/pkg/mservice/services.go index 68d1f42a..04349df9 100644 --- a/api/pkg/mservice/services.go +++ b/api/pkg/mservice/services.go @@ -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: diff --git a/api/pkg/vault/kv/module.go b/api/pkg/vault/kv/module.go index 1b8c961c..18fcfb09 100644 --- a/api/pkg/vault/kv/module.go +++ b/api/pkg/vault/kv/module.go @@ -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. diff --git a/api/pkg/vault/kv/service.go b/api/pkg/vault/kv/service.go index c10b8432..7b6fd651 100644 --- a/api/pkg/vault/kv/service.go +++ b/api/pkg/vault/kv/service.go @@ -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) diff --git a/api/pkg/vault/managedkey/module.go b/api/pkg/vault/managedkey/module.go index c5006171..67e910e3 100644 --- a/api/pkg/vault/managedkey/module.go +++ b/api/pkg/vault/managedkey/module.go @@ -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. diff --git a/api/pkg/vault/managedkey/service.go b/api/pkg/vault/managedkey/service.go index 7f1b49d1..274952ac 100644 --- a/api/pkg/vault/managedkey/service.go +++ b/api/pkg/vault/managedkey/service.go @@ -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, }) diff --git a/ci/dev/README.md b/ci/dev/README.md index 2ca81dc9..733c8e14 100644 --- a/ci/dev/README.md +++ b/ci/dev/README.md @@ -65,7 +65,7 @@ Examples: Infrastructure (MongoDB, NATS) uses plain `.env.dev` credentials. -Callbacks, Chain, and TRON run Vault Agent sidecars with AppRole. +Callbacks, BFF, Chain, and TRON run Vault Agent sidecars with AppRole. Set the corresponding `*_VAULT_ROLE_ID` and `*_VAULT_SECRET_ID` values in `.env.dev`. ## Network diff --git a/ci/dev/vault-agent/bff.hcl b/ci/dev/vault-agent/bff.hcl new file mode 100644 index 00000000..663ea0da --- /dev/null +++ b/ci/dev/vault-agent/bff.hcl @@ -0,0 +1,21 @@ +vault { + address = "http://dev-vault:8200" +} + +auto_auth { + method "approle" { + mount_path = "auth/approle" + config = { + role_id_file_path = "/run/vault/role_id" + secret_id_file_path = "/run/vault/secret_id" + } + } + + sink "file" { + config = { + path = "/run/vault/token" + mode = 0600 + } + } +} + diff --git a/ci/prod/.env.runtime b/ci/prod/.env.runtime index fb8128e7..8caca209 100644 --- a/ci/prod/.env.runtime +++ b/ci/prod/.env.runtime @@ -181,6 +181,7 @@ BFF_DIR=bff BFF_COMPOSE_PROJECT=sendico-bff BFF_SERVICE_NAME=sendico_bff BFF_HTTP_PORT=8080 +BFF_VAULT_SECRET_PATH=sendico/edge/bff/vault # Callbacks service CALLBACKS_DIR=callbacks diff --git a/ci/prod/compose/bff.yml b/ci/prod/compose/bff.yml index 83fc074b..d452da18 100644 --- a/ci/prod/compose/bff.yml +++ b/ci/prod/compose/bff.yml @@ -5,6 +5,14 @@ x-common-env: &common-env - ../env/.env.runtime - ../env/.env.version +volumes: + bff-vault-run: + driver: local + driver_opts: + type: tmpfs + device: tmpfs + o: size=8m,uid=0,gid=0,mode=0700 + networks: sendico-net: external: true @@ -51,8 +59,15 @@ services: PERMISSION_COLLECTION: ${PERMISSION_COLLECTION} PERMISSION_TIMEOUT: ${PERMISSION_TIMEOUT} PERMISSION_IS_FILTERED: ${PERMISSION_IS_FILTERED} + VAULT_ADDR: ${VAULT_ADDR} + VAULT_TOKEN_FILE: /run/vault/token ports: - "0.0.0.0:${BFF_HTTP_PORT}:8081" + volumes: + - bff-vault-run:/run/vault:ro + depends_on: + sendico_bff_vault_agent: + condition: service_healthy healthcheck: test: ["CMD-SHELL","wget -qO- http://localhost:8081/api/v1/health | grep -q '\"status\":\"ok\"'"] interval: 30s @@ -61,3 +76,32 @@ services: start_period: 60s networks: - sendico-net + + sendico_bff_vault_agent: + <<: *common-env + container_name: sendico-bff-vault-agent + restart: unless-stopped + image: hashicorp/vault:latest + pull_policy: always + cap_add: ["IPC_LOCK"] + environment: + VAULT_ADDR: ${VAULT_ADDR} + BFF_VAULT_ROLE_ID: ${BFF_VAULT_ROLE_ID} + BFF_VAULT_SECRET_ID: ${BFF_VAULT_SECRET_ID} + command: > + sh -lc 'set -euo pipefail; umask 077; + : "${BFF_VAULT_ROLE_ID:?}"; : "${BFF_VAULT_SECRET_ID:?}"; + printf "%s" "$BFF_VAULT_ROLE_ID" > /run/vault/role_id; + printf "%s" "$BFF_VAULT_SECRET_ID" > /run/vault/secret_id; + unset BFF_VAULT_ROLE_ID BFF_VAULT_SECRET_ID; + exec vault agent -config=/etc/vault/agent/bff.hcl' + volumes: + - ./vault-agent/bff.hcl:/etc/vault/agent/bff.hcl:ro + - bff-vault-run:/run/vault + healthcheck: + test: ["CMD","test","-s","/run/vault/token"] + interval: 10s + timeout: 5s + retries: 6 + networks: + - sendico-net diff --git a/ci/prod/compose/vault-agent/bff.hcl b/ci/prod/compose/vault-agent/bff.hcl new file mode 100644 index 00000000..4dc97ad3 --- /dev/null +++ b/ci/prod/compose/vault-agent/bff.hcl @@ -0,0 +1,21 @@ +vault { + address = "https://vault.sendico.io" +} + +auto_auth { + method "approle" { + mount_path = "auth/approle" + config = { + role_id_file_path = "/run/vault/role_id" + secret_id_file_path = "/run/vault/secret_id" + } + } + + sink "file" { + config = { + path = "/run/vault/token" + mode = 0600 + } + } +} + diff --git a/ci/prod/scripts/deploy/bff.sh b/ci/prod/scripts/deploy/bff.sh index 8d1ac704..2f1c91eb 100755 --- a/ci/prod/scripts/deploy/bff.sh +++ b/ci/prod/scripts/deploy/bff.sh @@ -19,6 +19,8 @@ REQUIRED_SECRETS=( MONGO_USER MONGO_PASSWORD API_ENDPOINT_SECRET + BFF_VAULT_ROLE_ID + BFF_VAULT_SECRET_ID NATS_USER NATS_PASSWORD NATS_URL @@ -43,6 +45,8 @@ b64enc() { MONGO_USER_B64="$(b64enc "${MONGO_USER}")" MONGO_PASSWORD_B64="$(b64enc "${MONGO_PASSWORD}")" API_ENDPOINT_SECRET_B64="$(b64enc "${API_ENDPOINT_SECRET}")" +BFF_VAULT_ROLE_ID_B64="$(b64enc "${BFF_VAULT_ROLE_ID}")" +BFF_VAULT_SECRET_ID_B64="$(b64enc "${BFF_VAULT_SECRET_ID}")" NATS_USER_B64="$(b64enc "${NATS_USER}")" NATS_PASSWORD_B64="$(b64enc "${NATS_PASSWORD}")" NATS_URL_B64="$(b64enc "${NATS_URL}")" @@ -77,6 +81,8 @@ ssh "${SSH_OPTS[@]}" "$REMOTE_TARGET" \ MONGO_USER_B64="$MONGO_USER_B64" \ MONGO_PASSWORD_B64="$MONGO_PASSWORD_B64" \ API_ENDPOINT_SECRET_B64="$API_ENDPOINT_SECRET_B64" \ + BFF_VAULT_ROLE_ID_B64="$BFF_VAULT_ROLE_ID_B64" \ + BFF_VAULT_SECRET_ID_B64="$BFF_VAULT_SECRET_ID_B64" \ NATS_USER_B64="$NATS_USER_B64" \ NATS_PASSWORD_B64="$NATS_PASSWORD_B64" \ NATS_URL_B64="$NATS_URL_B64" \ @@ -124,11 +130,14 @@ decode_b64() { MONGO_USER="$(decode_b64 "$MONGO_USER_B64")" MONGO_PASSWORD="$(decode_b64 "$MONGO_PASSWORD_B64")" API_ENDPOINT_SECRET="$(decode_b64 "$API_ENDPOINT_SECRET_B64")" +BFF_VAULT_ROLE_ID="$(decode_b64 "$BFF_VAULT_ROLE_ID_B64")" +BFF_VAULT_SECRET_ID="$(decode_b64 "$BFF_VAULT_SECRET_ID_B64")" NATS_USER="$(decode_b64 "$NATS_USER_B64")" NATS_PASSWORD="$(decode_b64 "$NATS_PASSWORD_B64")" NATS_URL="$(decode_b64 "$NATS_URL_B64")" export MONGO_USER MONGO_PASSWORD API_ENDPOINT_SECRET +export BFF_VAULT_ROLE_ID BFF_VAULT_SECRET_ID export NATS_USER NATS_PASSWORD NATS_URL COMPOSE_PROJECT_NAME="$COMPOSE_PROJECT" export COMPOSE_PROJECT_NAME diff --git a/ci/scripts/bff/deploy.sh b/ci/scripts/bff/deploy.sh index 9901e542..f646e608 100755 --- a/ci/scripts/bff/deploy.sh +++ b/ci/scripts/bff/deploy.sh @@ -50,11 +50,18 @@ load_env_file ./.env.version BFF_MONGO_SECRET_PATH="${BFF_MONGO_SECRET_PATH:?missing BFF_MONGO_SECRET_PATH}" BFF_API_SECRET_PATH="${BFF_API_SECRET_PATH:?missing BFF_API_SECRET_PATH}" +BFF_VAULT_SECRET_PATH="${BFF_VAULT_SECRET_PATH:?missing BFF_VAULT_SECRET_PATH}" export MONGO_USER="$(./ci/vlt kv_get kv "${BFF_MONGO_SECRET_PATH}" user)" export MONGO_PASSWORD="$(./ci/vlt kv_get kv "${BFF_MONGO_SECRET_PATH}" password)" export API_ENDPOINT_SECRET="$(./ci/vlt kv_get kv "${BFF_API_SECRET_PATH}" secret)" +export BFF_VAULT_ROLE_ID="$(./ci/vlt kv_get kv "${BFF_VAULT_SECRET_PATH}" role_id)" +export BFF_VAULT_SECRET_ID="$(./ci/vlt kv_get kv "${BFF_VAULT_SECRET_PATH}" secret_id)" +if [ -z "${BFF_VAULT_ROLE_ID}" ] || [ -z "${BFF_VAULT_SECRET_ID}" ]; then + echo "[bff-deploy] vault approle creds are empty for path ${BFF_VAULT_SECRET_PATH}" >&2 + exit 1 +fi load_nats_env diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml index 07773287..1223df50 100644 --- a/docker-compose.dev.yml +++ b/docker-compose.dev.yml @@ -35,6 +35,12 @@ volumes: type: tmpfs device: tmpfs o: size=8m,uid=0,gid=0,mode=0700 + dev-bff-vault-run: + driver: local + driver_opts: + type: tmpfs + device: tmpfs + o: size=8m,uid=0,gid=0,mode=0700 # ============================================================================ # INFRASTRUCTURE SERVICES @@ -923,6 +929,39 @@ services: VAULT_ADDR: ${VAULT_ADDR} VAULT_TOKEN_FILE: /run/vault/token + # -------------------------------------------------------------------------- + # BFF Vault Agent (sidecar for AppRole authentication) + # -------------------------------------------------------------------------- + dev-bff-vault-agent: + <<: *common-env + image: hashicorp/vault:latest + container_name: dev-bff-vault-agent + restart: unless-stopped + cap_add: ["IPC_LOCK"] + environment: + VAULT_ADDR: ${VAULT_ADDR} + BFF_VAULT_ROLE_ID: ${BFF_VAULT_ROLE_ID} + BFF_VAULT_SECRET_ID: ${BFF_VAULT_SECRET_ID} + command: > + sh -c 'set -eu; umask 077; + : "$$BFF_VAULT_ROLE_ID"; : "$$BFF_VAULT_SECRET_ID"; + echo "$$BFF_VAULT_ROLE_ID" > /run/vault/role_id; + echo "$$BFF_VAULT_SECRET_ID" > /run/vault/secret_id; + unset BFF_VAULT_ROLE_ID BFF_VAULT_SECRET_ID; + exec vault agent -config=/etc/vault/agent/bff.hcl' + volumes: + - ./ci/dev/vault-agent/bff.hcl:/etc/vault/agent/bff.hcl:ro + - dev-bff-vault-run:/run/vault + depends_on: + dev-vault: { condition: service_healthy } + healthcheck: + test: ["CMD", "test", "-s", "/run/vault/token"] + interval: 10s + timeout: 5s + retries: 6 + networks: + - sendico-dev + # -------------------------------------------------------------------------- # BFF (Backend for Frontend / Server) Service # -------------------------------------------------------------------------- @@ -942,9 +981,11 @@ services: dev-payments-quotation: { condition: service_started } dev-payments-methods: { condition: service_started } dev-chain-gateway: { condition: service_started } + dev-bff-vault-agent: { condition: service_healthy } volumes: - ./api/edge/bff:/src/api/edge/bff - ./api/edge/bff/config.dev.yml:/app/config.yml:ro + - dev-bff-vault-run:/run/vault:ro ports: - "8080:8080" networks: @@ -977,6 +1018,8 @@ services: API_PROTOCOL: http SERVICE_HOST: localhost API_ENDPOINT: /api/v1 + VAULT_ADDR: ${VAULT_ADDR} + VAULT_TOKEN_FILE: /run/vault/token # -------------------------------------------------------------------------- # Frontend (Flutter Web) diff --git a/interface/api.yaml b/interface/api.yaml index e4d325c7..75f4e636 100644 --- a/interface/api.yaml +++ b/interface/api.yaml @@ -23,6 +23,8 @@ tags: description: Recipient CRUD and archive flows - name: Payment Methods description: Payment method CRUD and archive flows + - name: Callbacks + description: Webhook callback subscription CRUD and signing secret rotation - name: Payments description: Quotation and payment orchestration @@ -70,6 +72,19 @@ paths: /payment_methods/archive/{organizations_ref}/{payment_methods_ref}: $ref: ./api/payment_methods/archive.yaml + /callbacks/list/{org_ref}/{organizations_ref}: + $ref: ./api/callbacks/list.yaml + /callbacks/{org_ref}: + $ref: ./api/callbacks/create.yaml + /callbacks/{callbacks_ref}: + $ref: ./api/callbacks/object.yaml + /callbacks: + $ref: ./api/callbacks/update.yaml + /callbacks/archive/{org_ref}/{callbacks_ref}: + $ref: ./api/callbacks/archive.yaml + /callbacks/rotate-secret/{callbacks_ref}: + $ref: ./api/callbacks/rotate_secret.yaml + /payments/quote/{organizations_ref}: $ref: ./api/payments/quote.yaml /payments/multiquote/{organizations_ref}: diff --git a/interface/api/callbacks/archive.yaml b/interface/api/callbacks/archive.yaml new file mode 100644 index 00000000..38d8b53e --- /dev/null +++ b/interface/api/callbacks/archive.yaml @@ -0,0 +1,38 @@ +get: + tags: [Callbacks] + summary: Archive/unarchive callback subscription + description: Sets callback archive state by `callbacks_ref` and required `archived` query parameter. + operationId: callbacksArchive + security: + - bearerAuth: [] + parameters: + - $ref: ../parameters/org_ref.yaml#/components/parameters/OrgRef + - $ref: ../parameters/callbacks_ref.yaml#/components/parameters/CallbacksRef + - name: archived + in: query + required: true + description: Target archive value to set on the callback. + schema: + type: boolean + - $ref: ../parameters/cascade.yaml#/components/parameters/Cascade + responses: + '200': + description: Archive state updated + content: + application/json: + schema: + allOf: + - $ref: ../response/response.yaml#/components/schemas/BaseResponse + - type: object + properties: + data: + $ref: ./response/callback.yaml#/components/schemas/CallbacksAuthData + '400': + $ref: ../response/operation.yaml#/components/responses/BadRequest + '401': + $ref: ../response/operation.yaml#/components/responses/Unauthorized + '403': + $ref: ../response/operation.yaml#/components/responses/Forbidden + '500': + $ref: ../response/operation.yaml#/components/responses/InternalServerError + diff --git a/interface/api/callbacks/bodies/callback.yaml b/interface/api/callbacks/bodies/callback.yaml new file mode 100644 index 00000000..b6735a0a --- /dev/null +++ b/interface/api/callbacks/bodies/callback.yaml @@ -0,0 +1,9 @@ +components: + requestBodies: + CallbackBody: + required: true + content: + application/json: + schema: + $ref: ../request/callback.yaml#/components/schemas/CallbackRequest + diff --git a/interface/api/callbacks/create.yaml b/interface/api/callbacks/create.yaml new file mode 100644 index 00000000..b2a14bb2 --- /dev/null +++ b/interface/api/callbacks/create.yaml @@ -0,0 +1,34 @@ +post: + tags: [Callbacks] + summary: Create callback subscription + description: Creates callback subscription for the organization identified by `org_ref`. + operationId: callbacksCreate + security: + - bearerAuth: [] + parameters: + - $ref: ../parameters/org_ref.yaml#/components/parameters/OrgRef + requestBody: + $ref: ./bodies/callback.yaml#/components/requestBodies/CallbackBody + responses: + '201': + description: Callback created + content: + application/json: + schema: + allOf: + - $ref: ../response/response.yaml#/components/schemas/BaseResponse + - type: object + properties: + data: + $ref: ./response/callback.yaml#/components/schemas/CallbacksAuthData + '400': + $ref: ../response/operation.yaml#/components/responses/BadRequest + '401': + $ref: ../response/operation.yaml#/components/responses/Unauthorized + '403': + $ref: ../response/operation.yaml#/components/responses/Forbidden + '409': + $ref: ../response/operation.yaml#/components/responses/Conflict + '500': + $ref: ../response/operation.yaml#/components/responses/InternalServerError + diff --git a/interface/api/callbacks/list.yaml b/interface/api/callbacks/list.yaml new file mode 100644 index 00000000..c98608fa --- /dev/null +++ b/interface/api/callbacks/list.yaml @@ -0,0 +1,34 @@ +get: + tags: [Callbacks] + summary: List callback subscriptions + description: Lists callbacks for the given organization context. + operationId: callbacksList + security: + - bearerAuth: [] + parameters: + - $ref: ../parameters/org_ref.yaml#/components/parameters/OrgRef + - $ref: ../parameters/organizations_ref.yaml#/components/parameters/OrganizationsRef + - $ref: ../parameters/limit.yaml#/components/parameters/Limit + - $ref: ../parameters/offset.yaml#/components/parameters/Offset + - $ref: ../parameters/archived.yaml#/components/parameters/Archived + responses: + '200': + description: Callback list + content: + application/json: + schema: + allOf: + - $ref: ../response/response.yaml#/components/schemas/BaseResponse + - type: object + properties: + data: + $ref: ./response/callback.yaml#/components/schemas/CallbacksAuthData + '400': + $ref: ../response/operation.yaml#/components/responses/BadRequest + '401': + $ref: ../response/operation.yaml#/components/responses/Unauthorized + '403': + $ref: ../response/operation.yaml#/components/responses/Forbidden + '500': + $ref: ../response/operation.yaml#/components/responses/InternalServerError + diff --git a/interface/api/callbacks/object.yaml b/interface/api/callbacks/object.yaml new file mode 100644 index 00000000..914b00db --- /dev/null +++ b/interface/api/callbacks/object.yaml @@ -0,0 +1,65 @@ +get: + tags: [Callbacks] + summary: Get callback subscription + description: Returns callback subscription by `callbacks_ref`. + operationId: callbacksGet + security: + - bearerAuth: [] + parameters: + - $ref: ../parameters/callbacks_ref.yaml#/components/parameters/CallbacksRef + responses: + '200': + description: Callback data + content: + application/json: + schema: + allOf: + - $ref: ../response/response.yaml#/components/schemas/BaseResponse + - type: object + properties: + data: + $ref: ./response/callback.yaml#/components/schemas/CallbacksAuthData + '400': + $ref: ../response/operation.yaml#/components/responses/BadRequest + '401': + $ref: ../response/operation.yaml#/components/responses/Unauthorized + '403': + $ref: ../response/operation.yaml#/components/responses/Forbidden + '404': + $ref: ../response/operation.yaml#/components/responses/NotFound + '500': + $ref: ../response/operation.yaml#/components/responses/InternalServerError + +delete: + tags: [Callbacks] + summary: Delete callback subscription + description: Deletes callback by reference. + operationId: callbacksDelete + security: + - bearerAuth: [] + parameters: + - $ref: ../parameters/callbacks_ref.yaml#/components/parameters/CallbacksRef + - $ref: ../parameters/cascade.yaml#/components/parameters/Cascade + responses: + '200': + description: Callback deleted + content: + application/json: + schema: + allOf: + - $ref: ../response/response.yaml#/components/schemas/BaseResponse + - type: object + properties: + data: + $ref: ./response/callback.yaml#/components/schemas/CallbacksAuthData + '400': + $ref: ../response/operation.yaml#/components/responses/BadRequest + '401': + $ref: ../response/operation.yaml#/components/responses/Unauthorized + '403': + $ref: ../response/operation.yaml#/components/responses/Forbidden + '404': + $ref: ../response/operation.yaml#/components/responses/NotFound + '500': + $ref: ../response/operation.yaml#/components/responses/InternalServerError + diff --git a/interface/api/callbacks/request/callback.yaml b/interface/api/callbacks/request/callback.yaml new file mode 100644 index 00000000..a6e90fc6 --- /dev/null +++ b/interface/api/callbacks/request/callback.yaml @@ -0,0 +1,5 @@ +components: + schemas: + CallbackRequest: + $ref: ../../../models/callback/callback.yaml#/components/schemas/Callback + diff --git a/interface/api/callbacks/response/callback.yaml b/interface/api/callbacks/response/callback.yaml new file mode 100644 index 00000000..de16bfa8 --- /dev/null +++ b/interface/api/callbacks/response/callback.yaml @@ -0,0 +1,19 @@ +components: + schemas: + CallbacksAuthData: + type: object + additionalProperties: false + required: + - accessToken + - callbacks + properties: + accessToken: + $ref: ../../../models/auth/token_data.yaml#/components/schemas/TokenData + callbacks: + type: array + items: + $ref: ../../../models/callback/callback.yaml#/components/schemas/Callback + generatedSigningSecret: + type: string + nullable: true + diff --git a/interface/api/callbacks/rotate_secret.yaml b/interface/api/callbacks/rotate_secret.yaml new file mode 100644 index 00000000..cf16c4ea --- /dev/null +++ b/interface/api/callbacks/rotate_secret.yaml @@ -0,0 +1,32 @@ +post: + tags: [Callbacks] + summary: Rotate callback signing secret + description: Generates and stores a new HMAC secret for the callback in Vault and returns it once. + operationId: callbacksRotateSecret + security: + - bearerAuth: [] + parameters: + - $ref: ../parameters/callbacks_ref.yaml#/components/parameters/CallbacksRef + responses: + '200': + description: Callback secret rotated + content: + application/json: + schema: + allOf: + - $ref: ../response/response.yaml#/components/schemas/BaseResponse + - type: object + properties: + data: + $ref: ./response/callback.yaml#/components/schemas/CallbacksAuthData + '400': + $ref: ../response/operation.yaml#/components/responses/BadRequest + '401': + $ref: ../response/operation.yaml#/components/responses/Unauthorized + '403': + $ref: ../response/operation.yaml#/components/responses/Forbidden + '404': + $ref: ../response/operation.yaml#/components/responses/NotFound + '500': + $ref: ../response/operation.yaml#/components/responses/InternalServerError + diff --git a/interface/api/callbacks/update.yaml b/interface/api/callbacks/update.yaml new file mode 100644 index 00000000..df30ec9e --- /dev/null +++ b/interface/api/callbacks/update.yaml @@ -0,0 +1,32 @@ +put: + tags: [Callbacks] + summary: Update callback subscription + description: Updates callback subscription fields. + operationId: callbacksUpdate + security: + - bearerAuth: [] + requestBody: + $ref: ./bodies/callback.yaml#/components/requestBodies/CallbackBody + responses: + '200': + description: Callback updated + content: + application/json: + schema: + allOf: + - $ref: ../response/response.yaml#/components/schemas/BaseResponse + - type: object + properties: + data: + $ref: ./response/callback.yaml#/components/schemas/CallbacksAuthData + '400': + $ref: ../response/operation.yaml#/components/responses/BadRequest + '401': + $ref: ../response/operation.yaml#/components/responses/Unauthorized + '403': + $ref: ../response/operation.yaml#/components/responses/Forbidden + '404': + $ref: ../response/operation.yaml#/components/responses/NotFound + '500': + $ref: ../response/operation.yaml#/components/responses/InternalServerError + diff --git a/interface/api/parameters/callbacks_ref.yaml b/interface/api/parameters/callbacks_ref.yaml new file mode 100644 index 00000000..c8fdd7b7 --- /dev/null +++ b/interface/api/parameters/callbacks_ref.yaml @@ -0,0 +1,10 @@ +components: + parameters: + CallbacksRef: + name: callbacks_ref + in: path + required: true + description: Callback subscription reference (Mongo ObjectId, 24 hex chars). + schema: + $ref: ../../models/objectid.yaml#/components/schemas/ObjectId + diff --git a/interface/models/callback/callback.yaml b/interface/models/callback/callback.yaml new file mode 100644 index 00000000..fa66e4a4 --- /dev/null +++ b/interface/models/callback/callback.yaml @@ -0,0 +1,67 @@ +components: + schemas: + CallbackRetryPolicy: + type: object + additionalProperties: false + required: + - minDelayMs + - maxDelayMs + - signingMode + - maxAttempts + - requestTimeoutMs + properties: + minDelayMs: + type: integer + minimum: 1 + maxDelayMs: + type: integer + minimum: 1 + signingMode: + type: string + enum: + - none + - hmac_sha256 + secretRef: + type: string + nullable: true + headers: + type: object + additionalProperties: + type: string + maxAttempts: + type: integer + minimum: 1 + requestTimeoutMs: + type: integer + minimum: 1 + + Callback: + allOf: + - $ref: ../permission_bound.yaml#/components/schemas/PermissionBound + - $ref: ../common/describable.yaml#/components/schemas/Describable + - type: object + additionalProperties: false + required: + - clientId + - status + - url + - eventTypes + - retryPolicy + properties: + clientId: + type: string + status: + type: string + enum: + - active + - disabled + url: + type: string + format: uri + eventTypes: + type: array + items: + type: string + retryPolicy: + $ref: '#/components/schemas/CallbackRetryPolicy' +