Remove platform tenancy from SXCHNGE customer BFF (#34)

This commit is contained in:
Stephan D committed 2026-09-12 14:00:14 +02:00
1 parent 7062672a87
commit 981e84a4b4
244 files changed
+651 -17130

No files matched your search

@@ -9,14 +9,11 @@ import (
authstorage "git.sendico.io/tech/sxchnge/bff/edge/shared/auth/storage"
edgeapi "git.sendico.io/tech/sxchnge/bff/edge/shared/interface/api"
"git.sendico.io/tech/sxchnge/bff/edge/shared/interface/api/sresponse"
wsh "git.sendico.io/tech/sxchnge/bff/edge/shared/interface/api/ws"
"git.sendico.io/tech/sxchnge/bff/edge/shared/interface/middleware"
httprouter "git.sendico.io/tech/sxchnge/bff/edge/shared/internal/transport/http/router"
mr "git.sendico.io/tech/sxchnge/bff/edge/shared/internal/transport/http/router/metrics"
ws "git.sendico.io/tech/sxchnge/bff/edge/shared/internal/transport/http/websocket"
api "git.sendico.io/tech/sxchnge/bff/pkg/api/http"
"git.sendico.io/tech/sxchnge/bff/pkg/api/http/response"
"git.sendico.io/tech/sxchnge/bff/pkg/auth"
"git.sendico.io/tech/sxchnge/bff/pkg/db"
"git.sendico.io/tech/sxchnge/bff/pkg/merrors"
"git.sendico.io/tech/sxchnge/bff/pkg/mlogger"
@@ -34,7 +31,6 @@ type Middleware struct {
router *chi.Mux
apiEndpoint string
metrics mr.Metrics
wshandler ws.Router
epdispatcher *httprouter.Dispatcher
}
@@ -50,14 +46,6 @@ func (mw *Middleware) PendingAccountHandler(service mservice.Type, endpoint stri
mw.epdispatcher.PendingAccountHandler(service, endpoint, method, handler)
}
func (mw *Middleware) WSHandler(messageType string, handler wsh.HandlerFunc) {
if mw.wshandler == nil {
mw.logger.Info("WebSocket handler registration skipped because websocket router is disabled", zap.String("message_type", messageType))
return
}
mw.wshandler.InstallHandler(messageType, handler)
}
func (mw *Middleware) Finish() {
mw.metrics.Finish()
}
@@ -116,11 +104,9 @@ func isObservabilityRequest(apiEndpoint, requestPath string) bool {
func CreateMiddleware(
logger mlogger.Logger,
db db.Factory,
enforcer auth.Enforcer,
apiRouter *chi.Mux,
config *middleware.Config,
authStorage *edgeapi.AuthStorageConfig,
installWebSocket bool,
debug bool,
) (*Middleware, error) {
if config == nil {
@@ -161,12 +147,8 @@ func CreateMiddleware(
stores.Verification,
stores.RefreshTokens,
stores.LoginAttempts,
enforcer,
config,
loginRateLimits,
)
if installWebSocket {
p.wshandler = ws.NewRouter(p.logger, p.router, &config.WebSocket, p.apiEndpoint)
}
return p, nil
}
@@ -0,0 +1,74 @@
package authorized
import (
"context"
"net/http"
"net/http/httptest"
"testing"
"git.sendico.io/tech/sxchnge/bff/edge/shared/interface/api/sresponse"
"git.sendico.io/tech/sxchnge/bff/edge/shared/interface/middleware"
emodel "git.sendico.io/tech/sxchnge/bff/edge/shared/interface/model"
api "git.sendico.io/tech/sxchnge/bff/pkg/api/http"
"git.sendico.io/tech/sxchnge/bff/pkg/db/account"
"git.sendico.io/tech/sxchnge/bff/pkg/merrors"
"git.sendico.io/tech/sxchnge/bff/pkg/model"
"git.sendico.io/tech/sxchnge/bff/pkg/mservice"
"github.com/go-chi/chi/v5"
"github.com/go-chi/jwtauth/v5"
"github.com/stretchr/testify/require"
"go.mongodb.org/mongo-driver/v2/bson"
"go.uber.org/zap"
)
type customerDB struct {
account.DB
customer model.Account
requested bson.ObjectID
}
func (d *customerDB) Get(_ context.Context, id bson.ObjectID, result *model.Account) error {
d.requested = id
if id != d.customer.ID {
return merrors.NoData("customer not found")
}
*result = d.customer
return nil
}
func (d *sessionDB) GetClient(context.Context, string) (*model.Client, error) {
return &model.Client{ClientID: "web"}, nil
}
func TestCustomerHandlerResolvesIdentityWithoutRoles(t *testing.T) {
for _, status := range []model.AccountStatus{model.AccountActive, model.AccountBlocked, model.AccountPendingVerification} {
t.Run(string(status), func(t *testing.T) {
accounts := &customerDB{}
accounts.customer.ID = bson.NewObjectID()
accounts.customer.Status = status
routes := chi.NewRouter()
signature := &middleware.Signature{Algorithm: "HS256", PrivateKey: []byte("test-only-signing-key-at-least-32-bytes")}
router := NewRouter(zap.NewNop(), "/api/v1", routes, accounts, &sessionDB{token: &model.RefreshToken{}}, &middleware.TokenConfig{Expiration: middleware.TokenExpiration{Account: 1}}, signature)
called := false
router.AccountHandler(mservice.Accounts, "/me", api.Get, func(_ *http.Request, c *model.Account, _ *sresponse.TokenData) http.HandlerFunc {
called = true
require.Equal(t, accounts.customer.ID, c.ID)
return func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusNoContent) }
})
ja := jwtauth.New(signature.Algorithm, signature.PrivateKey, nil)
_, token, err := ja.Encode(emodel.Account2ClaimsForSession(&accounts.customer, 1, model.SessionIdentifier{ClientID: "web", DeviceID: "device", SessionVersion: "v1"}))
require.NoError(t, err)
req := httptest.NewRequest(http.MethodGet, "/api/v1/accounts/me?accountRef="+bson.NewObjectID().Hex(), nil)
req.Header.Set("Authorization", "Bearer "+token)
recorder := httptest.NewRecorder()
routes.ServeHTTP(recorder, req)
require.Equal(t, accounts.customer.ID, accounts.requested)
if status == model.AccountActive {
require.Equal(t, http.StatusNoContent, recorder.Code)
require.True(t, called)
} else {
require.Equal(t, http.StatusUnauthorized, recorder.Code)
require.False(t, called)
}
})
}
}
@@ -128,6 +128,10 @@ func (ar *AuthorizedRouter) AccountHandler(service mservice.Type, endpoint strin
}
return response.Internal(ar.logger, ar.service, err)
}
if !a.IsActive() || a.IsArchived() {
return response.Unauthorized(ar.logger, ar.service, "customer is inactive")
}
accessToken, err := ar.imp.CreateAccessTokenForSession(&a, t.SessionIdentifier)
if err != nil {
ar.logger.Warn("Failed to generate access token", zap.Error(err))
@@ -148,6 +152,9 @@ func (ar *AuthorizedRouter) PendingAccountHandler(service mservice.Type, endpoin
}
return response.Internal(ar.logger, ar.service, err)
}
if a.Status == model.AccountBlocked || a.IsArchived() {
return response.Unauthorized(ar.logger, ar.service, "customer is inactive")
}
return handler(r, &a, t)
}
ar.tokenHandler(service, endpoint, method, hndlr)
@@ -10,7 +10,6 @@ import (
"git.sendico.io/tech/sxchnge/bff/edge/shared/storage/refreshtokens"
api "git.sendico.io/tech/sxchnge/bff/pkg/api/http"
"git.sendico.io/tech/sxchnge/bff/pkg/api/http/response"
"git.sendico.io/tech/sxchnge/bff/pkg/auth"
"git.sendico.io/tech/sxchnge/bff/pkg/db/account"
"git.sendico.io/tech/sxchnge/bff/pkg/mlogger"
"git.sendico.io/tech/sxchnge/bff/pkg/mservice"
@@ -28,7 +27,7 @@ type AuthorizedRouter struct {
service mservice.Type
}
func NewRouter(logger mlogger.Logger, apiEndpoint string, router chi.Router, db account.DB, rtdb refreshtokens.DB, enforcer auth.Enforcer, config *middleware.TokenConfig, signature *middleware.Signature) *AuthorizedRouter {
func NewRouter(logger mlogger.Logger, apiEndpoint string, router chi.Router, db account.DB, rtdb refreshtokens.DB, config *middleware.TokenConfig, signature *middleware.Signature) *AuthorizedRouter {
ja := jwtauth.New(signature.Algorithm, signature.PrivateKey, signature.PublicKey)
router.Use(jwtauth.Verifier(ja))
router.Use(jwtauth.Authenticator(ja))
@@ -13,7 +13,6 @@ import (
"git.sendico.io/tech/sxchnge/bff/edge/shared/storage/refreshtokens"
"git.sendico.io/tech/sxchnge/bff/edge/shared/storage/verification"
api "git.sendico.io/tech/sxchnge/bff/pkg/api/http"
"git.sendico.io/tech/sxchnge/bff/pkg/auth"
"git.sendico.io/tech/sxchnge/bff/pkg/db/account"
"git.sendico.io/tech/sxchnge/bff/pkg/mlogger"
"git.sendico.io/tech/sxchnge/bff/pkg/mservice"
@@ -44,7 +43,6 @@ func NewDispatcher(
vdb verification.DB,
rtdb refreshtokens.DB,
attempts ratelimits.Store,
enforcer auth.Enforcer,
config *middleware.Config,
loginRateLimits middleware.LoginRateLimits,
) *Dispatcher {
@@ -59,7 +57,7 @@ func NewDispatcher(
d.public = rpublic.NewRouter(d.logger, endpoint, db, vdb, rtdb, attempts, r, &config.Token, &signature, loginRateLimits)
})
router.Group(func(r chi.Router) {
d.protected = rauthorized.NewRouter(d.logger, endpoint, r, db, rtdb, enforcer, &config.Token, &signature)
d.protected = rauthorized.NewRouter(d.logger, endpoint, r, db, rtdb, &config.Token, &signature)
})
return d
@@ -46,7 +46,7 @@ func (pr *PublicRouter) authenticateAccount(ctx context.Context, req *srequest.L
return nil, true, response.Unauthorized(pr.logger, pr.service, "invalid credentials")
}
if !account.IsActive() {
if !account.IsActive() || account.IsArchived() {
return nil, false, response.Forbidden(pr.logger, pr.service, "account_not_verified", "Account verification required")
}
@@ -87,10 +87,6 @@ func (db *testAccountDB) GetByEmail(ctx context.Context, email string) (*model.A
return db.result, db.err
}
func (db *testAccountDB) GetAccountsByRefs(ctx context.Context, orgRef bson.ObjectID, refs []bson.ObjectID) ([]model.Account, error) {
return nil, merrors.NotImplemented("stub")
}
func (db *testAccountDB) Create(ctx context.Context, object *model.Account) error {
return merrors.NotImplemented("stub")
}
@@ -135,3 +135,17 @@ func TestNativeRefreshRejectsStaleTokenWithoutCookieMutation(t *testing.T) {
require.Contains(t, response.Body.String(), `"error":"unauthorized"`)
require.Empty(t, response.Header().Get("Set-Cookie"))
}
func TestRefreshRejectsInactiveCustomerAndClientMismatch(t *testing.T) {
for _, status := range []model.AccountStatus{model.AccountBlocked, model.AccountPendingVerification} {
t.Run(string(status), func(t *testing.T) {
router, _ := newBrowserRefreshRouter(t, time.Now().Add(24*time.Hour))
router.db.(*testAccountDB).result.Status = status
require.Equal(t, http.StatusUnauthorized, executeBrowserRefresh(t, router).Code)
})
}
router, sessions := newBrowserRefreshRouter(t, time.Now().Add(24*time.Hour))
other := testActiveAccount(t)
sessions.client.AccountRef = &other.ID
require.Equal(t, http.StatusUnauthorized, executeBrowserRefresh(t, router).Code)
}
@@ -88,6 +88,13 @@ func (pr *PublicRouter) validateRefreshToken(ctx context.Context, r *http.Reques
return nil, nil, nil, err
}
if !account.IsActive() || account.IsArchived() {
return nil, nil, nil, merrors.Unauthorized("customer is inactive")
}
if client.AccountRef != nil && *client.AccountRef != account.ID {
return nil, nil, nil, merrors.Unauthorized("client account mismatch")
}
accessToken, err := pr.imp.CreateAccessTokenForSession(&account, rt.SessionIdentifier)
if err != nil {
pr.logger.Warn("Failed to generate access token", zap.Error(err))
@@ -1,68 +0,0 @@
package websocket
import (
"context"
"fmt"
"net/http"
"os"
"time"
"git.sendico.io/tech/sxchnge/bff/edge/shared/interface/api/ws"
"git.sendico.io/tech/sxchnge/bff/pkg/mlogger"
"github.com/go-chi/chi/v5"
"go.uber.org/zap"
"golang.org/x/net/websocket"
)
type DispatcherImpl struct {
logger mlogger.Logger
handlers map[string]ws.HandlerFunc
timeout int
}
func (d *DispatcherImpl) InstallHandler(messageType string, handler ws.HandlerFunc) {
d.handlers[messageType] = handler
d.logger.Info("Handler installed", zap.String("message_type", messageType))
}
func (d *DispatcherImpl) dispatchMessage(ctx context.Context, conn *websocket.Conn) {
var msg ws.Message
err := websocket.JSON.Receive(conn, &msg)
if err != nil {
d.logger.Warn("Failed to read websocket message", zap.Error(err))
return
}
if handler, exists := d.handlers[msg.MessageType]; exists {
responseHandler := handler(ctx, msg)
responseHandler(msg.MessageType, conn)
} else {
d.logger.Warn("Unknown websocket message type", zap.String("message_type", msg.MessageType), zap.Any("message", &msg))
}
}
func (d *DispatcherImpl) handle(w http.ResponseWriter, r *http.Request) {
//nolint:contextcheck // websocket.Handler callback signature does not carry request context.
websocket.Handler(func(conn *websocket.Conn) {
ctx, cancel := context.WithTimeout(r.Context(), time.Duration(d.timeout)*time.Second)
defer cancel()
d.dispatchMessage(ctx, conn)
}).ServeHTTP(w, r)
}
func NewDispatcher(logger mlogger.Logger, router chi.Router, config *ws.Config, apiEndpoint string) *DispatcherImpl {
d := &DispatcherImpl{
logger: logger.Named("websocket"),
handlers: make(map[string]ws.HandlerFunc),
timeout: config.Timeout,
}
d.logger.Debug("Installing websocket middleware...")
router.Group(func(r chi.Router) {
ep := fmt.Sprintf("%s%s", apiEndpoint, os.Getenv(config.EndpointEnv))
d.logger.Info("Installing websockets handler", zap.String("endpoint", ep))
r.Get(ep, d.handle)
})
return d
}
@@ -1,15 +0,0 @@
package websocket
import (
"git.sendico.io/tech/sxchnge/bff/edge/shared/interface/api/ws"
"git.sendico.io/tech/sxchnge/bff/pkg/mlogger"
"github.com/go-chi/chi/v5"
)
type Router interface {
InstallHandler(messageType string, handler ws.HandlerFunc)
}
func NewRouter(logger mlogger.Logger, router chi.Router, config *ws.Config, apiEndpoint string) Router {
return NewDispatcher(logger, router, config, apiEndpoint)
}