Run local infrastructure and product images with Vault-managed secrets #43
No files matched your search
@@ -28,7 +28,10 @@ Signup and email verification delivery/completion are available when
|
||||
`api.notifications` is configured. Flutter selects the backend using
|
||||
`SXCHNGE_API_URL` and `SXCHNGE_CLIENT_ID`. See
|
||||
[auth notification setup and contracts](../../docs/auth-notifications.md).
|
||||
Profile HTTP wiring remains follow-up work.
|
||||
API versions are installed explicitly by the product composition. See
|
||||
[version installation and compatibility](../../docs/api-versions.md) for selection,
|
||||
mounts, shared lifecycle and adding a future version. Customer profile endpoint
|
||||
wiring remains follow-up work.
|
||||
|
||||
Sendico integration is not wired. Its organization context belongs only to the
|
||||
future public HTTP adapter. There is no unused public API snapshot in this module;
|
||||
|
||||
@@ -1,23 +1,37 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"git.sendico.io/tech/sxchnge/product/app/urls"
|
||||
"git.sendico.io/tech/sxchnge/shared/database"
|
||||
"git.sendico.io/tech/sxchnge/product/auth/config"
|
||||
"git.sendico.io/tech/sxchnge/product/auth/flow"
|
||||
"git.sendico.io/tech/sxchnge/product/auth/storage"
|
||||
"git.sendico.io/tech/sxchnge/product/customers"
|
||||
"git.sendico.io/tech/sxchnge/product/twofactor"
|
||||
"git.sendico.io/tech/sxchnge/shared/logging"
|
||||
"git.sendico.io/tech/sxchnge/shared/service"
|
||||
"github.com/go-chi/chi/v5"
|
||||
)
|
||||
|
||||
type API interface {
|
||||
LifecycleContext() context.Context
|
||||
Logger() mlogger.Logger
|
||||
DomainProvider() domainprovider.DomainProvider
|
||||
Config() *Config
|
||||
ProfileConfig() ConfigProvider
|
||||
DBFactory() db.Factory
|
||||
RuntimeComponent(name string) any
|
||||
Register() Register
|
||||
// Dependencies are initialized once and shared by installed transports.
|
||||
type Dependencies struct {
|
||||
Stores *storage.Stores
|
||||
Customers accountservice.AccountService
|
||||
Auth *flow.Service
|
||||
Factors *twofactor.Service
|
||||
Token middleware.TokenConfig
|
||||
Signature middleware.Signature
|
||||
LoginLimits middleware.LoginRateLimits
|
||||
}
|
||||
|
||||
type MicroServiceFactoryT = func(API) (mservice.MicroService, error)
|
||||
// Scope belongs to one installation. Router paths are relative to Mount.
|
||||
type Scope struct {
|
||||
Router chi.Router
|
||||
Mount string
|
||||
URLs domainprovider.DomainProvider
|
||||
Logger mlogger.Logger
|
||||
}
|
||||
type Installer func(Scope, *Dependencies) error
|
||||
|
||||
type Mount struct {
|
||||
Version string `yaml:"version"`
|
||||
Path string `yaml:"path"`
|
||||
}
|
||||
@@ -7,6 +7,8 @@ import (
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
// nil preserves the legacy v1 mount; an explicit empty list installs no API.
|
||||
Versions []Mount `yaml:"versions"`
|
||||
Notifications *notifications.Config `yaml:"notifications"`
|
||||
Mw *mwa.Config `yaml:"middleware"`
|
||||
AuthStorage *mwa.AuthStorageConfig `yaml:"auth_storage"`
|
||||
|
||||
@@ -1,13 +0,0 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"git.sendico.io/tech/sxchnge/product/auth/response"
|
||||
api "git.sendico.io/tech/sxchnge/shared/http"
|
||||
"git.sendico.io/tech/sxchnge/shared/service"
|
||||
)
|
||||
|
||||
type Register interface {
|
||||
Handler(service mservice.Type, endpoint string, method api.HTTPMethod, handler sresponse.HandlerFunc)
|
||||
AccountHandler(service mservice.Type, endpoint string, method api.HTTPMethod, handler sresponse.AccountHandlerFunc)
|
||||
PendingAccountHandler(service mservice.Type, endpoint string, method api.HTTPMethod, handler sresponse.PendingAccountHandlerFunc)
|
||||
}
|
||||
@@ -1,36 +1,32 @@
|
||||
package api
|
||||
|
||||
import "git.sendico.io/tech/sxchnge/shared/service"
|
||||
import (
|
||||
"context"
|
||||
|
||||
type ConfigProvider interface {
|
||||
SharedConfig() *Config
|
||||
}
|
||||
"git.sendico.io/tech/sxchnge/shared/database"
|
||||
"git.sendico.io/tech/sxchnge/shared/logging"
|
||||
)
|
||||
|
||||
type ConfigProvider interface{ SharedConfig() *Config }
|
||||
type ConfigFactory func() ConfigProvider
|
||||
type RuntimeComponents map[string]any
|
||||
type RuntimeBootstrapFunc func(API) (RuntimeComponents, error)
|
||||
type RuntimeFinishFunc func(API) error
|
||||
|
||||
// Bootstrap opens process-owned resources. Its cleanup is called once even if
|
||||
// installation fails. On error, bootstrap must return cleanup or clean its own
|
||||
// resources before returning, never both.
|
||||
type Bootstrap func(context.Context, mlogger.Logger, *Config, db.Factory) (*Dependencies, func(context.Context) error, error)
|
||||
type RuntimeConfig struct {
|
||||
Name string
|
||||
Services []ServiceFactory
|
||||
ConfigFactory ConfigFactory
|
||||
Bootstrap RuntimeBootstrapFunc
|
||||
Finish RuntimeFinishFunc
|
||||
}
|
||||
|
||||
type ServiceFactory struct {
|
||||
Name mservice.Type
|
||||
Factory MicroServiceFactoryT
|
||||
Bootstrap Bootstrap
|
||||
Versions map[string]Installer
|
||||
}
|
||||
|
||||
func (cfg RuntimeConfig) RuntimeName() string {
|
||||
if cfg.Name == "" {
|
||||
return "edge"
|
||||
return "product"
|
||||
}
|
||||
return cfg.Name
|
||||
}
|
||||
|
||||
func (cfg RuntimeConfig) NewConfig() ConfigProvider {
|
||||
if cfg.ConfigFactory != nil {
|
||||
return cfg.ConfigFactory()
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
// Package v1 composes the product's first public HTTP contract.
|
||||
package v1
|
||||
|
||||
import (
|
||||
"git.sendico.io/tech/sxchnge/product/app/api"
|
||||
auth "git.sendico.io/tech/sxchnge/product/auth/http/v1"
|
||||
"git.sendico.io/tech/sxchnge/product/auth/http/v1/public"
|
||||
factors "git.sendico.io/tech/sxchnge/product/twofactor/http/v1"
|
||||
"git.sendico.io/tech/sxchnge/shared/errors"
|
||||
)
|
||||
|
||||
func Install(scope api.Scope, deps *api.Dependencies) error {
|
||||
if deps == nil || deps.Stores == nil {
|
||||
return merrors.InvalidArgument("v1 auth dependencies are required")
|
||||
}
|
||||
flows := public.Flows{Onboarding: deps.Auth}
|
||||
if deps.Factors != nil {
|
||||
flows.Factors = deps.Factors
|
||||
}
|
||||
// Signature is resolved at bootstrap, not by each version from the environment.
|
||||
routes := auth.Install(scope.Logger, scope.Router, "", deps.Stores.Accounts, deps.Stores.RefreshTokens, deps.Stores.LoginAttempts, &deps.Token, deps.Signature, deps.LoginLimits, flows)
|
||||
if deps.Factors != nil {
|
||||
factors.Install(routes, deps.Factors, scope.Logger)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -2,26 +2,12 @@ package httptransport
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"os"
|
||||
"path"
|
||||
|
||||
mr "git.sendico.io/tech/sxchnge/product/app/metrics"
|
||||
"git.sendico.io/tech/sxchnge/product/auth/config"
|
||||
"git.sendico.io/tech/sxchnge/product/auth/flow"
|
||||
httprouter "git.sendico.io/tech/sxchnge/product/auth/http"
|
||||
public "git.sendico.io/tech/sxchnge/product/auth/http/public"
|
||||
"git.sendico.io/tech/sxchnge/product/auth/response"
|
||||
authstorage "git.sendico.io/tech/sxchnge/product/auth/storage"
|
||||
accountservice "git.sendico.io/tech/sxchnge/product/customers"
|
||||
"git.sendico.io/tech/sxchnge/product/notifications"
|
||||
"git.sendico.io/tech/sxchnge/product/sessions/browser"
|
||||
"git.sendico.io/tech/sxchnge/product/twofactor"
|
||||
delivery "git.sendico.io/tech/sxchnge/product/twofactor/delivery"
|
||||
factorhttp "git.sendico.io/tech/sxchnge/product/twofactor/http"
|
||||
factorstore "git.sendico.io/tech/sxchnge/product/twofactor/store"
|
||||
"git.sendico.io/tech/sxchnge/shared/database"
|
||||
"git.sendico.io/tech/sxchnge/shared/errors"
|
||||
api "git.sendico.io/tech/sxchnge/shared/http"
|
||||
"git.sendico.io/tech/sxchnge/shared/http/response"
|
||||
"git.sendico.io/tech/sxchnge/shared/logging"
|
||||
"git.sendico.io/tech/sxchnge/shared/service"
|
||||
@@ -34,33 +20,17 @@ import (
|
||||
)
|
||||
|
||||
type Middleware struct {
|
||||
notifications *notifications.Client
|
||||
logger mlogger.Logger
|
||||
router *chi.Mux
|
||||
apiEndpoint string
|
||||
metrics mr.Metrics
|
||||
epdispatcher *httprouter.Dispatcher
|
||||
}
|
||||
|
||||
func (mw *Middleware) Handler(service mservice.Type, endpoint string, method api.HTTPMethod, handler sresponse.HandlerFunc) {
|
||||
mw.epdispatcher.Handler(service, endpoint, method, handler)
|
||||
}
|
||||
|
||||
func (mw *Middleware) AccountHandler(service mservice.Type, endpoint string, method api.HTTPMethod, handler sresponse.AccountHandlerFunc) {
|
||||
mw.epdispatcher.AccountHandler(service, endpoint, method, handler)
|
||||
}
|
||||
|
||||
func (mw *Middleware) PendingAccountHandler(service mservice.Type, endpoint string, method api.HTTPMethod, handler sresponse.PendingAccountHandlerFunc) {
|
||||
mw.epdispatcher.PendingAccountHandler(service, endpoint, method, handler)
|
||||
}
|
||||
|
||||
func (mw *Middleware) Finish() {
|
||||
if mw.metrics != nil {
|
||||
mw.metrics.Finish()
|
||||
if mw.notifications != nil {
|
||||
_ = mw.notifications.Close()
|
||||
}
|
||||
}
|
||||
|
||||
func (mw *Middleware) installMiddleware(config *middleware.Config, debug bool) {
|
||||
mw.logger.Debug("Installing middleware stack...")
|
||||
// Collect metrics for application traffic only. Scrapes and health probes are
|
||||
@@ -112,15 +82,7 @@ func isObservabilityRequest(apiEndpoint, requestPath string) bool {
|
||||
return requestPath == healthPath || requestPath == metricsPath || requestPath == "/metrics"
|
||||
}
|
||||
|
||||
func CreateMiddleware(
|
||||
logger mlogger.Logger,
|
||||
db db.Factory,
|
||||
apiRouter *chi.Mux,
|
||||
config *middleware.Config,
|
||||
authStorage *middleware.AuthStorageConfig,
|
||||
debug bool,
|
||||
notificationConfig ...*notifications.Config,
|
||||
) (*Middleware, error) {
|
||||
func CreateMiddleware(logger mlogger.Logger, apiRouter *chi.Mux, config *middleware.Config, endpoint string, debug bool) (*Middleware, error) {
|
||||
if config == nil {
|
||||
return nil, merrors.InvalidArgument("middleware configuration is required", "middleware")
|
||||
}
|
||||
@@ -130,72 +92,12 @@ func CreateMiddleware(
|
||||
if err := config.CORS.Validate(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
loginRateLimits, err := config.ResolveLoginRateLimits()
|
||||
if err != nil {
|
||||
logger.Error("Invalid login rate limit configuration", zap.Error(err))
|
||||
return nil, err
|
||||
}
|
||||
p := &Middleware{
|
||||
logger: logger.Named("middleware"),
|
||||
router: apiRouter,
|
||||
apiEndpoint: os.Getenv(config.EndPointEnv),
|
||||
}
|
||||
p.logger.Info("Set endpoint", zap.String("endpoint", p.apiEndpoint))
|
||||
p := &Middleware{logger: logger.Named("middleware"), router: apiRouter, apiEndpoint: endpoint}
|
||||
p.installMiddleware(config, debug)
|
||||
if p.metrics, err = mr.NewMetricsRouter(p.logger, p.router, p.apiEndpoint); err != nil {
|
||||
p.logger.Error("Failed to create metrics router", zap.Error(err), zap.String("api_endpoint", p.apiEndpoint))
|
||||
return nil, err
|
||||
}
|
||||
|
||||
stores, err := authstorage.OpenStores(p.logger, db, authStorage)
|
||||
var err error
|
||||
p.metrics, err = mr.NewMetricsRouter(p.logger, p.router, p.apiEndpoint)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var authFlow *flow.Service
|
||||
var factors *twofactor.Service
|
||||
if len(notificationConfig) > 0 && notificationConfig[0] != nil {
|
||||
c := notificationConfig[0]
|
||||
customers, err := accountservice.NewAccountService(p.logger, db, &config.Password, authStorage)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
authFlow = &flow.Service{Accounts: stores.Accounts, Customers: customers, Tokens: stores.Verification, Transactions: db.TransactionFactory(), Attempts: stores.LoginAttempts, Secret: middleware.SignatureConf(config).PrivateKey, ActivationURL: c.ActivationURL}
|
||||
if err = authFlow.Validate(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
p.notifications, err = notifications.Open(*c)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
authFlow.Sender = p.notifications
|
||||
records, err := factorstore.Open(p.logger, db)
|
||||
if err != nil {
|
||||
_ = p.notifications.Close()
|
||||
return nil, err
|
||||
}
|
||||
factors, err = twofactor.New(stores.Accounts, stores.Verification, records, db.TransactionFactory(), middleware.SignatureConf(config).PrivateKey, p.logger, delivery.Email{Sender: p.notifications})
|
||||
if err != nil {
|
||||
_ = p.notifications.Close()
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
flows := public.Flows{Onboarding: authFlow}
|
||||
if factors != nil {
|
||||
flows.Factors = factors
|
||||
}
|
||||
p.epdispatcher = httprouter.NewDispatcher(
|
||||
p.logger,
|
||||
p.router,
|
||||
stores.Accounts,
|
||||
stores.RefreshTokens,
|
||||
stores.LoginAttempts,
|
||||
config,
|
||||
loginRateLimits,
|
||||
flows,
|
||||
)
|
||||
if factors != nil {
|
||||
factorhttp.Install(p.epdispatcher, factors, p.logger)
|
||||
}
|
||||
return p, nil
|
||||
}
|
||||
@@ -1,21 +1,12 @@
|
||||
// Package profile selects the locally owned components of the copied BFF.
|
||||
// Package profile selects the product's supported API implementations.
|
||||
package profile
|
||||
|
||||
import (
|
||||
"git.sendico.io/tech/sxchnge/product/app/api"
|
||||
"git.sendico.io/tech/sxchnge/product/customers"
|
||||
"git.sendico.io/tech/sxchnge/product/app/api/v1"
|
||||
"git.sendico.io/tech/sxchnge/product/app/runtime"
|
||||
)
|
||||
|
||||
const Accounts = "accounts"
|
||||
|
||||
func Runtime() api.RuntimeConfig {
|
||||
return api.RuntimeConfig{Name: "sxchnge.bff", Bootstrap: bootstrap}
|
||||
}
|
||||
|
||||
func bootstrap(a api.API) (api.RuntimeComponents, error) {
|
||||
accounts, err := accountservice.NewAccountService(a.Logger(), a.DBFactory(), &a.Config().Mw.Password, a.Config().AuthStorage)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return api.RuntimeComponents{Accounts: accounts}, nil
|
||||
return api.RuntimeConfig{Name: "sxchnge.bff", Bootstrap: runtime.OpenDependencies, Versions: map[string]api.Installer{"v1": v1.Install}}
|
||||
}
|
||||
@@ -2,68 +2,20 @@ package runtime
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync"
|
||||
|
||||
"git.sendico.io/tech/sxchnge/product/app/api"
|
||||
amr "git.sendico.io/tech/sxchnge/product/app/health"
|
||||
httptransport "git.sendico.io/tech/sxchnge/product/app/http"
|
||||
"git.sendico.io/tech/sxchnge/product/app/urls"
|
||||
"git.sendico.io/tech/sxchnge/shared/database"
|
||||
"git.sendico.io/tech/sxchnge/shared/logging"
|
||||
"git.sendico.io/tech/sxchnge/shared/service"
|
||||
)
|
||||
|
||||
type Microservices = []mservice.MicroService
|
||||
|
||||
// APIImp represents the structure of the APIImp
|
||||
type APIImp struct {
|
||||
lifecycleCtx context.Context
|
||||
type instance struct {
|
||||
logger mlogger.Logger
|
||||
db db.Factory
|
||||
domain domainprovider.DomainProvider
|
||||
config *api.Config
|
||||
profile api.ConfigProvider
|
||||
runtime api.RuntimeConfig
|
||||
components api.RuntimeComponents
|
||||
services Microservices
|
||||
health amr.Health
|
||||
mw *httptransport.Middleware
|
||||
cleanup func(context.Context) error
|
||||
once sync.Once
|
||||
finishErr error
|
||||
}
|
||||
|
||||
func (a *APIImp) LifecycleContext() context.Context {
|
||||
return a.lifecycleCtx
|
||||
}
|
||||
|
||||
func (a *APIImp) Logger() mlogger.Logger {
|
||||
return a.logger
|
||||
}
|
||||
|
||||
func (a *APIImp) Config() *api.Config {
|
||||
return a.config
|
||||
}
|
||||
|
||||
func (a *APIImp) ProfileConfig() api.ConfigProvider {
|
||||
return a.profile
|
||||
}
|
||||
|
||||
func (a *APIImp) DBFactory() db.Factory {
|
||||
return a.db
|
||||
}
|
||||
|
||||
func (a *APIImp) DomainProvider() domainprovider.DomainProvider {
|
||||
return a.domain
|
||||
}
|
||||
|
||||
func (a *APIImp) Register() api.Register {
|
||||
return a.mw
|
||||
}
|
||||
|
||||
func (a *APIImp) RuntimeComponent(name string) any {
|
||||
if a == nil || a.components == nil {
|
||||
return nil
|
||||
}
|
||||
return a.components[name]
|
||||
}
|
||||
|
||||
func (a *APIImp) Name() string {
|
||||
return "api"
|
||||
}
|
||||
func (a *instance) Name() string { return "api" }
|
||||
@@ -6,9 +6,12 @@ import (
|
||||
|
||||
"git.sendico.io/tech/sxchnge/product/app/api"
|
||||
amr "git.sendico.io/tech/sxchnge/product/app/health"
|
||||
"git.sendico.io/tech/sxchnge/product/app/health/status"
|
||||
httptransport "git.sendico.io/tech/sxchnge/product/app/http"
|
||||
"git.sendico.io/tech/sxchnge/product/app/urls"
|
||||
"git.sendico.io/tech/sxchnge/product/sessions/browser"
|
||||
"git.sendico.io/tech/sxchnge/shared/database"
|
||||
"git.sendico.io/tech/sxchnge/shared/errors"
|
||||
"git.sendico.io/tech/sxchnge/shared/logging"
|
||||
"git.sendico.io/tech/sxchnge/shared/service"
|
||||
"github.com/go-chi/chi/v5"
|
||||
@@ -16,93 +19,67 @@ import (
|
||||
)
|
||||
|
||||
func CreateAPI(
|
||||
lifecycleCtx context.Context,
|
||||
ctx context.Context,
|
||||
logger mlogger.Logger,
|
||||
config *api.Config,
|
||||
profileConfig api.ConfigProvider,
|
||||
db db.Factory,
|
||||
rootRouter *chi.Mux,
|
||||
apiRouter *chi.Mux,
|
||||
rootRouter, apiRouter *chi.Mux,
|
||||
debug bool,
|
||||
runtime api.RuntimeConfig,
|
||||
) (mservice.MicroService, error) {
|
||||
p := &APIImp{
|
||||
lifecycleCtx: lifecycleCtx,
|
||||
logger: logger.Named("api"),
|
||||
config: config,
|
||||
profile: profileConfig,
|
||||
db: db,
|
||||
runtime: runtime,
|
||||
) (_ mservice.MicroService, result error) {
|
||||
if config == nil || config.Mw == nil {
|
||||
return nil, merrors.InvalidArgument("API middleware configuration is required")
|
||||
}
|
||||
|
||||
if err := p.installRuntimeBasics(logger, db, rootRouter, apiRouter, debug); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := p.bootstrapConfiguredRuntime(config, db); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
p.logger.Info("Installing microservices...", zap.String("runtime", runtime.RuntimeName()))
|
||||
if err := p.installServices(); err != nil {
|
||||
p.logger.Error("Failed to install a microservice", zap.Error(err))
|
||||
return nil, err
|
||||
}
|
||||
p.logger.Info("Microservices installation complete", zap.Int("microservices", len(p.services)))
|
||||
|
||||
return p, nil
|
||||
}
|
||||
|
||||
func (a *APIImp) installRuntimeBasics(
|
||||
logger mlogger.Logger,
|
||||
db db.Factory,
|
||||
rootRouter *chi.Mux,
|
||||
apiRouter *chi.Mux,
|
||||
debug bool,
|
||||
) error {
|
||||
var err error
|
||||
if a.domain, err = domainprovider.CreateDomainProvider(
|
||||
a.logger,
|
||||
a.config.Mw.DomainEnv,
|
||||
a.config.Mw.APIProtocolEnv,
|
||||
a.config.Mw.EndPointEnv,
|
||||
); err != nil {
|
||||
a.logger.Error("Failed to initialize domain provider")
|
||||
return err
|
||||
}
|
||||
a.logger.Info("Domain provider installed")
|
||||
|
||||
apiEndpoint := os.Getenv(a.config.Mw.EndPointEnv)
|
||||
if a.health, err = amr.NewHealthRouter(a.logger, rootRouter, apiEndpoint); err != nil {
|
||||
a.logger.Error("Failed to create healthcheck router", zap.Error(err), zap.String("api_endpoint", apiEndpoint))
|
||||
return err
|
||||
}
|
||||
if a.mw, err = httptransport.CreateMiddleware(
|
||||
logger,
|
||||
db,
|
||||
apiRouter,
|
||||
a.config.Mw,
|
||||
a.config.AuthStorage,
|
||||
debug,
|
||||
a.config.Notifications,
|
||||
); err != nil {
|
||||
a.logger.Error("Failed to create middleware", zap.Error(err))
|
||||
return err
|
||||
}
|
||||
a.logger.Info("Middleware installed", zap.Bool("debug_mode", debug))
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *APIImp) bootstrapConfiguredRuntime(_ *api.Config, _ db.Factory) error {
|
||||
if a.runtime.Bootstrap != nil {
|
||||
components, err := a.runtime.Bootstrap(a)
|
||||
endpoint := os.Getenv(config.Mw.EndPointEnv)
|
||||
mounts, err := resolveMounts(config.Versions, endpoint, runtime.Versions)
|
||||
if err != nil {
|
||||
a.logger.Error("Failed to bootstrap runtime profile", zap.Error(err), zap.String("runtime", a.runtime.RuntimeName()))
|
||||
return err
|
||||
return nil, err
|
||||
}
|
||||
a.components = components
|
||||
a.logger.Info("Runtime profile bootstrap complete",
|
||||
zap.String("runtime", a.runtime.RuntimeName()),
|
||||
zap.Int("components", len(components)))
|
||||
if runtime.Bootstrap == nil {
|
||||
return nil, merrors.InvalidArgument("runtime bootstrap is required")
|
||||
}
|
||||
return nil
|
||||
// Resolve every URL before opening resources or publishing routes.
|
||||
scopes := make([]api.Scope, len(mounts))
|
||||
for i, mount := range mounts {
|
||||
urls, err := domainprovider.CreateDomainProvider(logger, os.Getenv(config.Mw.DomainEnv), os.Getenv(config.Mw.APIProtocolEnv), mount.Path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
scopes[i] = api.Scope{Router: chi.NewRouter(), Mount: mount.Path, URLs: urls, Logger: logger.With(zap.String("api_version", mount.Version), zap.String("mount", mount.Path))}
|
||||
scopes[i].Router.Use(browser.Scope(mount.Path))
|
||||
}
|
||||
a := &instance{logger: logger.Named("api").With(zap.String("runtime", runtime.RuntimeName()))}
|
||||
defer func() {
|
||||
if result != nil {
|
||||
if err := a.Finish(context.Background()); err != nil {
|
||||
a.logger.Warn("Failed startup resource cleanup", zap.Error(err))
|
||||
}
|
||||
}
|
||||
}()
|
||||
a.mw, err = httptransport.CreateMiddleware(logger, apiRouter, config.Mw, endpoint, debug)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
deps, cleanup, err := runtime.Bootstrap(ctx, a.logger, config, db)
|
||||
a.cleanup = cleanup
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for i, mount := range mounts {
|
||||
if err := runtime.Versions[mount.Version](scopes[i], deps); err != nil {
|
||||
return nil, merrors.OpaqueWrap(merrors.ErrInternal, err, "API version installation failed: "+mount.Version)
|
||||
}
|
||||
}
|
||||
// Attach only after every installer succeeds.
|
||||
for i, mount := range mounts {
|
||||
apiRouter.Mount(mount.Path, scopes[i].Router)
|
||||
scopes[i].Logger.Info("API version installed")
|
||||
}
|
||||
a.health, err = amr.NewHealthRouter(a.logger, rootRouter, endpoint)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
a.logger.Info("API installation complete", zap.Int("versions", len(mounts)))
|
||||
a.health.SetStatus(health.SSRunning)
|
||||
return a, nil
|
||||
}
|
||||
@@ -50,7 +50,7 @@ func TestProfileStartsOnEmptyDatabase(t *testing.T) {
|
||||
t.Setenv("SXCHNGE_JWT_SECRET", "test-only-signing-key-at-least-32-bytes")
|
||||
example.API.Notifications.CAFile = "" // This startup test makes no notification RPCs.
|
||||
root, routes := chi.NewRouter(), chi.NewRouter()
|
||||
runtime, err := sharedruntime.CreateAPI(ctx, zap.NewNop(), &example.API, &example.API, factory, root, routes, false, profile.Runtime())
|
||||
runtime, err := sharedruntime.CreateAPI(ctx, zap.NewNop(), &example.API, factory, root, routes, false, profile.Runtime())
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(func() { require.NoError(t, runtime.Finish(context.Background())) })
|
||||
root.Mount("/", routes)
|
||||
|
||||
@@ -4,28 +4,20 @@ import (
|
||||
"context"
|
||||
|
||||
"git.sendico.io/tech/sxchnge/product/app/health/status"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
func (a *APIImp) Finish(ctx context.Context) error {
|
||||
func (a *instance) Finish(ctx context.Context) error {
|
||||
a.once.Do(func() {
|
||||
if a.health != nil {
|
||||
a.health.SetStatus(health.SSTerminating)
|
||||
a.health.Finish()
|
||||
}
|
||||
if a.cleanup != nil {
|
||||
a.finishErr = a.cleanup(ctx)
|
||||
}
|
||||
if a.mw != nil {
|
||||
a.mw.Finish()
|
||||
var lastError error
|
||||
for i := len(a.services) - 1; i >= 0; i-- {
|
||||
if err := (a.services[i]).Finish(ctx); err != nil {
|
||||
lastError = err
|
||||
a.logger.Warn("Error occurred when finishing service",
|
||||
zap.Error(err), zap.String("service_name", (a.services[i]).Name()))
|
||||
} else {
|
||||
a.logger.Info("Microservice is down", zap.String("service_name", (a.services[i]).Name()))
|
||||
}
|
||||
}
|
||||
if a.runtime.Finish != nil {
|
||||
if err := a.runtime.Finish(a); err != nil {
|
||||
lastError = err
|
||||
a.logger.Warn("Error occurred when finishing runtime profile", zap.String("runtime", a.runtime.RuntimeName()), zap.Error(err))
|
||||
}
|
||||
}
|
||||
return lastError
|
||||
})
|
||||
return a.finishErr
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
package runtime
|
||||
|
||||
import (
|
||||
"path"
|
||||
"strings"
|
||||
|
||||
"git.sendico.io/tech/sxchnge/product/app/api"
|
||||
"git.sendico.io/tech/sxchnge/shared/errors"
|
||||
)
|
||||
|
||||
func validMount(value string) bool {
|
||||
if value == "" || value == "/" || !strings.HasPrefix(value, "/") || path.Clean(value) != value {
|
||||
return false
|
||||
}
|
||||
for _, c := range value {
|
||||
if c >= 'a' && c <= 'z' || c >= 'A' && c <= 'Z' || c >= '0' && c <= '9' || strings.ContainsRune("/-._~", c) {
|
||||
continue
|
||||
}
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
func overlaps(a, b string) bool {
|
||||
return a == b || strings.HasPrefix(a, b+"/") || strings.HasPrefix(b, a+"/")
|
||||
}
|
||||
|
||||
func resolveMounts(configured []api.Mount, endpoint string, supported map[string]api.Installer) ([]api.Mount, error) {
|
||||
if !validMount(endpoint) {
|
||||
return nil, merrors.InvalidArgument("a canonical absolute API endpoint is required", "api_endpoint")
|
||||
}
|
||||
if configured == nil {
|
||||
configured = []api.Mount{{Version: "v1", Path: endpoint}}
|
||||
}
|
||||
seen := map[string]bool{}
|
||||
for i, mount := range configured {
|
||||
if supported[mount.Version] == nil {
|
||||
return nil, merrors.InvalidArgument("unknown or unavailable API version: "+mount.Version, "api.versions")
|
||||
}
|
||||
if seen[mount.Version] {
|
||||
return nil, merrors.InvalidArgument("duplicate API version: "+mount.Version, "api.versions")
|
||||
}
|
||||
seen[mount.Version] = true
|
||||
if !validMount(mount.Path) {
|
||||
return nil, merrors.InvalidArgument("invalid API mount: "+mount.Path, "api.versions")
|
||||
}
|
||||
for _, reserved := range []string{"/metrics", endpoint + "/metrics", endpoint + "/health"} {
|
||||
if mount.Path == reserved || strings.HasPrefix(mount.Path, reserved+"/") {
|
||||
return nil, merrors.InvalidArgument("API mount conflicts with operational endpoint", "api.versions")
|
||||
}
|
||||
}
|
||||
for _, previous := range configured[:i] {
|
||||
if overlaps(mount.Path, previous.Path) {
|
||||
return nil, merrors.InvalidArgument("overlapping API mounts", "api.versions")
|
||||
}
|
||||
}
|
||||
}
|
||||
return configured, nil
|
||||
}
|
||||
@@ -0,0 +1,190 @@
|
||||
package runtime
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.sendico.io/tech/sxchnge/product/app/api"
|
||||
"git.sendico.io/tech/sxchnge/product/sessions/browser"
|
||||
"git.sendico.io/tech/sxchnge/shared/database"
|
||||
merrors "git.sendico.io/tech/sxchnge/shared/errors"
|
||||
"git.sendico.io/tech/sxchnge/shared/logging"
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/stretchr/testify/require"
|
||||
"go.uber.org/zap"
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
func testConfig(t *testing.T) *api.Config {
|
||||
t.Helper()
|
||||
data, err := os.ReadFile("../../config.example.yml")
|
||||
require.NoError(t, err)
|
||||
var raw struct {
|
||||
API api.Config `yaml:"api"`
|
||||
}
|
||||
require.NoError(t, yaml.Unmarshal(data, &raw))
|
||||
t.Setenv(raw.API.Mw.EndPointEnv, "/api/v1")
|
||||
t.Setenv(raw.API.Mw.DomainEnv, "example.test")
|
||||
t.Setenv(raw.API.Mw.APIProtocolEnv, "https")
|
||||
return &raw.API
|
||||
}
|
||||
|
||||
func TestInstalledVersionsShareDependenciesAndKeepScopes(t *testing.T) {
|
||||
config := testConfig(t)
|
||||
config.Versions = []api.Mount{{Version: "v1", Path: "/api/v1"}, {Version: "v2", Path: "/gateway/v2"}}
|
||||
initialized, closed := 0, 0
|
||||
deps := &api.Dependencies{}
|
||||
installer := func(version string) api.Installer {
|
||||
return func(scope api.Scope, got *api.Dependencies) error {
|
||||
require.Same(t, deps, got)
|
||||
link, err := scope.URLs.GetAPILink("accounts")
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "https://example.test"+scope.Mount+"/accounts", link)
|
||||
scope.Router.Get("/contract", func(w http.ResponseWriter, _ *http.Request) { _, _ = w.Write([]byte(version)) })
|
||||
scope.Router.Post("/accounts/session", func(w http.ResponseWriter, r *http.Request) {
|
||||
browser.SetRefreshCookie(w, r, "credential", time.Now().Add(time.Hour))
|
||||
})
|
||||
scope.Router.Delete("/accounts/session", browser.ClearRefreshCookie)
|
||||
return nil
|
||||
}
|
||||
}
|
||||
runtime := api.RuntimeConfig{Versions: map[string]api.Installer{"v1": installer("old"), "v2": installer("new"), "v3": installer("disabled")}, Bootstrap: func(context.Context, mlogger.Logger, *api.Config, db.Factory) (*api.Dependencies, func(context.Context) error, error) {
|
||||
initialized++
|
||||
return deps, func(context.Context) error { closed++; return nil }, nil
|
||||
}}
|
||||
root, routes := chi.NewRouter(), chi.NewRouter()
|
||||
srv, err := CreateAPI(context.Background(), zap.NewNop(), config, nil, root, routes, false, runtime)
|
||||
require.NoError(t, err)
|
||||
root.Mount("/", routes)
|
||||
for target, expected := range map[string]string{"/api/v1/contract": "old", "/gateway/v2/contract": "new"} {
|
||||
result := httptest.NewRecorder()
|
||||
root.ServeHTTP(result, httptest.NewRequest(http.MethodGet, target, nil))
|
||||
require.Equal(t, http.StatusOK, result.Code)
|
||||
require.Equal(t, expected, result.Body.String())
|
||||
}
|
||||
t.Setenv("API_ENDPOINT", "/wrong/global")
|
||||
for _, mount := range config.Versions {
|
||||
for _, method := range []string{http.MethodPost, http.MethodDelete} {
|
||||
result := httptest.NewRecorder()
|
||||
request := httptest.NewRequest(method, mount.Path+"/accounts/session", nil)
|
||||
request.Header.Set(browser.TransportHeader, browser.TransportCookie)
|
||||
request.Header.Set(browser.CSRFHeader, browser.CSRFValue)
|
||||
request.Header.Set("Origin", config.Mw.CORS.AllowedOrigins[0])
|
||||
root.ServeHTTP(result, request)
|
||||
require.Equal(t, http.StatusOK, result.Code)
|
||||
cookies := result.Result().Cookies()
|
||||
require.Len(t, cookies, 1)
|
||||
require.Equal(t, mount.Path+"/accounts", cookies[0].Path)
|
||||
if method == http.MethodDelete {
|
||||
require.Equal(t, -1, cookies[0].MaxAge)
|
||||
}
|
||||
}
|
||||
}
|
||||
result := httptest.NewRecorder()
|
||||
root.ServeHTTP(result, httptest.NewRequest(http.MethodGet, "/api/v3/contract", nil))
|
||||
require.Equal(t, 404, result.Code)
|
||||
require.Equal(t, 1, initialized)
|
||||
var wg sync.WaitGroup
|
||||
results := make(chan error, 3)
|
||||
for range 3 {
|
||||
wg.Go(func() { results <- srv.Finish(context.Background()) })
|
||||
}
|
||||
wg.Wait()
|
||||
close(results)
|
||||
for err := range results {
|
||||
require.NoError(t, err)
|
||||
}
|
||||
require.Equal(t, 1, closed)
|
||||
}
|
||||
|
||||
func TestInvalidMountsFailBeforeBootstrap(t *testing.T) {
|
||||
for name, mounts := range map[string][]api.Mount{
|
||||
"unknown": {{Version: "v9", Path: "/api/v9"}}, "duplicate": {{Version: "v1", Path: "/api/v1"}, {Version: "v1", Path: "/other"}},
|
||||
"same path": {{Version: "v1", Path: "/api/v1"}, {Version: "v2", Path: "/api/v1"}},
|
||||
"nested": {{Version: "v1", Path: "/api"}, {Version: "v2", Path: "/api/v2"}},
|
||||
"cookie delimiter": {{Version: "v1", Path: "/api;v1"}},
|
||||
"control": {{Version: "v1", Path: "/api/\x01"}},
|
||||
"relative": {{Version: "v1", Path: "api/v1"}}, "wildcard": {{Version: "v1", Path: "/api/*"}},
|
||||
"unclean": {{Version: "v1", Path: "/api/../v1"}}, "metrics": {{Version: "v1", Path: "/metrics"}},
|
||||
"health": {{Version: "v1", Path: "/api/v1/health"}}, "root": {{Version: "v1", Path: "/"}},
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
config := testConfig(t)
|
||||
config.Versions = mounts
|
||||
install := func(api.Scope, *api.Dependencies) error { t.Fatal("installer must not run"); return nil }
|
||||
_, err := CreateAPI(context.Background(), zap.NewNop(), config, nil, chi.NewRouter(), chi.NewRouter(), false, api.RuntimeConfig{Versions: map[string]api.Installer{"v1": install, "v2": install}, Bootstrap: func(context.Context, mlogger.Logger, *api.Config, db.Factory) (*api.Dependencies, func(context.Context) error, error) {
|
||||
t.Fatal("bootstrap must not run")
|
||||
return nil, nil, nil
|
||||
}})
|
||||
require.ErrorIs(t, err, merrors.ErrInvalidArg)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestInstallFailureCleansResourcesWithoutPublishingRoutes(t *testing.T) {
|
||||
config := testConfig(t)
|
||||
config.Versions = []api.Mount{{Version: "v1", Path: "/api/v1"}, {Version: "v2", Path: "/api/v2"}}
|
||||
closed := 0
|
||||
cause := merrors.InvalidArgument("test installation failure")
|
||||
routes := chi.NewRouter()
|
||||
_, err := CreateAPI(context.Background(), zap.NewNop(), config, nil, chi.NewRouter(), routes, false, api.RuntimeConfig{
|
||||
Bootstrap: func(context.Context, mlogger.Logger, *api.Config, db.Factory) (*api.Dependencies, func(context.Context) error, error) {
|
||||
return &api.Dependencies{}, func(context.Context) error { closed++; return nil }, nil
|
||||
},
|
||||
Versions: map[string]api.Installer{
|
||||
"v1": func(scope api.Scope, _ *api.Dependencies) error {
|
||||
scope.Router.Get("/ready", func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(200) })
|
||||
return nil
|
||||
},
|
||||
"v2": func(api.Scope, *api.Dependencies) error { return cause },
|
||||
}})
|
||||
require.True(t, errors.Is(err, cause))
|
||||
require.Equal(t, 1, closed)
|
||||
result := httptest.NewRecorder()
|
||||
routes.ServeHTTP(result, httptest.NewRequest(http.MethodGet, "/api/v1/ready", nil))
|
||||
require.Equal(t, 404, result.Code)
|
||||
}
|
||||
|
||||
func TestExplicitEmptyVersionsInstallNoAPI(t *testing.T) {
|
||||
config := testConfig(t)
|
||||
config.Versions = []api.Mount{}
|
||||
root, routes := chi.NewRouter(), chi.NewRouter()
|
||||
srv, err := CreateAPI(context.Background(), zap.NewNop(), config, nil, root, routes, false, api.RuntimeConfig{Bootstrap: func(context.Context, mlogger.Logger, *api.Config, db.Factory) (*api.Dependencies, func(context.Context) error, error) {
|
||||
return &api.Dependencies{}, nil, nil
|
||||
}})
|
||||
require.NoError(t, err)
|
||||
defer srv.Finish(context.Background())
|
||||
root.Mount("/", routes)
|
||||
for target, status := range map[string]int{"/api/v1/accounts/login": 404, "/api/v1/health": 200, "/metrics": 200, "/api/v1/metrics": 200} {
|
||||
result := httptest.NewRecorder()
|
||||
root.ServeHTTP(result, httptest.NewRequest(http.MethodGet, target, nil))
|
||||
require.Equal(t, status, result.Code, target)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBootstrapFailureClosesReturnedResources(t *testing.T) {
|
||||
config := testConfig(t)
|
||||
config.Versions = []api.Mount{}
|
||||
closed := 0
|
||||
cause := merrors.Internal("bootstrap failure")
|
||||
_, err := CreateAPI(context.Background(), zap.NewNop(), config, nil, chi.NewRouter(), chi.NewRouter(), false, api.RuntimeConfig{Bootstrap: func(context.Context, mlogger.Logger, *api.Config, db.Factory) (*api.Dependencies, func(context.Context) error, error) {
|
||||
return nil, func(context.Context) error { closed++; return nil }, cause
|
||||
}})
|
||||
require.ErrorIs(t, err, cause)
|
||||
require.Equal(t, 1, closed)
|
||||
}
|
||||
|
||||
func TestFinishPreservesCleanupErrorAndRunsOnce(t *testing.T) {
|
||||
cause := merrors.Internal("cleanup failure")
|
||||
closed := 0
|
||||
srv := &instance{logger: zap.NewNop(), cleanup: func(context.Context) error { closed++; return cause }}
|
||||
require.ErrorIs(t, srv.Finish(context.Background()), cause)
|
||||
require.ErrorIs(t, srv.Finish(context.Background()), cause)
|
||||
require.Equal(t, 1, closed)
|
||||
}
|
||||
@@ -1,39 +1,69 @@
|
||||
package runtime
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"git.sendico.io/tech/sxchnge/product/app/api"
|
||||
"git.sendico.io/tech/sxchnge/product/app/health/status"
|
||||
"git.sendico.io/tech/sxchnge/shared/errors"
|
||||
"git.sendico.io/tech/sxchnge/shared/service"
|
||||
"git.sendico.io/tech/sxchnge/product/auth/config"
|
||||
"git.sendico.io/tech/sxchnge/product/auth/flow"
|
||||
"git.sendico.io/tech/sxchnge/product/auth/storage"
|
||||
"git.sendico.io/tech/sxchnge/product/customers"
|
||||
"git.sendico.io/tech/sxchnge/product/notifications"
|
||||
"git.sendico.io/tech/sxchnge/product/twofactor"
|
||||
"git.sendico.io/tech/sxchnge/product/twofactor/delivery"
|
||||
factorstore "git.sendico.io/tech/sxchnge/product/twofactor/store"
|
||||
"git.sendico.io/tech/sxchnge/shared/database"
|
||||
"git.sendico.io/tech/sxchnge/shared/logging"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
func (a *APIImp) installMicroservice(srv mservice.MicroService) {
|
||||
a.services = append(a.services, srv)
|
||||
a.logger.Info("Microservice installed", zap.String("service", srv.Name()))
|
||||
}
|
||||
|
||||
func (a *APIImp) addMicroservice(srvf api.MicroServiceFactoryT) error {
|
||||
srv, err := srvf(a)
|
||||
func OpenDependencies(_ context.Context, logger mlogger.Logger, config *api.Config, db db.Factory) (_ *api.Dependencies, _ func(context.Context) error, result error) {
|
||||
limits, err := config.Mw.ResolveLoginRateLimits()
|
||||
if err != nil {
|
||||
a.logger.Error("Failed to install a microservice", zap.Error(err))
|
||||
return err
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
a.installMicroservice(srv)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *APIImp) installServices() error {
|
||||
for _, service := range a.runtime.Services {
|
||||
if service.Factory == nil {
|
||||
return merrors.InvalidArgument("microservice factory is required", "runtime.services")
|
||||
stores, err := storage.OpenStores(logger, db, config.AuthStorage)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
if err := a.addMicroservice(service.Factory); err != nil {
|
||||
return err
|
||||
customers, err := accountservice.FromStores(logger, &config.Mw.Password, stores, db.TransactionFactory())
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
deps := &api.Dependencies{Stores: stores, Customers: customers, Token: config.Mw.Token, Signature: middleware.SignatureConf(config.Mw), LoginLimits: limits}
|
||||
var client *notifications.Client
|
||||
cleanup := func(context.Context) error {
|
||||
if client != nil {
|
||||
return client.Close()
|
||||
}
|
||||
|
||||
a.health.SetStatus(health.SSRunning)
|
||||
return nil
|
||||
}
|
||||
defer func() {
|
||||
if result != nil {
|
||||
if err := cleanup(context.Background()); err != nil {
|
||||
logger.Warn("Partial bootstrap cleanup failed", zap.Error(err))
|
||||
}
|
||||
}
|
||||
}()
|
||||
if c := config.Notifications; c != nil {
|
||||
deps.Auth = &flow.Service{Accounts: stores.Accounts, Customers: customers, Tokens: stores.Verification, Transactions: db.TransactionFactory(), Attempts: stores.LoginAttempts, Secret: deps.Signature.PrivateKey, ActivationURL: c.ActivationURL}
|
||||
if err := deps.Auth.Validate(); err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
client, err = notifications.Open(*c)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
deps.Auth.Sender = client
|
||||
records, err := factorstore.Open(logger, db)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
deps.Factors, err = twofactor.New(stores.Accounts, stores.Verification, records, db.TransactionFactory(), deps.Signature.PrivateKey, logger, delivery.Email{Sender: client})
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
}
|
||||
logger.Info("Product dependencies initialized")
|
||||
return deps, cleanup, nil
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"git.sendico.io/tech/sxchnge/product/app/api"
|
||||
"git.sendico.io/tech/sxchnge/shared/errors"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
@@ -32,3 +33,34 @@ func TestExampleConfigMatchesCopiedRuntime(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVersionSelectionPreservesOmittedAndEmptyLists(t *testing.T) {
|
||||
data, err := os.ReadFile("../../../config.example.yml")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, tc := range []struct {
|
||||
name, setting string
|
||||
wantNil bool
|
||||
wantLength int
|
||||
}{
|
||||
{name: "omitted", wantNil: true},
|
||||
{name: "disabled", setting: " versions: []\n"},
|
||||
{name: "explicit", setting: " versions: [{version: v1, path: /gateway/v1}]\n", wantLength: 1},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
instance := &Imp{runtime: api.RuntimeConfig{}}
|
||||
configured := strings.Replace(string(data), "api:\n", "api:\n"+tc.setting, 1)
|
||||
if err := instance.decodeConfig([]byte(configured)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
versions := instance.config.API.SharedConfig().Versions
|
||||
if (versions == nil) != tc.wantNil || len(versions) != tc.wantLength {
|
||||
t.Fatalf("unexpected versions: %#v", versions)
|
||||
}
|
||||
if tc.wantLength == 1 && (versions[0].Version != "v1" || versions[0].Path != "/gateway/v1") {
|
||||
t.Fatalf("lost version configuration: %#v", versions)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -112,7 +112,7 @@ func (i *Imp) Start() error {
|
||||
|
||||
rootRouter := chi.NewRouter()
|
||||
apiRouter := chi.NewRouter()
|
||||
if i.api, err = sharedruntime.CreateAPI(i.lifecycleCtx, i.logger, i.config.API.SharedConfig(), i.config.API, i.db, rootRouter, apiRouter, i.debug, i.runtime); err != nil {
|
||||
if i.api, err = sharedruntime.CreateAPI(i.lifecycleCtx, i.logger, i.config.API.SharedConfig(), i.db, rootRouter, apiRouter, i.debug, i.runtime); err != nil {
|
||||
i.db.CloseConnection()
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ type DomainProvider interface {
|
||||
GetAPILink(linkElem ...string) (string, error)
|
||||
}
|
||||
|
||||
// CreateDomainProvider accepts a resolved host, protocol and installation mount.
|
||||
func CreateDomainProvider(logger mlogger.Logger, domain, protocol, apiEndpoint string) (DomainProvider, error) {
|
||||
return dimp.CreateDomainProviderImp(logger, domain, protocol, apiEndpoint)
|
||||
}
|
||||
@@ -2,8 +2,8 @@ package domainproviderimp
|
||||
|
||||
import (
|
||||
"net/url"
|
||||
"os"
|
||||
|
||||
"git.sendico.io/tech/sxchnge/shared/errors"
|
||||
"git.sendico.io/tech/sxchnge/shared/logging"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
@@ -21,11 +21,19 @@ func (dp *DomainProviderImp) GetAPILink(linkElem ...string) (string, error) {
|
||||
return url.JoinPath(dp.apiEndpoint, linkElem...)
|
||||
}
|
||||
|
||||
func CreateDomainProviderImp(logger mlogger.Logger, domain, protocol, apiPointEnv string) (*DomainProviderImp, error) {
|
||||
func CreateDomainProviderImp(logger mlogger.Logger, domain, protocol, apiPath string) (*DomainProviderImp, error) {
|
||||
if protocol != "http" && protocol != "https" {
|
||||
return nil, merrors.InvalidArgument("API protocol must be http or https")
|
||||
}
|
||||
parsed, err := url.Parse(protocol + "://" + domain)
|
||||
if err != nil {
|
||||
return nil, merrors.OpaqueWrap(merrors.ErrInvalidArg, err, "invalid API origin")
|
||||
}
|
||||
if parsed.Hostname() == "" || parsed.Host != domain || parsed.User != nil || parsed.Path != "" || parsed.RawQuery != "" || parsed.Fragment != "" {
|
||||
return nil, merrors.InvalidArgument("API domain must be a host with optional port")
|
||||
}
|
||||
p := new(DomainProviderImp)
|
||||
p.domain = os.Getenv(protocol) + "://" + os.Getenv(domain)
|
||||
var err error
|
||||
apiPath := os.Getenv(apiPointEnv)
|
||||
p.domain = protocol + "://" + domain
|
||||
if p.apiEndpoint, err = url.JoinPath(p.domain, apiPath); err != nil {
|
||||
logger.Error("Failed to create domain provider", zap.String("domain", p.domain), zap.String("api_path", apiPath))
|
||||
return nil, err
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
package domainproviderimp
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
func TestExplicitOriginAndMount(t *testing.T) {
|
||||
provider, err := CreateDomainProviderImp(zap.NewNop(), "localhost:8082", "https", "/gateway/v2")
|
||||
require.NoError(t, err)
|
||||
link, err := provider.GetAPILink("accounts", "me")
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "https://localhost:8082/gateway/v2/accounts/me", link)
|
||||
link, err = provider.GetFullLink("signup")
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "https://localhost:8082/signup", link)
|
||||
}
|
||||
func TestInvalidOrigin(t *testing.T) {
|
||||
for _, domain := range []string{"", "host/path", "user@host", "host?query", "host?", "host#", "host#fragment", "bad host"} {
|
||||
_, err := CreateDomainProviderImp(zap.NewNop(), domain, "https", "/v1")
|
||||
require.Error(t, err, domain)
|
||||
}
|
||||
_, err := CreateDomainProviderImp(zap.NewNop(), "host", "ftp", "/v1")
|
||||
require.Error(t, err)
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
package architecture
|
||||
|
||||
import (
|
||||
"go/parser"
|
||||
"go/token"
|
||||
"io/fs"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestGenericRuntimeDoesNotInstallConcreteContracts(t *testing.T) {
|
||||
for _, dir := range []string{"../app/runtime", "../app/http"} {
|
||||
err := filepath.WalkDir(dir, func(path string, entry fs.DirEntry, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if entry.IsDir() || !strings.HasSuffix(path, ".go") || strings.HasSuffix(path, "_test.go") {
|
||||
return nil
|
||||
}
|
||||
file, err := parser.ParseFile(token.NewFileSet(), path, nil, parser.ImportsOnly)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, im := range file.Imports {
|
||||
name, err := strconv.Unquote(im.Path.Value)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if strings.Contains(name, "/http/v") || strings.Contains(name, "/app/api/v") || strings.HasSuffix(name, "/app/profile") {
|
||||
t.Errorf("generic runtime imports a concrete API contract: %s in %s", name, path)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
+2
-2
@@ -9,8 +9,8 @@ import (
|
||||
"testing"
|
||||
|
||||
"git.sendico.io/tech/sxchnge/product/auth/config"
|
||||
"git.sendico.io/tech/sxchnge/product/auth/http/v1/response"
|
||||
emodel "git.sendico.io/tech/sxchnge/product/auth/model"
|
||||
"git.sendico.io/tech/sxchnge/product/auth/response"
|
||||
"git.sendico.io/tech/sxchnge/product/customers/store"
|
||||
"git.sendico.io/tech/sxchnge/shared/errors"
|
||||
api "git.sendico.io/tech/sxchnge/shared/http"
|
||||
@@ -48,7 +48,7 @@ func TestCustomerHandlerResolvesIdentityWithoutRoles(t *testing.T) {
|
||||
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: &sessionmodel.RefreshToken{}}, &middleware.TokenConfig{Expiration: middleware.TokenExpiration{Account: 1}}, signature)
|
||||
router := Install(zap.NewNop(), "/api/v1", routes, accounts, &sessionDB{token: &sessionmodel.RefreshToken{}}, &middleware.TokenConfig{Expiration: middleware.TokenExpiration{Account: 1}}, signature)
|
||||
called := false
|
||||
router.AccountHandler(mservice.Accounts, "/me", api.Get, func(_ *http.Request, c *customermodel.Account, _ *sresponse.TokenData) http.HandlerFunc {
|
||||
called = true
|
||||
+1
-1
@@ -6,9 +6,9 @@ import (
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"git.sendico.io/tech/sxchnge/product/auth/http/v1/response"
|
||||
"git.sendico.io/tech/sxchnge/product/auth/ipguard"
|
||||
emodel "git.sendico.io/tech/sxchnge/product/auth/model"
|
||||
"git.sendico.io/tech/sxchnge/product/auth/response"
|
||||
"git.sendico.io/tech/sxchnge/product/sessions/metrics"
|
||||
"git.sendico.io/tech/sxchnge/shared/errors"
|
||||
api "git.sendico.io/tech/sxchnge/shared/http"
|
||||
+2
-2
@@ -4,7 +4,7 @@ import (
|
||||
"net/http"
|
||||
|
||||
"git.sendico.io/tech/sxchnge/product/auth/config"
|
||||
re "git.sendico.io/tech/sxchnge/product/auth/http/endpoint"
|
||||
re "git.sendico.io/tech/sxchnge/product/auth/http/v1/endpoint"
|
||||
emodel "git.sendico.io/tech/sxchnge/product/auth/model"
|
||||
"git.sendico.io/tech/sxchnge/product/customers/store"
|
||||
"git.sendico.io/tech/sxchnge/product/sessions/metrics"
|
||||
@@ -27,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, config *middleware.TokenConfig, signature *middleware.Signature) *AuthorizedRouter {
|
||||
func Install(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))
|
||||
File renamed without changes.
+9
-11
@@ -1,12 +1,10 @@
|
||||
package router
|
||||
|
||||
import (
|
||||
"os"
|
||||
|
||||
"git.sendico.io/tech/sxchnge/product/auth/config"
|
||||
rauthorized "git.sendico.io/tech/sxchnge/product/auth/http/authorized"
|
||||
rpublic "git.sendico.io/tech/sxchnge/product/auth/http/public"
|
||||
"git.sendico.io/tech/sxchnge/product/auth/response"
|
||||
rauthorized "git.sendico.io/tech/sxchnge/product/auth/http/v1/authorized"
|
||||
rpublic "git.sendico.io/tech/sxchnge/product/auth/http/v1/public"
|
||||
"git.sendico.io/tech/sxchnge/product/auth/http/v1/response"
|
||||
"git.sendico.io/tech/sxchnge/product/customers/store"
|
||||
"git.sendico.io/tech/sxchnge/product/sessions/store"
|
||||
api "git.sendico.io/tech/sxchnge/shared/http"
|
||||
@@ -34,13 +32,15 @@ func (d *Dispatcher) PendingAccountHandler(service mservice.Type, endpoint strin
|
||||
d.protected.PendingAccountHandler(service, endpoint, method, handler)
|
||||
}
|
||||
|
||||
func NewDispatcher(
|
||||
func Install(
|
||||
logger mlogger.Logger,
|
||||
router chi.Router,
|
||||
endpoint string,
|
||||
db account.DB,
|
||||
rtdb refreshtokens.DB,
|
||||
attempts ratelimits.Store,
|
||||
config *middleware.Config,
|
||||
token *middleware.TokenConfig,
|
||||
signature middleware.Signature,
|
||||
loginRateLimits middleware.LoginRateLimits,
|
||||
flows ...rpublic.Flows,
|
||||
) *Dispatcher {
|
||||
@@ -49,13 +49,11 @@ func NewDispatcher(
|
||||
}
|
||||
|
||||
d.logger.Debug("Installing endpoints middleware...")
|
||||
endpoint := os.Getenv(config.EndPointEnv)
|
||||
signature := middleware.SignatureConf(config)
|
||||
router.Group(func(r chi.Router) {
|
||||
d.public = rpublic.NewRouter(d.logger, endpoint, db, rtdb, attempts, r, &config.Token, &signature, loginRateLimits, flows...)
|
||||
d.public = rpublic.Install(d.logger, endpoint, db, rtdb, attempts, r, token, &signature, loginRateLimits, flows...)
|
||||
})
|
||||
router.Group(func(r chi.Router) {
|
||||
d.protected = rauthorized.NewRouter(d.logger, endpoint, r, db, rtdb, &config.Token, &signature)
|
||||
d.protected = rauthorized.Install(d.logger, endpoint, r, db, rtdb, token, &signature)
|
||||
})
|
||||
|
||||
return d
|
||||
+1
-1
@@ -7,7 +7,7 @@ import (
|
||||
"net/http"
|
||||
|
||||
"git.sendico.io/tech/sxchnge/product/auth/config"
|
||||
"git.sendico.io/tech/sxchnge/product/auth/response"
|
||||
"git.sendico.io/tech/sxchnge/product/auth/http/v1/response"
|
||||
"git.sendico.io/tech/sxchnge/shared/logging"
|
||||
"github.com/go-chi/chi/v5"
|
||||
)
|
||||
+2
-2
@@ -4,7 +4,7 @@ import (
|
||||
"net/http"
|
||||
"path"
|
||||
|
||||
"git.sendico.io/tech/sxchnge/product/auth/response"
|
||||
"git.sendico.io/tech/sxchnge/product/auth/http/v1/response"
|
||||
api "git.sendico.io/tech/sxchnge/shared/http"
|
||||
"git.sendico.io/tech/sxchnge/shared/service"
|
||||
"github.com/go-chi/chi/v5"
|
||||
@@ -33,7 +33,7 @@ func (er *HttpEndpointRouter) chooseMethod(method api.HTTPMethod) RegistratorT {
|
||||
}
|
||||
|
||||
func (er *HttpEndpointRouter) endpoint(service mservice.Type, handler string) string {
|
||||
return path.Join(er.apiEndpoint, service, handler)
|
||||
return path.Join("/", er.apiEndpoint, service, handler)
|
||||
}
|
||||
|
||||
func (er *HttpEndpointRouter) InstallHandler(service mservice.Type, endpoint string, method api.HTTPMethod, handler sresponse.HandlerFunc) {
|
||||
+1
-1
@@ -5,8 +5,8 @@ import (
|
||||
sessionmodel "git.sendico.io/tech/sxchnge/product/sessions/model"
|
||||
"time"
|
||||
|
||||
"git.sendico.io/tech/sxchnge/product/auth/http/v1/response"
|
||||
emodel "git.sendico.io/tech/sxchnge/product/auth/model"
|
||||
"git.sendico.io/tech/sxchnge/product/auth/response"
|
||||
"github.com/go-chi/jwtauth/v5"
|
||||
)
|
||||
|
||||
+2
-2
@@ -9,9 +9,9 @@ import (
|
||||
"strings"
|
||||
|
||||
clientauth "git.sendico.io/tech/sxchnge/product/auth/client"
|
||||
"git.sendico.io/tech/sxchnge/product/auth/http/v1/request"
|
||||
"git.sendico.io/tech/sxchnge/product/auth/http/v1/response"
|
||||
"git.sendico.io/tech/sxchnge/product/auth/ipguard"
|
||||
"git.sendico.io/tech/sxchnge/product/auth/request"
|
||||
"git.sendico.io/tech/sxchnge/product/auth/response"
|
||||
customermodel "git.sendico.io/tech/sxchnge/product/customers/model"
|
||||
sessionmodel "git.sendico.io/tech/sxchnge/product/sessions/model"
|
||||
"git.sendico.io/tech/sxchnge/shared/errors"
|
||||
+1
-1
@@ -9,8 +9,8 @@ import (
|
||||
"time"
|
||||
|
||||
"git.sendico.io/tech/sxchnge/product/auth/config"
|
||||
"git.sendico.io/tech/sxchnge/product/auth/http/v1/request"
|
||||
"git.sendico.io/tech/sxchnge/product/auth/ipguard"
|
||||
"git.sendico.io/tech/sxchnge/product/auth/request"
|
||||
"git.sendico.io/tech/sxchnge/shared/errors"
|
||||
"git.sendico.io/tech/sxchnge/shared/http/response"
|
||||
"git.sendico.io/tech/sxchnge/shared/ratelimit/store"
|
||||
+1
-1
@@ -13,7 +13,7 @@ import (
|
||||
"time"
|
||||
|
||||
"git.sendico.io/tech/sxchnge/product/auth/config"
|
||||
"git.sendico.io/tech/sxchnge/product/auth/request"
|
||||
"git.sendico.io/tech/sxchnge/product/auth/http/v1/request"
|
||||
"git.sendico.io/tech/sxchnge/shared/errors"
|
||||
"git.sendico.io/tech/sxchnge/shared/service"
|
||||
"go.uber.org/zap"
|
||||
+2
-2
@@ -11,7 +11,7 @@ import (
|
||||
"testing"
|
||||
|
||||
"git.sendico.io/tech/sxchnge/product/auth/config"
|
||||
"git.sendico.io/tech/sxchnge/product/auth/request"
|
||||
"git.sendico.io/tech/sxchnge/product/auth/http/v1/request"
|
||||
"git.sendico.io/tech/sxchnge/shared/errors"
|
||||
"git.sendico.io/tech/sxchnge/shared/service"
|
||||
"github.com/go-chi/chi/v5"
|
||||
@@ -183,7 +183,7 @@ func TestAPILoginAllowsLoginAgainAfterRevokeSameSession(t *testing.T) {
|
||||
PrivateKey: []byte("test-secret"),
|
||||
}
|
||||
|
||||
router := NewRouter(
|
||||
router := Install(
|
||||
zap.NewNop(),
|
||||
"/",
|
||||
&testAccountDB{result: account},
|
||||
File renamed without changes.
+1
-1
@@ -4,7 +4,7 @@ import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
|
||||
"git.sendico.io/tech/sxchnge/product/auth/request"
|
||||
"git.sendico.io/tech/sxchnge/product/auth/http/v1/request"
|
||||
"git.sendico.io/tech/sxchnge/product/sessions/browser"
|
||||
"git.sendico.io/tech/sxchnge/product/sessions/metrics"
|
||||
"git.sendico.io/tech/sxchnge/shared/http/response"
|
||||
File renamed without changes.
+1
-1
@@ -4,7 +4,7 @@ import (
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
sresponse "git.sendico.io/tech/sxchnge/product/auth/response"
|
||||
sresponse "git.sendico.io/tech/sxchnge/product/auth/http/v1/response"
|
||||
"github.com/go-chi/chi/v5/middleware"
|
||||
"github.com/prometheus/client_golang/prometheus"
|
||||
"github.com/prometheus/client_golang/prometheus/promauto"
|
||||
+2
-2
@@ -6,8 +6,8 @@ import (
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"git.sendico.io/tech/sxchnge/product/auth/request"
|
||||
"git.sendico.io/tech/sxchnge/product/auth/response"
|
||||
"git.sendico.io/tech/sxchnge/product/auth/http/v1/request"
|
||||
"git.sendico.io/tech/sxchnge/product/auth/http/v1/response"
|
||||
"git.sendico.io/tech/sxchnge/product/sessions/browser"
|
||||
"git.sendico.io/tech/sxchnge/shared/http/response"
|
||||
"git.sendico.io/tech/sxchnge/shared/service"
|
||||
+1
-1
@@ -47,7 +47,7 @@ func newBrowserRefreshRouter(t *testing.T, expiresAt time.Time) (*PublicRouter,
|
||||
Algorithm: "HS256",
|
||||
PrivateKey: []byte("browser-refresh-test-secret"),
|
||||
}
|
||||
return NewRouter(
|
||||
return Install(
|
||||
zap.NewNop(),
|
||||
"/api/v1",
|
||||
&testAccountDB{result: account},
|
||||
+1
-1
@@ -6,7 +6,7 @@ import (
|
||||
sessionmodel "git.sendico.io/tech/sxchnge/product/sessions/model"
|
||||
"net/http"
|
||||
|
||||
"git.sendico.io/tech/sxchnge/product/auth/response"
|
||||
"git.sendico.io/tech/sxchnge/product/auth/http/v1/response"
|
||||
rtokens "git.sendico.io/tech/sxchnge/product/sessions/tokens"
|
||||
"git.sendico.io/tech/sxchnge/shared/http/response"
|
||||
"git.sendico.io/tech/sxchnge/shared/logging/fields"
|
||||
+1
-1
@@ -5,7 +5,7 @@ import (
|
||||
"errors"
|
||||
"net/http"
|
||||
|
||||
"git.sendico.io/tech/sxchnge/product/auth/request"
|
||||
"git.sendico.io/tech/sxchnge/product/auth/http/v1/request"
|
||||
"git.sendico.io/tech/sxchnge/shared/http/response"
|
||||
"git.sendico.io/tech/sxchnge/shared/service"
|
||||
"go.uber.org/zap"
|
||||
+3
-3
@@ -5,8 +5,8 @@ import (
|
||||
|
||||
"git.sendico.io/tech/sxchnge/product/auth/config"
|
||||
"git.sendico.io/tech/sxchnge/product/auth/flow"
|
||||
re "git.sendico.io/tech/sxchnge/product/auth/http/endpoint"
|
||||
"git.sendico.io/tech/sxchnge/product/auth/response"
|
||||
re "git.sendico.io/tech/sxchnge/product/auth/http/v1/endpoint"
|
||||
"git.sendico.io/tech/sxchnge/product/auth/http/v1/response"
|
||||
"git.sendico.io/tech/sxchnge/product/customers/store"
|
||||
"git.sendico.io/tech/sxchnge/product/sessions/store"
|
||||
"git.sendico.io/tech/sxchnge/product/twofactor"
|
||||
@@ -41,7 +41,7 @@ func (pr *PublicRouter) InstallHandler(service mservice.Type, endpoint string, m
|
||||
pr.imp.InstallHandler(service, endpoint, method, handler)
|
||||
}
|
||||
|
||||
func NewRouter(
|
||||
func Install(
|
||||
logger mlogger.Logger,
|
||||
apiEndpoint string,
|
||||
db account.DB,
|
||||
+2
-2
@@ -8,8 +8,8 @@ import (
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"git.sendico.io/tech/sxchnge/product/auth/request"
|
||||
"git.sendico.io/tech/sxchnge/product/auth/response"
|
||||
"git.sendico.io/tech/sxchnge/product/auth/http/v1/request"
|
||||
"git.sendico.io/tech/sxchnge/product/auth/http/v1/response"
|
||||
"git.sendico.io/tech/sxchnge/product/sessions/browser"
|
||||
"git.sendico.io/tech/sxchnge/shared/errors"
|
||||
"git.sendico.io/tech/sxchnge/shared/logging/fields"
|
||||
+1
-1
@@ -11,9 +11,9 @@ import (
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
sresponse "git.sendico.io/tech/sxchnge/product/auth/http/v1/response"
|
||||
"git.sendico.io/tech/sxchnge/product/auth/ipguard"
|
||||
authmodel "git.sendico.io/tech/sxchnge/product/auth/model"
|
||||
sresponse "git.sendico.io/tech/sxchnge/product/auth/response"
|
||||
customer "git.sendico.io/tech/sxchnge/product/customers/model"
|
||||
session "git.sendico.io/tech/sxchnge/product/sessions/model"
|
||||
factor "git.sendico.io/tech/sxchnge/product/twofactor/model"
|
||||
+1
-1
@@ -71,7 +71,7 @@ func TestSignupActivationAndLoginEmailFlow(t *testing.T) {
|
||||
factors, err := twofactor.New(stores.Accounts, stores.Verification, records, factory.TransactionFactory(), service.Secret, zap.NewNop(), delivery.Email{Sender: mail})
|
||||
require.NoError(t, err)
|
||||
routes := chi.NewRouter()
|
||||
NewRouter(zap.NewNop(), "/api/v1", stores.Accounts, sessions, nil, routes, &middleware.TokenConfig{Length: 32, Expiration: middleware.TokenExpiration{Account: 1, Refresh: 24}}, &middleware.Signature{Algorithm: "HS256", PrivateKey: service.Secret}, middleware.LoginRateLimits{}, Flows{Onboarding: service, Factors: factors})
|
||||
Install(zap.NewNop(), "/api/v1", stores.Accounts, sessions, nil, routes, &middleware.TokenConfig{Length: 32, Expiration: middleware.TokenExpiration{Account: 1, Refresh: 24}}, &middleware.Signature{Algorithm: "HS256", PrivateKey: service.Secret}, middleware.LoginRateLimits{}, Flows{Onboarding: service, Factors: factors})
|
||||
post := func(path string, body any) *httptest.ResponseRecorder {
|
||||
raw, err := json.Marshal(body)
|
||||
require.NoError(t, err)
|
||||
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
@@ -1,7 +1,7 @@
|
||||
package router
|
||||
|
||||
import (
|
||||
"git.sendico.io/tech/sxchnge/product/auth/response"
|
||||
"git.sendico.io/tech/sxchnge/product/auth/http/v1/response"
|
||||
api "git.sendico.io/tech/sxchnge/shared/http"
|
||||
"git.sendico.io/tech/sxchnge/shared/service"
|
||||
)
|
||||
@@ -9,6 +9,10 @@ database:
|
||||
uri_env: SXCHNGE_MONGO_URI
|
||||
database: sxchnge
|
||||
api:
|
||||
# Optional: omit for v1 at SXCHNGE_API_ENDPOINT; [] disables application APIs.
|
||||
# versions:
|
||||
# - version: v1
|
||||
# path: /api/v1
|
||||
middleware:
|
||||
domain_env: SXCHNGE_DOMAIN
|
||||
api_endpoint_env: SXCHNGE_API_ENDPOINT
|
||||
|
||||
@@ -243,6 +243,13 @@ func NewAccountService(
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &service{logger: logger, config: config, accountDB: stores.Accounts,
|
||||
vdb: stores.Verification, sessions: stores.RefreshTokens, transactions: dbf.TransactionFactory()}, nil
|
||||
return FromStores(l, config, stores, dbf.TransactionFactory())
|
||||
}
|
||||
|
||||
func FromStores(l mlogger.Logger, config *middleware.PasswordConfig, stores *authstorage.Stores, transactions transaction.Factory) (*service, error) {
|
||||
if config == nil || stores == nil || transactions == nil {
|
||||
return nil, merrors.InvalidArgument("customer dependencies are required")
|
||||
}
|
||||
return &service{logger: l.Named("account_service"), config: config, accountDB: stores.Accounts,
|
||||
vdb: stores.Verification, sessions: stores.RefreshTokens, transactions: transactions}, nil
|
||||
}
|
||||
@@ -2,11 +2,13 @@ package accountservice
|
||||
|
||||
import (
|
||||
"context"
|
||||
customermodel "git.sendico.io/tech/sxchnge/product/customers/model"
|
||||
|
||||
"git.sendico.io/tech/sxchnge/product/auth/config"
|
||||
authstorage "git.sendico.io/tech/sxchnge/product/auth/storage"
|
||||
accountserviceimp "git.sendico.io/tech/sxchnge/product/customers/internal"
|
||||
customermodel "git.sendico.io/tech/sxchnge/product/customers/model"
|
||||
"git.sendico.io/tech/sxchnge/shared/database"
|
||||
"git.sendico.io/tech/sxchnge/shared/database/transaction"
|
||||
"git.sendico.io/tech/sxchnge/shared/logging"
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
)
|
||||
@@ -61,3 +63,8 @@ func NewAccountService(
|
||||
) (AccountService, error) {
|
||||
return accountserviceimp.NewAccountService(logger, dbf, config, storageConfig)
|
||||
}
|
||||
|
||||
// FromStores composes a customer service using already initialized storage.
|
||||
func FromStores(logger mlogger.Logger, config *middleware.PasswordConfig, stores *authstorage.Stores, transactions transaction.Factory) (AccountService, error) {
|
||||
return accountserviceimp.FromStores(logger, config, stores, transactions)
|
||||
}
|
||||
@@ -1,9 +1,9 @@
|
||||
package browser
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"path"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -86,8 +86,10 @@ func ValidateCSRF(r *http.Request, allowedOrigins []string) error {
|
||||
|
||||
func cookiePath(r *http.Request) string {
|
||||
const accountsSegment = "/accounts"
|
||||
if apiEndpoint := strings.TrimSpace(os.Getenv("API_ENDPOINT")); apiEndpoint != "" {
|
||||
return path.Join("/", apiEndpoint, accountsSegment)
|
||||
if r != nil {
|
||||
if mount, ok := r.Context().Value(scopeKey{}).(string); ok {
|
||||
return path.Join(mount, accountsSegment)
|
||||
}
|
||||
}
|
||||
if r != nil && r.URL != nil {
|
||||
if index := strings.Index(r.URL.Path, accountsSegment); index >= 0 {
|
||||
@@ -119,3 +121,14 @@ func originAllowed(origin string, allowedOrigins []string) bool {
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
type scopeKey struct{}
|
||||
|
||||
// Scope binds cookie creation and deletion to a validated installed API mount.
|
||||
func Scope(mount string) func(http.Handler) http.Handler {
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
next.ServeHTTP(w, r.WithContext(context.WithValue(r.Context(), scopeKey{}, mount)))
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -31,13 +31,15 @@ func TestSetRefreshCookieUsesHardenedHostOnlyPolicy(t *testing.T) {
|
||||
require.Positive(t, cookie.MaxAge)
|
||||
}
|
||||
|
||||
func TestSetRefreshCookieUsesConfiguredAPIPathOutsideAccountsRouter(t *testing.T) {
|
||||
func TestSetRefreshCookieUsesInstalledScopeOutsideAccountsRouter(t *testing.T) {
|
||||
t.Setenv("API_ENDPOINT", "/gateway/v2/")
|
||||
req := httptest.NewRequest(http.MethodPost, "https://app.sendico.io/gateway/v2/verification/login", nil)
|
||||
req.Header.Set(TransportHeader, TransportCookie)
|
||||
recorder := httptest.NewRecorder()
|
||||
|
||||
SetRefreshCookie(recorder, req, "refresh", time.Now().Add(time.Hour))
|
||||
Scope("/gateway/v2")(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
SetRefreshCookie(w, r, "refresh", time.Now().Add(time.Hour))
|
||||
})).ServeHTTP(recorder, req)
|
||||
|
||||
require.Equal(t, "/gateway/v2/accounts", recorder.Result().Cookies()[0].Path)
|
||||
}
|
||||
|
||||
+1
-1
@@ -11,8 +11,8 @@ import (
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
sresponse "git.sendico.io/tech/sxchnge/product/auth/http/v1/response"
|
||||
"git.sendico.io/tech/sxchnge/product/auth/ipguard"
|
||||
sresponse "git.sendico.io/tech/sxchnge/product/auth/response"
|
||||
customer "git.sendico.io/tech/sxchnge/product/customers/model"
|
||||
"git.sendico.io/tech/sxchnge/product/twofactor"
|
||||
model "git.sendico.io/tech/sxchnge/product/twofactor/model"
|
||||
+1
-1
@@ -6,7 +6,7 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
sresponse "git.sendico.io/tech/sxchnge/product/auth/response"
|
||||
sresponse "git.sendico.io/tech/sxchnge/product/auth/http/v1/response"
|
||||
customer "git.sendico.io/tech/sxchnge/product/customers/model"
|
||||
api "git.sendico.io/tech/sxchnge/shared/http"
|
||||
mservice "git.sendico.io/tech/sxchnge/shared/service"
|
||||
@@ -0,0 +1,91 @@
|
||||
# Installing product HTTP API versions
|
||||
|
||||
The product executable selects supported implementations in
|
||||
`backend/product/app/profile/runtime.go`. Each entry maps a version name to an
|
||||
`api.Installer`; v1 composes the existing authentication and second-factor
|
||||
contracts. Adding an implementation requires a package and a registry entry,
|
||||
without changing runtime installation code.
|
||||
|
||||
Select installations in the product YAML:
|
||||
|
||||
```yaml
|
||||
api:
|
||||
versions:
|
||||
- version: v1
|
||||
path: /api/v1
|
||||
# Existing middleware, storage and notification configuration follows.
|
||||
```
|
||||
|
||||
Only v1 ships today. Once a v2 implementation is registered, a second entry with
|
||||
`version: v2` and `path: /api/v2` serves both versions in the same process. Each
|
||||
version can be selected once. Unknown/unavailable versions, duplicate versions,
|
||||
overlapping mounts, noncanonical paths and mounts occupying operational endpoints
|
||||
fail startup. Paths must be absolute, non-root, without trailing slashes, URL
|
||||
escapes, queries, fragments or router pattern syntax. Mounts use ASCII letters,
|
||||
digits, slashes, hyphens, underscores, periods and tildes.
|
||||
|
||||
Omitting `versions` retains compatibility: v1 mounts at the path in the environment
|
||||
variable named by `api.middleware.api_endpoint_env` (normally
|
||||
`SXCHNGE_API_ENDPOINT=/api/v1`). An explicit `versions: []` installs no application
|
||||
routes; process infrastructure and operational endpoints remain available.
|
||||
|
||||
The endpoint environment variable continues to locate the process health and
|
||||
metrics compatibility endpoints, including when explicit application mounts are
|
||||
used. With `/api/v1`, these are `/api/v1/health`, `/api/v1/metrics` and `/metrics`.
|
||||
Keep this value unchanged when adding a version. Health is outside application
|
||||
middleware; the existing HTTP collector excludes operational traffic. These
|
||||
operational endpoints are reserved and are never version-owned contracts.
|
||||
|
||||
## Ownership and lifecycle
|
||||
|
||||
Runtime bootstrap opens shared stores once and injects those stores into the
|
||||
customer service, auth flow and second-factor service. Notification connections
|
||||
are opened once. Every installer receives the same typed dependencies and its
|
||||
own router, validated mount, URL provider and logger. Installers register routes
|
||||
relative to their mount and do not open or close process resources.
|
||||
|
||||
Auth v1 handlers, request DTOs and response mappers live under
|
||||
`backend/product/auth/http/v1`; factor transport lives under
|
||||
`backend/product/twofactor/http/v1`. Models, services and storage remain in their
|
||||
owning features. A future version owns its wire contract and reuses domain
|
||||
services, so changing its DTOs does not silently change v1. The generic runtime
|
||||
and HTTP middleware do not import concrete API versions.
|
||||
|
||||
All version routers are built before any application router is attached. An
|
||||
installer error aborts startup, preserves the cause and closes shared resources.
|
||||
Bootstrap must either return a cleanup callback for resources it opened or close
|
||||
them itself before returning an error; it must not do both. The built-in bootstrap
|
||||
cleans its partial initialization internally. Runtime invokes an acquired cleanup
|
||||
callback once, including on installation failure or repeated/concurrent shutdown.
|
||||
The server retains ownership of the shared database factory.
|
||||
|
||||
The removed generic service registry and untyped runtime component lookup have no
|
||||
product consumers. The typed dependencies and installer registry replace them.
|
||||
This changes internal Go composition APIs; public v1 JSON and HTTP paths remain
|
||||
compatible. No database migration is required.
|
||||
|
||||
## Cookies and links
|
||||
|
||||
Runtime binds browser cookie scope to the installed mount using request context.
|
||||
Set and clear operations use the same `<mount>/accounts` path. The existing v1
|
||||
path remains `/api/v1/accounts`; `/api/v2` would use `/api/v2/accounts`. Cookie
|
||||
name, host-only policy, Secure, HttpOnly and SameSite settings are unchanged.
|
||||
Changing an existing mount requires clients to authenticate at the new path;
|
||||
cookies at the old path do not migrate automatically. Versions share domain
|
||||
session state, so account-wide revocation continues to apply across versions.
|
||||
|
||||
`API_ENDPOINT` no longer overrides cookies globally. Direct standalone handler
|
||||
calls retain their request-path fallback; installed APIs always use explicit
|
||||
scope. Installers generate API links through `scope.URLs`, which combines the
|
||||
configured origin and that installation's mount. The origin must use HTTP or
|
||||
HTTPS with a hostname and optional port. Configured notification activation URLs
|
||||
remain frontend links and are not rewritten as API links.
|
||||
|
||||
## Validation
|
||||
|
||||
Runtime tests exercise independently behaving versions, shared dependency identity,
|
||||
disabled routes, invalid mounts, scoped links, cookie set/delete behavior, failed
|
||||
installation cleanup and repeated shutdown. The existing Mongo-backed startup
|
||||
test covers the default v1 installation on an empty database. Existing auth and
|
||||
factor tests moved with their transports and retain their contract assertions.
|
||||
Run `make test` and `make lint-backend` from the repository root.
|
||||
Reference in new issue
Block a user