Merge pull request 'bff for callbacks' (#590) from bff-589 into main
All checks were successful
ci/woodpecker/push/bff Pipeline was successful
ci/woodpecker/push/billing_documents Pipeline was successful
ci/woodpecker/push/billing_fees Pipeline was successful
ci/woodpecker/push/callbacks Pipeline was successful
ci/woodpecker/push/discovery Pipeline was successful
ci/woodpecker/push/fx_ingestor Pipeline was successful
ci/woodpecker/push/frontend Pipeline was successful
ci/woodpecker/push/gateway_chain Pipeline was successful
ci/woodpecker/push/gateway_mntx Pipeline was successful
ci/woodpecker/push/gateway_tgsettle Pipeline was successful
ci/woodpecker/push/fx_oracle Pipeline was successful
ci/woodpecker/push/notification Pipeline was successful
ci/woodpecker/push/payments_methods Pipeline was successful
ci/woodpecker/push/payments_orchestrator Pipeline was successful
ci/woodpecker/push/payments_quotation Pipeline was successful
ci/woodpecker/push/gateway_tron Pipeline was successful
ci/woodpecker/push/ledger Pipeline was successful

Reviewed-on: #590
This commit was merged in pull request #590.
This commit is contained in:
2026-03-01 01:04:50 +00:00
44 changed files with 1563 additions and 25 deletions

View File

@@ -4,6 +4,7 @@ matrix:
BFF_DOCKERFILE: ci/prod/compose/bff.dockerfile BFF_DOCKERFILE: ci/prod/compose/bff.dockerfile
BFF_MONGO_SECRET_PATH: sendico/db BFF_MONGO_SECRET_PATH: sendico/db
BFF_API_SECRET_PATH: sendico/api/endpoint BFF_API_SECRET_PATH: sendico/api/endpoint
BFF_VAULT_SECRET_PATH: sendico/edge/bff/vault
BFF_ENV: prod BFF_ENV: prod
when: when:

View File

@@ -109,6 +109,19 @@ api:
dial_timeout_seconds: 5 dial_timeout_seconds: 5
call_timeout_seconds: 5 call_timeout_seconds: 5
insecure: true 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: app:

View File

@@ -111,6 +111,19 @@ api:
dial_timeout_seconds: 5 dial_timeout_seconds: 5
call_timeout_seconds: 5 call_timeout_seconds: 5
insecure: true 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: app:

View File

@@ -25,6 +25,7 @@ require (
github.com/go-chi/metrics v0.1.1 github.com/go-chi/metrics v0.1.1
github.com/google/uuid v1.6.0 github.com/google/uuid v1.6.0
github.com/mitchellh/mapstructure v1.5.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/shopspring/decimal v1.4.0
github.com/stretchr/testify v1.11.1 github.com/stretchr/testify v1.11.1
github.com/tech/sendico/gateway/tron v0.0.0-00010101000000-000000000000 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/docker/go-units v0.5.0 // indirect
github.com/felixge/httpsnoop v1.0.4 // indirect github.com/felixge/httpsnoop v1.0.4 // indirect
github.com/go-chi/chi v1.5.5 // 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/logr v1.4.3 // indirect
github.com/go-logr/stdr v1.2.2 // indirect github.com/go-logr/stdr v1.2.2 // indirect
github.com/go-ole/go-ole v1.3.0 // indirect github.com/go-ole/go-ole v1.3.0 // indirect
github.com/goccy/go-json v0.10.5 // indirect github.com/goccy/go-json v0.10.5 // indirect
github.com/gogo/protobuf v1.3.2 // 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/klauspost/compress v1.18.4 // indirect
github.com/lestrrat-go/blackmagic v1.0.4 // indirect github.com/lestrrat-go/blackmagic v1.0.4 // indirect
github.com/lestrrat-go/dsig v1.0.0 // 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/magiconair/properties v1.8.7 // indirect
github.com/mattn/go-colorable v0.1.14 // indirect github.com/mattn/go-colorable v0.1.14 // indirect
github.com/mattn/go-isatty v0.0.20 // 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/docker-image-spec v1.3.1 // indirect
github.com/moby/patternmatcher v0.6.0 // indirect github.com/moby/patternmatcher v0.6.0 // indirect
github.com/moby/sys/sequential 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/pkg/errors v0.9.1 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // 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/client_model v0.6.2 // indirect
github.com/prometheus/common v0.67.5 // indirect github.com/prometheus/common v0.67.5 // indirect
github.com/prometheus/procfs v0.20.1 // 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/segmentio/asm v1.2.1 // indirect
github.com/shirou/gopsutil/v3 v3.24.5 // indirect github.com/shirou/gopsutil/v3 v3.24.5 // indirect
github.com/shoenig/go-m1cpu v0.1.6 // 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/sync v0.19.0 // indirect
golang.org/x/sys v0.41.0 // indirect golang.org/x/sys v0.41.0 // indirect
golang.org/x/text v0.34.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 google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171 // indirect
) )

