13 Commits
Author SHA1 Message Date
tech 30f0455122 Merge pull request 'Run local infrastructure and product images with Vault-managed secrets' (#43) from dev-env-42 into main
Reviewed-on: #43
2026-09-13 08:35:01 +00:00
tech 9f9525df56 Merge pull request 'Install product API versions independently with shared services' (#45) from product-api-44 into dev-env-42
Reviewed-on: #45
2026-09-13 08:34:33 +00:00
Stephan D 259f84e0a1 Record API version delivery and validation 2026-09-13 10:27:08 +02:00
Stephan D d04c8b12ab Make product API versions independently installable 2026-09-13 10:26:06 +02:00
Stephan D ce6583f454 Show service links and next steps after local startup 2026-09-13 08:40:24 +02:00
Stephan D caad7a3d5b Remove internal notification caller tokens 2026-09-12 23:59:30 +02:00
Stephan D 1fe173397f Keep email runtime configuration out of Vault 2026-09-12 23:45:58 +02:00
Stephan D 9d57a65010 Group Mongo bootstrap secrets under infrastructure ownership 2026-09-12 23:36:47 +02:00
Stephan D 9dbf0bd80c Organize local infrastructure and Vault secrets by service ownership 2026-09-12 23:30:18 +02:00
Stephan D 1b8800ee73 Replace Python development tooling with Make and containerized shell 2026-09-12 23:14:34 +02:00
Stephan D 23f7b78c3b Keep Mongo operations online across Vault credential rotation 2026-09-12 22:19:36 +02:00
Stephan D 80d6917aa7 Record local environment delivery PR (#42) 2026-09-12 21:05:39 +02:00
Stephan D 184a3be413 Build local product stack with Vault-managed secrets (#42) 2026-09-12 21:04:42 +02:00
141 changed files with 3543 additions and 705 deletions

No files matched your search

+16
View File
@@ -0,0 +1,16 @@
.git
.local
**/.dart_tool
**/build
**/.env*
**/node_modules
**/*.pb.go
frontend/app/lib/generated
**/.DS_Store
dist
website
**/config.yml
**/tls
**/*.pem
**/*.key
**/__pycache__
+6
View File
@@ -34,3 +34,9 @@ pnpm-debug.log*
/backend/notifications/interface/notifications/**/*.pb.go /backend/notifications/interface/notifications/**/*.pb.go
/backend/notifications/config.yml /backend/notifications/config.yml
/backend/notifications/notifications /backend/notifications/notifications
# Local container pins, Vault bootstrap material and service identities
/.local/
# Python bytecode
__pycache__/
+31
View File
@@ -22,6 +22,22 @@
generated outputs. Document the generation workflow so a clean checkout can generated outputs. Document the generation workflow so a clean checkout can
build without committed generated code. build without committed generated code.
## Local development tooling
Use Make and shell for local development orchestration. Do not introduce Python
scripts or a Python runtime dependency. Keep bootstrap tools inside Docker so
the host needs only Make, a POSIX shell and Docker with Compose/BuildKit.
## Container versions
Use only the latest stable upstream releases for infrastructure, product base
images, build stages and disposable test containers. Exclude prereleases and
vendor mainline channels when a separate stable channel exists. Resolve stable
versions when building, pull fresh manifests, and pin the resolved image digests
for that local build. Fail when stable release resolution fails; never silently
fall back to an older cached version. Keep the reproducible resolver tracked and
its generated local pins ignored by Git.
## Data and state architecture ## Data and state architecture
Preserve explicit **DTO → mapper → service → data model → provider → UI** Preserve explicit **DTO → mapper → service → data model → provider → UI**
@@ -153,3 +169,18 @@ an uncommitted working tree. Preserve unrelated changes and avoid mixing them
into task commits. Record scope, validation, limitations, and PR links in the into task commits. Record scope, validation, limitations, and PR links in the
task and durable repository documentation. Merge only when authorized by the task and durable repository documentation. Merge only when authorized by the
user. If work is delivered as stacked PRs, document dependencies and merge order. user. If work is delivered as stacked PRs, document dependencies and merge order.
## Local infrastructure ownership
Keep infrastructure Dockerfiles under `infra/images/` and local Compose, service
configuration and shell tooling under `infra/local/`. Keep product source under
`backend/` and `frontend/`. Organize Vault KV paths by owning service under
`services/<service>/<purpose>`, infrastructure under `infrastructure/<component>/<purpose>`,
and bootstrap administration under `infrastructure/<component>/bootstrap`.
Do not add BFF-to-notifications caller authentication on the trusted private network.
Preserve NATS authentication.
Use Caddy for local browser HTTPS and certificate management. Treat the private
local Docker network as trusted; do not add internal gRPC TLS or Vault-managed
local TLS certificates without an explicit requirement.
Store only secret values in Vault. Driver selection, sender identities, endpoints
and other non-secret runtime settings belong in service configuration files.
+47 -4
View File
@@ -17,10 +17,12 @@ test-frontend:
.PHONY: test-backend lint-backend run-backend .PHONY: test-backend lint-backend run-backend
test-backend: generate-notifications test-backend: generate-notifications
cd backend/shared && go test -race ./... docker pull mongo:latest
cd backend/notifications/interface && go test -race ./... docker pull nats:latest
cd backend/notifications && go test -race ./... cd backend/shared && go test -race -count=1 ./...
cd backend/product && go test -race ./... cd backend/notifications/interface && go test -race -count=1 ./...
cd backend/notifications && go test -race -count=1 ./...
cd backend/product && go test -race -count=1 ./...
lint-backend: generate-notifications lint-backend: generate-notifications
cd backend/product && go test ./architecture cd backend/product && go test ./architecture
@@ -49,3 +51,44 @@ package-notifications-interface: generate-notifications
mkdir -p dist mkdir -p dist
tar -czf dist/notifications-proto-v1.tar.gz -C backend/notifications/interface proto README.md tar -czf dist/notifications-proto-v1.tar.gz -C backend/notifications/interface proto README.md
tar -czf dist/notifications-go-v1.tar.gz -C backend/notifications/interface go.mod go.sum notifications README.md tar -czf dist/notifications-go-v1.tar.gz -C backend/notifications/interface go.mod go.sum notifications README.md
# Local product stack. Releases are resolved afresh, then pinned by digest.
COMPOSE = docker compose --project-directory . -f infra/local/compose.yml --env-file .local/dev/images.env
.PHONY: build up down logs ps test-dev
build:
sh infra/local/tools.sh build
sh infra/local/tools.sh images
$(COMPOSE) pull vault mongo
$(COMPOSE) build --pull
up:
sh infra/local/tools.sh up
down:
$(COMPOSE) down
logs:
$(COMPOSE) logs --tail=100 -f
ps:
$(COMPOSE) ps
test-dev:
sh infra/local/tools.sh test
sh infra/local/tools.sh verify
.PHONY: test-dev-infra
test-dev-infra:
sh infra/local/tools.sh test
sh infra/local/tools.sh verify-infra
.PHONY: test-dev-rotation
test-dev-rotation:
sh infra/local/tools.sh rotation
.PHONY: email test-dev-tooling
email:
sh infra/local/tools.sh email
test-dev-tooling:
sh infra/local/tools.sh test
+20 -1
View File
@@ -32,6 +32,24 @@ Each application owns its dependencies and build configuration. Shared Dart and
Flutter packages can be reused by a future mobile app; mobile runners are not Flutter packages can be reused by a future mobile app; mobile runners are not
currently included. Local `.vscode/` configuration is ignored. currently included. Local `.vscode/` configuration is ignored.
## Local Docker development
```sh
make build && make up
```
This builds the product images and starts an isolated Vault, authenticated MongoDB
replica set and NATS JetStream. First startup intentionally stops after bootstrap
until you set `api_key` in Vault's `kv/services/notifications/email/resend` secret
and the verified sender in `infra/local/notifications/runtime.yml`.
Then rerun `make up` to start notifications, BFF and the frontend at
[https://localhost:5126](https://localhost:5126).
All image inputs resolve to current stable releases and are pinned by digest for
the build. Services fetch secrets from Vault; Mongo application credentials come
from Vault's database plugin. See the [local development guide](docs/local-development.md)
for Vault provisioning, local HTTPS trust, lifecycle commands and verification.
## Run the consumer app ## Run the consumer app
Requires Flutter **3.47.0 or later** with Dart **3.13.0 or later** (below Dart 4), matching Requires Flutter **3.47.0 or later** with Dart **3.13.0 or later** (below Dart 4), matching
@@ -54,7 +72,8 @@ Output is `frontend/app/build/web/`. To inspect that build locally, from the
repository root: repository root:
```sh ```sh
python3 -m http.server 5123 --bind 127.0.0.1 --directory frontend/app/build/web docker run --rm --pull always -p 127.0.0.1:5123:80 \
-v "$PWD/frontend/app/build/web:/srv:ro" caddy:alpine caddy file-server --root /srv --listen :80
``` ```
Open [the local app](http://127.0.0.1:5123/). The language selector supports EN/RU Open [the local app](http://127.0.0.1:5123/). The language selector supports EN/RU
+4 -4
View File
@@ -22,12 +22,12 @@ subject, text and HTML, bounded timeouts and safe error classification.
Requires Go 1.27, protoc, an SXCHNGE MongoDB database and a NATS server with Requires Go 1.27, protoc, an SXCHNGE MongoDB database and a NATS server with
JetStream enabled. Copy `config.example.yml` to an untracked `config.yml` here. JetStream enabled. Copy `config.example.yml` to an untracked `config.yml` here.
Set `SXCHNGE_MONGO_URI`, `SXCHNGE_NATS_URL`, a unique token of at least 32 bytes for Set `SXCHNGE_MONGO_URI`, `SXCHNGE_NATS_URL` and the selected email provider's API key environment
each configured caller, and the selected email provider's API key environment variable. Configure a verified sender address and template
variable. Configure a verified sender address, TLS certificate/key and template
catalog. Paths are relative to the process working directory. catalog. Paths are relative to the process working directory.
From the root, run `make run-notifications`. The gRPC listener always uses TLS. From the root, run `make run-notifications`. The gRPC listener uses plaintext on the private local network. Configure both
`tls_cert` and `tls_key` only when TLS is explicitly required.
Use a private authenticated NATS endpoint; `tls://` URLs and credentials in the URL Use a private authenticated NATS endpoint; `tls://` URLs and credentials in the URL
are supported by the shared driver. Mongo and messaging both use shared are supported by the shared driver. Mongo and messaging both use shared
`DriverConfig` settings; email settings reject unknown keys. `DriverConfig` settings; email settings reject unknown keys.
+4 -11
View File
@@ -4,7 +4,6 @@ import (
"bytes" "bytes"
"io" "io"
"os" "os"
"strings"
"git.sendico.io/tech/sxchnge/notifications/bus" "git.sendico.io/tech/sxchnge/notifications/bus"
"git.sendico.io/tech/sxchnge/notifications/email" "git.sendico.io/tech/sxchnge/notifications/email"
@@ -22,7 +21,6 @@ type Config struct {
TLSKey string `yaml:"tls_key"` TLSKey string `yaml:"tls_key"`
Database db.Config `yaml:"database"` Database db.Config `yaml:"database"`
Templates string `yaml:"templates"` Templates string `yaml:"templates"`
Callers map[string]string `yaml:"callers"` // caller -> token environment variable
Email email.Config `yaml:"email"` Email email.Config `yaml:"email"`
} }
@@ -40,19 +38,14 @@ func Load(path string) (Config, error) {
if err = decoder.Decode(new(any)); err != io.EOF { if err = decoder.Decode(new(any)); err != io.EOF {
return c, merrors.InvalidArgument("unexpected configuration content") return c, merrors.InvalidArgument("unexpected configuration content")
} }
if c.Listen == "" || c.TLSCert == "" || c.TLSKey == "" || c.Database.Driver != db.Mongo || c.Messaging.Driver != messaging.BBNats || c.Templates == "" || len(c.Callers) == 0 { if c.Listen == "" || c.Database.Driver != db.Mongo || c.Messaging.Driver != messaging.BBNats || c.Templates == "" {
return c, merrors.InvalidArgument("missing notification configuration") return c, merrors.InvalidArgument("missing notification configuration")
} }
if (c.TLSCert == "") != (c.TLSKey == "") {
return c, merrors.InvalidArgument("both TLS certificate and key must be configured together")
}
if err = c.Queue.Validate(); err != nil { if err = c.Queue.Validate(); err != nil {
return c, err return c, err
} }
seen := map[string]bool{}
for caller, env := range c.Callers {
token := os.Getenv(env)
if strings.TrimSpace(caller) == "" || len(strings.TrimSpace(token)) < 32 || seen[token] {
return c, merrors.InvalidArgument("caller credentials must be unique and at least 32 bytes")
}
seen[token] = true
}
return c, nil return c, nil
} }
+27 -7
View File
@@ -3,12 +3,10 @@ package app
import ( import (
"os" "os"
"path/filepath" "path/filepath"
"strings"
"testing" "testing"
) )
func TestExampleConfigAndSecrets(t *testing.T) { func TestExampleConfigAndSecrets(t *testing.T) {
t.Setenv("SXCHNGE_NOTIFICATIONS_PRODUCT_TOKEN", strings.Repeat("a", 32))
c, err := Load("../config.example.yml") c, err := Load("../config.example.yml")
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
@@ -16,11 +14,6 @@ func TestExampleConfigAndSecrets(t *testing.T) {
if c.Email.Driver != "resend" || c.Messaging.Driver != "NATS" || c.Database.Driver != "mongodb" { if c.Email.Driver != "resend" || c.Messaging.Driver != "NATS" || c.Database.Driver != "mongodb" {
t.Fatal("wrong driver configuration") t.Fatal("wrong driver configuration")
} }
t.Setenv("SXCHNGE_NOTIFICATIONS_PRODUCT_TOKEN", "")
if _, err = Load("../config.example.yml"); err == nil {
t.Fatal("missing credentials accepted")
}
t.Setenv("SXCHNGE_NOTIFICATIONS_PRODUCT_TOKEN", strings.Repeat("a", 32))
data, err := os.ReadFile("../config.example.yml") data, err := os.ReadFile("../config.example.yml")
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
@@ -33,3 +26,30 @@ func TestExampleConfigAndSecrets(t *testing.T) {
t.Fatal("extra YAML document accepted") t.Fatal("extra YAML document accepted")
} }
} }
func TestPlaintextConfigAndIncompleteTLS(t *testing.T) {
c, err := Load("../config.example.yml")
if err != nil {
t.Fatal(err)
}
options, err := serverOptions(c)
if err != nil || len(options) != 0 {
t.Fatal("plain gRPC must not require TLS material")
}
data, err := os.ReadFile("../config.example.yml")
if err != nil {
t.Fatal(err)
}
for _, partial := range []string{"\ntls_cert: missing.crt\n", "\ntls_key: missing.key\n"} {
path := filepath.Join(t.TempDir(), "config.yml")
if err := os.WriteFile(path, append(data, []byte(partial)...), 0600); err != nil {
t.Fatal(err)
}
if _, err := Load(path); err == nil {
t.Fatal("incomplete TLS configuration accepted")
}
}
if _, err := serverOptions(Config{TLSCert: "missing.crt", TLSKey: "missing.key"}); err == nil {
t.Fatal("bad explicit certificate accepted")
}
}
+15 -8
View File
@@ -4,7 +4,6 @@ import (
"context" "context"
"crypto/tls" "crypto/tls"
"net" "net"
"os"
"sync" "sync"
"time" "time"
@@ -33,9 +32,9 @@ func Run(ctx context.Context, c Config, logger mlogger.Logger) error {
if err != nil { if err != nil {
return err return err
} }
cert, err := tls.LoadX509KeyPair(c.TLSCert, c.TLSKey) options, err := serverOptions(c)
if err != nil { if err != nil {
return merrors.OpaqueWrap(merrors.ErrInvalidArg, err, "invalid gRPC TLS certificate") return err
} }
client, err := db.ConnectMongo(logger, &c.Database) client, err := db.ConnectMongo(logger, &c.Database)
if err != nil { if err != nil {
@@ -63,11 +62,7 @@ func Run(ctx context.Context, c Config, logger mlogger.Logger) error {
return err return err
} }
defer broker.Close() defer broker.Close()
tokens := map[string]string{} server := grpc.NewServer(append(options, grpc.MaxRecvMsgSize(128<<10), grpc.MaxConcurrentStreams(64))...)
for caller, env := range c.Callers {
tokens[caller] = os.Getenv(env)
}
server := grpc.NewServer(grpc.Creds(credentials.NewTLS(&tls.Config{Certificates: []tls.Certificate{cert}, MinVersion: tls.VersionTLS12})), grpc.UnaryInterceptor(transport.Authenticate(tokens)), grpc.MaxRecvMsgSize(128<<10), grpc.MaxConcurrentStreams(64))
pb.RegisterNotificationsServiceServer(server, transport.New(svc)) pb.RegisterNotificationsServiceServer(server, transport.New(svc))
listener, err := net.Listen("tcp", c.Listen) listener, err := net.Listen("tcp", c.Listen)
if err != nil { if err != nil {
@@ -103,3 +98,15 @@ func Run(ctx context.Context, c Config, logger mlogger.Logger) error {
logger.Info("Notifications service stopped") logger.Info("Notifications service stopped")
return nil return nil
} }
// An omitted certificate pair uses plaintext gRPC on the private service network.
func serverOptions(c Config) ([]grpc.ServerOption, error) {
if c.TLSCert == "" && c.TLSKey == "" {
return nil, nil
}
cert, err := tls.LoadX509KeyPair(c.TLSCert, c.TLSKey)
if err != nil {
return nil, merrors.OpaqueWrap(merrors.ErrInvalidArg, err, "invalid gRPC TLS certificate")
}
return []grpc.ServerOption{grpc.Creds(credentials.NewTLS(&tls.Config{Certificates: []tls.Certificate{cert}, MinVersion: tls.VersionTLS12}))}, nil
}
-4
View File
@@ -1,6 +1,4 @@
listen: "127.0.0.1:8083" listen: "127.0.0.1:8083"
tls_cert: "server.crt"
tls_key: "server.key"
database: database:
driver: mongodb driver: mongodb
settings: settings:
@@ -14,8 +12,6 @@ messaging:
max_reconnects: -1 max_reconnects: -1
reconnect_wait: 2 reconnect_wait: 2
templates: "templates.example.json" templates: "templates.example.json"
callers:
product: "SXCHNGE_NOTIFICATIONS_PRODUCT_TOKEN"
email: email:
driver: resend # sendgrid or resend driver: resend # sendgrid or resend
settings: settings:
+3 -3
View File
@@ -20,14 +20,14 @@ Internal Go consumers use the module
`git.sendico.io/tech/sxchnge/notifications/interface/notifications/v1`. Within this `git.sendico.io/tech/sxchnge/notifications/interface/notifications/v1`. Within this
checkout, use a local replace to this directory and generate before building. checkout, use a local replace to this directory and generate before building.
Calls require TLS and `authorization: Bearer <service-token>` metadata. The service Calls use the trusted private network without caller authentication. The service
maps tokens to configured caller identities. Callers authorize user/destination uses the existing product storage namespace. Callers authorize user/destination
ownership and preferences before sending; this contract is not a public user API. ownership and preferences before sending; this contract is not a public user API.
Parameters are string values keyed by template parameter name. The entire request Parameters are string values keyed by template parameter name. The entire request
is validated before persistence. An identical idempotency key/request returns the is validated before persistence. An identical idempotency key/request returns the
original resource, even after completion or expiry. A changed request conflicts. original resource, even after completion or expiry. A changed request conflicts.
Keys are scoped to the authenticated caller and retained at least seven days; Keys are scoped to the product namespace and retained at least seven days;
Mongo TTL deletion is asynchronous. Expiry defaults to 24 hours and cannot exceed Mongo TTL deletion is asynchronous. Expiry defaults to 24 hours and cannot exceed
24 hours. One email target is accepted; other declared channels return UNIMPLEMENTED. 24 hours. One email target is accepted; other declared channels return UNIMPLEMENTED.
@@ -3,13 +3,13 @@ package sxchnge.notifications.v1;
option go_package = "git.sendico.io/tech/sxchnge/notifications/interface/notifications/v1;notificationsv1"; option go_package = "git.sendico.io/tech/sxchnge/notifications/interface/notifications/v1;notificationsv1";
import "google/protobuf/timestamp.proto"; import "google/protobuf/timestamp.proto";
// Internal service: authenticate callers over TLS. Send durably queues work. // Internal service on the trusted private network; no caller authentication. Send durably queues work.
service NotificationsService { service NotificationsService {
rpc Send(SendRequest) returns (SendResponse); rpc Send(SendRequest) returns (SendResponse);
rpc Get(GetRequest) returns (GetResponse); rpc Get(GetRequest) returns (GetResponse);
} }
message SendRequest { message SendRequest {
string idempotency_key = 1; // Required; scoped to authenticated caller, retained seven days. string idempotency_key = 1; // Required; scoped to the product namespace, retained seven days.
string user_id = 2; // Caller resolves and authorizes recipient ownership. string user_id = 2; // Caller resolves and authorizes recipient ownership.
string template_id = 3; string template_id = 3;
string template_version = 4; string template_version = 4;
+2 -7
View File
@@ -1,7 +1,6 @@
package service_test package service_test
import ( import (
"context"
"crypto/ecdsa" "crypto/ecdsa"
"crypto/elliptic" "crypto/elliptic"
"crypto/rand" "crypto/rand"
@@ -17,10 +16,9 @@ import (
"git.sendico.io/tech/sxchnge/notifications/transport" "git.sendico.io/tech/sxchnge/notifications/transport"
"google.golang.org/grpc" "google.golang.org/grpc"
"google.golang.org/grpc/credentials" "google.golang.org/grpc/credentials"
"google.golang.org/grpc/metadata"
) )
// Exercise authenticated TLS transport against the real service and storage. // Exercise optional TLS transport against the real service and storage.
func grpcClient(t *testing.T, svc *service.Service) pb.NotificationsServiceClient { func grpcClient(t *testing.T, svc *service.Service) pb.NotificationsServiceClient {
t.Helper() t.Helper()
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
@@ -42,7 +40,7 @@ func grpcClient(t *testing.T, svc *service.Service) pb.NotificationsServiceClien
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
server := grpc.NewServer(grpc.Creds(credentials.NewTLS(&tls.Config{MinVersion: tls.VersionTLS12, Certificates: []tls.Certificate{{Certificate: [][]byte{der}, PrivateKey: key}}})), grpc.UnaryInterceptor(transport.Authenticate(map[string]string{"product": "product-test-token", "other": "other-test-token"}))) server := grpc.NewServer(grpc.Creds(credentials.NewTLS(&tls.Config{MinVersion: tls.VersionTLS12, Certificates: []tls.Certificate{{Certificate: [][]byte{der}, PrivateKey: key}}})))
pb.RegisterNotificationsServiceServer(server, transport.New(svc)) pb.RegisterNotificationsServiceServer(server, transport.New(svc))
go func() { _ = server.Serve(listener) }() go func() { _ = server.Serve(listener) }()
t.Cleanup(server.Stop) t.Cleanup(server.Stop)
@@ -53,6 +51,3 @@ func grpcClient(t *testing.T, svc *service.Service) pb.NotificationsServiceClien
t.Cleanup(func() { _ = conn.Close() }) t.Cleanup(func() { _ = conn.Close() })
return pb.NewNotificationsServiceClient(conn) return pb.NewNotificationsServiceClient(conn)
} }
func callerContext(ctx context.Context, caller string) context.Context {
return metadata.NewOutgoingContext(ctx, metadata.Pairs("authorization", "Bearer "+caller+"-test-token"))
}
@@ -26,8 +26,6 @@ import (
"github.com/testcontainers/testcontainers-go/modules/mongodb" "github.com/testcontainers/testcontainers-go/modules/mongodb"
"github.com/testcontainers/testcontainers-go/wait" "github.com/testcontainers/testcontainers-go/wait"
"go.uber.org/zap" "go.uber.org/zap"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
) )
type sender struct { type sender struct {
@@ -50,7 +48,7 @@ func (s *sender) Send(_ context.Context, id string, message model.Email) email.O
func TestDurableDelivery(t *testing.T) { func TestDurableDelivery(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
defer cancel() defer cancel()
container, err := mongodb.Run(ctx, "mongo:8.0") container, err := mongodb.Run(ctx, "mongo:latest")
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
@@ -192,7 +190,7 @@ func TestDurableDelivery(t *testing.T) {
} }
} }
// Verify the real JetStream publisher/consumer path with an isolated broker. // Verify the real JetStream publisher/consumer path with an isolated broker.
ncContainer, err := testcontainers.GenericContainer(ctx, testcontainers.GenericContainerRequest{ContainerRequest: testcontainers.ContainerRequest{Image: "nats:2.11-alpine", ExposedPorts: []string{"4222/tcp"}, Cmd: []string{"-js"}, WaitingFor: wait.ForListeningPort("4222/tcp")}, Started: true}) ncContainer, err := testcontainers.GenericContainer(ctx, testcontainers.GenericContainerRequest{ContainerRequest: testcontainers.ContainerRequest{Image: "nats:latest", ExposedPorts: []string{"4222/tcp"}, Cmd: []string{"-js"}, WaitingFor: wait.ForListeningPort("4222/tcp")}, Started: true})
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
@@ -233,11 +231,8 @@ func TestDurableDelivery(t *testing.T) {
} }
client := grpcClient(t, svc) client := grpcClient(t, svc)
rpcCtx := callerContext(ctx, "product") rpcCtx := ctx
input := &pb.SendRequest{IdempotencyKey: "jetstream", UserId: "u", TemplateId: "test", TemplateVersion: "1", Parameters: map[string]string{"name": "End to end & escaped"}, Targets: []*pb.Target{{Destination: &pb.Target_Email{Email: &pb.EmailTarget{Address: "user@example.com"}}}}} input := &pb.SendRequest{IdempotencyKey: "jetstream", UserId: "u", TemplateId: "test", TemplateVersion: "1", Parameters: map[string]string{"name": "End to end & escaped"}, Targets: []*pb.Target{{Destination: &pb.Target_Email{Email: &pb.EmailTarget{Address: "user@example.com"}}}}}
if _, e := client.Send(ctx, input); status.Code(e) != codes.Unauthenticated {
t.Fatal("unauthenticated request accepted", e)
}
sent, err := client.Send(rpcCtx, input) sent, err := client.Send(rpcCtx, input)
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
@@ -246,8 +241,8 @@ func TestDurableDelivery(t *testing.T) {
if err != nil || replay.Notification.Id != sent.Notification.Id { if err != nil || replay.Notification.Id != sent.Notification.Id {
t.Fatal("RPC idempotency failed", err) t.Fatal("RPC idempotency failed", err)
} }
if _, e := client.Get(callerContext(ctx, "other"), &pb.GetRequest{Id: sent.Notification.Id}); status.Code(e) != codes.NotFound { if _, e := client.Get(ctx, &pb.GetRequest{Id: sent.Notification.Id}); e != nil {
t.Fatal("RPC caller isolation failed", e) t.Fatal("RPC retrieval without credentials failed", e)
} }
if err = broker.PublishDue(ctx); err != nil { if err = broker.PublishDue(ctx); err != nil {
t.Fatal(err) t.Fatal(err)
+2 -2
View File
@@ -9,11 +9,11 @@ import (
"git.sendico.io/tech/sxchnge/shared/database/repository" "git.sendico.io/tech/sxchnge/shared/database/repository"
"git.sendico.io/tech/sxchnge/shared/database/repository/builder" "git.sendico.io/tech/sxchnge/shared/database/repository/builder"
ri "git.sendico.io/tech/sxchnge/shared/database/repository/index" ri "git.sendico.io/tech/sxchnge/shared/database/repository/index"
"git.sendico.io/tech/sxchnge/shared/database/source"
"git.sendico.io/tech/sxchnge/shared/database/template" "git.sendico.io/tech/sxchnge/shared/database/template"
merrors "git.sendico.io/tech/sxchnge/shared/errors" merrors "git.sendico.io/tech/sxchnge/shared/errors"
mlogger "git.sendico.io/tech/sxchnge/shared/logging" mlogger "git.sendico.io/tech/sxchnge/shared/logging"
"go.mongodb.org/mongo-driver/v2/bson" "go.mongodb.org/mongo-driver/v2/bson"
"go.mongodb.org/mongo-driver/v2/mongo"
"go.uber.org/zap" "go.uber.org/zap"
) )
@@ -21,7 +21,7 @@ type Store struct {
template.DBImp[*model.Notification] template.DBImp[*model.Notification]
} }
func New(logger mlogger.Logger, db *mongo.Database) (*Store, error) { func New(logger mlogger.Logger, db source.Source) (*Store, error) {
s := &Store{*template.Create[*model.Notification](mlogger.Named(logger, "store"), "notifications", db)} s := &Store{*template.Create[*model.Notification](mlogger.Named(logger, "store"), "notifications", db)}
ttl := int32(0) ttl := int32(0)
for _, index := range []*ri.Definition{ for _, index := range []*ri.Definition{
+3 -35
View File
@@ -2,21 +2,15 @@ package transport
import ( import (
"context" "context"
"crypto/sha256"
"crypto/subtle"
"errors" "errors"
"strings"
pb "git.sendico.io/tech/sxchnge/notifications/interface/notifications/v1" pb "git.sendico.io/tech/sxchnge/notifications/interface/notifications/v1"
"git.sendico.io/tech/sxchnge/notifications/model" "git.sendico.io/tech/sxchnge/notifications/model"
merrors "git.sendico.io/tech/sxchnge/shared/errors" merrors "git.sendico.io/tech/sxchnge/shared/errors"
"google.golang.org/grpc"
"google.golang.org/grpc/codes" "google.golang.org/grpc/codes"
"google.golang.org/grpc/metadata"
"google.golang.org/grpc/status" "google.golang.org/grpc/status"
) )
type callerKey struct{}
type API interface { type API interface {
Send(context.Context, string, model.Request) (*model.Notification, error) Send(context.Context, string, model.Request) (*model.Notification, error)
Get(context.Context, string, string) (*model.Notification, error) Get(context.Context, string, string) (*model.Notification, error)
@@ -26,14 +20,14 @@ type Server struct {
service API service API
} }
// The private endpoint uses the existing product storage namespace without caller authentication.
func New(service API) *Server { return &Server{service: service} } func New(service API) *Server { return &Server{service: service} }
func (s *Server) Send(ctx context.Context, r *pb.SendRequest) (*pb.SendResponse, error) { func (s *Server) Send(ctx context.Context, r *pb.SendRequest) (*pb.SendResponse, error) {
input, err := request(r) input, err := request(r)
if err != nil { if err != nil {
return nil, rpcError(err) return nil, rpcError(err)
} }
caller, _ := ctx.Value(callerKey{}).(string) n, err := s.service.Send(ctx, "product", input)
n, err := s.service.Send(ctx, caller, input)
if err != nil { if err != nil {
return nil, rpcError(err) return nil, rpcError(err)
} }
@@ -43,39 +37,13 @@ func (s *Server) Get(ctx context.Context, r *pb.GetRequest) (*pb.GetResponse, er
if r == nil { if r == nil {
return nil, status.Error(codes.InvalidArgument, "missing request") return nil, status.Error(codes.InvalidArgument, "missing request")
} }
caller, _ := ctx.Value(callerKey{}).(string) n, err := s.service.Get(ctx, "product", r.Id)
n, err := s.service.Get(ctx, caller, r.Id)
if err != nil { if err != nil {
return nil, rpcError(err) return nil, rpcError(err)
} }
return &pb.GetResponse{Notification: response(n)}, nil return &pb.GetResponse{Notification: response(n)}, nil
} }
// Tokens are mapped to stable server-configured caller identities, never supplied IDs.
func Authenticate(tokens map[string]string) grpc.UnaryServerInterceptor {
hashes := map[string][32]byte{}
for caller, token := range tokens {
hashes[caller] = sha256.Sum256([]byte(token))
}
return func(ctx context.Context, req any, info *grpc.UnaryServerInfo, handler grpc.UnaryHandler) (any, error) {
md, _ := metadata.FromIncomingContext(ctx)
values := md.Get("authorization")
if len(values) != 1 || !strings.HasPrefix(values[0], "Bearer ") {
return nil, status.Error(codes.Unauthenticated, "invalid service credentials")
}
hash := sha256.Sum256([]byte(strings.TrimPrefix(values[0], "Bearer ")))
identity := ""
for caller, expected := range hashes {
if subtle.ConstantTimeCompare(hash[:], expected[:]) == 1 {
identity = caller
}
}
if identity == "" {
return nil, status.Error(codes.Unauthenticated, "invalid service credentials")
}
return handler(context.WithValue(ctx, callerKey{}, identity), req)
}
}
func rpcError(err error) error { func rpcError(err error) error {
if errors.Is(err, context.Canceled) { if errors.Is(err, context.Canceled) {
return status.Error(codes.Canceled, "notification operation canceled") return status.Error(codes.Canceled, "notification operation canceled")
@@ -15,7 +15,6 @@ import (
"google.golang.org/grpc" "google.golang.org/grpc"
"google.golang.org/grpc/codes" "google.golang.org/grpc/codes"
"google.golang.org/grpc/credentials/insecure" "google.golang.org/grpc/credentials/insecure"
"google.golang.org/grpc/metadata"
"google.golang.org/grpc/status" "google.golang.org/grpc/status"
"google.golang.org/grpc/test/bufconn" "google.golang.org/grpc/test/bufconn"
) )
@@ -31,10 +30,9 @@ func (fakeAPI) Send(_ context.Context, caller string, r model.Request) (*model.N
func (fakeAPI) Get(context.Context, string, string) (*model.Notification, error) { func (fakeAPI) Get(context.Context, string, string) (*model.Notification, error) {
return nil, merrors.NoData("private details") return nil, merrors.NoData("private details")
} }
func TestGRPCAuthenticationAndMapping(t *testing.T) { func TestGRPCWithoutAuthenticationAndMapping(t *testing.T) {
listener := bufconn.Listen(1 << 20) listener := bufconn.Listen(1 << 20)
token := strings.Repeat("a", 32) server := grpc.NewServer()
server := grpc.NewServer(grpc.UnaryInterceptor(Authenticate(map[string]string{"product": token})))
pb.RegisterNotificationsServiceServer(server, New(fakeAPI{})) pb.RegisterNotificationsServiceServer(server, New(fakeAPI{}))
go func() { _ = server.Serve(listener) }() go func() { _ = server.Serve(listener) }()
defer server.Stop() defer server.Stop()
@@ -45,10 +43,7 @@ func TestGRPCAuthenticationAndMapping(t *testing.T) {
defer conn.Close() defer conn.Close()
client := pb.NewNotificationsServiceClient(conn) client := pb.NewNotificationsServiceClient(conn)
req := &pb.SendRequest{UserId: "u", Targets: []*pb.Target{{Destination: &pb.Target_Email{Email: &pb.EmailTarget{Address: "user@example.com"}}}}} req := &pb.SendRequest{UserId: "u", Targets: []*pb.Target{{Destination: &pb.Target_Email{Email: &pb.EmailTarget{Address: "user@example.com"}}}}}
if _, err = client.Send(context.Background(), req); status.Code(err) != codes.Unauthenticated { ctx := context.Background()
t.Fatal(err)
}
ctx := metadata.NewOutgoingContext(context.Background(), metadata.Pairs("authorization", "Bearer "+token))
result, err := client.Send(ctx, req) result, err := client.Send(ctx, req)
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
+4 -1
View File
@@ -28,7 +28,10 @@ Signup and email verification delivery/completion are available when
`api.notifications` is configured. Flutter selects the backend using `api.notifications` is configured. Flutter selects the backend using
`SXCHNGE_API_URL` and `SXCHNGE_CLIENT_ID`. See `SXCHNGE_API_URL` and `SXCHNGE_CLIENT_ID`. See
[auth notification setup and contracts](../../docs/auth-notifications.md). [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 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; future public HTTP adapter. There is no unused public API snapshot in this module;
+28 -14
View File
@@ -1,23 +1,37 @@
package api package api
import ( import (
"context"
"git.sendico.io/tech/sxchnge/product/app/urls" "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/logging"
"git.sendico.io/tech/sxchnge/shared/service" "github.com/go-chi/chi/v5"
) )
type API interface { // Dependencies are initialized once and shared by installed transports.
LifecycleContext() context.Context type Dependencies struct {
Logger() mlogger.Logger Stores *storage.Stores
DomainProvider() domainprovider.DomainProvider Customers accountservice.AccountService
Config() *Config Auth *flow.Service
ProfileConfig() ConfigProvider Factors *twofactor.Service
DBFactory() db.Factory Token middleware.TokenConfig
RuntimeComponent(name string) any Signature middleware.Signature
Register() Register 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"`
}
+2
View File
@@ -7,6 +7,8 @@ import (
) )
type Config struct { 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"` Notifications *notifications.Config `yaml:"notifications"`
Mw *mwa.Config `yaml:"middleware"` Mw *mwa.Config `yaml:"middleware"`
AuthStorage *mwa.AuthStorageConfig `yaml:"auth_storage"` AuthStorage *mwa.AuthStorageConfig `yaml:"auth_storage"`
-13
View File
@@ -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)
}
+13 -17
View File
@@ -1,36 +1,32 @@
package api package api
import "git.sendico.io/tech/sxchnge/shared/service" import (
"context"
type ConfigProvider interface { "git.sendico.io/tech/sxchnge/shared/database"
SharedConfig() *Config "git.sendico.io/tech/sxchnge/shared/logging"
} )
type ConfigProvider interface{ SharedConfig() *Config }
type ConfigFactory func() ConfigProvider 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 { type RuntimeConfig struct {
Name string Name string
Services []ServiceFactory
ConfigFactory ConfigFactory ConfigFactory ConfigFactory
Bootstrap RuntimeBootstrapFunc Bootstrap Bootstrap
Finish RuntimeFinishFunc Versions map[string]Installer
}
type ServiceFactory struct {
Name mservice.Type
Factory MicroServiceFactoryT
} }
func (cfg RuntimeConfig) RuntimeName() string { func (cfg RuntimeConfig) RuntimeName() string {
if cfg.Name == "" { if cfg.Name == "" {
return "edge" return "product"
} }
return cfg.Name return cfg.Name
} }
func (cfg RuntimeConfig) NewConfig() ConfigProvider { func (cfg RuntimeConfig) NewConfig() ConfigProvider {
if cfg.ConfigFactory != nil { if cfg.ConfigFactory != nil {
return cfg.ConfigFactory() return cfg.ConfigFactory()
+26
View File
@@ -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
}
+5 -103
View File
@@ -2,26 +2,12 @@ package httptransport
import ( import (
"net/http" "net/http"
"os"
"path" "path"
mr "git.sendico.io/tech/sxchnge/product/app/metrics" mr "git.sendico.io/tech/sxchnge/product/app/metrics"
"git.sendico.io/tech/sxchnge/product/auth/config" "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/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" "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/http/response"
"git.sendico.io/tech/sxchnge/shared/logging" "git.sendico.io/tech/sxchnge/shared/logging"
"git.sendico.io/tech/sxchnge/shared/service" "git.sendico.io/tech/sxchnge/shared/service"
@@ -34,33 +20,17 @@ import (
) )
type Middleware struct { type Middleware struct {
notifications *notifications.Client
logger mlogger.Logger logger mlogger.Logger
router *chi.Mux router *chi.Mux
apiEndpoint string apiEndpoint string
metrics mr.Metrics 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() { func (mw *Middleware) Finish() {
if mw.metrics != nil {
mw.metrics.Finish() mw.metrics.Finish()
if mw.notifications != nil {
_ = mw.notifications.Close()
} }
} }
func (mw *Middleware) installMiddleware(config *middleware.Config, debug bool) { func (mw *Middleware) installMiddleware(config *middleware.Config, debug bool) {
mw.logger.Debug("Installing middleware stack...") mw.logger.Debug("Installing middleware stack...")
// Collect metrics for application traffic only. Scrapes and health probes are // 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" return requestPath == healthPath || requestPath == metricsPath || requestPath == "/metrics"
} }
func CreateMiddleware( func CreateMiddleware(logger mlogger.Logger, apiRouter *chi.Mux, config *middleware.Config, endpoint string, debug bool) (*Middleware, error) {
logger mlogger.Logger,
db db.Factory,
apiRouter *chi.Mux,
config *middleware.Config,
authStorage *middleware.AuthStorageConfig,
debug bool,
notificationConfig ...*notifications.Config,
) (*Middleware, error) {
if config == nil { if config == nil {
return nil, merrors.InvalidArgument("middleware configuration is required", "middleware") return nil, merrors.InvalidArgument("middleware configuration is required", "middleware")
} }
@@ -130,72 +92,12 @@ func CreateMiddleware(
if err := config.CORS.Validate(); err != nil { if err := config.CORS.Validate(); err != nil {
return nil, err return nil, err
} }
loginRateLimits, err := config.ResolveLoginRateLimits() p := &Middleware{logger: logger.Named("middleware"), router: apiRouter, apiEndpoint: endpoint}
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.installMiddleware(config, debug) p.installMiddleware(config, debug)
if p.metrics, err = mr.NewMetricsRouter(p.logger, p.router, p.apiEndpoint); err != nil { var err error
p.logger.Error("Failed to create metrics router", zap.Error(err), zap.String("api_endpoint", p.apiEndpoint)) p.metrics, err = mr.NewMetricsRouter(p.logger, p.router, p.apiEndpoint)
return nil, err
}
stores, err := authstorage.OpenStores(p.logger, db, authStorage)
if err != nil { if err != nil {
return nil, err 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 return p, nil
} }
+4 -13
View File
@@ -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 package profile
import ( import (
"git.sendico.io/tech/sxchnge/product/app/api" "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 { func Runtime() api.RuntimeConfig {
return api.RuntimeConfig{Name: "sxchnge.bff", Bootstrap: bootstrap} return api.RuntimeConfig{Name: "sxchnge.bff", Bootstrap: runtime.OpenDependencies, Versions: map[string]api.Installer{"v1": v1.Install}}
}
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
} }
+6 -54
View File
@@ -2,68 +2,20 @@ package runtime
import ( import (
"context" "context"
"sync"
"git.sendico.io/tech/sxchnge/product/app/api"
amr "git.sendico.io/tech/sxchnge/product/app/health" amr "git.sendico.io/tech/sxchnge/product/app/health"
httptransport "git.sendico.io/tech/sxchnge/product/app/http" 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/logging"
"git.sendico.io/tech/sxchnge/shared/service"
) )
type Microservices = []mservice.MicroService type instance struct {
// APIImp represents the structure of the APIImp
type APIImp struct {
lifecycleCtx context.Context
logger mlogger.Logger 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 health amr.Health
mw *httptransport.Middleware mw *httptransport.Middleware
cleanup func(context.Context) error
once sync.Once
finishErr error
} }
func (a *APIImp) LifecycleContext() context.Context { func (a *instance) Name() string { return "api" }
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"
}
+57 -80
View File
@@ -6,9 +6,12 @@ import (
"git.sendico.io/tech/sxchnge/product/app/api" "git.sendico.io/tech/sxchnge/product/app/api"
amr "git.sendico.io/tech/sxchnge/product/app/health" 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" httptransport "git.sendico.io/tech/sxchnge/product/app/http"
"git.sendico.io/tech/sxchnge/product/app/urls" "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/database"
"git.sendico.io/tech/sxchnge/shared/errors"
"git.sendico.io/tech/sxchnge/shared/logging" "git.sendico.io/tech/sxchnge/shared/logging"
"git.sendico.io/tech/sxchnge/shared/service" "git.sendico.io/tech/sxchnge/shared/service"
"github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5"
@@ -16,93 +19,67 @@ import (
) )
func CreateAPI( func CreateAPI(
lifecycleCtx context.Context, ctx context.Context,
logger mlogger.Logger, logger mlogger.Logger,
config *api.Config, config *api.Config,
profileConfig api.ConfigProvider,
db db.Factory, db db.Factory,
rootRouter *chi.Mux, rootRouter, apiRouter *chi.Mux,
apiRouter *chi.Mux,
debug bool, debug bool,
runtime api.RuntimeConfig, runtime api.RuntimeConfig,
) (mservice.MicroService, error) { ) (_ mservice.MicroService, result error) {
p := &APIImp{ if config == nil || config.Mw == nil {
lifecycleCtx: lifecycleCtx, return nil, merrors.InvalidArgument("API middleware configuration is required")
logger: logger.Named("api"),
config: config,
profile: profileConfig,
db: db,
runtime: runtime,
} }
endpoint := os.Getenv(config.Mw.EndPointEnv)
if err := p.installRuntimeBasics(logger, db, rootRouter, apiRouter, debug); err != nil { mounts, err := resolveMounts(config.Versions, endpoint, runtime.Versions)
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)
if err != nil { if err != nil {
a.logger.Error("Failed to bootstrap runtime profile", zap.Error(err), zap.String("runtime", a.runtime.RuntimeName())) return nil, err
return err
} }
a.components = components if runtime.Bootstrap == nil {
a.logger.Info("Runtime profile bootstrap complete", return nil, merrors.InvalidArgument("runtime bootstrap is required")
zap.String("runtime", a.runtime.RuntimeName()),
zap.Int("components", len(components)))
} }
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
} }
@@ -24,7 +24,7 @@ import (
func TestProfileStartsOnEmptyDatabase(t *testing.T) { func TestProfileStartsOnEmptyDatabase(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), time.Minute) ctx, cancel := context.WithTimeout(context.Background(), time.Minute)
defer cancel() defer cancel()
container, err := mongodb.Run(ctx, "mongo:8", mongodb.WithReplicaSet("rs")) container, err := mongodb.Run(ctx, "mongo:latest", mongodb.WithReplicaSet("rs"))
require.NoError(t, err) require.NoError(t, err)
t.Cleanup(func() { require.NoError(t, container.Terminate(context.Background())) }) t.Cleanup(func() { require.NoError(t, container.Terminate(context.Background())) })
uri, err := container.ConnectionString(ctx) uri, err := container.ConnectionString(ctx)
@@ -48,10 +48,9 @@ func TestProfileStartsOnEmptyDatabase(t *testing.T) {
t.Setenv("SXCHNGE_API_PROTOCOL", "http") t.Setenv("SXCHNGE_API_PROTOCOL", "http")
t.Setenv("SXCHNGE_API_ENDPOINT", "/api/v1") t.Setenv("SXCHNGE_API_ENDPOINT", "/api/v1")
t.Setenv("SXCHNGE_JWT_SECRET", "test-only-signing-key-at-least-32-bytes") t.Setenv("SXCHNGE_JWT_SECRET", "test-only-signing-key-at-least-32-bytes")
t.Setenv("SXCHNGE_NOTIFICATIONS_TOKEN", "test-notification-token-at-least-32-bytes")
example.API.Notifications.CAFile = "" // This startup test makes no notification RPCs. example.API.Notifications.CAFile = "" // This startup test makes no notification RPCs.
root, routes := chi.NewRouter(), chi.NewRouter() 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) require.NoError(t, err)
t.Cleanup(func() { require.NoError(t, runtime.Finish(context.Background())) }) t.Cleanup(func() { require.NoError(t, runtime.Finish(context.Background())) })
root.Mount("/", routes) root.Mount("/", routes)
+10 -18
View File
@@ -4,28 +4,20 @@ import (
"context" "context"
"git.sendico.io/tech/sxchnge/product/app/health/status" "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.SetStatus(health.SSTerminating)
a.health.Finish() a.health.Finish()
}
if a.cleanup != nil {
a.finishErr = a.cleanup(ctx)
}
if a.mw != nil {
a.mw.Finish() 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 { return a.finishErr
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
} }
+58
View File
@@ -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
}
+190
View File
@@ -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)
}
+55 -25
View File
@@ -1,39 +1,69 @@
package runtime package runtime
import ( import (
"context"
"git.sendico.io/tech/sxchnge/product/app/api" "git.sendico.io/tech/sxchnge/product/app/api"
"git.sendico.io/tech/sxchnge/product/app/health/status" "git.sendico.io/tech/sxchnge/product/auth/config"
"git.sendico.io/tech/sxchnge/shared/errors" "git.sendico.io/tech/sxchnge/product/auth/flow"
"git.sendico.io/tech/sxchnge/shared/service" "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" "go.uber.org/zap"
) )
func (a *APIImp) installMicroservice(srv mservice.MicroService) { func OpenDependencies(_ context.Context, logger mlogger.Logger, config *api.Config, db db.Factory) (_ *api.Dependencies, _ func(context.Context) error, result error) {
a.services = append(a.services, srv) limits, err := config.Mw.ResolveLoginRateLimits()
a.logger.Info("Microservice installed", zap.String("service", srv.Name()))
}
func (a *APIImp) addMicroservice(srvf api.MicroServiceFactoryT) error {
srv, err := srvf(a)
if err != nil { if err != nil {
a.logger.Error("Failed to install a microservice", zap.Error(err)) return nil, nil, err
return err
} }
stores, err := storage.OpenStores(logger, db, config.AuthStorage)
a.installMicroservice(srv) if err != nil {
return nil return nil, nil, err
}
func (a *APIImp) installServices() error {
for _, service := range a.runtime.Services {
if service.Factory == nil {
return merrors.InvalidArgument("microservice factory is required", "runtime.services")
} }
if err := a.addMicroservice(service.Factory); err != nil { customers, err := accountservice.FromStores(logger, &config.Mw.Password, stores, db.TransactionFactory())
return err 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 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/product/app/api"
"git.sendico.io/tech/sxchnge/shared/errors" "git.sendico.io/tech/sxchnge/shared/errors"
"os" "os"
"strings"
"testing" "testing"
) )
@@ -32,3 +33,34 @@ func TestExampleConfigMatchesCopiedRuntime(t *testing.T) {
t.Fatal(err) 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() rootRouter := chi.NewRouter()
apiRouter := 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() i.db.CloseConnection()
return err return err
} }
@@ -10,6 +10,7 @@ type DomainProvider interface {
GetAPILink(linkElem ...string) (string, error) 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) { func CreateDomainProvider(logger mlogger.Logger, domain, protocol, apiEndpoint string) (DomainProvider, error) {
return dimp.CreateDomainProviderImp(logger, domain, protocol, apiEndpoint) return dimp.CreateDomainProviderImp(logger, domain, protocol, apiEndpoint)
} }
@@ -2,8 +2,8 @@ package domainproviderimp
import ( import (
"net/url" "net/url"
"os"
"git.sendico.io/tech/sxchnge/shared/errors"
"git.sendico.io/tech/sxchnge/shared/logging" "git.sendico.io/tech/sxchnge/shared/logging"
"go.uber.org/zap" "go.uber.org/zap"
) )
@@ -21,11 +21,19 @@ func (dp *DomainProviderImp) GetAPILink(linkElem ...string) (string, error) {
return url.JoinPath(dp.apiEndpoint, linkElem...) 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 := new(DomainProviderImp)
p.domain = os.Getenv(protocol) + "://" + os.Getenv(domain) p.domain = protocol + "://" + domain
var err error
apiPath := os.Getenv(apiPointEnv)
if p.apiEndpoint, err = url.JoinPath(p.domain, apiPath); err != nil { 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)) logger.Error("Failed to create domain provider", zap.String("domain", p.domain), zap.String("api_path", apiPath))
return nil, err 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)
}
}
}
@@ -9,8 +9,8 @@ import (
"testing" "testing"
"git.sendico.io/tech/sxchnge/product/auth/config" "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" 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/product/customers/store"
"git.sendico.io/tech/sxchnge/shared/errors" "git.sendico.io/tech/sxchnge/shared/errors"
api "git.sendico.io/tech/sxchnge/shared/http" api "git.sendico.io/tech/sxchnge/shared/http"
@@ -48,7 +48,7 @@ func TestCustomerHandlerResolvesIdentityWithoutRoles(t *testing.T) {
accounts.customer.Status = status accounts.customer.Status = status
routes := chi.NewRouter() routes := chi.NewRouter()
signature := &middleware.Signature{Algorithm: "HS256", PrivateKey: []byte("test-only-signing-key-at-least-32-bytes")} 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 called := false
router.AccountHandler(mservice.Accounts, "/me", api.Get, func(_ *http.Request, c *customermodel.Account, _ *sresponse.TokenData) http.HandlerFunc { router.AccountHandler(mservice.Accounts, "/me", api.Get, func(_ *http.Request, c *customermodel.Account, _ *sresponse.TokenData) http.HandlerFunc {
called = true called = true
@@ -6,9 +6,9 @@ import (
"net/http" "net/http"
"strings" "strings"
"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/ipguard"
emodel "git.sendico.io/tech/sxchnge/product/auth/model" 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/product/sessions/metrics"
"git.sendico.io/tech/sxchnge/shared/errors" "git.sendico.io/tech/sxchnge/shared/errors"
api "git.sendico.io/tech/sxchnge/shared/http" api "git.sendico.io/tech/sxchnge/shared/http"
@@ -4,7 +4,7 @@ import (
"net/http" "net/http"
"git.sendico.io/tech/sxchnge/product/auth/config" "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" emodel "git.sendico.io/tech/sxchnge/product/auth/model"
"git.sendico.io/tech/sxchnge/product/customers/store" "git.sendico.io/tech/sxchnge/product/customers/store"
"git.sendico.io/tech/sxchnge/product/sessions/metrics" "git.sendico.io/tech/sxchnge/product/sessions/metrics"
@@ -27,7 +27,7 @@ type AuthorizedRouter struct {
service mservice.Type 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) ja := jwtauth.New(signature.Algorithm, signature.PrivateKey, signature.PublicKey)
router.Use(jwtauth.Verifier(ja)) router.Use(jwtauth.Verifier(ja))
router.Use(jwtauth.Authenticator(ja)) router.Use(jwtauth.Authenticator(ja))
@@ -1,12 +1,10 @@
package router package router
import ( import (
"os"
"git.sendico.io/tech/sxchnge/product/auth/config" "git.sendico.io/tech/sxchnge/product/auth/config"
rauthorized "git.sendico.io/tech/sxchnge/product/auth/http/authorized" rauthorized "git.sendico.io/tech/sxchnge/product/auth/http/v1/authorized"
rpublic "git.sendico.io/tech/sxchnge/product/auth/http/public" rpublic "git.sendico.io/tech/sxchnge/product/auth/http/v1/public"
"git.sendico.io/tech/sxchnge/product/auth/response" "git.sendico.io/tech/sxchnge/product/auth/http/v1/response"
"git.sendico.io/tech/sxchnge/product/customers/store" "git.sendico.io/tech/sxchnge/product/customers/store"
"git.sendico.io/tech/sxchnge/product/sessions/store" "git.sendico.io/tech/sxchnge/product/sessions/store"
api "git.sendico.io/tech/sxchnge/shared/http" 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) d.protected.PendingAccountHandler(service, endpoint, method, handler)
} }
func NewDispatcher( func Install(
logger mlogger.Logger, logger mlogger.Logger,
router chi.Router, router chi.Router,
endpoint string,
db account.DB, db account.DB,
rtdb refreshtokens.DB, rtdb refreshtokens.DB,
attempts ratelimits.Store, attempts ratelimits.Store,
config *middleware.Config, token *middleware.TokenConfig,
signature middleware.Signature,
loginRateLimits middleware.LoginRateLimits, loginRateLimits middleware.LoginRateLimits,
flows ...rpublic.Flows, flows ...rpublic.Flows,
) *Dispatcher { ) *Dispatcher {
@@ -49,13 +49,11 @@ func NewDispatcher(
} }
d.logger.Debug("Installing endpoints middleware...") d.logger.Debug("Installing endpoints middleware...")
endpoint := os.Getenv(config.EndPointEnv)
signature := middleware.SignatureConf(config)
router.Group(func(r chi.Router) { 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) { 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 return d
@@ -7,7 +7,7 @@ import (
"net/http" "net/http"
"git.sendico.io/tech/sxchnge/product/auth/config" "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" "git.sendico.io/tech/sxchnge/shared/logging"
"github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5"
) )
@@ -4,7 +4,7 @@ import (
"net/http" "net/http"
"path" "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" api "git.sendico.io/tech/sxchnge/shared/http"
"git.sendico.io/tech/sxchnge/shared/service" "git.sendico.io/tech/sxchnge/shared/service"
"github.com/go-chi/chi/v5" "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 { 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) { func (er *HttpEndpointRouter) InstallHandler(service mservice.Type, endpoint string, method api.HTTPMethod, handler sresponse.HandlerFunc) {
@@ -5,8 +5,8 @@ import (
sessionmodel "git.sendico.io/tech/sxchnge/product/sessions/model" sessionmodel "git.sendico.io/tech/sxchnge/product/sessions/model"
"time" "time"
"git.sendico.io/tech/sxchnge/product/auth/http/v1/response"
emodel "git.sendico.io/tech/sxchnge/product/auth/model" emodel "git.sendico.io/tech/sxchnge/product/auth/model"
"git.sendico.io/tech/sxchnge/product/auth/response"
"github.com/go-chi/jwtauth/v5" "github.com/go-chi/jwtauth/v5"
) )
@@ -9,9 +9,9 @@ import (
"strings" "strings"
clientauth "git.sendico.io/tech/sxchnge/product/auth/client" 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/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" customermodel "git.sendico.io/tech/sxchnge/product/customers/model"
sessionmodel "git.sendico.io/tech/sxchnge/product/sessions/model" sessionmodel "git.sendico.io/tech/sxchnge/product/sessions/model"
"git.sendico.io/tech/sxchnge/shared/errors" "git.sendico.io/tech/sxchnge/shared/errors"
@@ -9,8 +9,8 @@ import (
"time" "time"
"git.sendico.io/tech/sxchnge/product/auth/config" "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/ipguard"
"git.sendico.io/tech/sxchnge/product/auth/request"
"git.sendico.io/tech/sxchnge/shared/errors" "git.sendico.io/tech/sxchnge/shared/errors"
"git.sendico.io/tech/sxchnge/shared/http/response" "git.sendico.io/tech/sxchnge/shared/http/response"
"git.sendico.io/tech/sxchnge/shared/ratelimit/store" "git.sendico.io/tech/sxchnge/shared/ratelimit/store"
@@ -13,7 +13,7 @@ import (
"time" "time"
"git.sendico.io/tech/sxchnge/product/auth/config" "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/errors"
"git.sendico.io/tech/sxchnge/shared/service" "git.sendico.io/tech/sxchnge/shared/service"
"go.uber.org/zap" "go.uber.org/zap"
@@ -11,7 +11,7 @@ import (
"testing" "testing"
"git.sendico.io/tech/sxchnge/product/auth/config" "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/errors"
"git.sendico.io/tech/sxchnge/shared/service" "git.sendico.io/tech/sxchnge/shared/service"
"github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5"
@@ -183,7 +183,7 @@ func TestAPILoginAllowsLoginAgainAfterRevokeSameSession(t *testing.T) {
PrivateKey: []byte("test-secret"), PrivateKey: []byte("test-secret"),
} }
router := NewRouter( router := Install(
zap.NewNop(), zap.NewNop(),
"/", "/",
&testAccountDB{result: account}, &testAccountDB{result: account},
@@ -4,7 +4,7 @@ import (
"encoding/json" "encoding/json"
"net/http" "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/browser"
"git.sendico.io/tech/sxchnge/product/sessions/metrics" "git.sendico.io/tech/sxchnge/product/sessions/metrics"
"git.sendico.io/tech/sxchnge/shared/http/response" "git.sendico.io/tech/sxchnge/shared/http/response"
@@ -4,7 +4,7 @@ import (
"net/http" "net/http"
"time" "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/go-chi/chi/v5/middleware"
"github.com/prometheus/client_golang/prometheus" "github.com/prometheus/client_golang/prometheus"
"github.com/prometheus/client_golang/prometheus/promauto" "github.com/prometheus/client_golang/prometheus/promauto"
@@ -6,8 +6,8 @@ import (
"net/http" "net/http"
"time" "time"
"git.sendico.io/tech/sxchnge/product/auth/request" "git.sendico.io/tech/sxchnge/product/auth/http/v1/request"
"git.sendico.io/tech/sxchnge/product/auth/response" "git.sendico.io/tech/sxchnge/product/auth/http/v1/response"
"git.sendico.io/tech/sxchnge/product/sessions/browser" "git.sendico.io/tech/sxchnge/product/sessions/browser"
"git.sendico.io/tech/sxchnge/shared/http/response" "git.sendico.io/tech/sxchnge/shared/http/response"
"git.sendico.io/tech/sxchnge/shared/service" "git.sendico.io/tech/sxchnge/shared/service"
@@ -47,7 +47,7 @@ func newBrowserRefreshRouter(t *testing.T, expiresAt time.Time) (*PublicRouter,
Algorithm: "HS256", Algorithm: "HS256",
PrivateKey: []byte("browser-refresh-test-secret"), PrivateKey: []byte("browser-refresh-test-secret"),
} }
return NewRouter( return Install(
zap.NewNop(), zap.NewNop(),
"/api/v1", "/api/v1",
&testAccountDB{result: account}, &testAccountDB{result: account},
@@ -6,7 +6,7 @@ import (
sessionmodel "git.sendico.io/tech/sxchnge/product/sessions/model" sessionmodel "git.sendico.io/tech/sxchnge/product/sessions/model"
"net/http" "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" rtokens "git.sendico.io/tech/sxchnge/product/sessions/tokens"
"git.sendico.io/tech/sxchnge/shared/http/response" "git.sendico.io/tech/sxchnge/shared/http/response"
"git.sendico.io/tech/sxchnge/shared/logging/fields" "git.sendico.io/tech/sxchnge/shared/logging/fields"
@@ -5,7 +5,7 @@ import (
"errors" "errors"
"net/http" "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/http/response"
"git.sendico.io/tech/sxchnge/shared/service" "git.sendico.io/tech/sxchnge/shared/service"
"go.uber.org/zap" "go.uber.org/zap"
@@ -5,8 +5,8 @@ import (
"git.sendico.io/tech/sxchnge/product/auth/config" "git.sendico.io/tech/sxchnge/product/auth/config"
"git.sendico.io/tech/sxchnge/product/auth/flow" "git.sendico.io/tech/sxchnge/product/auth/flow"
re "git.sendico.io/tech/sxchnge/product/auth/http/endpoint" re "git.sendico.io/tech/sxchnge/product/auth/http/v1/endpoint"
"git.sendico.io/tech/sxchnge/product/auth/response" "git.sendico.io/tech/sxchnge/product/auth/http/v1/response"
"git.sendico.io/tech/sxchnge/product/customers/store" "git.sendico.io/tech/sxchnge/product/customers/store"
"git.sendico.io/tech/sxchnge/product/sessions/store" "git.sendico.io/tech/sxchnge/product/sessions/store"
"git.sendico.io/tech/sxchnge/product/twofactor" "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) pr.imp.InstallHandler(service, endpoint, method, handler)
} }
func NewRouter( func Install(
logger mlogger.Logger, logger mlogger.Logger,
apiEndpoint string, apiEndpoint string,
db account.DB, db account.DB,
@@ -8,8 +8,8 @@ import (
"net/http" "net/http"
"time" "time"
"git.sendico.io/tech/sxchnge/product/auth/request" "git.sendico.io/tech/sxchnge/product/auth/http/v1/request"
"git.sendico.io/tech/sxchnge/product/auth/response" "git.sendico.io/tech/sxchnge/product/auth/http/v1/response"
"git.sendico.io/tech/sxchnge/product/sessions/browser" "git.sendico.io/tech/sxchnge/product/sessions/browser"
"git.sendico.io/tech/sxchnge/shared/errors" "git.sendico.io/tech/sxchnge/shared/errors"
"git.sendico.io/tech/sxchnge/shared/logging/fields" "git.sendico.io/tech/sxchnge/shared/logging/fields"
@@ -11,9 +11,9 @@ import (
"net/http" "net/http"
"time" "time"
sresponse "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/ipguard"
authmodel "git.sendico.io/tech/sxchnge/product/auth/model" 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" customer "git.sendico.io/tech/sxchnge/product/customers/model"
session "git.sendico.io/tech/sxchnge/product/sessions/model" session "git.sendico.io/tech/sxchnge/product/sessions/model"
factor "git.sendico.io/tech/sxchnge/product/twofactor/model" factor "git.sendico.io/tech/sxchnge/product/twofactor/model"
@@ -44,7 +44,7 @@ func (m *capturedMail) Send(_ context.Context, r notifications.Message) error {
func TestSignupActivationAndLoginEmailFlow(t *testing.T) { func TestSignupActivationAndLoginEmailFlow(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), time.Minute) ctx, cancel := context.WithTimeout(context.Background(), time.Minute)
defer cancel() defer cancel()
container, err := mongodb.Run(ctx, "mongo:8", mongodb.WithReplicaSet("rs")) container, err := mongodb.Run(ctx, "mongo:latest", mongodb.WithReplicaSet("rs"))
require.NoError(t, err) require.NoError(t, err)
t.Cleanup(func() { require.NoError(t, container.Terminate(context.Background())) }) t.Cleanup(func() { require.NoError(t, container.Terminate(context.Background())) })
uri, err := container.ConnectionString(ctx) uri, err := container.ConnectionString(ctx)
@@ -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}) factors, err := twofactor.New(stores.Accounts, stores.Verification, records, factory.TransactionFactory(), service.Secret, zap.NewNop(), delivery.Email{Sender: mail})
require.NoError(t, err) require.NoError(t, err)
routes := chi.NewRouter() 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 { post := func(path string, body any) *httptest.ResponseRecorder {
raw, err := json.Marshal(body) raw, err := json.Marshal(body)
require.NoError(t, err) require.NoError(t, err)
@@ -1,7 +1,7 @@
package router package router
import ( 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" api "git.sendico.io/tech/sxchnge/shared/http"
"git.sendico.io/tech/sxchnge/shared/service" "git.sendico.io/tech/sxchnge/shared/service"
) )
+5 -4
View File
@@ -9,6 +9,10 @@ database:
uri_env: SXCHNGE_MONGO_URI uri_env: SXCHNGE_MONGO_URI
database: sxchnge database: sxchnge
api: api:
# Optional: omit for v1 at SXCHNGE_API_ENDPOINT; [] disables application APIs.
# versions:
# - version: v1
# path: /api/v1
middleware: middleware:
domain_env: SXCHNGE_DOMAIN domain_env: SXCHNGE_DOMAIN
api_endpoint_env: SXCHNGE_API_ENDPOINT api_endpoint_env: SXCHNGE_API_ENDPOINT
@@ -32,8 +36,5 @@ api:
check: check:
min_length: 12 min_length: 12
notifications: notifications:
address: localhost:8443 address: localhost:8083
token_env: SXCHNGE_NOTIFICATIONS_TOKEN
ca_file: ../notifications/tls/ca.pem
server_name: localhost
activation_url: http://localhost:5126/verify-email activation_url: http://localhost:5126/verify-email
@@ -2,6 +2,10 @@ package accountserviceimp
import ( import (
"context" "context"
"net/url"
"testing"
"time"
"git.sendico.io/tech/sxchnge/product/auth/config" "git.sendico.io/tech/sxchnge/product/auth/config"
customermodel "git.sendico.io/tech/sxchnge/product/customers/model" customermodel "git.sendico.io/tech/sxchnge/product/customers/model"
sessionmodel "git.sendico.io/tech/sxchnge/product/sessions/model" sessionmodel "git.sendico.io/tech/sxchnge/product/sessions/model"
@@ -15,9 +19,6 @@ import (
"github.com/testcontainers/testcontainers-go/modules/mongodb" "github.com/testcontainers/testcontainers-go/modules/mongodb"
"go.mongodb.org/mongo-driver/v2/bson" "go.mongodb.org/mongo-driver/v2/bson"
"go.uber.org/zap" "go.uber.org/zap"
"net/url"
"testing"
"time"
) )
func customer(login string) *customermodel.Account { func customer(login string) *customermodel.Account {
@@ -29,7 +30,7 @@ func customer(login string) *customermodel.Account {
func TestCustomerLifecycleWithoutTenancy(t *testing.T) { func TestCustomerLifecycleWithoutTenancy(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
defer cancel() defer cancel()
container, err := mongodb.Run(ctx, "mongo:8", mongodb.WithReplicaSet("rs")) container, err := mongodb.Run(ctx, "mongo:latest", mongodb.WithReplicaSet("rs"))
require.NoError(t, err) require.NoError(t, err)
t.Cleanup(func() { require.NoError(t, container.Terminate(context.Background())) }) t.Cleanup(func() { require.NoError(t, container.Terminate(context.Background())) })
uri, err := container.ConnectionString(ctx) uri, err := container.ConnectionString(ctx)
@@ -86,7 +87,10 @@ func TestCustomerLifecycleWithoutTenancy(t *testing.T) {
deps, err := edgemongo.ResolveDependencies(factory) deps, err := edgemongo.ResolveDependencies(factory)
require.NoError(t, err) require.NoError(t, err)
collections, err := deps.Database.ListCollectionNames(ctx, bson.D{}) raw, rawContext, release, err := deps.Database.Acquire(ctx)
require.NoError(t, err)
defer release()
collections, err := raw.ListCollectionNames(rawContext, bson.D{})
require.NoError(t, err) require.NoError(t, err)
for _, forbidden := range []string{"organizations", "roles", "policies", "policy_assignments", "invitations", "accounts"} { for _, forbidden := range []string{"organizations", "roles", "policies", "policy_assignments", "invitations", "accounts"} {
require.NotContains(t, collections, forbidden) require.NotContains(t, collections, forbidden)
@@ -116,7 +120,7 @@ func TestCustomerLifecycleWithoutTenancy(t *testing.T) {
require.ErrorIs(t, err, outerFailure) require.ErrorIs(t, err, outerFailure)
_, err = svc.accountDB.GetByEmail(ctx, "outer@example.test") _, err = svc.accountDB.GetByEmail(ctx, "outer@example.test")
require.ErrorIs(t, err, merrors.ErrNoData) require.ErrorIs(t, err, merrors.ErrNoData)
count, err := deps.Database.Collection(config.VerificationTokensCollection).CountDocuments(ctx, bson.D{}) count, err := raw.Collection(config.VerificationTokensCollection).CountDocuments(rawContext, bson.D{})
require.NoError(t, err) require.NoError(t, err)
require.EqualValues(t, 2, count) require.EqualValues(t, 2, count)
} }
@@ -243,6 +243,13 @@ func NewAccountService(
if err != nil { if err != nil {
return nil, err return nil, err
} }
return &service{logger: logger, config: config, accountDB: stores.Accounts, return FromStores(l, config, stores, dbf.TransactionFactory())
vdb: stores.Verification, sessions: stores.RefreshTokens, transactions: dbf.TransactionFactory()}, nil }
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
} }
+4 -4
View File
@@ -1,14 +1,14 @@
package accountdb package accountdb
import ( import (
customermodel "git.sendico.io/tech/sxchnge/product/customers/model"
"strings" "strings"
customermodel "git.sendico.io/tech/sxchnge/product/customers/model"
ri "git.sendico.io/tech/sxchnge/shared/database/repository/index" ri "git.sendico.io/tech/sxchnge/shared/database/repository/index"
"git.sendico.io/tech/sxchnge/shared/database/source"
"git.sendico.io/tech/sxchnge/shared/database/template" "git.sendico.io/tech/sxchnge/shared/database/template"
"git.sendico.io/tech/sxchnge/shared/logging" "git.sendico.io/tech/sxchnge/shared/logging"
"git.sendico.io/tech/sxchnge/shared/service" "git.sendico.io/tech/sxchnge/shared/service"
"go.mongodb.org/mongo-driver/v2/mongo"
"go.uber.org/zap" "go.uber.org/zap"
) )
@@ -16,11 +16,11 @@ type AccountDB struct {
template.DBImp[*customermodel.Account] template.DBImp[*customermodel.Account]
} }
func Create(logger mlogger.Logger, db *mongo.Database) (*AccountDB, error) { func Create(logger mlogger.Logger, db source.Source) (*AccountDB, error) {
return CreateWithCollection(logger, db, mservice.Accounts) return CreateWithCollection(logger, db, mservice.Accounts)
} }
func CreateWithCollection(logger mlogger.Logger, db *mongo.Database, collection string) (*AccountDB, error) { func CreateWithCollection(logger mlogger.Logger, db source.Source, collection string) (*AccountDB, error) {
if normalized := strings.TrimSpace(collection); normalized != "" { if normalized := strings.TrimSpace(collection); normalized != "" {
collection = normalized collection = normalized
} else { } else {
+8 -1
View File
@@ -2,11 +2,13 @@ package accountservice
import ( import (
"context" "context"
customermodel "git.sendico.io/tech/sxchnge/product/customers/model"
"git.sendico.io/tech/sxchnge/product/auth/config" "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" 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"
"git.sendico.io/tech/sxchnge/shared/database/transaction"
"git.sendico.io/tech/sxchnge/shared/logging" "git.sendico.io/tech/sxchnge/shared/logging"
"go.mongodb.org/mongo-driver/v2/bson" "go.mongodb.org/mongo-driver/v2/bson"
) )
@@ -61,3 +63,8 @@ func NewAccountService(
) (AccountService, error) { ) (AccountService, error) {
return accountserviceimp.NewAccountService(logger, dbf, config, storageConfig) 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)
}
+9 -9
View File
@@ -12,13 +12,12 @@ import (
merrors "git.sendico.io/tech/sxchnge/shared/errors" merrors "git.sendico.io/tech/sxchnge/shared/errors"
"google.golang.org/grpc" "google.golang.org/grpc"
"google.golang.org/grpc/credentials" "google.golang.org/grpc/credentials"
"google.golang.org/grpc/metadata" "google.golang.org/grpc/credentials/insecure"
"google.golang.org/protobuf/types/known/timestamppb" "google.golang.org/protobuf/types/known/timestamppb"
) )
type Config struct { type Config struct {
Address string `yaml:"address"` Address string `yaml:"address"`
TokenEnv string `yaml:"token_env"`
CAFile string `yaml:"ca_file"` CAFile string `yaml:"ca_file"`
ServerName string `yaml:"server_name"` ServerName string `yaml:"server_name"`
ActivationURL string `yaml:"activation_url"` ActivationURL string `yaml:"activation_url"`
@@ -34,13 +33,11 @@ type Sender interface {
type Client struct { type Client struct {
conn *grpc.ClientConn conn *grpc.ClientConn
rpc pb.NotificationsServiceClient rpc pb.NotificationsServiceClient
token string
} }
func Open(c Config) (*Client, error) { func Open(c Config) (*Client, error) {
token := os.Getenv(c.TokenEnv) if c.Address == "" {
if c.Address == "" || len(token) < 32 { return nil, merrors.InvalidArgument("notification address is required")
return nil, merrors.InvalidArgument("notification address and caller token of at least 32 bytes are required")
} }
tlsConfig := &tls.Config{MinVersion: tls.VersionTLS12, ServerName: c.ServerName} tlsConfig := &tls.Config{MinVersion: tls.VersionTLS12, ServerName: c.ServerName}
if c.CAFile != "" { if c.CAFile != "" {
@@ -57,17 +54,20 @@ func Open(c Config) (*Client, error) {
} }
tlsConfig.RootCAs = pool tlsConfig.RootCAs = pool
} }
conn, err := grpc.NewClient(c.Address, grpc.WithTransportCredentials(credentials.NewTLS(tlsConfig))) var transport credentials.TransportCredentials = insecure.NewCredentials()
if c.CAFile != "" || c.ServerName != "" {
transport = credentials.NewTLS(tlsConfig)
}
conn, err := grpc.NewClient(c.Address, grpc.WithTransportCredentials(transport))
if err != nil { if err != nil {
return nil, merrors.InternalWrap(err, "create notification client") return nil, merrors.InternalWrap(err, "create notification client")
} }
return &Client{conn: conn, rpc: pb.NewNotificationsServiceClient(conn), token: token}, nil return &Client{conn: conn, rpc: pb.NewNotificationsServiceClient(conn)}, nil
} }
func (c *Client) Close() error { return c.conn.Close() } func (c *Client) Close() error { return c.conn.Close() }
func (c *Client) Send(ctx context.Context, m Message) error { func (c *Client) Send(ctx context.Context, m Message) error {
ctx, cancel := context.WithTimeout(ctx, 5*time.Second) ctx, cancel := context.WithTimeout(ctx, 5*time.Second)
defer cancel() defer cancel()
ctx = metadata.AppendToOutgoingContext(ctx, "authorization", "Bearer "+c.token)
res, err := c.rpc.Send(ctx, request(m)) res, err := c.rpc.Send(ctx, request(m))
if err != nil { if err != nil {
return merrors.ServiceUnavailableWrap(err, "notification queue unavailable") return merrors.ServiceUnavailableWrap(err, "notification queue unavailable")
+28 -15
View File
@@ -23,7 +23,7 @@ import (
type rpcStub struct { type rpcStub struct {
pb.NotificationsServiceClient pb.NotificationsServiceClient
request *pb.SendRequest request *pb.SendRequest
auth string auth []string
result *pb.SendResponse result *pb.SendResponse
err error err error
} }
@@ -31,16 +31,16 @@ type rpcStub struct {
func (s *rpcStub) Send(ctx context.Context, r *pb.SendRequest, _ ...grpc.CallOption) (*pb.SendResponse, error) { func (s *rpcStub) Send(ctx context.Context, r *pb.SendRequest, _ ...grpc.CallOption) (*pb.SendResponse, error) {
s.request = r s.request = r
md, _ := metadata.FromOutgoingContext(ctx) md, _ := metadata.FromOutgoingContext(ctx)
s.auth = md.Get("authorization")[0] s.auth = md.Get("authorization")
return s.result, s.err return s.result, s.err
} }
func TestSendMapsOwnedContractAndRejectsFailedReceipt(t *testing.T) { func TestSendMapsOwnedContractAndRejectsFailedReceipt(t *testing.T) {
rpc := &rpcStub{result: &pb.SendResponse{Notification: &pb.Notification{Id: "job", Deliveries: []*pb.Delivery{{State: pb.DeliveryState_DELIVERY_STATE_QUEUED}}}}} rpc := &rpcStub{result: &pb.SendResponse{Notification: &pb.Notification{Id: "job", Deliveries: []*pb.Delivery{{State: pb.DeliveryState_DELIVERY_STATE_QUEUED}}}}}
client := &Client{rpc: rpc, token: "service-secret"} client := &Client{rpc: rpc}
expiry := time.Now().Add(time.Minute) expiry := time.Now().Add(time.Minute)
m := Message{Key: "key", UserID: "customer", Email: "user@example.com", Locale: "ru", Template: "auth.login", Parameters: map[string]string{"code": "123456"}, ExpiresAt: expiry} m := Message{Key: "key", UserID: "customer", Email: "user@example.com", Locale: "ru", Template: "auth.login", Parameters: map[string]string{"code": "123456"}, ExpiresAt: expiry}
require.NoError(t, client.Send(context.Background(), m)) require.NoError(t, client.Send(context.Background(), m))
require.Equal(t, "Bearer service-secret", rpc.auth) require.Empty(t, rpc.auth)
require.Equal(t, m.Email, rpc.request.Targets[0].GetEmail().Address) require.Equal(t, m.Email, rpc.request.Targets[0].GetEmail().Address)
require.Equal(t, "1", rpc.request.TemplateVersion) require.Equal(t, "1", rpc.request.TemplateVersion)
require.Equal(t, m.Parameters, rpc.request.Parameters) require.Equal(t, m.Parameters, rpc.request.Parameters)
@@ -50,41 +50,54 @@ func TestSendMapsOwnedContractAndRejectsFailedReceipt(t *testing.T) {
rpc.result = nil rpc.result = nil
require.ErrorIs(t, client.Send(context.Background(), m), merrors.ErrServiceUnavailable) require.ErrorIs(t, client.Send(context.Background(), m), merrors.ErrServiceUnavailable)
} }
func TestOpenRequiresCredentialsAndValidCA(t *testing.T) { func TestOpenRequiresAddressAndValidCA(t *testing.T) {
_, err := Open(Config{Address: "localhost:8443", TokenEnv: "ABSENT_NOTIFICATION_TEST_TOKEN"}) _, err := Open(Config{})
require.ErrorIs(t, err, merrors.ErrInvalidArg) require.ErrorIs(t, err, merrors.ErrInvalidArg)
t.Setenv("TEST_NOTIFICATION_TOKEN", "test-token-that-is-at-least-32-bytes") _, err = Open(Config{Address: "localhost:8443", CAFile: "missing.pem"})
_, err = Open(Config{Address: "localhost:8443", TokenEnv: "TEST_NOTIFICATION_TOKEN", CAFile: "missing.pem"})
require.Error(t, err) require.Error(t, err)
} }
type tlsRPC struct { type tlsRPC struct {
pb.UnimplementedNotificationsServiceServer pb.UnimplementedNotificationsServiceServer
auth chan string auth chan []string
} }
func (s *tlsRPC) Send(ctx context.Context, _ *pb.SendRequest) (*pb.SendResponse, error) { func (s *tlsRPC) Send(ctx context.Context, _ *pb.SendRequest) (*pb.SendResponse, error) {
md, _ := metadata.FromIncomingContext(ctx) md, _ := metadata.FromIncomingContext(ctx)
s.auth <- md.Get("authorization")[0] s.auth <- md.Get("authorization")
return &pb.SendResponse{Notification: &pb.Notification{Id: "tls-job", Deliveries: []*pb.Delivery{{State: pb.DeliveryState_DELIVERY_STATE_QUEUED}}}}, nil return &pb.SendResponse{Notification: &pb.Notification{Id: "tls-job", Deliveries: []*pb.Delivery{{State: pb.DeliveryState_DELIVERY_STATE_QUEUED}}}}, nil
} }
func TestOpenUsesVerifiedTLSAndCallerAuthentication(t *testing.T) { func TestOpenUsesVerifiedTLSWithoutCallerToken(t *testing.T) {
fixture := httptest.NewTLSServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {})) fixture := httptest.NewTLSServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {}))
cert := fixture.TLS.Certificates[0] cert := fixture.TLS.Certificates[0]
fixture.Close() fixture.Close()
listener, err := net.Listen("tcp", "127.0.0.1:0") listener, err := net.Listen("tcp", "127.0.0.1:0")
require.NoError(t, err) require.NoError(t, err)
server := grpc.NewServer(grpc.Creds(credentials.NewTLS(&tls.Config{Certificates: []tls.Certificate{cert}, MinVersion: tls.VersionTLS12}))) server := grpc.NewServer(grpc.Creds(credentials.NewTLS(&tls.Config{Certificates: []tls.Certificate{cert}, MinVersion: tls.VersionTLS12})))
rpc := &tlsRPC{auth: make(chan string, 1)} rpc := &tlsRPC{auth: make(chan []string, 1)}
pb.RegisterNotificationsServiceServer(server, rpc) pb.RegisterNotificationsServiceServer(server, rpc)
go func() { _ = server.Serve(listener) }() go func() { _ = server.Serve(listener) }()
t.Cleanup(server.Stop) t.Cleanup(server.Stop)
ca := filepath.Join(t.TempDir(), "ca.pem") ca := filepath.Join(t.TempDir(), "ca.pem")
require.NoError(t, os.WriteFile(ca, pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: cert.Certificate[0]}), 0600)) require.NoError(t, os.WriteFile(ca, pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: cert.Certificate[0]}), 0600))
t.Setenv("TEST_TLS_CALLER_TOKEN", "test-service-token-with-at-least-32-bytes") client, err := Open(Config{Address: listener.Addr().String(), CAFile: ca})
client, err := Open(Config{Address: listener.Addr().String(), TokenEnv: "TEST_TLS_CALLER_TOKEN", CAFile: ca})
require.NoError(t, err) require.NoError(t, err)
t.Cleanup(func() { _ = client.Close() }) t.Cleanup(func() { _ = client.Close() })
require.NoError(t, client.Send(context.Background(), Message{ExpiresAt: time.Now().Add(time.Minute)})) require.NoError(t, client.Send(context.Background(), Message{ExpiresAt: time.Now().Add(time.Minute)}))
require.Equal(t, "Bearer test-service-token-with-at-least-32-bytes", <-rpc.auth) require.Empty(t, <-rpc.auth)
}
func TestOpenUsesPlaintextWithoutCallerToken(t *testing.T) {
listener, err := net.Listen("tcp", "127.0.0.1:0")
require.NoError(t, err)
server := grpc.NewServer()
rpc := &tlsRPC{auth: make(chan []string, 1)}
pb.RegisterNotificationsServiceServer(server, rpc)
go func() { _ = server.Serve(listener) }()
t.Cleanup(server.Stop)
client, err := Open(Config{Address: listener.Addr().String()})
require.NoError(t, err)
t.Cleanup(func() { _ = client.Close() })
require.NoError(t, client.Send(context.Background(), Message{ExpiresAt: time.Now().Add(time.Minute)}))
require.Empty(t, <-rpc.auth)
} }
+16 -3
View File
@@ -1,9 +1,9 @@
package browser package browser
import ( import (
"context"
"net/http" "net/http"
"net/url" "net/url"
"os"
"path" "path"
"strings" "strings"
"time" "time"
@@ -86,8 +86,10 @@ func ValidateCSRF(r *http.Request, allowedOrigins []string) error {
func cookiePath(r *http.Request) string { func cookiePath(r *http.Request) string {
const accountsSegment = "/accounts" const accountsSegment = "/accounts"
if apiEndpoint := strings.TrimSpace(os.Getenv("API_ENDPOINT")); apiEndpoint != "" { if r != nil {
return path.Join("/", apiEndpoint, accountsSegment) if mount, ok := r.Context().Value(scopeKey{}).(string); ok {
return path.Join(mount, accountsSegment)
}
} }
if r != nil && r.URL != nil { if r != nil && r.URL != nil {
if index := strings.Index(r.URL.Path, accountsSegment); index >= 0 { if index := strings.Index(r.URL.Path, accountsSegment); index >= 0 {
@@ -119,3 +121,14 @@ func originAllowed(origin string, allowedOrigins []string) bool {
} }
return false 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) require.Positive(t, cookie.MaxAge)
} }
func TestSetRefreshCookieUsesConfiguredAPIPathOutsideAccountsRouter(t *testing.T) { func TestSetRefreshCookieUsesInstalledScopeOutsideAccountsRouter(t *testing.T) {
t.Setenv("API_ENDPOINT", "/gateway/v2/") t.Setenv("API_ENDPOINT", "/gateway/v2/")
req := httptest.NewRequest(http.MethodPost, "https://app.sendico.io/gateway/v2/verification/login", nil) req := httptest.NewRequest(http.MethodPost, "https://app.sendico.io/gateway/v2/verification/login", nil)
req.Header.Set(TransportHeader, TransportCookie) req.Header.Set(TransportHeader, TransportCookie)
recorder := httptest.NewRecorder() 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) require.Equal(t, "/gateway/v2/accounts", recorder.Result().Cookies()[0].Path)
} }
+5 -5
View File
@@ -1,15 +1,15 @@
package refreshtokens package refreshtokens
import ( import (
sessionmodel "git.sendico.io/tech/sxchnge/product/sessions/model"
"strings" "strings"
sessionmodel "git.sendico.io/tech/sxchnge/product/sessions/model"
"git.sendico.io/tech/sxchnge/shared/database/repository" "git.sendico.io/tech/sxchnge/shared/database/repository"
ri "git.sendico.io/tech/sxchnge/shared/database/repository/index" ri "git.sendico.io/tech/sxchnge/shared/database/repository/index"
"git.sendico.io/tech/sxchnge/shared/database/source"
"git.sendico.io/tech/sxchnge/shared/database/template" "git.sendico.io/tech/sxchnge/shared/database/template"
"git.sendico.io/tech/sxchnge/shared/logging" "git.sendico.io/tech/sxchnge/shared/logging"
"git.sendico.io/tech/sxchnge/shared/service" "git.sendico.io/tech/sxchnge/shared/service"
"go.mongodb.org/mongo-driver/v2/mongo"
"go.uber.org/zap" "go.uber.org/zap"
) )
@@ -24,7 +24,7 @@ type RefreshTokenDB struct {
collection Collections collection Collections
} }
func Create(logger mlogger.Logger, db *mongo.Database) (*RefreshTokenDB, error) { func Create(logger mlogger.Logger, db source.Source) (*RefreshTokenDB, error) {
return CreateWithCollections(logger, db, Collections{}) return CreateWithCollections(logger, db, Collections{})
} }
@@ -38,11 +38,11 @@ func (c Collections) WithDefaults() Collections {
return c return c
} }
func CreateWithCollections(logger mlogger.Logger, db *mongo.Database, collections Collections) (*RefreshTokenDB, error) { func CreateWithCollections(logger mlogger.Logger, db source.Source, collections Collections) (*RefreshTokenDB, error) {
collections = collections.WithDefaults() collections = collections.WithDefaults()
p := &RefreshTokenDB{ p := &RefreshTokenDB{
DBImp: *template.Create[*sessionmodel.RefreshToken](logger, mservice.Type(collections.RefreshTokens), db), DBImp: *template.Create[*sessionmodel.RefreshToken](logger, mservice.Type(collections.RefreshTokens), db),
clients: repository.CreateMongoRepository(db, collections.Clients), clients: repository.CreateRepository(db, collections.Clients),
collection: collections, collection: collections,
} }
@@ -8,11 +8,12 @@ import (
"crypto/sha1" "crypto/sha1"
"errors" "errors"
"fmt" "fmt"
sessionmodel "git.sendico.io/tech/sxchnge/product/sessions/model"
"testing" "testing"
"time" "time"
sessionmodel "git.sendico.io/tech/sxchnge/product/sessions/model"
"git.sendico.io/tech/sxchnge/product/sessions/store" "git.sendico.io/tech/sxchnge/product/sessions/store"
"git.sendico.io/tech/sxchnge/shared/database/source"
"git.sendico.io/tech/sxchnge/shared/errors" "git.sendico.io/tech/sxchnge/shared/errors"
factory "git.sendico.io/tech/sxchnge/shared/logging/factory" factory "git.sendico.io/tech/sxchnge/shared/logging/factory"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
@@ -55,7 +56,7 @@ func setupTestDBWithMongo(t *testing.T) (*refreshtokens.RefreshTokenDB, *mongo.D
database := client.Database(fmt.Sprintf("test_refresh_tokens_%x", sha1.Sum([]byte(t.Name())))) database := client.Database(fmt.Sprintf("test_refresh_tokens_%x", sha1.Sum([]byte(t.Name()))))
logger := factory.NewLogger(true) logger := factory.NewLogger(true)
db, err := refreshtokens.Create(logger, database) db, err := refreshtokens.Create(logger, source.Fixed(database))
require.NoError(t, err, "failed to create refresh tokens db") require.NoError(t, err, "failed to create refresh tokens db")
cleanup := func() { cleanup := func() {
@@ -11,8 +11,8 @@ import (
"strconv" "strconv"
"time" "time"
sresponse "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/ipguard"
sresponse "git.sendico.io/tech/sxchnge/product/auth/response"
customer "git.sendico.io/tech/sxchnge/product/customers/model" customer "git.sendico.io/tech/sxchnge/product/customers/model"
"git.sendico.io/tech/sxchnge/product/twofactor" "git.sendico.io/tech/sxchnge/product/twofactor"
model "git.sendico.io/tech/sxchnge/product/twofactor/model" model "git.sendico.io/tech/sxchnge/product/twofactor/model"
@@ -6,7 +6,7 @@ import (
"strings" "strings"
"testing" "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" customer "git.sendico.io/tech/sxchnge/product/customers/model"
api "git.sendico.io/tech/sxchnge/shared/http" api "git.sendico.io/tech/sxchnge/shared/http"
mservice "git.sendico.io/tech/sxchnge/shared/service" mservice "git.sendico.io/tech/sxchnge/shared/service"
+1 -1
View File
@@ -43,7 +43,7 @@ func (d *capturedDriver) Send(_ context.Context, m model.Delivery) error {
func TestServiceTransactionsAndFactors(t *testing.T) { func TestServiceTransactionsAndFactors(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
defer cancel() defer cancel()
container, err := mongodb.Run(ctx, "mongo:8", mongodb.WithReplicaSet("rs")) container, err := mongodb.Run(ctx, "mongo:latest", mongodb.WithReplicaSet("rs"))
require.NoError(t, err) require.NoError(t, err)
t.Cleanup(func() { require.NoError(t, container.Terminate(context.Background())) }) t.Cleanup(func() { require.NoError(t, container.Terminate(context.Background())) })
uri, err := container.ConnectionString(ctx) uri, err := container.ConnectionString(ctx)
+4 -4
View File
@@ -1,16 +1,16 @@
package verification package verification
import ( import (
verificationmodel "git.sendico.io/tech/sxchnge/product/verification/model"
"strings" "strings"
verificationmodel "git.sendico.io/tech/sxchnge/product/verification/model"
ri "git.sendico.io/tech/sxchnge/shared/database/repository/index" ri "git.sendico.io/tech/sxchnge/shared/database/repository/index"
"git.sendico.io/tech/sxchnge/shared/database/source"
"git.sendico.io/tech/sxchnge/shared/database/template" "git.sendico.io/tech/sxchnge/shared/database/template"
"git.sendico.io/tech/sxchnge/shared/database/transaction" "git.sendico.io/tech/sxchnge/shared/database/transaction"
"git.sendico.io/tech/sxchnge/shared/logging" "git.sendico.io/tech/sxchnge/shared/logging"
"git.sendico.io/tech/sxchnge/shared/ratelimit/store" "git.sendico.io/tech/sxchnge/shared/ratelimit/store"
"git.sendico.io/tech/sxchnge/shared/service" "git.sendico.io/tech/sxchnge/shared/service"
"go.mongodb.org/mongo-driver/v2/mongo"
"go.uber.org/zap" "go.uber.org/zap"
) )
@@ -29,7 +29,7 @@ const verificationContextIdempotencyIndexName = "uniq_verification_context_idemp
func Create( func Create(
logger mlogger.Logger, logger mlogger.Logger,
db *mongo.Database, db source.Source,
tf transaction.Factory, tf transaction.Factory,
) (*verificationDB, error) { ) (*verificationDB, error) {
return CreateWithCollection(logger, db, tf, "") return CreateWithCollection(logger, db, tf, "")
@@ -37,7 +37,7 @@ func Create(
func CreateWithCollection( func CreateWithCollection(
logger mlogger.Logger, logger mlogger.Logger,
db *mongo.Database, db source.Source,
tf transaction.Factory, tf transaction.Factory,
collection string, collection string,
) (*verificationDB, error) { ) (*verificationDB, error) {
+19 -37
View File
@@ -4,56 +4,38 @@ import (
"context" "context"
mongoimpl "git.sendico.io/tech/sxchnge/shared/database/internal/mongo" mongoimpl "git.sendico.io/tech/sxchnge/shared/database/internal/mongo"
"git.sendico.io/tech/sxchnge/shared/errors" "git.sendico.io/tech/sxchnge/shared/database/source"
"git.sendico.io/tech/sxchnge/shared/logging" merrors "git.sendico.io/tech/sxchnge/shared/errors"
"go.mongodb.org/mongo-driver/v2/mongo" mlogger "git.sendico.io/tech/sxchnge/shared/logging"
"go.mongodb.org/mongo-driver/v2/mongo/readpref" "go.mongodb.org/mongo-driver/v2/mongo/readpref"
) )
// Connection represents a low-level database connection lifecycle.
type Connection interface { type Connection interface {
Disconnect(ctx context.Context) error Disconnect(context.Context) error
Ping(ctx context.Context) error Ping(context.Context) error
} }
// MongoConnection provides direct access to the underlying mongo client.
type MongoConnection struct { type MongoConnection struct {
client *mongo.Client database source.Source
database string close func(context.Context) error
}
func (c *MongoConnection) Client() *mongo.Client {
return c.client
}
func (c *MongoConnection) Database() *mongo.Database {
return c.client.Database(c.database)
}
func (c *MongoConnection) Disconnect(ctx context.Context) error {
return c.client.Disconnect(ctx)
} }
func (c *MongoConnection) Database() source.Source { return c.database }
func (c *MongoConnection) Disconnect(ctx context.Context) error { return c.close(ctx) }
func (c *MongoConnection) Ping(ctx context.Context) error { func (c *MongoConnection) Ping(ctx context.Context) error {
return c.client.Ping(ctx, readpref.Primary()) database, ctx, release, err := c.database.Acquire(ctx)
if err != nil {
return err
}
defer release()
return database.Client().Ping(ctx, readpref.Primary())
} }
// ConnectMongo returns a low-level MongoDB connection without constructing repositories.
func ConnectMongo(logger mlogger.Logger, config *Config) (*MongoConnection, error) { func ConnectMongo(logger mlogger.Logger, config *Config) (*MongoConnection, error) {
if config == nil { if config == nil || config.Driver != Mongo {
return nil, merrors.InvalidArgument("database configuration is nil", "config") return nil, merrors.InvalidArgument("MongoDB configuration is required")
} }
if config.Driver != Mongo { database, close, err := mongoimpl.OpenSource(logger, config.Settings)
return nil, merrors.InvalidArgument("unsupported database driver: "+string(config.Driver), "config.driver")
}
client, _, settings, err := mongoimpl.ConnectClient(logger, config.Settings)
if err != nil { if err != nil {
return nil, err return nil, err
} }
return &MongoConnection{database: database, close: close}, nil
return &MongoConnection{
client: client,
database: settings.Database,
}, nil
} }
@@ -2,19 +2,19 @@ package mongo
import ( import (
"git.sendico.io/tech/sxchnge/shared/database" "git.sendico.io/tech/sxchnge/shared/database"
"git.sendico.io/tech/sxchnge/shared/database/source"
"git.sendico.io/tech/sxchnge/shared/database/transaction" "git.sendico.io/tech/sxchnge/shared/database/transaction"
"git.sendico.io/tech/sxchnge/shared/errors" "git.sendico.io/tech/sxchnge/shared/errors"
"go.mongodb.org/mongo-driver/v2/mongo"
) )
// Dependencies resolves local DB/transaction primitives for edge-local store wiring only. // Dependencies resolves local DB/transaction primitives for edge-local store wiring only.
type Dependencies struct { type Dependencies struct {
Database *mongo.Database Database source.Source
Transactions transaction.Factory Transactions transaction.Factory
} }
type DatabaseProvider interface { type DatabaseProvider interface {
MongoDatabase() *mongo.Database MongoDatabase() source.Source
} }
func ResolveDependencies(dbf db.Factory) (*Dependencies, error) { func ResolveDependencies(dbf db.Factory) (*Dependencies, error) {
+41 -39
View File
@@ -3,15 +3,18 @@ package mongo
import ( import (
"context" "context"
"fmt" "fmt"
sharedmodel "git.sendico.io/tech/sxchnge/shared/model"
"net" "net"
"os" "os"
"strings" "strings"
"time"
mutil "git.sendico.io/tech/sxchnge/shared/config" mutil "git.sendico.io/tech/sxchnge/shared/config"
"git.sendico.io/tech/sxchnge/shared/database/internal/transaction" "git.sendico.io/tech/sxchnge/shared/database/internal/transaction"
"git.sendico.io/tech/sxchnge/shared/database/source"
"git.sendico.io/tech/sxchnge/shared/database/transaction" "git.sendico.io/tech/sxchnge/shared/database/transaction"
dbvault "git.sendico.io/tech/sxchnge/shared/database/vault"
"git.sendico.io/tech/sxchnge/shared/logging" "git.sendico.io/tech/sxchnge/shared/logging"
sharedmodel "git.sendico.io/tech/sxchnge/shared/model"
"github.com/go-viper/mapstructure/v2" "github.com/go-viper/mapstructure/v2"
"go.mongodb.org/mongo-driver/v2/mongo" "go.mongodb.org/mongo-driver/v2/mongo"
"go.mongodb.org/mongo-driver/v2/mongo/readpref" "go.mongodb.org/mongo-driver/v2/mongo/readpref"
@@ -20,6 +23,7 @@ import (
// Config represents configuration // Config represents configuration
type Config struct { type Config struct {
Vault *dbvault.Config `mapstructure:"vault"`
Port *string `mapstructure:"port"` Port *string `mapstructure:"port"`
PortEnv *string `mapstructure:"port_env"` PortEnv *string `mapstructure:"port_env"`
User *string `mapstructure:"user"` User *string `mapstructure:"user"`
@@ -119,37 +123,42 @@ func dialMongo(logger mlogger.Logger, dbSettings *DBSettings) (*mongo.Client, er
return client, nil return client, nil
} }
func ConnectClient(logger mlogger.Logger, settings sharedmodel.SettingsT) (*mongo.Client, *Config, *DBSettings, error) {
config, dbSettings, err := decodeConfig(logger, settings)
if err != nil {
return nil, nil, nil, err
}
client, err := dialMongo(logger, dbSettings)
if err != nil {
return nil, nil, nil, err
}
return client, config, dbSettings, nil
}
// DB represents the structure of the database // DB represents the structure of the database
type DB struct { type DB struct {
logger mlogger.Logger logger mlogger.Logger
config *DBSettings database source.Source
client *mongo.Client close func(context.Context) error
}
func (db *DB) db() *mongo.Database {
return db.client.Database(db.config.Database)
}
func (db *DB) MongoDatabase() *mongo.Database {
return db.db()
} }
func (db *DB) MongoDatabase() source.Source { return db.database }
func (db *DB) TransactionFactory() transaction.Factory { func (db *DB) TransactionFactory() transaction.Factory {
return transactionimp.CreateFactory(db.client) return transactionimp.CreateSourceFactory(db.database)
}
// OpenSource owns either a fixed connection or a rotating Vault-backed source.
func OpenSource(logger mlogger.Logger, settings sharedmodel.SettingsT) (source.Source, func(context.Context) error, error) {
config, dbSettings, err := decodeConfig(logger, settings)
if err != nil {
return nil, nil, err
}
if config.Vault != nil {
issuer, err := dbvault.New(*config.Vault, dbSettings.URI)
if err != nil {
return nil, nil, err
}
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
manager, err := source.New(ctx, logger.Named("credentials"), dbSettings.Database, issuer)
if err != nil {
return nil, nil, err
}
return manager, manager.Close, nil
}
client, err := dialMongo(logger, dbSettings)
if err != nil {
return nil, nil, err
}
return source.Fixed(client.Database(dbSettings.Database)), client.Disconnect, nil
} }
func collectReplicaHosts(configuredHosts []string, replicaSet, defaultPort, hostPrefix, portPrefix string) []string { func collectReplicaHosts(configuredHosts []string, replicaSet, defaultPort, hostPrefix, portPrefix string) []string {
@@ -217,24 +226,17 @@ func collectReplicaHosts(configuredHosts []string, replicaSet, defaultPort, host
} }
func (db *DB) CloseConnection() { func (db *DB) CloseConnection() {
if err := db.client.Disconnect(context.Background()); err != nil { ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
db.logger.Warn("Failed to close connection", zap.Error(err)) defer cancel()
if err := db.close(ctx); err != nil {
db.logger.Warn("Failed to drain database connection", zap.Error(err))
} }
db.logger.Info("Database connection closed")
} }
// NewConnection creates a new database connection
func NewConnection(logger mlogger.Logger, settings sharedmodel.SettingsT) (*DB, error) { func NewConnection(logger mlogger.Logger, settings sharedmodel.SettingsT) (*DB, error) {
client, _, dbSettings, err := ConnectClient(logger, settings) database, close, err := OpenSource(logger, settings)
if err != nil { if err != nil {
return nil, err return nil, err
} }
return &DB{logger: logger, database: database, close: close}, nil
db := &DB{
logger: logger.Named("db"),
config: dbSettings,
client: client,
}
return db, nil
} }
@@ -12,7 +12,14 @@ import (
) )
func (r *MongoRepository) CreateIndex(def *ri.Definition) error { func (r *MongoRepository) CreateIndex(def *ri.Definition) error {
if r.collection == nil { ctx := context.Background()
collection, ctx, release, err := r.acquire(ctx)
if err != nil {
return err
}
defer release()
if collection == nil {
return merrors.NoData("data collection is not set") return merrors.NoData("data collection is not set")
} }
if len(def.Keys) == 0 { if len(def.Keys) == 0 {
@@ -49,28 +56,40 @@ func (r *MongoRepository) CreateIndex(def *ri.Definition) error {
opts.SetSparse(def.Sparse) opts.SetSparse(def.Sparse)
} }
_, err := r.collection.Indexes().CreateOne( _, err = collection.Indexes().CreateOne(
context.Background(), ctx,
mongo.IndexModel{Keys: keys, Options: opts}, mongo.IndexModel{Keys: keys, Options: opts},
) )
return err return err
} }
func (r *MongoRepository) DropIndex(ctx context.Context, name string) error { func (r *MongoRepository) DropIndex(ctx context.Context, name string) error {
if r.collection == nil { collection, ctx, release, err := r.acquire(ctx)
if err != nil {
return err
}
defer release()
if collection == nil {
return merrors.NoData("data collection is not set") return merrors.NoData("data collection is not set")
} }
if name == "" { if name == "" {
return merrors.InvalidArgument("index name is required", "index.name") return merrors.InvalidArgument("index name is required", "index.name")
} }
return r.collection.Indexes().DropOne(ctx, name) return collection.Indexes().DropOne(ctx, name)
} }
func (r *MongoRepository) ListIndexDefinitions(ctx context.Context) ([]*ri.Definition, error) { func (r *MongoRepository) ListIndexDefinitions(ctx context.Context) ([]*ri.Definition, error) {
if r.collection == nil { collection, ctx, release, err := r.acquire(ctx)
if err != nil {
return nil, err
}
defer release()
if collection == nil {
return nil, merrors.NoData("data collection is not set") return nil, merrors.NoData("data collection is not set")
} }
cursor, err := r.collection.Indexes().List(ctx) cursor, err := collection.Indexes().List(ctx)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -8,6 +8,7 @@ import (
"git.sendico.io/tech/sxchnge/shared/database/record" "git.sendico.io/tech/sxchnge/shared/database/record"
"git.sendico.io/tech/sxchnge/shared/database/repository/builder" "git.sendico.io/tech/sxchnge/shared/database/repository/builder"
rd "git.sendico.io/tech/sxchnge/shared/database/repository/decoder" rd "git.sendico.io/tech/sxchnge/shared/database/repository/decoder"
"git.sendico.io/tech/sxchnge/shared/database/source"
"git.sendico.io/tech/sxchnge/shared/database/transaction" "git.sendico.io/tech/sxchnge/shared/database/transaction"
"git.sendico.io/tech/sxchnge/shared/errors" "git.sendico.io/tech/sxchnge/shared/errors"
"go.mongodb.org/mongo-driver/v2/bson" "go.mongodb.org/mongo-driver/v2/bson"
@@ -17,7 +18,7 @@ import (
type MongoRepository struct { type MongoRepository struct {
collectionName string collectionName string
collection *mongo.Collection source source.Source
} }
func idFilter(id bson.ObjectID) bson.D { func idFilter(id bson.ObjectID) bson.D {
@@ -27,10 +28,23 @@ func idFilter(id bson.ObjectID) bson.D {
} }
func NewMongoRepository(db *mongo.Database, collection string) *MongoRepository { func NewMongoRepository(db *mongo.Database, collection string) *MongoRepository {
return &MongoRepository{ return NewRepository(source.Fixed(db), collection)
collectionName: collection, }
collection: db.Collection(collection),
func NewRepository(database source.Source, collection string) *MongoRepository {
return &MongoRepository{collectionName: collection, source: database}
}
func (r *MongoRepository) acquire(ctx context.Context) (*mongo.Collection, context.Context, func(), error) {
if r.source == nil {
return nil, ctx, nil, merrors.FailedPrecondition("database source is not configured")
} }
database, scoped, release, err := r.source.Acquire(ctx)
if err != nil {
return nil, ctx, nil, err
}
return database.Collection(r.collectionName), scoped, release, nil
} }
func (r *MongoRepository) Collection() string { func (r *MongoRepository) Collection() string {
@@ -48,8 +62,14 @@ func prepareForWrite(obj storable.Storable) bson.ObjectID {
} }
func (r *MongoRepository) Insert(ctx context.Context, obj storable.Storable, getFilter builder.Query) error { func (r *MongoRepository) Insert(ctx context.Context, obj storable.Storable, getFilter builder.Query) error {
collection, ctx, release, err := r.acquire(ctx)
if err != nil {
return err
}
defer release()
prepareForWrite(obj) prepareForWrite(obj)
_, err := r.collection.InsertOne(ctx, obj) _, err = collection.InsertOne(ctx, obj)
if mongo.IsDuplicateKeyError(err) { if mongo.IsDuplicateKeyError(err) {
if getFilter != nil { if getFilter != nil {
if err = r.FindOneByFilter(ctx, getFilter, obj); err != nil { if err = r.FindOneByFilter(ctx, getFilter, obj); err != nil {
@@ -62,6 +82,12 @@ func (r *MongoRepository) Insert(ctx context.Context, obj storable.Storable, get
} }
func (r *MongoRepository) InsertMany(ctx context.Context, objects []storable.Storable) error { func (r *MongoRepository) InsertMany(ctx context.Context, objects []storable.Storable) error {
collection, ctx, release, err := r.acquire(ctx)
if err != nil {
return err
}
defer release()
if len(objects) == 0 { if len(objects) == 0 {
return nil return nil
} }
@@ -72,12 +98,18 @@ func (r *MongoRepository) InsertMany(ctx context.Context, objects []storable.Sto
docs[i] = obj docs[i] = obj
} }
_, err := r.collection.InsertMany(ctx, docs) _, err = collection.InsertMany(ctx, docs)
return err return err
} }
func (r *MongoRepository) findOneByFilterImp(ctx context.Context, filter bson.D, errMessage string, result storable.Storable) error { func (r *MongoRepository) findOneByFilterImp(ctx context.Context, filter bson.D, errMessage string, result storable.Storable) error {
err := r.collection.FindOne(ctx, filter).Decode(result) collection, ctx, release, err := r.acquire(ctx)
if err != nil {
return err
}
defer release()
err = collection.FindOne(ctx, filter).Decode(result)
if errors.Is(err, mongo.ErrNoDocuments) { if errors.Is(err, mongo.ErrNoDocuments) {
return merrors.NoData(errMessage) return merrors.NoData(errMessage)
} }
@@ -94,7 +126,13 @@ func (r *MongoRepository) Get(ctx context.Context, id bson.ObjectID, result stor
type QueryFunc func(ctx context.Context, collection *mongo.Collection) (*mongo.Cursor, error) type QueryFunc func(ctx context.Context, collection *mongo.Collection) (*mongo.Cursor, error)
func (r *MongoRepository) executeQuery(ctx context.Context, queryFunc QueryFunc, decoder rd.DecodingFunc) error { func (r *MongoRepository) executeQuery(ctx context.Context, queryFunc QueryFunc, decoder rd.DecodingFunc) error {
cursor, err := queryFunc(ctx, r.collection) collection, ctx, release, err := r.acquire(ctx)
if err != nil {
return err
}
defer release()
cursor, err := queryFunc(ctx, collection)
if errors.Is(err, mongo.ErrNoDocuments) { if errors.Is(err, mongo.ErrNoDocuments) {
return merrors.NoData("no_items_in_array") return merrors.NoData("no_items_in_array")
} }
@@ -111,7 +149,7 @@ func (r *MongoRepository) executeQuery(ctx context.Context, queryFunc QueryFunc,
} }
} }
return nil return cursor.Err()
} }
func (r *MongoRepository) Aggregate(ctx context.Context, pipeline builder.Pipeline, decoder rd.DecodingFunc) error { func (r *MongoRepository) Aggregate(ctx context.Context, pipeline builder.Pipeline, decoder rd.DecodingFunc) error {
@@ -129,8 +167,14 @@ func (r *MongoRepository) FindManyByFilter(ctx context.Context, query builder.Qu
} }
func (r *MongoRepository) FindAllByFilter(ctx context.Context, query builder.Query, result any) error { func (r *MongoRepository) FindAllByFilter(ctx context.Context, query builder.Query, result any) error {
collection, ctx, release, err := r.acquire(ctx)
if err != nil {
return err
}
defer release()
if query == nil { if query == nil {
cursor, err := r.collection.Find(ctx, bson.D{}) cursor, err := collection.Find(ctx, bson.D{})
if err != nil { if err != nil {
return err return err
} }
@@ -139,7 +183,7 @@ func (r *MongoRepository) FindAllByFilter(ctx context.Context, query builder.Que
}() }()
return cursor.All(ctx, result) return cursor.All(ctx, result)
} }
cursor, err := r.collection.Find(ctx, query.BuildQuery(), query.BuildOptions()) cursor, err := collection.Find(ctx, query.BuildQuery(), query.BuildOptions())
if err != nil { if err != nil {
return err return err
} }
@@ -154,18 +198,30 @@ func (r *MongoRepository) FindOneByFilter(ctx context.Context, query builder.Que
} }
func (r *MongoRepository) Update(ctx context.Context, obj storable.Storable) error { func (r *MongoRepository) Update(ctx context.Context, obj storable.Storable) error {
collection, ctx, release, err := r.acquire(ctx)
if err != nil {
return err
}
defer release()
obj.Update() obj.Update()
return r.collection.FindOneAndReplace(ctx, idFilter(*obj.GetID()), obj).Err() return collection.FindOneAndReplace(ctx, idFilter(*obj.GetID()), obj).Err()
} }
func (r *MongoRepository) ReplaceOneByFilter(ctx context.Context, query builder.Query, obj storable.Storable) (bool, error) { func (r *MongoRepository) ReplaceOneByFilter(ctx context.Context, query builder.Query, obj storable.Storable) (bool, error) {
collection, ctx, release, err := r.acquire(ctx)
if err != nil {
return false, err
}
defer release()
if query == nil { if query == nil {
return false, merrors.InvalidArgument("filter is required", "filter") return false, merrors.InvalidArgument("filter is required", "filter")
} }
if obj == nil { if obj == nil {
return false, merrors.InvalidArgument("object is required", "obj") return false, merrors.InvalidArgument("object is required", "obj")
} }
result, err := r.collection.ReplaceOne(ctx, query.BuildQuery(), obj) result, err := collection.ReplaceOne(ctx, query.BuildQuery(), obj)
if mongo.IsDuplicateKeyError(err) { if mongo.IsDuplicateKeyError(err) {
return false, merrors.DataConflict("duplicate_key") return false, merrors.DataConflict("duplicate_key")
} }
@@ -176,21 +232,39 @@ func (r *MongoRepository) ReplaceOneByFilter(ctx context.Context, query builder.
} }
func (r *MongoRepository) Upsert(ctx context.Context, obj storable.Storable) error { func (r *MongoRepository) Upsert(ctx context.Context, obj storable.Storable) error {
collection, ctx, release, err := r.acquire(ctx)
if err != nil {
return err
}
defer release()
id := prepareForWrite(obj) id := prepareForWrite(obj)
_, err := r.collection.ReplaceOne(ctx, idFilter(id), obj, options.Replace().SetUpsert(true)) _, err = collection.ReplaceOne(ctx, idFilter(id), obj, options.Replace().SetUpsert(true))
return err return err
} }
func (r *MongoRepository) Patch(ctx context.Context, id bson.ObjectID, patch builder.Patch) error { func (r *MongoRepository) Patch(ctx context.Context, id bson.ObjectID, patch builder.Patch) error {
collection, ctx, release, err := r.acquire(ctx)
if err != nil {
return err
}
defer release()
if id.IsZero() { if id.IsZero() {
return merrors.InvalidArgument("zero id provided while patching", "id") return merrors.InvalidArgument("zero id provided while patching", "id")
} }
_, err := r.collection.UpdateByID(ctx, id, patch.Build()) _, err = collection.UpdateByID(ctx, id, patch.Build())
return err return err
} }
func (r *MongoRepository) PatchMany(ctx context.Context, query builder.Query, patch builder.Patch) (int, error) { func (r *MongoRepository) PatchMany(ctx context.Context, query builder.Query, patch builder.Patch) (int, error) {
result, err := r.collection.UpdateMany(ctx, query.BuildQuery(), patch.Build()) collection, ctx, release, err := r.acquire(ctx)
if err != nil {
return 0, err
}
defer release()
result, err := collection.UpdateMany(ctx, query.BuildQuery(), patch.Build())
if err != nil { if err != nil {
return 0, err return 0, err
} }
@@ -198,10 +272,16 @@ func (r *MongoRepository) PatchMany(ctx context.Context, query builder.Query, pa
} }
func (r *MongoRepository) ListIDs(ctx context.Context, query builder.Query) ([]bson.ObjectID, error) { func (r *MongoRepository) ListIDs(ctx context.Context, query builder.Query) ([]bson.ObjectID, error) {
collection, ctx, release, err := r.acquire(ctx)
if err != nil {
return nil, err
}
defer release()
filter := query.BuildQuery() filter := query.BuildQuery()
findOptions := query.BuildOptions().SetProjection(bson.M{storable.IDField: 1}) findOptions := query.BuildOptions().SetProjection(bson.M{storable.IDField: 1})
cursor, err := r.collection.Find(ctx, filter, findOptions) cursor, err := collection.Find(ctx, filter, findOptions)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -227,24 +307,45 @@ func (r *MongoRepository) ListIDs(ctx context.Context, query builder.Query) ([]b
} }
func (r *MongoRepository) Delete(ctx context.Context, id bson.ObjectID) error { func (r *MongoRepository) Delete(ctx context.Context, id bson.ObjectID) error {
_, err := r.collection.DeleteOne(ctx, idFilter(id)) collection, ctx, release, err := r.acquire(ctx)
if err != nil {
return err
}
defer release()
_, err = collection.DeleteOne(ctx, idFilter(id))
return err return err
} }
func (r *MongoRepository) DeleteMany(ctx context.Context, query builder.Query) error { func (r *MongoRepository) DeleteMany(ctx context.Context, query builder.Query) error {
_, err := r.collection.DeleteMany(ctx, query.BuildQuery()) collection, ctx, release, err := r.acquire(ctx)
if err != nil {
return err
}
defer release()
_, err = collection.DeleteMany(ctx, query.BuildQuery())
return err return err
} }
func (r *MongoRepository) Name() string { func (r *MongoRepository) Name() string {
return r.collection.Name() return r.collectionName
} }
func (r *MongoRepository) WithTransaction(ctx context.Context, cb transaction.Callback) (any, error) { func (r *MongoRepository) WithTransaction(ctx context.Context, cb transaction.Callback) (any, error) {
collection, ctx, release, err := r.acquire(ctx)
if err != nil {
return nil, err
}
defer release()
if cb == nil { if cb == nil {
return nil, merrors.InvalidArgument("transaction callback is required", "callback") return nil, merrors.InvalidArgument("transaction callback is required", "callback")
} }
session, err := r.collection.Database().Client().StartSession() if transaction.ActiveMongoTransaction(ctx) {
return cb(ctx)
}
session, err := collection.Database().Client().StartSession()
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -12,8 +12,14 @@ import (
) )
func (r *MongoRepository) FindFirstByFilter(ctx context.Context, query builder.Query, result storable.Storable) error { func (r *MongoRepository) FindFirstByFilter(ctx context.Context, query builder.Query, result storable.Storable) error {
collection, ctx, release, err := r.acquire(ctx)
if err != nil {
return err
}
defer release()
opts := query.BuildOptions().SetLimit(1) opts := query.BuildOptions().SetLimit(1)
cursor, err := r.collection.Find(ctx, query.BuildQuery(), opts) cursor, err := collection.Find(ctx, query.BuildQuery(), opts)
if err != nil { if err != nil {
return err return err
} }
@@ -30,10 +36,22 @@ func (r *MongoRepository) FindFirstByFilter(ctx context.Context, query builder.Q
} }
func (r *MongoRepository) CountByFilter(ctx context.Context, query builder.Query) (int64, error) { func (r *MongoRepository) CountByFilter(ctx context.Context, query builder.Query) (int64, error) {
return r.collection.CountDocuments(ctx, query.BuildQuery()) collection, ctx, release, err := r.acquire(ctx)
if err != nil {
return 0, err
}
defer release()
return collection.CountDocuments(ctx, query.BuildQuery())
} }
func (r *MongoRepository) PatchOneByFilter(ctx context.Context, query builder.Query, patch builder.Patch, result storable.Storable) (bool, error) { func (r *MongoRepository) PatchOneByFilter(ctx context.Context, query builder.Query, patch builder.Patch, result storable.Storable) (bool, error) {
collection, ctx, release, err := r.acquire(ctx)
if err != nil {
return false, err
}
defer release()
if query == nil { if query == nil {
return false, merrors.InvalidArgument("filter is required", "filter") return false, merrors.InvalidArgument("filter is required", "filter")
} }
@@ -43,7 +61,7 @@ func (r *MongoRepository) PatchOneByFilter(ctx context.Context, query builder.Qu
if result == nil { if result == nil {
return false, merrors.InvalidArgument("result is required", "result") return false, merrors.InvalidArgument("result is required", "result")
} }
err := r.collection.FindOneAndUpdate( err = collection.FindOneAndUpdate(
ctx, ctx,
query.BuildQuery(), query.BuildQuery(),
patch.Build(), patch.Build(),
@@ -1,18 +1,15 @@
package transactionimp package transactionimp
import ( import (
"git.sendico.io/tech/sxchnge/shared/database/source"
"git.sendico.io/tech/sxchnge/shared/database/transaction" "git.sendico.io/tech/sxchnge/shared/database/transaction"
"go.mongodb.org/mongo-driver/v2/mongo"
) )
type MongoTransactionFactory struct { type MongoTransactionFactory struct{ database source.Source }
client *mongo.Client
}
func (mtf *MongoTransactionFactory) CreateTransaction() transaction.Transaction { func (f *MongoTransactionFactory) CreateTransaction() transaction.Transaction {
return Create(mtf.client) return &MongoTransaction{database: f.database}
} }
func CreateSourceFactory(database source.Source) transaction.Factory {
func CreateFactory(client *mongo.Client) transaction.Factory { return &MongoTransactionFactory{database: database}
return &MongoTransactionFactory{client: client}
} }
Loaded 100 of 141 files, more files were not shown because too many files have changed in this diff. Show more