View File

@@ -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-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 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4=
github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk= 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 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
github.com/go-chi/chi v1.5.4/go.mod h1:uaf8YgoFazUOkPBG7fxPftUylNumIev9awIWOENIuEg= 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/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 h1:CXhbnkAVVjb0k73EBRQ6Z2YdWFnbXZgNtg1Mboguibk=
github.com/go-chi/metrics v0.1.1/go.mod h1:mcGTM1pPalP7WCtb+akNYFO/lwNwBBLCuedepqjoPn4= 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.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 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= 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.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 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE=
github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78= 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 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= 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/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 h1:X+2YciYSxvMQK0UZ7sg45ZVabVZBeBuvMkmuI2V3Fak=
github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.7/go.mod h1:lW34nIZuQ8UDPdkon5fmfp2l3+ZkQ2me/+oecHYLOII= 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/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8=
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= 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= 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-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 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= 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 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY=
github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= 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= 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/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 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= 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 h1:DTNbBqs57ioxAD4PrArqftgypG4/qNpXoJx8TVXxPR0=
github.com/segmentio/asm v1.2.1/go.mod h1:BqMnlJP91P8d+4ibuonYZw9mfnzI9HfxselHZr5aAcs= 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= github.com/sergi/go-diff v1.0.0/go.mod h1:0CfEIISq7TuYL3j771MWULgwwjU+GofnZX9QAmXWZgo=

View File

@@ -1,6 +1,7 @@
package api package api
import ( import (
"github.com/tech/sendico/pkg/vault/kv"
mwa "github.com/tech/sendico/server/interface/middleware" mwa "github.com/tech/sendico/server/interface/middleware"
fsc "github.com/tech/sendico/server/interface/services/fileservice/config" fsc "github.com/tech/sendico/server/interface/services/fileservice/config"
) )
@@ -13,6 +14,7 @@ type Config struct {
PaymentOrchestrator *PaymentOrchestratorConfig `yaml:"payment_orchestrator"` PaymentOrchestrator *PaymentOrchestratorConfig `yaml:"payment_orchestrator"`
PaymentQuotation *PaymentOrchestratorConfig `yaml:"payment_quotation"` PaymentQuotation *PaymentOrchestratorConfig `yaml:"payment_quotation"`
PaymentMethods *PaymentOrchestratorConfig `yaml:"payment_methods"` PaymentMethods *PaymentOrchestratorConfig `yaml:"payment_methods"`
Callbacks *CallbacksConfig `yaml:"callbacks"`
} }
type ChainGatewayConfig struct { type ChainGatewayConfig struct {
@@ -45,3 +47,12 @@ type PaymentOrchestratorConfig struct {
CallTimeoutSeconds int `yaml:"call_timeout_seconds"` CallTimeoutSeconds int `yaml:"call_timeout_seconds"`
Insecure bool `yaml:"insecure"` 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"`
}

View File

@@ -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)
}

View File

@@ -12,6 +12,7 @@ import (
"github.com/tech/sendico/pkg/mservice" "github.com/tech/sendico/pkg/mservice"
"github.com/tech/sendico/server/interface/api" "github.com/tech/sendico/server/interface/api"
"github.com/tech/sendico/server/interface/services/account" "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/invitation"
"github.com/tech/sendico/server/interface/services/ledger" "github.com/tech/sendico/server/interface/services/ledger"
"github.com/tech/sendico/server/interface/services/logo" "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, wallet.Create)
srvf = append(srvf, ledger.Create) srvf = append(srvf, ledger.Create)
srvf = append(srvf, recipient.Create) srvf = append(srvf, recipient.Create)
srvf = append(srvf, callbacks.Create)
srvf = append(srvf, paymethod.Create) srvf = append(srvf, paymethod.Create)
srvf = append(srvf, payment.Create) srvf = append(srvf, payment.Create)

View 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
}

View 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
}

View 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
}

View File

@@ -13,6 +13,7 @@ import (
"github.com/tech/sendico/pkg/db/storable" "github.com/tech/sendico/pkg/db/storable"
"github.com/tech/sendico/pkg/merrors" "github.com/tech/sendico/pkg/merrors"
"github.com/tech/sendico/pkg/mlogger" "github.com/tech/sendico/pkg/mlogger"
"github.com/tech/sendico/pkg/mservice"
mutil "github.com/tech/sendico/pkg/mutil/db" mutil "github.com/tech/sendico/pkg/mutil/db"
"go.mongodb.org/mongo-driver/v2/bson" "go.mongodb.org/mongo-driver/v2/bson"
"go.mongodb.org/mongo-driver/v2/mongo" "go.mongodb.org/mongo-driver/v2/mongo"
@@ -22,7 +23,7 @@ import (
const ( const (
inboxCollection string = "inbox" inboxCollection string = "inbox"
tasksCollection string = "tasks" tasksCollection string = "tasks"
endpointsCollection string = "endpoints" endpointsCollection string = mservice.Callbacks
) )
type mongoRepository struct { type mongoRepository struct {

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 ( import (
"github.com/tech/sendico/pkg/auth" "github.com/tech/sendico/pkg/auth"
"github.com/tech/sendico/pkg/db/account" "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/chainassets"
"github.com/tech/sendico/pkg/db/chainwalletroutes" "github.com/tech/sendico/pkg/db/chainwalletroutes"
mongoimpl "github.com/tech/sendico/pkg/db/internal/mongo" mongoimpl "github.com/tech/sendico/pkg/db/internal/mongo"
@@ -29,6 +30,7 @@ type Factory interface {
NewOrganizationDB() (organization.DB, error) NewOrganizationDB() (organization.DB, error)
NewInvitationsDB() (invitation.DB, error) NewInvitationsDB() (invitation.DB, error)
NewRecipientsDB() (recipient.DB, error) NewRecipientsDB() (recipient.DB, error)
NewCallbacksDB() (callbacks.DB, error)
NewVerificationsDB() (verification.DB, error) NewVerificationsDB() (verification.DB, error)
NewRolesDB() (role.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/mitchellh/mapstructure"
"github.com/tech/sendico/pkg/auth" "github.com/tech/sendico/pkg/auth"
"github.com/tech/sendico/pkg/db/account" "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/chainassets"
"github.com/tech/sendico/pkg/db/chainwalletroutes" "github.com/tech/sendico/pkg/db/chainwalletroutes"
"github.com/tech/sendico/pkg/db/internal/mongo/accountdb" "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/chainassetsdb"
"github.com/tech/sendico/pkg/db/internal/mongo/chainwalletroutesdb" "github.com/tech/sendico/pkg/db/internal/mongo/chainwalletroutesdb"
"github.com/tech/sendico/pkg/db/internal/mongo/invitationdb" "github.com/tech/sendico/pkg/db/internal/mongo/invitationdb"
@@ -218,6 +220,10 @@ func (db *DB) NewRecipientsDB() (recipient.DB, error) {
return newProtectedDB(db, create) return newProtectedDB(db, create)
} }
func (db *DB) NewCallbacksDB() (callbacks.DB, error) {
return newProtectedDB(db, callbacksdb.Create)
}
func (db *DB) NewRefreshTokensDB() (refreshtokens.DB, error) { func (db *DB) NewRefreshTokensDB() (refreshtokens.DB, error) {
return refreshtokensdb.Create(db.logger, db.db()) 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 ChainWalletBalances Type = "chain_wallet_balances" // Represents managed chain wallet balances
ChainTransfers Type = "chain_transfers" // Represents chain transfers ChainTransfers Type = "chain_transfers" // Represents chain transfers
ChainDeposits Type = "chain_deposits" // Represents chain deposits ChainDeposits Type = "chain_deposits" // Represents chain deposits
Callbacks Type = "callbacks" // Represents webhook callback subscriptions
Notifications Type = "notifications" // Represents notifications sent to users Notifications Type = "notifications" // Represents notifications sent to users
Organizations Type = "organizations" // Represents organizations in the system Organizations Type = "organizations" // Represents organizations in the system
Payments Type = "payments" // Represents payments service Payments Type = "payments" // Represents payments service
@@ -58,7 +59,7 @@ const (
func StringToSType(s string) (Type, error) { func StringToSType(s string) (Type, error) {
switch Type(s) { switch Type(s) {
case Accounts, Verification, Amplitude, Site, Changes, Clients, ChainGateway, ChainWallets, WalletRoutes, ChainWalletBalances, 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, LedgerAccounts, LedgerBalances, LedgerEntries, LedgerOutbox, LedgerParties, LedgerPlines, Notifications,
Organizations, Payments, PaymentRoutes, PaymentOrchestrator, PaymentMethods, Permissions, Policies, PolicyAssignements, Organizations, Payments, PaymentRoutes, PaymentOrchestrator, PaymentMethods, Permissions, Policies, PolicyAssignements,
Recipients, RefreshTokens, Roles, Storage, Tenants, Workflows, Discovery: Recipients, RefreshTokens, Roles, Storage, Tenants, Workflows, Discovery:

View File

@@ -8,10 +8,12 @@ import (
// Config describes Vault KV v2 connection settings. // Config describes Vault KV v2 connection settings.
type Config struct { type Config struct {
Address string `mapstructure:"address" yaml:"address"` Address string `mapstructure:"address" yaml:"address"`
TokenEnv string `mapstructure:"token_env" yaml:"token_env"` TokenEnv string `mapstructure:"token_env" yaml:"token_env"`
Namespace string `mapstructure:"namespace" yaml:"namespace"` TokenFileEnv string `mapstructure:"token_file_env" yaml:"token_file_env"`
MountPath string `mapstructure:"mount_path" yaml:"mount_path"` 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. // 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") return nil, merrors.InvalidArgument(component + ": address is required")
} }
tokenEnv := strings.TrimSpace(opts.Config.TokenEnv) token, tokenSource, err := resolveToken(opts.Config)
if tokenEnv == "" { if err != nil {
logger.Error("Vault token env missing") logger.Error("Vault token configuration is invalid", zap.Error(err))
return nil, merrors.InvalidArgument(component + ": token_env is required") return nil, err
} }
token := strings.TrimSpace(os.Getenv(tokenEnv))
if token == "" { if token == "" {
logger.Error("Vault token missing; expected Vault Agent to export token", zap.String("env", tokenEnv)) logger.Error("Vault token missing", zap.String("source", tokenSource))
return nil, merrors.InvalidArgument(component + ": token env " + tokenEnv + " is not set (expected Vault Agent sink to populate it)") return nil, merrors.InvalidArgument(component + ": vault token is empty")
} }
mountPath := strings.Trim(strings.TrimSpace(opts.Config.MountPath), "/") mountPath := strings.Trim(strings.TrimSpace(opts.Config.MountPath), "/")
@@ -148,4 +146,36 @@ func normalizePath(secretPath string) (string, error) {
return normalizedPath, nil 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) var _ Client = (*service)(nil)

View File

@@ -10,11 +10,13 @@ import (
// Config describes how to connect to Vault for managed wallet keys. // Config describes how to connect to Vault for managed wallet keys.
type Config struct { type Config struct {
Address string `mapstructure:"address" yaml:"address"` Address string `mapstructure:"address" yaml:"address"`
TokenEnv string `mapstructure:"token_env" yaml:"token_env"` TokenEnv string `mapstructure:"token_env" yaml:"token_env"`
Namespace string `mapstructure:"namespace" yaml:"namespace"` TokenFileEnv string `mapstructure:"token_file_env" yaml:"token_file_env"`
MountPath string `mapstructure:"mount_path" yaml:"mount_path"` TokenFile string `mapstructure:"token_file" yaml:"token_file"`
KeyPrefix string `mapstructure:"key_prefix" yaml:"key_prefix"` 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. // 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{ store, err := kv.New(kv.Options{
Logger: logger, Logger: logger,
Config: kv.Config{ Config: kv.Config{
Address: opts.Config.Address, Address: opts.Config.Address,
TokenEnv: opts.Config.TokenEnv, TokenEnv: opts.Config.TokenEnv,
Namespace: opts.Config.Namespace, TokenFileEnv: opts.Config.TokenFileEnv,
MountPath: opts.Config.MountPath, TokenFile: opts.Config.TokenFile,
Namespace: opts.Config.Namespace,
MountPath: opts.Config.MountPath,
}, },
Component: component, Component: component,
}) })

View File

@@ -65,7 +65,7 @@ Examples:
Infrastructure (MongoDB, NATS) uses plain `.env.dev` credentials. 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`. Set the corresponding `*_VAULT_ROLE_ID` and `*_VAULT_SECRET_ID` values in `.env.dev`.
## Network ## Network

View File

@@ -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
}
}
}

View File

@@ -181,6 +181,7 @@ BFF_DIR=bff
BFF_COMPOSE_PROJECT=sendico-bff BFF_COMPOSE_PROJECT=sendico-bff
BFF_SERVICE_NAME=sendico_bff BFF_SERVICE_NAME=sendico_bff
BFF_HTTP_PORT=8080 BFF_HTTP_PORT=8080
BFF_VAULT_SECRET_PATH=sendico/edge/bff/vault
# Callbacks service # Callbacks service
CALLBACKS_DIR=callbacks CALLBACKS_DIR=callbacks

View File

@@ -5,6 +5,14 @@ x-common-env: &common-env
- ../env/.env.runtime - ../env/.env.runtime
- ../env/.env.version - ../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: networks:
sendico-net: sendico-net:
external: true external: true
@@ -51,8 +59,15 @@ services:
PERMISSION_COLLECTION: ${PERMISSION_COLLECTION} PERMISSION_COLLECTION: ${PERMISSION_COLLECTION}
PERMISSION_TIMEOUT: ${PERMISSION_TIMEOUT} PERMISSION_TIMEOUT: ${PERMISSION_TIMEOUT}
PERMISSION_IS_FILTERED: ${PERMISSION_IS_FILTERED} PERMISSION_IS_FILTERED: ${PERMISSION_IS_FILTERED}
VAULT_ADDR: ${VAULT_ADDR}
VAULT_TOKEN_FILE: /run/vault/token
ports: ports:
- "0.0.0.0:${BFF_HTTP_PORT}:8081" - "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: healthcheck:
test: ["CMD-SHELL","wget -qO- http://localhost:8081/api/v1/health | grep -q '\"status\":\"ok\"'"] test: ["CMD-SHELL","wget -qO- http://localhost:8081/api/v1/health | grep -q '\"status\":\"ok\"'"]
interval: 30s interval: 30s
@@ -61,3 +76,32 @@ services:
start_period: 60s start_period: 60s
networks: networks:
- sendico-net - 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

View File

@@ -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
}
}
}

View File

@@ -19,6 +19,8 @@ REQUIRED_SECRETS=(
MONGO_USER MONGO_USER
MONGO_PASSWORD MONGO_PASSWORD
API_ENDPOINT_SECRET API_ENDPOINT_SECRET
BFF_VAULT_ROLE_ID
BFF_VAULT_SECRET_ID
NATS_USER NATS_USER
NATS_PASSWORD NATS_PASSWORD
NATS_URL NATS_URL
@@ -43,6 +45,8 @@ b64enc() {
MONGO_USER_B64="$(b64enc "${MONGO_USER}")" MONGO_USER_B64="$(b64enc "${MONGO_USER}")"
MONGO_PASSWORD_B64="$(b64enc "${MONGO_PASSWORD}")" MONGO_PASSWORD_B64="$(b64enc "${MONGO_PASSWORD}")"
API_ENDPOINT_SECRET_B64="$(b64enc "${API_ENDPOINT_SECRET}")" 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_USER_B64="$(b64enc "${NATS_USER}")"
NATS_PASSWORD_B64="$(b64enc "${NATS_PASSWORD}")" NATS_PASSWORD_B64="$(b64enc "${NATS_PASSWORD}")"
NATS_URL_B64="$(b64enc "${NATS_URL}")" NATS_URL_B64="$(b64enc "${NATS_URL}")"
@@ -77,6 +81,8 @@ ssh "${SSH_OPTS[@]}" "$REMOTE_TARGET" \
MONGO_USER_B64="$MONGO_USER_B64" \ MONGO_USER_B64="$MONGO_USER_B64" \
MONGO_PASSWORD_B64="$MONGO_PASSWORD_B64" \ MONGO_PASSWORD_B64="$MONGO_PASSWORD_B64" \
API_ENDPOINT_SECRET_B64="$API_ENDPOINT_SECRET_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_USER_B64="$NATS_USER_B64" \
NATS_PASSWORD_B64="$NATS_PASSWORD_B64" \ NATS_PASSWORD_B64="$NATS_PASSWORD_B64" \
NATS_URL_B64="$NATS_URL_B64" \ NATS_URL_B64="$NATS_URL_B64" \
@@ -124,11 +130,14 @@ decode_b64() {
MONGO_USER="$(decode_b64 "$MONGO_USER_B64")" MONGO_USER="$(decode_b64 "$MONGO_USER_B64")"
MONGO_PASSWORD="$(decode_b64 "$MONGO_PASSWORD_B64")" MONGO_PASSWORD="$(decode_b64 "$MONGO_PASSWORD_B64")"
API_ENDPOINT_SECRET="$(decode_b64 "$API_ENDPOINT_SECRET_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_USER="$(decode_b64 "$NATS_USER_B64")"
NATS_PASSWORD="$(decode_b64 "$NATS_PASSWORD_B64")" NATS_PASSWORD="$(decode_b64 "$NATS_PASSWORD_B64")"
NATS_URL="$(decode_b64 "$NATS_URL_B64")" NATS_URL="$(decode_b64 "$NATS_URL_B64")"
export MONGO_USER MONGO_PASSWORD API_ENDPOINT_SECRET export MONGO_USER MONGO_PASSWORD API_ENDPOINT_SECRET
export BFF_VAULT_ROLE_ID BFF_VAULT_SECRET_ID
export NATS_USER NATS_PASSWORD NATS_URL export NATS_USER NATS_PASSWORD NATS_URL
COMPOSE_PROJECT_NAME="$COMPOSE_PROJECT" COMPOSE_PROJECT_NAME="$COMPOSE_PROJECT"
export COMPOSE_PROJECT_NAME export COMPOSE_PROJECT_NAME

View File

@@ -50,11 +50,18 @@ load_env_file ./.env.version
BFF_MONGO_SECRET_PATH="${BFF_MONGO_SECRET_PATH:?missing BFF_MONGO_SECRET_PATH}" 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_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_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 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 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 load_nats_env

View File

@@ -35,6 +35,12 @@ volumes:
type: tmpfs type: tmpfs
device: tmpfs device: tmpfs
o: size=8m,uid=0,gid=0,mode=0700 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 # INFRASTRUCTURE SERVICES
@@ -923,6 +929,39 @@ services:
VAULT_ADDR: ${VAULT_ADDR} VAULT_ADDR: ${VAULT_ADDR}
VAULT_TOKEN_FILE: /run/vault/token 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 # BFF (Backend for Frontend / Server) Service
# -------------------------------------------------------------------------- # --------------------------------------------------------------------------
@@ -942,9 +981,11 @@ services:
dev-payments-quotation: { condition: service_started } dev-payments-quotation: { condition: service_started }
dev-payments-methods: { condition: service_started } dev-payments-methods: { condition: service_started }
dev-chain-gateway: { condition: service_started } dev-chain-gateway: { condition: service_started }
dev-bff-vault-agent: { condition: service_healthy }
volumes: volumes:
- ./api/edge/bff:/src/api/edge/bff - ./api/edge/bff:/src/api/edge/bff
- ./api/edge/bff/config.dev.yml:/app/config.yml:ro - ./api/edge/bff/config.dev.yml:/app/config.yml:ro
- dev-bff-vault-run:/run/vault:ro
ports: ports:
- "8080:8080" - "8080:8080"
networks: networks:
@@ -977,6 +1018,8 @@ services:
API_PROTOCOL: http API_PROTOCOL: http
SERVICE_HOST: localhost SERVICE_HOST: localhost
API_ENDPOINT: /api/v1 API_ENDPOINT: /api/v1
VAULT_ADDR: ${VAULT_ADDR}
VAULT_TOKEN_FILE: /run/vault/token
# -------------------------------------------------------------------------- # --------------------------------------------------------------------------
# Frontend (Flutter Web) # Frontend (Flutter Web)

View File

@@ -23,6 +23,8 @@ tags:
description: Recipient CRUD and archive flows description: Recipient CRUD and archive flows
- name: Payment Methods - name: Payment Methods
description: Payment method CRUD and archive flows description: Payment method CRUD and archive flows
- name: Callbacks
description: Webhook callback subscription CRUD and signing secret rotation
- name: Payments - name: Payments
description: Quotation and payment orchestration description: Quotation and payment orchestration
@@ -70,6 +72,19 @@ paths:
/payment_methods/archive/{organizations_ref}/{payment_methods_ref}: /payment_methods/archive/{organizations_ref}/{payment_methods_ref}:
$ref: ./api/payment_methods/archive.yaml $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}: /payments/quote/{organizations_ref}:
$ref: ./api/payments/quote.yaml $ref: ./api/payments/quote.yaml
/payments/multiquote/{organizations_ref}: /payments/multiquote/{organizations_ref}:

View File

@@ -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

View File

@@ -0,0 +1,9 @@
components:
requestBodies:
CallbackBody:
required: true
content:
application/json:
schema:
$ref: ../request/callback.yaml#/components/schemas/CallbackRequest

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -0,0 +1,5 @@
components:
schemas:
CallbackRequest:
$ref: ../../../models/callback/callback.yaml#/components/schemas/Callback

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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'